import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page as browserPage } from 'vitest/browser'; const mockPage = { url: new URL('http://localhost/documents/d1'), state: {} }; vi.mock('$app/state', () => ({ get page() { return mockPage; } })); vi.mock('$app/navigation', () => ({ beforeNavigate: () => {}, afterNavigate: () => {}, goto: vi.fn(), invalidate: vi.fn(), invalidateAll: vi.fn(), preloadCode: vi.fn(), preloadData: vi.fn(), pushState: vi.fn(), replaceState: vi.fn(), disableScrollHandling: vi.fn(), onNavigate: () => () => {} })); vi.mock('$lib/shared/services/confirm.svelte', () => ({ getConfirmService: () => ({ confirm: async () => false }) })); const { default: DocumentDetailPage } = await import('./+page.svelte'); afterEach(cleanup); const baseDoc = { id: 'd1', title: 'Brief an Helene', originalFilename: 'brief.pdf', documentDate: '1923-04-15', sender: null, receivers: [], tags: [], filePath: null, contentType: null, location: null, status: 'UPLOADED', fileHash: null }; const baseData = (overrides: Record = {}) => ({ document: baseDoc, canWrite: false, canBlogWrite: false, user: null, geschichten: [], inferredRelationship: null, ...overrides }); describe('documents/[id] page', () => { it('renders the DocumentTopBar and resolves the document title in svelte:head', async () => { mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); it('mounts the page region with the [data-hydrated] container', async () => { mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); expect(document.querySelector('[data-hydrated]')).not.toBeNull(); }); it('persists last-visited document ID to localStorage on mount', async () => { localStorage.removeItem('familienarchiv.lastVisited'); mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); await vi.waitFor(() => { const stored = localStorage.getItem('familienarchiv.lastVisited'); expect(stored).toContain('d1'); }); }); it('uses doc.title as the document title when set', async () => { mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); it('falls back to originalFilename when title is empty', async () => { mockPage.url = new URL('http://localhost/documents/d2'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd2', title: '', originalFilename: 'fallback.pdf' } }) } }); await vi.waitFor(() => expect(document.title).toContain('fallback.pdf')); }); it('falls back to "Dokument" when title and originalFilename are empty', async () => { mockPage.url = new URL('http://localhost/documents/d3'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd3', title: '', originalFilename: '' } }) } }); await vi.waitFor(() => expect(document.title).toContain('Dokument')); }); it('renders the topbar Edit-link affordance when canWrite is true', async () => { mockPage.url = new URL('http://localhost/documents/d4'); render(DocumentDetailPage, { props: { data: baseData({ canWrite: true }) } }); await expect.element(browserPage.getByRole('link', { name: 'Bearbeiten' })).toBeVisible(); }); it('renders the topbar when geschichten and inferredRelationship are passed through', async () => { mockPage.url = new URL('http://localhost/documents/d5'); render(DocumentDetailPage, { props: { data: baseData({ geschichten: [{ id: 'g1', title: 'Story', publishedAt: null }], inferredRelationship: { label: 'PARENT_OF', from: 'p1', to: 'p2' }, canBlogWrite: true }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); it('renders the topbar even when doc.id is empty (defensive)', async () => { mockPage.url = new URL('http://localhost/documents/d-empty'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: '', title: 'No ID' } }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); await vi.waitFor(() => expect(document.title).toContain('No ID')); }); it('renders sender data in the metadata drawer when sender is populated', async () => { mockPage.url = new URL('http://localhost/documents/d7'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd7', sender: { id: 's1', displayName: 'Anna Schmidt' }, receivers: [{ id: 'r1', displayName: 'Bert Meier' }] } }) } }); // The topbar chip row is hidden below md; the drawer renders the full displayName at any viewport. await browserPage.getByRole('button', { name: 'Details' }).click(); await expect.element(browserPage.getByText('Anna Schmidt')).toBeVisible(); }); it('renders the topbar when filePath is set on the document', async () => { mockPage.url = new URL('http://localhost/documents/d8'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd8', filePath: 's3://bucket/file.pdf', contentType: 'application/pdf' } }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); it('renders the topbar with a complete user object passed through', async () => { mockPage.url = new URL('http://localhost/documents/d9'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd9' }, user: { id: 'u1', firstName: 'Anna', lastName: 'S', email: 'a@x' } }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); it('Escape keydown leaves the transcribe panel hidden when not already in transcribe mode', async () => { mockPage.url = new URL('http://localhost/documents/d10'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd10' } }) } }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(document.querySelector('[data-testid="panel-close"]')).toBeNull(); }); it('non-Escape keydown does not affect the transcribe panel state', async () => { mockPage.url = new URL('http://localhost/documents/d11'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd11' } }) } }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); expect(document.querySelector('[data-topbar]')).not.toBeNull(); expect(document.querySelector('[data-testid="panel-close"]')).toBeNull(); }); it('renders the topbar with a deep-link comment query param', async () => { mockPage.url = new URL('http://localhost/documents/d12?comment=c-abc'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd12' } }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); it('renders sender name and Edit affordance with all metadata populated', async () => { mockPage.url = new URL('http://localhost/documents/d-meta'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-meta', sender: { id: 's1', displayName: 'Anna' }, receivers: [ { id: 'r1', displayName: 'Bert' }, { id: 'r2', displayName: 'Carl' } ], tags: [ { id: 't1', name: 'Familie' }, { id: 't2', name: 'Reise' } ], location: 'Berlin', scriptType: 'KURRENT', trainingLabels: ['KURRENT_RECOGNITION'] }, user: { id: 'u1', firstName: 'Anna' }, geschichten: [{ id: 'g1', title: 'Story', publishedAt: null }], inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' }, canBlogWrite: true, canWrite: true }) } }); // Edit affordance is always rendered when canWrite=true; sender chip is below md, so // open the drawer and assert sender displayName surfaces there. await expect.element(browserPage.getByRole('link', { name: 'Bearbeiten' })).toBeVisible(); await browserPage.getByRole('button', { name: 'Details' }).click(); await expect.element(browserPage.getByText('Anna')).toBeVisible(); }); it('enters transcribe mode and shows the panel close button when ?task=transcribe is set', async () => { mockPage.url = new URL('http://localhost/documents/d-task?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-task' }, canWrite: true }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); }); it('keeps the transcribe panel mounted when the transcription-block fetch rejects', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network')); try { mockPage.url = new URL('http://localhost/documents/d-fail?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-fail' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); } finally { fetchSpy.mockRestore(); } }); it('renders fetched block text in read mode after blocks resolve', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify([ { id: 'b1', annotationId: 'ann-1', text: 'Erster', sortOrder: 1, reviewed: false, mentionedPersons: [], label: null } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); try { mockPage.url = new URL('http://localhost/documents/d-blocks?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-blocks' } }) } }); await expect.element(browserPage.getByText('Erster')).toBeVisible(); } finally { fetchSpy.mockRestore(); } }); it('overwrites a previously stored last-visited document with the current one', async () => { localStorage.setItem( 'familienarchiv.lastVisited', JSON.stringify({ id: 'old-doc', title: 'Old' }) ); mockPage.url = new URL('http://localhost/documents/d-new'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-new', title: 'New Doc' } }) } }); await vi.waitFor(() => { const stored = JSON.parse(localStorage.getItem('familienarchiv.lastVisited') ?? '{}'); expect(stored.id).toBe('d-new'); }); }); it('does not show the OCR-running spinner when ocr-status fetch returns 500', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockResolvedValue(new Response('error', { status: 500 })); try { mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-fail' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); expect(browserPage.getByText('OCR läuft').elements()).toHaveLength(0); } finally { fetchSpy.mockRestore(); } }); it('shows the OCR-running spinner heading when ocr-status returns RUNNING with a jobId', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { const u = url.toString(); if (u.includes('ocr-status')) { return new Response(JSON.stringify({ status: 'RUNNING', jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (u.includes('/ocr/jobs/')) { return new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'WORKING' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-run' } }) } }); await expect.element(browserPage.getByText('OCR läuft')).toBeVisible(); } finally { fetchSpy.mockRestore(); } }); it('renders the topbar when the document has all OCR-relevant fields populated', async () => { mockPage.url = new URL('http://localhost/documents/d-ocr-meta?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-meta', scriptType: 'KURRENT', trainingLabels: ['KURRENT_RECOGNITION'], filePath: 's3://bucket/file.pdf', fileHash: 'hash-abc' }, canWrite: true, user: { id: 'u1', firstName: 'Anna' } }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); it('treats undefined geschichten as the empty array (geschichten ?? [] branch)', async () => { mockPage.url = new URL('http://localhost/documents/d-no-stories'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-no-stories' }, geschichten: undefined }) } }); expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); it('does not show the OCR-running spinner when ocr-status returns DONE', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { const u = url.toString(); if (u.includes('ocr-status')) { return new Response(JSON.stringify({ status: 'DONE', jobId: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-done?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-done' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); expect(browserPage.getByText('OCR läuft').elements()).toHaveLength(0); } finally { fetchSpy.mockRestore(); } }); it('does not show the OCR-running spinner when ocr-status returns PENDING without jobId', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { const u = url.toString(); if (u.includes('ocr-status')) { return new Response(JSON.stringify({ status: 'PENDING', jobId: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-no-job?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-no-job' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); expect(browserPage.getByText('OCR läuft').elements()).toHaveLength(0); } finally { fetchSpy.mockRestore(); } }); it('does not show the OCR-running spinner when the ocr-status fetch throws', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { const u = url.toString(); if (u.includes('ocr-status')) { throw new Error('network down'); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-throw?task=transcribe'); render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-throw' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); }); expect(browserPage.getByText('OCR läuft').elements()).toHaveLength(0); } finally { fetchSpy.mockRestore(); } }); });