The error state showed a hardcoded German string ("Fehler beim Laden
der PDF" / "Direkt öffnen") to all users regardless of locale. Use the
localized doc_render_failed and doc_download_link messages so the
recovery path (message + working download link) is honest in de/en/es.
Refs #708
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|