import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import JourneyEditor from './JourneyEditor.svelte'; const docSummary = (id: string, title: string) => ({ id, title, datePrecision: 'DAY' as const }); 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('publish button disabled when no items', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); }); it('shows empty state message when items list is empty', async () => { render(JourneyEditor, defaultProps()); await expect.element(page.getByText('Diese Lesereise ist noch leer.')).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 disabled when title empty', () => { 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(); }); }); 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') }; mockCsrfFetch(() => newItem); vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValueOnce({ // picker search results ok: true, json: vi.fn().mockResolvedValue({ items: [ { id: 'd1', title: 'Brief von Karl', 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' } ] }) }) .mockResolvedValueOnce({ // POST /items ok: true, json: vi.fn().mockResolvedValue(newItem) }) ); render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText('Brief hinzufügen')); await userEvent.fill(page.getByRole('combobox'), 'Karl'); await new Promise((r) => setTimeout(r, 350)); // wait debounce await userEvent.click(page.getByText(/Brief von Karl/)); 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('Zwischentext hinzufügen')); await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien'); await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items'), expect.objectContaining({ method: 'POST', body: JSON.stringify({ note: 'Reise nach Wien' }) }) ); }); }); describe('JourneyEditor — remove with rollback', () => { it('restores item 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: 'Eintrag entfernen' })); await new Promise((r) => setTimeout(r, 50)); // Item should be restored after rollback await expect.element(page.getByText('Brief A')).toBeInTheDocument(); }); it('item-add enables publish button (isDirty stays false, 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('Zwischentext hinzufügen')); await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test'); await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); await new Promise((r) => setTimeout(r, 50)); // After item add, publish becomes enabled — item was added and state is correct await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); }); }); 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 new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels 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 new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items/reorder'), expect.objectContaining({ method: 'PUT', body: JSON.stringify({ itemIds: ['i2', 'i1'] }) }) ); }); }); describe('JourneyEditor — live announce region', () => { it('clears the live announce region 500ms after a move operation', 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 new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch const liveRegion = document.querySelector('[aria-live="polite"]'); expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0); await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer expect((liveRegion?.textContent ?? '').trim()).toBe(''); }); }); 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 new Promise((r) => setTimeout(r, 50)); 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: [ { id: 'd1', title: 'Brief von Karl', 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' } ] }) }) ); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); await userEvent.click(page.getByText('Brief hinzufügen')); await userEvent.fill(page.getByRole('combobox'), 'Karl'); await new Promise((r) => setTimeout(r, 350)); // The dropdown item includes the date ("Brief von Karl · …"), the list item does not const option = page .getByText(/Brief von Karl ·/) .element() .closest('li')!; expect(option.getAttribute('aria-disabled')).toBe('true'); }); });