282 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|