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): 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'); }); });