Files
familienarchiv/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts
Marcel 5c8034d298 fix(document): surface PDF render failures instead of a silent blank canvas
renderCurrentPage swallowed every render rejection with a bare return,
so a decode failure left a blank white viewer with no feedback. Now a
non-cancellation rejection sets a localized doc_render_failed message,
which routes into the existing error UI (message + download link).
Cancellation (page-nav / zoom) still returns silently — no error.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00

300 lines
9.5 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { createPdfRenderer } from './usePdfRenderer.svelte';
import { makeFakeLibLoader } from './testHelpers';
import { m } from '$lib/paraglide/messages.js';
function makeRenderingLib(renderPromise: Promise<void>): typeof import('pdfjs-dist') {
const page = {
getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }),
render: vi.fn().mockReturnValue({ promise: renderPromise, cancel: vi.fn() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) })
}),
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
}
// Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits.
describe('createPdfRenderer', () => {
it('starts at page 1 with scale 1.5 and no error', () => {
const r = createPdfRenderer();
expect(r.currentPage).toBe(1);
expect(r.scale).toBe(1.5);
expect(r.totalPages).toBe(0);
expect(r.loading).toBe(false);
expect(r.error).toBeNull();
expect(r.isLoaded).toBe(false);
expect(r.pdfjsReady).toBe(false);
});
it('prevPage does not go below page 1', () => {
const r = createPdfRenderer();
r.prevPage();
expect(r.currentPage).toBe(1);
});
it('nextPage does not exceed totalPages', () => {
const r = createPdfRenderer();
// totalPages = 0, so 1 < 0 is false → stays at 1
r.nextPage();
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n > totalPages', () => {
const r = createPdfRenderer();
r.goToPage(5);
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n < 1', () => {
const r = createPdfRenderer();
r.goToPage(0);
expect(r.currentPage).toBe(1);
});
it('zoomIn increases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomIn();
expect(r.scale).toBeCloseTo(1.75);
});
it('zoomOut decreases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.25);
});
it('zoomOut does not go below 0.5', () => {
const r = createPdfRenderer();
for (let i = 0; i < 20; i++) r.zoomOut();
expect(r.scale).toBeCloseTo(0.5);
});
it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
const r = createPdfRenderer();
await r.loadDocument('/some/path');
// No-op because pdfjsLib is null (init not called)
expect(r.error).toBeNull();
expect(r.loading).toBe(false);
});
it('renderCurrentPage is a no-op when pdfjsLib is not initialized', async () => {
const r = createPdfRenderer();
// Should not throw — early-return branch
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('prerender is a no-op when pdfDoc is null', async () => {
const r = createPdfRenderer();
await expect(r.prerender()).resolves.toBeUndefined();
});
it('destroy is safe to call when no document is loaded', () => {
const r = createPdfRenderer();
expect(() => r.destroy()).not.toThrow();
});
it('setElements stores canvas and text layer refs', () => {
const r = createPdfRenderer();
const canvas = document.createElement('canvas');
const textLayer = document.createElement('div');
expect(() => r.setElements(canvas, textLayer)).not.toThrow();
});
it('isLoaded reflects totalPages > 0', () => {
const r = createPdfRenderer();
// Initial state — totalPages=0 → not loaded
expect(r.isLoaded).toBe(false);
});
it('multiple zoomIn calls accumulate', () => {
const r = createPdfRenderer();
r.zoomIn();
r.zoomIn();
r.zoomIn();
expect(r.scale).toBeCloseTo(2.25);
});
it('mixed zoom in then zoom out lands back at start', () => {
const r = createPdfRenderer();
r.zoomIn();
r.zoomIn();
r.zoomOut();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.5);
});
it('zoomOut at the floor does nothing', () => {
const r = createPdfRenderer();
// Force scale down to 0.5
for (let i = 0; i < 20; i++) r.zoomOut();
const before = r.scale;
r.zoomOut();
expect(r.scale).toBe(before);
});
it('init() sets pdfjsReady to true when loader resolves', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await expect(r.init()).resolves.toBeUndefined();
expect(r.pdfjsReady).toBe(true);
});
it('after init, loadDocument completes and loading returns to false', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
await r.loadDocument('/some/path');
expect(r.loading).toBe(false);
});
it('renderCurrentPage is a no-op when canvasEl is null but pdfjsLib is initialized', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
// Without setElements, canvasEl is null — early return
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('renderCurrentPage is a no-op when textLayerEl is null', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
// Without setElements, textLayerEl is null — early return
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('init() can be called multiple times safely', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
await r.init();
expect(r.pdfjsReady).toBe(true);
});
it('zoomIn after multiple zoomOuts lands at predictable scale', () => {
const r = createPdfRenderer();
// 1.5 -> 0.5 (floor) -> 0.75
for (let i = 0; i < 10; i++) r.zoomOut();
r.zoomIn();
expect(r.scale).toBeCloseTo(0.75);
});
it('goToPage(1) works when totalPages would be at least 1 (no-op currently)', () => {
const r = createPdfRenderer();
r.goToPage(1);
expect(r.currentPage).toBe(1);
});
it('calls injected libLoader during init and sets pdfjsReady', async () => {
const fakePdfjs = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(),
TextLayer: class {}
} as unknown as typeof import('pdfjs-dist');
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
const r = createPdfRenderer(fakeLoader);
await r.init();
expect(fakeLoader).toHaveBeenCalledOnce();
expect(r.pdfjsReady).toBe(true);
});
it('leaves pdfjsReady false when libLoader rejects', async () => {
const failingLoader = vi.fn().mockRejectedValue(new Error('load failed'));
const r = createPdfRenderer(failingLoader);
await expect(r.init()).rejects.toThrow('load failed');
expect(r.pdfjsReady).toBe(false);
});
it('init() is idempotent — libLoader called only once on repeated calls', async () => {
const fakePdfjs = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(),
TextLayer: class {}
} as unknown as typeof import('pdfjs-dist');
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
const r = createPdfRenderer(fakeLoader);
await r.init();
await r.init();
expect(fakeLoader).toHaveBeenCalledOnce();
});
it('passes a non-null wasmUrl directory (ending in /) to getDocument, not a bare src string', async () => {
const getDocument = vi.fn().mockReturnValue({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() })
});
const lib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument,
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
await r.loadDocument('/api/documents/abc/file');
expect(getDocument).toHaveBeenCalledTimes(1);
const arg = getDocument.mock.calls[0][0] as { url?: string; wasmUrl?: string };
expect(arg.url).toBe('/api/documents/abc/file');
expect(typeof arg.wasmUrl).toBe('string');
expect(arg.wasmUrl).not.toBe('');
expect(arg.wasmUrl?.endsWith('/')).toBe(true);
});
it('renderCurrentPage sets a localized error when the render rejects (not silently blank)', async () => {
const lib = makeRenderingLib(Promise.reject(new Error('JBig2 failed to initialize')));
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
r.setElements(document.createElement('canvas'), document.createElement('div'));
await r.loadDocument('/x');
await r.renderCurrentPage();
expect(r.error).toBe(m.doc_render_failed());
});
it('renderCurrentPage does NOT set an error when the render is cancelled', async () => {
const cancelled = Object.assign(new Error('cancelled'), {
name: 'RenderingCancelledException'
});
const lib = makeRenderingLib(Promise.reject(cancelled));
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
r.setElements(document.createElement('canvas'), document.createElement('div'));
await r.loadDocument('/x');
await r.renderCurrentPage();
expect(r.error).toBeNull();
});
it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
const failingLib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.reject(new Error('PDF not found'))
}),
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
const r = createPdfRenderer(vi.fn().mockResolvedValue([failingLib, { default: '' }] as const));
await r.init();
await r.loadDocument('/bad/path');
expect(r.loading).toBe(false);
expect(r.error).toBe('PDF not found');
});
});