import { describe, expect, it } from "vitest"; import { epubFileName } from "./EpubReader"; import { pdfDocumentOptions } from "./pdfDocumentOptions"; import { clampReaderPage, clampReaderZoom, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, readerZoomLabel, zoomReaderSize } from "./readerLayout"; import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; import { isElementFullscreen, isReaderHeaderActive, isReaderInteractiveTarget, readerArrowAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; describe("reader runtime helpers", () => { it("extracts an EPUB file name from the file URL", () => { expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub"); expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub"); }); it("keeps PDF.js worker fallback source on the bundled module worker", () => { expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs"); }); it("configures the PDF.js worker fallback without creating a worker outside the browser", () => { const pdfjs = { GlobalWorkerOptions: { workerPort: null, workerSrc: "" } }; expect(configurePdfWorker(pdfjs as unknown as Parameters[0])).toBe(false); expect(pdfjs.GlobalWorkerOptions.workerPort).toBeNull(); expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc); }); it("builds browser PDF document options with bundled asset URLs", () => { expect(pdfDocumentOptions({ url: "/books/1/file", withCredentials: true }, "browser")).toMatchObject({ url: "/books/1/file", withCredentials: true, cMapUrl: "/pdfjs/cmaps/", cMapPacked: true, iccUrl: "/pdfjs/iccs/", standardFontDataUrl: "/pdfjs/standard_fonts/", wasmUrl: "/pdfjs/wasm/", useWorkerFetch: true, useWasm: true }); }); it("builds Node PDF document options with local wasm decoder assets", () => { const data = new Uint8Array([1, 2, 3]); expect(pdfDocumentOptions({ data, disableWorker: true }, "node")).toMatchObject({ data, disableWorker: true, cMapPacked: true, useWorkerFetch: false, useWasm: true }); expect(pdfDocumentOptions({ data, disableWorker: true }, "node").wasmUrl).toContain("node_modules/pdfjs-dist/wasm/"); }); it("normalizes reader technical errors", () => { expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed"); expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible"); }); it("fits PDF pages by containment inside the reader viewport", () => { expect(pdfRenderScale({ width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75); expect(pdfRenderScale({ width: 800, height: 600 }, { width: 800, height: 400 })).toBe(1); }); it("does not expose a PDF canvas before a successful page render", () => { expect(pdfCanvasVisible(false, false)).toBe(false); expect(pdfCanvasVisible(true, true)).toBe(false); expect(pdfCanvasVisible(true, false)).toBe(true); }); it("classifies blank PDF canvas data separately from render failures", () => { expect(classifyPdfCanvas({ data: new Uint8ClampedArray([255, 255, 255, 255, 255, 255, 255, 255]) })).toBe("blank-detected"); expect(classifyPdfCanvas({ data: new Uint8ClampedArray([247, 240, 223, 255, 247, 240, 223, 255]) })).toBe("blank-detected"); const inkPixels = new Uint8ClampedArray(12 * 4); for (let index = 0; index < inkPixels.length; index += 4) { inkPixels[index] = 32; inkPixels[index + 1] = 28; inkPixels[index + 2] = 24; inkPixels[index + 3] = 255; } expect(classifyPdfCanvas({ data: inkPixels })).toBe("rendered-ok"); }); it("does not treat isolated PDF canvas noise as visible content", () => { const noisyPixels = new Uint8ClampedArray([ 255, 255, 255, 255, 32, 28, 24, 255, 255, 255, 255, 255 ]); expect(pdfCanvasHasVisibleContent({ data: noisyPixels })).toBe(false); }); it("classifies very light but non-empty PDF canvas data as rendered", () => { const lightInkPixels = new Uint8ClampedArray(12 * 4); for (let index = 0; index < lightInkPixels.length; index += 4) { lightInkPixels[index] = 244; lightInkPixels[index + 1] = 244; lightInkPixels[index + 2] = 244; lightInkPixels[index + 3] = 255; } expect(classifyPdfCanvas({ data: lightInkPixels })).toBe("rendered-ok"); }); it("contains page media inside the reader viewport without cropping", () => { expect(containPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 }); expect(containPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 }); }); it("fits comic pages by intrinsic orientation without cropping", () => { expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 }); expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 }); expect(orientedContainPageSize({ width: 500, height: 900 }, { width: 1200, height: 900 })).toEqual({ width: 500, height: 375 }); }); it("clamps reader zoom to 25 percent steps between 50 and 300", () => { expect(clampReaderZoom(20)).toBe(50); expect(clampReaderZoom(63)).toBe(75); expect(clampReaderZoom(186)).toBe(175); expect(clampReaderZoom(340)).toBe(300); expect(readerZoomLabel(125)).toBe("125 %"); expect(readerZoomFactor(175)).toBe(1.75); }); it("applies reader zoom above the contained media size", () => { expect(zoomReaderSize({ width: 800, height: 600 }, 50)).toEqual({ width: 400, height: 300 }); expect(zoomReaderSize({ width: 800, height: 600 }, 200)).toEqual({ width: 1600, height: 1200 }); }); it("ignores unusable reader viewport measurements", () => { expect(readableViewportSize({ width: 0, height: 833 })).toBeNull(); expect(readableViewportSize({ width: 1, height: 1 })).toBeNull(); expect(readableViewportSize({ width: 1548.8, height: 833.2 })).toEqual({ width: 1548, height: 833 }); }); it("keeps the minimal reader on one clamped page", () => { expect(clampReaderPage(0, 12)).toBe(1); expect(clampReaderPage(8, 12)).toBe(8); expect(clampReaderPage(99, 12)).toBe(12); expect(readerPositionLabel(8, 12)).toBe("Page 8 / 12"); }); it("navigates page by page without leaving document bounds", () => { expect(previousReaderPage(1, 12)).toBe(1); expect(previousReaderPage(8, 12)).toBe(7); expect(nextReaderPage(8, 12)).toBe(9); expect(nextReaderPage(12, 12)).toBe(12); }); it("only maps unmodified horizontal reader arrow shortcuts", () => { expect(readerArrowAction({ key: "ArrowLeft" })).toBe("previous"); expect(readerArrowAction({ key: "ArrowRight" })).toBe("next"); expect(readerArrowAction({ key: "ArrowUp" })).toBeNull(); expect(readerArrowAction({ key: "ArrowDown" })).toBeNull(); expect(readerArrowAction({ key: "ArrowRight", defaultPrevented: true })).toBeNull(); expect(readerArrowAction({ key: "ArrowRight", ctrlKey: true })).toBeNull(); }); it("detects fullscreen support and active fullscreen element", () => { const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement; const regularElement = {} as HTMLElement; const documentRef = { fullscreenElement } as unknown as Document; expect(supportsElementFullscreen(fullscreenElement)).toBe(true); expect(supportsElementFullscreen(regularElement)).toBe(false); expect(isElementFullscreen(fullscreenElement, documentRef)).toBe(true); expect(isElementFullscreen(regularElement, documentRef)).toBe(false); }); it("keeps the fullscreen reader header visible while it is actively used", () => { const focusedControl = {}; const focusedHeader = { contains: (target: unknown) => target === focusedControl, matches: () => false, querySelector: () => null } as unknown as HTMLElement; const hoveredHeader = { contains: () => false, matches: (selector: string) => selector === ":hover", querySelector: () => null } as unknown as HTMLElement; const menuOpenHeader = { contains: () => false, matches: () => false, querySelector: () => ({}) } as unknown as HTMLElement; const idleHeader = { contains: () => false, matches: () => false, querySelector: () => null } as unknown as HTMLElement; expect(isReaderHeaderActive(focusedHeader, { activeElement: focusedControl } as unknown as Document)).toBe(true); expect(isReaderHeaderActive(hoveredHeader, { activeElement: null } as unknown as Document)).toBe(true); expect(isReaderHeaderActive(menuOpenHeader, { activeElement: null } as unknown as Document)).toBe(true); expect(isReaderHeaderActive(idleHeader, { activeElement: null } as unknown as Document)).toBe(false); }); it("keeps reader arrow shortcuts away from interactive or external targets", () => { const originalElement = globalThis.Element; const originalNode = globalThis.Node; let fakeDocument: { body: FakeNode; documentElement: FakeNode }; class FakeNode { get ownerDocument() { return fakeDocument; } addEventListener() { return undefined; } dispatchEvent() { return true; } removeEventListener() { return undefined; } } class FakeElement extends FakeNode { constructor( private readonly interactive: boolean, private readonly children: FakeNode[] = [] ) { super(); } closest() { return this.interactive ? this : null; } contains(target: EventTarget | null) { return (target as unknown) === this || this.children.includes(target as unknown as FakeNode); } } fakeDocument = { body: new FakeNode(), documentElement: new FakeNode() }; try { Object.defineProperty(globalThis, "Node", { configurable: true, value: FakeNode }); Object.defineProperty(globalThis, "Element", { configurable: true, value: FakeElement }); const readerChild = new FakeElement(false); const readerElement = new FakeElement(false, [readerChild]) as unknown as HTMLElement; const externalElement = new FakeElement(false); const interactiveElement = new FakeElement(true); const keyEvent = (event: object) => event as unknown as KeyboardEvent; expect(isReaderInteractiveTarget(interactiveElement as unknown as EventTarget)).toBe(true); expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: interactiveElement }), readerElement)).toBe(false); expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: externalElement }), readerElement)).toBe(false); expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: readerChild }), readerElement)).toBe(true); expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowLeft", target: fakeDocument.body }), readerElement)).toBe(true); expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", ctrlKey: true, target: readerChild }), readerElement)).toBe(false); } finally { Object.defineProperty(globalThis, "Node", { configurable: true, value: originalNode }); Object.defineProperty(globalThis, "Element", { configurable: true, value: originalElement }); } }); });