Files
ReadaBook/apps/web/src/reader/readerRuntime.test.ts

282 lines
12 KiB
TypeScript

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<typeof configurePdfWorker>[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 });
}
});
});