import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import { m } from '$lib/paraglide/messages.js'; import JourneyEditor from './JourneyEditor.svelte'; vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); import { beforeNavigate } from '$app/navigation'; const docSummary = (id: string, title: string) => ({ id, title, datePrecision: 'DAY' as const, receiverCount: 0 }); /** DocumentListItem fixture as returned by the picker search endpoint. */ const makeSearchResultItem = (id: string, title: string) => ({ id, title, documentDate: '1880-01-01', metaDatePrecision: 'DAY', originalFilename: 'brief.pdf', receivers: [], tags: [], completionPercentage: 0, contributors: [], matchData: { titleOffsets: [], senderMatched: false, matchedReceiverIds: [], matchedTagIds: [], snippetOffsets: [], summaryOffsets: [] }, status: 'UPLOADED', metadataComplete: false, scriptType: 'UNKNOWN', createdAt: '2024-01-01T00:00:00', updatedAt: '2024-01-01T00:00:00' }); const makeGeschichte = (overrides: Record = {}) => ({ id: 'g1', title: 'Briefe der Familie Raddatz', body: '', status: 'DRAFT' as const, type: 'JOURNEY' as const, persons: [], items: [], createdAt: '2024-01-01T00:00:00', updatedAt: '2024-01-01T00:00:00', ...overrides }); const defaultProps = (overrides: Record = {}) => ({ geschichte: makeGeschichte(), onSubmit: vi.fn().mockResolvedValue(undefined), submitting: false, ...overrides }); function mockCsrfFetch(responseFactory: () => object) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(responseFactory()) }) ); } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe('JourneyEditor — empty state', () => { it('renders title input and intro textarea', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument(); await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument(); }); it('labels the title input and intro textarea for screen readers', async () => { render(JourneyEditor, defaultProps()); await expect .element(page.getByRole('textbox', { name: m.journey_title_aria_label() })) .toBeInTheDocument(); await expect .element(page.getByRole('textbox', { name: m.journey_intro_aria_label() })) .toBeInTheDocument(); }); it('shows empty state message when items list is empty', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument(); }); }); describe('JourneyEditor — items in position order', () => { it('renders items sorted by position', async () => { const items = [ { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }, { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') } ]; render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); // Brief A (position 0) must appear before Brief B (position 1) in DOM order const briefA = page.getByText('Brief A').element(); const briefB = page.getByText('Brief B').element(); expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); describe('JourneyEditor — publish surface', () => { it('publish button disabled when no items', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); }); it('shows a visible hint while publishing is disabled', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument(); }); it('publish stays disabled until title is non-empty', async () => { render( JourneyEditor, defaultProps({ geschichte: makeGeschichte({ title: '', items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] }) }) ); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); const titleInput = page.getByPlaceholder(/Titel/); await userEvent.fill(titleInput, 'Meine Reise'); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); }); it('adding an item enables the publish button (canPublish becomes true)', async () => { const newItem = { id: 'i1', position: 0, note: 'Test' }; mockCsrfFetch(() => newItem); render(JourneyEditor, defaultProps()); // Publish should be disabled before adding item await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); // Add interlude await userEvent.click(page.getByText(m.journey_add_interlude())); await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test'); await userEvent.click( page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) ); // After item add, publish becomes enabled — item was added and state is correct await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); }); it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( JourneyEditor, defaultProps({ onSubmit, geschichte: makeGeschichte({ title: ' Meine Reise ', items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] }) }) ); await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ })); await vi.waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' }) ); }); }); it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( JourneyEditor, defaultProps({ onSubmit, geschichte: makeGeschichte({ status: 'PUBLISHED', items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] }) }) ); await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() })); await vi.waitFor(() => { expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' })); }); }); it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => { render( JourneyEditor, defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) }) ); await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument(); }); }); describe('JourneyEditor — add document', () => { it('calls POST with documentId when document selected from picker', async () => { const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }; vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValueOnce({ // picker search results ok: true, json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] }) }) .mockResolvedValueOnce({ // POST /items ok: true, json: vi.fn().mockResolvedValue(newItem) }) ); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText(m.journey_add_document())); await userEvent.fill(page.getByRole('combobox'), 'Karl'); // dropdown option appears after the typeahead debounce await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument(); await userEvent.click(page.getByText(/Brief von Karl ·/)); await vi.waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items'), expect.objectContaining({ method: 'POST' }) ); }); }); }); describe('JourneyEditor — add interlude', () => { it('calls POST with note on interlude confirm', async () => { const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' }; mockCsrfFetch(() => newItem); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText(m.journey_add_interlude())); await userEvent.fill( page.getByPlaceholder(m.journey_interlude_placeholder()), 'Reise nach Wien' ); await userEvent.click( page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) ); await vi.waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items'), expect.objectContaining({ method: 'POST', body: JSON.stringify({ note: 'Reise nach Wien' }) }) ); }); }); it('moves keyboard focus into the new row after the interlude is added', async () => { const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' }; mockCsrfFetch(() => newItem); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText(m.journey_add_interlude())); await userEvent.fill( page.getByPlaceholder(m.journey_interlude_placeholder()), 'Reise nach Wien' ); await userEvent.click( page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) ); await vi.waitFor(() => { expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy(); }); }); }); describe('JourneyEditor — mutation error code routing', () => { it('shows the specific i18n message when POST /items fails with a backend error code', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }) }) ); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText(m.journey_add_interlude())); await userEvent.fill( page.getByPlaceholder(m.journey_interlude_placeholder()), 'Reise nach Wien' ); await userEvent.click( page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) ); await expect .element(page.getByText(m.error_journey_document_already_added())) .toBeInTheDocument(); await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument(); }); }); describe('JourneyEditor — remove with pending state', () => { it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; let resolveFetch!: (value: unknown) => void; vi.stubGlobal( 'fetch', vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve))) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click( page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) ); // Row still present, marked as pending (text appears in the row AND the live region, // so scope the query to the row instead of using a page-wide locator) await expect.element(page.getByText('Brief A')).toBeInTheDocument(); await vi.waitFor(() => { const row = document.querySelector('[data-block-id="i1"]'); expect(row).toBeTruthy(); expect(row!.textContent).toContain(m.journey_item_pending_remove()); expect(row!.className).toContain('opacity-60'); }); resolveFetch({ ok: true }); await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); }); it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); // Click remove (no note → direct remove) await userEvent.click( page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) ); await expect.element(page.getByRole('alert')).toBeInTheDocument(); await expect.element(page.getByText('Brief A')).toBeInTheDocument(); }); it('removes the row on successful DELETE', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click( page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) ); await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); }); it('focuses a sensible target after a successful remove (not body)', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click( page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) }) ); await expect.element(page.getByText('Brief A')).not.toBeInTheDocument(); await vi.waitFor(() => { expect(document.activeElement).not.toBe(document.body); expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true); }); }); }); describe('JourneyEditor — reorder via move buttons', () => { it('move-up calls PUT reorder with swapped IDs', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([ { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } ]) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); await vi.waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items/reorder'), expect.objectContaining({ method: 'PUT', body: JSON.stringify({ itemIds: ['i2', 'i1'] }) }) ); }); }); it('move-down calls PUT reorder with swapped IDs', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([ { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } ]) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ })); await vi.waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items/reorder'), expect.objectContaining({ method: 'PUT', body: JSON.stringify({ itemIds: ['i2', 'i1'] }) }) ); }); }); it('renders the server-confirmed order after a successful reorder', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; // Server response deliberately NOT pre-sorted — pins items = updated.sort(...) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([ { id: 'i1', position: 20, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 10, document: docSummary('d2', 'Brief B') } ]) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ })); await vi.waitFor(() => { const briefB = page.getByText('Brief B').element(); const briefA = page.getByText('Brief A').element(); expect( briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); }); }); it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); await expect.element(page.getByRole('alert')).toBeInTheDocument(); const briefA = page.getByText('Brief A').element(); const briefB = page.getByText('Brief B').element(); expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it('restores the original DOM order and shows an alert when the reorder request rejects', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); await expect.element(page.getByRole('alert')).toBeInTheDocument(); const briefA = page.getByText('Brief A').element(); const briefB = page.getByText('Brief B').element(); expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); }); describe('JourneyEditor — live announce region', () => { it('announces the move only after the reorder resolved, then clears', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([ { id: 'i2', position: 0, document: docSummary('d2', 'Brief B') }, { id: 'i1', position: 1, document: docSummary('d1', 'Brief A') } ]) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); const liveRegion = document.querySelector('[aria-live="polite"]'); await vi.waitFor(() => { expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0); }); await vi.waitFor( () => { expect((liveRegion?.textContent ?? '').trim()).toBe(''); }, { timeout: 2000 } ); }); it('announces the error text instead of a success message when the move fails', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); const liveRegion = document.querySelector('[aria-live="polite"]'); await vi.waitFor(() => { expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload()); }); }); }); describe('JourneyEditor — note patch body', () => { it('sends {"note":null} when note textarea is cleared and blurred', async () => { const items = [ { id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' } ]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi .fn() .mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); await userEvent.clear(textarea); await textarea.element().dispatchEvent(new FocusEvent('blur')); await vi.waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items/i1'), expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ note: null }) }) ); }); }); }); describe('JourneyEditor — duplicate document aria-disabled', () => { it('already-added document appears as aria-disabled in picker', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] }) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByText(m.journey_add_document())); await userEvent.fill(page.getByRole('combobox'), 'Karl'); // The dropdown item includes the date ("Brief von Karl · …"), the list item does not await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument(); const option = page .getByText(/Brief von Karl ·/) .element() .closest('li')!; expect(option.getAttribute('aria-disabled')).toBe('true'); }); }); describe('JourneyEditor — unsaved warning banner', () => { function triggerNavigationAttempt() { const calls = vi.mocked(beforeNavigate).mock.calls; if (calls.length === 0) return; const [callback] = calls[calls.length - 1]; const cancel = vi.fn(); (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({ cancel, to: { url: new URL('http://localhost/geschichten') } }); return cancel; } it('banner is absent before any edit or navigation attempt', async () => { render(JourneyEditor, defaultProps()); expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull(); }); it('banner appears when dirty and a navigation is attempted', async () => { render(JourneyEditor, defaultProps()); // Mark dirty by editing the title const titleInput = page.getByPlaceholder(/Titel/); await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); // Simulate the user trying to navigate away const cancel = triggerNavigationAttempt(); expect(cancel).toHaveBeenCalled(); await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); }); it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => { const onSubmit = vi.fn().mockRejectedValue(new Error('server error')); render( JourneyEditor, defaultProps({ onSubmit, geschichte: makeGeschichte({ title: 'Titel', items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] }) }) ); // Mark dirty const titleInput = page.getByPlaceholder(/Titel/); await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); // Trigger navigation → banner appears triggerNavigationAttempt(); await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); // Attempt save — onSubmit throws await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() })); // Banner must still be visible (isDirty was not cleared) await vi.waitFor(() => { expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy(); }); }); it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => { // Regression guard for clearOnSuccess(): without it, a curator who edits the // title and saves successfully stays trapped — the page goto() gets cancelled // by the still-armed guard and the banner appears after a *successful* save. const onSubmit = vi.fn().mockResolvedValue(undefined); render(JourneyEditor, defaultProps({ onSubmit })); // Mark dirty const titleInput = page.getByPlaceholder(/Titel/); await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); // Dirty state blocks navigation expect(triggerNavigationAttempt()).toHaveBeenCalled(); // Save succeeds await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() })); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled()); // Guard is disarmed again — navigation passes and no banner shows await vi.waitFor(() => { expect(triggerNavigationAttempt()).not.toHaveBeenCalled(); }); expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull(); }); it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => { mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' })); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText(m.journey_add_interlude())); await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext'); await userEvent.click( page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true }) ); // The new interlude row renders its note textarea once the POST resolved await expect .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) .toBeInTheDocument(); // The item was persisted by its own POST — navigating away loses nothing expect(triggerNavigationAttempt()).not.toHaveBeenCalled(); }); }); describe('JourneyEditor — selectedPersons marks dirty', () => { function getNavCallback() { const calls = vi.mocked(beforeNavigate).mock.calls; const [callback] = calls[calls.length - 1]; return (cancel = vi.fn()) => { (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({ cancel, to: { url: new URL('http://localhost/geschichten') } }); return cancel; }; } it('removing a person chip marks the editor dirty', async () => { render( JourneyEditor, defaultProps({ geschichte: makeGeschichte({ persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }] }) }) ); // Confirm navigation is NOT blocked initially (clean state) const triggerNav = getNavCallback(); expect(triggerNav()).not.toHaveBeenCalled(); // Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen") await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() })); // After person removal, navigation should be blocked await vi.waitFor(() => { const cancel = triggerNav(); expect(cancel).toHaveBeenCalled(); }); }); }); describe('JourneyEditor — person chips from GeschichteView', () => { it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => { render( JourneyEditor, defaultProps({ geschichte: makeGeschichte({ persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }] }) }) ); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); }); });