import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PdfViewer from './PdfViewer.svelte'; import { makeFakeLibLoader } from './testHelpers'; import { m } from '$lib/paraglide/messages.js'; afterEach(cleanup); function makeFailingLibLoader() { const lib = { GlobalWorkerOptions: { workerSrc: '' }, getDocument: vi.fn().mockReturnValue({ promise: Promise.reject(new Error('JBig2 failed to initialize')) }), TextLayer: class { render() { return Promise.resolve(); } cancel() {} } } as unknown as typeof import('pdfjs-dist'); return vi.fn().mockResolvedValue([lib, { default: '' }] as const); } describe('PdfViewer — render failure', () => { it('shows the localized failure message and a download link, not a blank canvas', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', libLoader: makeFailingLibLoader() }); await expect.element(page.getByText(m.doc_render_failed())).toBeVisible(); await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible(); }); }); describe('PdfViewer — empty / error states', () => { it('renders the no-file placeholder when url is empty', async () => { render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() }); await expect.element(page.getByText('Keine Datei vorhanden')).toBeVisible(); }); it('does not render the controls when url is empty', async () => { render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() }); const buttons = document.querySelectorAll('button'); expect(buttons.length).toBe(0); }); }); describe('PdfViewer — loaded state', () => { it('renders the PDF navigation controls (Zurück/Weiter/Vergrößern/Verkleinern) when a url is provided', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', annotationReloadKey: 0, libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Vergrößern' })).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Verkleinern' })).toBeVisible(); }); it('renders the canvas background container when annotationsDimmed=true', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', annotationsDimmed: true, libLoader: makeFakeLibLoader() }); await vi.waitFor(() => { expect(document.querySelector('.bg-pdf-bg')).not.toBeNull(); }); }); it('forces the annotation toggle into "hide" mode when transcribeMode is true and annotations exist', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify([ { id: 'a1', documentId: 'test', pageNumber: 1, x: 0.1, y: 0.1, width: 0.1, height: 0.1, color: '#000', createdAt: '2026-01-01T00:00:00Z', fileHash: 'match' } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); try { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', transcribeMode: true, documentFileHash: 'match', libLoader: makeFakeLibLoader() }); await expect .element(page.getByRole('button', { name: /annotierungen verbergen/i })) .toBeVisible(); } finally { fetchSpy.mockRestore(); } }); it('makes the annotation surface drawable (crosshair) when transcribeMode and canAnnotate', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', transcribeMode: true, canAnnotate: true, libLoader: makeFakeLibLoader() }); await vi.waitFor(() => { const surface = document.querySelector('[role="presentation"]'); expect(surface).not.toBeNull(); expect(surface?.getAttribute('style') ?? '').toContain('crosshair'); }); }); it('does not make the annotation surface drawable when canAnnotate is false (read-only user)', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', transcribeMode: true, canAnnotate: false, libLoader: makeFakeLibLoader() }); await vi.waitFor(() => { expect(document.querySelector('.bg-pdf-bg')).not.toBeNull(); }); const surface = document.querySelector('[role="presentation"]'); expect(surface?.getAttribute('style') ?? '').not.toContain('crosshair'); }); it('renders the canvas region when documentFileHash is provided', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', documentFileHash: 'abc123', libLoader: makeFakeLibLoader() }); await vi.waitFor(() => { expect(document.querySelector('.bg-pdf-bg')).not.toBeNull(); }); }); it('renders the PDF controls when flashAnnotationId is set', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', flashAnnotationId: 'ann-flashing', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); }); it('renders the PDF controls when blockNumbers map is provided', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', blockNumbers: { 'ann-1': 1, 'ann-2': 2 }, libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); }); it('renders the PDF controls when activeAnnotationId is set', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', activeAnnotationId: 'ann-1', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); }); it('renders the PDF nav controls in transcribeMode + activeAnnotationId combo', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', transcribeMode: true, activeAnnotationId: 'ann-1', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible(); }); it('renders the PDF controls when an onAnnotationClick callback is wired up', async () => { const onAnnotationClick = vi.fn(); render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', onAnnotationClick, libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); }); it('shows the outdated-annotation notice when annotations have non-matching fileHash', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify([ { id: 'a1', documentId: 'test', pageNumber: 1, x: 0.1, y: 0.1, width: 0.1, height: 0.1, color: '#000', createdAt: '2026-01-01T00:00:00Z', fileHash: 'old-hash' } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); try { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', documentFileHash: 'new-hash', libLoader: makeFakeLibLoader() }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).not.toBeNull(); }); } finally { fetchSpy.mockRestore(); } }); it('does not show outdated-annotation notice when all annotations match', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify([ { id: 'a1', documentId: 'test', pageNumber: 1, x: 0.1, y: 0.1, width: 0.1, height: 0.1, color: '#000', createdAt: '2026-01-01T00:00:00Z', fileHash: 'matching-hash' } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); try { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', documentFileHash: 'matching-hash', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull(); } finally { fetchSpy.mockRestore(); } }); it('still renders the controls when the annotations fetch rejects', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network')); try { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull(); } finally { fetchSpy.mockRestore(); } }); it('still renders the controls when the annotations fetch returns a non-OK status', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockResolvedValue(new Response('error', { status: 500 })); try { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull(); } finally { fetchSpy.mockRestore(); } }); it('shows previous and next page navigation buttons', async () => { render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: /zurück/i })).toBeVisible(); await expect.element(page.getByRole('button', { name: /weiter/i })).toBeVisible(); }); it('shows zoom controls', async () => { render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() }); await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible(); await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeVisible(); }); it('displays the page counter once the PDF has loaded', async () => { render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() }); await expect.element(page.getByText(/1\s*\/\s*2/)).toBeVisible(); }); });