diff --git a/frontend/src/routes/documents/[id]/page.svelte.test.ts b/frontend/src/routes/documents/[id]/page.svelte.test.ts index 1f823757..589ea07c 100644 --- a/frontend/src/routes/documents/[id]/page.svelte.test.ts +++ b/frontend/src/routes/documents/[id]/page.svelte.test.ts @@ -1,5 +1,6 @@ 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'), @@ -63,22 +64,19 @@ const baseData = (overrides: Record = {}) => ({ }); describe('documents/[id] page', () => { - it('renders the DocumentTopBar with the document title', async () => { + 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() } }); - // Just verify the page mounts and renders the top bar - const topbar = document.querySelector('[data-topbar]'); - expect(topbar).not.toBeNull(); + expect(document.querySelector('[data-topbar]')).not.toBeNull(); + await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); - it('renders the DocumentViewer in the page', async () => { + it('mounts the page region with the [data-hydrated] container', async () => { mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); - // DocumentViewer renders an absolute container; just check the page mounted - const main = document.body.firstElementChild; - expect(main).not.toBeNull(); + expect(document.querySelector('[data-hydrated]')).not.toBeNull(); }); it('persists last-visited document ID to localStorage on mount', async () => { @@ -86,19 +84,17 @@ describe('documents/[id] page', () => { mockPage.url = new URL('http://localhost/documents/d1'); render(DocumentDetailPage, { props: { data: baseData() } }); - await new Promise((r) => setTimeout(r, 50)); - - const stored = localStorage.getItem('familienarchiv.lastVisited'); - expect(stored).toContain('d1'); + 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() } }); - // The browser reflects doc.title when set - await new Promise((r) => setTimeout(r, 30)); - expect(document.title).toContain('Brief an Helene'); + await vi.waitFor(() => expect(document.title).toContain('Brief an Helene')); }); it('falls back to originalFilename when title is empty', async () => { @@ -111,8 +107,7 @@ describe('documents/[id] page', () => { } }); - await new Promise((r) => setTimeout(r, 30)); - expect(document.title).toContain('fallback.pdf'); + await vi.waitFor(() => expect(document.title).toContain('fallback.pdf')); }); it('falls back to "Dokument" when title and originalFilename are empty', async () => { @@ -125,165 +120,165 @@ describe('documents/[id] page', () => { } }); - await new Promise((r) => setTimeout(r, 30)); - expect(document.title).toContain('Dokument'); + await vi.waitFor(() => expect(document.title).toContain('Dokument')); }); - it('renders without throwing when canWrite is true', async () => { + it('renders the topbar Edit-link affordance when canWrite is true', async () => { mockPage.url = new URL('http://localhost/documents/d4'); - expect(() => - render(DocumentDetailPage, { props: { data: baseData({ canWrite: true }) } }) - ).not.toThrow(); + render(DocumentDetailPage, { props: { data: baseData({ canWrite: true }) } }); + + await expect.element(browserPage.getByRole('link', { name: 'Bearbeiten' })).toBeVisible(); }); - it('renders without throwing when geschichten and inferredRelationship are set', async () => { + it('renders the topbar when geschichten and inferredRelationship are passed through', async () => { mockPage.url = new URL('http://localhost/documents/d5'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ - geschichten: [{ id: 'g1', title: 'Story', publishedAt: null }], - inferredRelationship: { label: 'PARENT_OF', from: 'p1', to: 'p2' }, - canBlogWrite: true - }) - } - }) - ).not.toThrow(); + 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 without throwing when doc.id is empty', async () => { + it('renders the topbar even when doc.id is empty (defensive)', async () => { mockPage.url = new URL('http://localhost/documents/d-empty'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: '', title: 'No ID' } }) } - }) - ).not.toThrow(); + 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 with task=transcribe in the URL without throwing', async () => { - mockPage.url = new URL('http://localhost/documents/d6?task=transcribe'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd6' } }) } - }) - ).not.toThrow(); - }); - - it('renders without throwing with sender/receivers populated', async () => { + it('renders sender data in the metadata drawer when sender is populated', async () => { mockPage.url = new URL('http://localhost/documents/d7'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ - document: { - ...baseDoc, - id: 'd7', - sender: { id: 's1', displayName: 'Anna Schmidt' }, - receivers: [{ id: 'r1', displayName: 'Bert Meier' }] - } - }) - } - }) - ).not.toThrow(); + 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 without throwing when filePath is set on the document', async () => { + it('renders the topbar when filePath is set on the document', async () => { mockPage.url = new URL('http://localhost/documents/d8'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ - document: { - ...baseDoc, - id: 'd8', - filePath: 's3://bucket/file.pdf', - contentType: 'application/pdf' - } - }) - } - }) - ).not.toThrow(); + 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 without throwing with a complete user object', async () => { + it('renders the topbar with a complete user object passed through', async () => { mockPage.url = new URL('http://localhost/documents/d9'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ - document: { ...baseDoc, id: 'd9' }, - user: { id: 'u1', firstName: 'Anna', lastName: 'S', email: 'a@x' } - }) - } - }) - ).not.toThrow(); + 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('handles Escape keydown without throwing (close transcribe path)', async () => { + 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' } }) } }); - expect(() => - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) - ).not.toThrow(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(document.querySelector('[data-testid="panel-close"]')).toBeNull(); }); - it('handles non-Escape keydown without firing close handler', async () => { + 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' } }) } }); - expect(() => - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) - ).not.toThrow(); + 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 without throwing with a deep-link comment query param', async () => { + it('renders the topbar with a deep-link comment query param', async () => { mockPage.url = new URL('http://localhost/documents/d12?comment=c-abc'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd12' } }) } - }) - ).not.toThrow(); + render(DocumentDetailPage, { + props: { data: baseData({ document: { ...baseDoc, id: 'd12' } }) } + }); + + expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); - it('renders without throwing with all metadata populated', async () => { + it('renders sender name and Edit affordance with all metadata populated', async () => { mockPage.url = new URL('http://localhost/documents/d-meta'); - expect(() => - 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 - }) - } - }) - ).not.toThrow(); + 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('renders the transcribe-mode forced via query string', async () => { + 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: { @@ -294,30 +289,29 @@ describe('documents/[id] page', () => { } }); - await new Promise((r) => setTimeout(r, 80)); - // DocumentTopBar should mount; transcribeMode flag was set by the URL param - const main = document.body.firstElementChild; - expect(main).not.toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); + }); }); - it('handles transcription-block fetch failure gracefully', async () => { + 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'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ document: { ...baseDoc, id: 'd-fail' } }) - } - }) - ).not.toThrow(); - await new Promise((r) => setTimeout(r, 100)); + 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 blocks fetched in transcribe mode', async () => { + it('renders fetched block text in read mode after blocks resolve', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify([ @@ -339,15 +333,13 @@ describe('documents/[id] page', () => { render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-blocks' } }) } }); - await new Promise((r) => setTimeout(r, 100)); - // No throw; the page rendered with fetched blocks. - expect(document.body.firstElementChild).not.toBeNull(); + await expect.element(browserPage.getByText('Erster')).toBeVisible(); } finally { fetchSpy.mockRestore(); } }); - it('reads localStorage on mount for last-visited (existing data overwritten)', async () => { + it('overwrites a previously stored last-visited document with the current one', async () => { localStorage.setItem( 'familienarchiv.lastVisited', JSON.stringify({ id: 'old-doc', title: 'Old' }) @@ -358,29 +350,31 @@ describe('documents/[id] page', () => { data: baseData({ document: { ...baseDoc, id: 'd-new', title: 'New Doc' } }) } }); - await new Promise((r) => setTimeout(r, 50)); - const stored = JSON.parse(localStorage.getItem('familienarchiv.lastVisited') ?? '{}'); - expect(stored.id).toBe('d-new'); + await vi.waitFor(() => { + const stored = JSON.parse(localStorage.getItem('familienarchiv.lastVisited') ?? '{}'); + expect(stored.id).toBe('d-new'); + }); }); - it('renders the OCR error path when ocr-status fetch returns 500', async () => { + 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'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-fail' } }) } - }) - ).not.toThrow(); - await new Promise((r) => setTimeout(r, 100)); + 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('renders ocr-status RUNNING and starts polling without throwing', async () => { + 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')) { @@ -402,52 +396,49 @@ describe('documents/[id] page', () => { render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-run' } }) } }); - await new Promise((r) => setTimeout(r, 100)); - // Page renders; the ocrRunning state is on but we don't assert specific UI - // because that requires deep DOM access into TranscriptionEditView. - expect(document.body.firstElementChild).not.toBeNull(); + await expect.element(browserPage.getByText('OCR läuft')).toBeVisible(); } finally { fetchSpy.mockRestore(); } }); - it('renders without throwing when document has all OCR-relevant fields populated', async () => { + 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'); - expect(() => - 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' } - }) - } - }) - ).not.toThrow(); + 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('handles empty geschichten array as falsy (geschichten ?? []) branch', async () => { + it('treats undefined geschichten as the empty array (geschichten ?? [] branch)', async () => { mockPage.url = new URL('http://localhost/documents/d-no-stories'); - expect(() => - render(DocumentDetailPage, { - props: { - data: baseData({ - document: { ...baseDoc, id: 'd-no-stories' }, - geschichten: undefined - }) - } - }) - ).not.toThrow(); + render(DocumentDetailPage, { + props: { + data: baseData({ + document: { ...baseDoc, id: 'd-no-stories' }, + geschichten: undefined + }) + } + }); + + expect(document.querySelector('[data-topbar]')).not.toBeNull(); }); - it('handles ocr-status DONE state without restarting polling', async () => { + 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')) { @@ -463,15 +454,16 @@ describe('documents/[id] page', () => { render(DocumentDetailPage, { props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-done' } }) } }); - await new Promise((r) => setTimeout(r, 100)); - // No throw — the ocrRunning state stays false because status was DONE - expect(document.body.firstElementChild).not.toBeNull(); + 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('handles ocr-status without jobId (no polling started)', async () => { + 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')) { @@ -484,18 +476,19 @@ describe('documents/[id] page', () => { }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-no-job?task=transcribe'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-no-job' } }) } - }) - ).not.toThrow(); - await new Promise((r) => setTimeout(r, 100)); + 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('handles ocr-status fetch network error gracefully', async () => { + 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')) { @@ -505,12 +498,13 @@ describe('documents/[id] page', () => { }); try { mockPage.url = new URL('http://localhost/documents/d-ocr-throw?task=transcribe'); - expect(() => - render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-throw' } }) } - }) - ).not.toThrow(); - await new Promise((r) => setTimeout(r, 100)); + 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(); }