/** * pdf-import.test.ts — coverage for the testable surface of the PDF import * path. * * What is tested here: * 1. `parseChunkEnvelope` — defensive parser for raw decoded text. Must * accept the v3 envelopes the live generator emits (earlier revisions * only accepted v2, silently rejecting every current Codex). * 2. `cellRectFor` — pixel geometry of each cell on a rendered page. Must * produce rectangles that cover the regions the generator draws into; * an off-by-one or off-by-margin here clips the Aztec and decoding * fails. The earlier regression that motivated this file was an even * `canvas / 6` slicer that ignored margins entirely. * 3. `decodeAztecFromArgb` — DOM-free decode path. Must round-trip a real * Aztec built from a real envelope. The earlier regression here was * `BrowserAztecCodeReader.decode(canvas)` silently no-opping on every * canvas — this test catches a similar regression because we no longer * go through that API at all. * 4. Page-grid integration — places 36 Aztec codes at the *exact* PDF * grid positions onto a synthetic page buffer, then extracts each cell * via `cellRectFor` and decodes it. End-to-end proof that the geometry * and decode path agree with the generator. Adding a third bug in * either layer would fail this test. * * The `importCodexPDF` entry point itself needs pdf.js + an `HTMLCanvasElement` * and is exercised only in the manual reliability matrix * (peach_codex_reliability.md §5.2). Everything else this module ships now * has automated coverage. */ import { describe, expect, it, vi } from "vitest"; import type { BitMatrix } from "@zxing/library"; import { buildAztecMatrix } from "./aztec"; import { buildChunkEnvelope } from "./helpers"; import { cellRectFor, claimPDFRenderPixels, createPDFRenderBudget, decodeAztecFromArgb, parseChunkEnvelope, } from "./pdf-import"; import { CODE_SIZE, COL_GAP, HEADER_GAP, HEADER_HEIGHT, MARGIN, PAGE_HEIGHT, PAGE_WIDTH, ROW_STEP, } from "./pdf-layout"; describe("PB-26-704 PDF aggregate render budget", () => { it("rejects cumulative near-limit pages before another canvas allocation", () => { const budget = createPDFRenderBudget(100); claimPDFRenderPixels(budget, 10, 5); claimPDFRenderPixels(budget, 5, 10); expect(() => claimPDFRenderPixels(budget, 1, 1)).toThrow(/aggregate render limit/i); }); }); // ── parseChunkEnvelope ────────────────────────────────────────────────────── describe("parseChunkEnvelope", () => { const v3 = { v: 3 as const, c: "PX-TEST-1234", s: 0, S: 1, i: 0, t: 15, p: false, d: "AAAA", h: "ABCDEF1234567890ABCDEF1234567890", }; it("parses a v3 envelope (current generator format)", () => { expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3); }); it("rejects a v2 envelope", () => { expect(parseChunkEnvelope(JSON.stringify({ v: 2, c: "PX-TEST-1234" }))).toBeNull(); }); it("rejects a v3 envelope missing stripe metadata", () => { const broken = { ...v3, s: undefined, S: undefined }; expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull(); }); // PCH-01-012: non-integer indices/stripe fields must be rejected at parse time. it("parse_chunk_envelope_rejects_non_integer_stripe_fields", () => { expect(parseChunkEnvelope(JSON.stringify({ ...v3, s: 0.5 }))).toBeNull(); expect(parseChunkEnvelope(JSON.stringify({ ...v3, S: 1.5 }))).toBeNull(); expect(parseChunkEnvelope(JSON.stringify({ ...v3, i: 1.5 }))).toBeNull(); expect(parseChunkEnvelope(JSON.stringify({ ...v3, t: 15.5 }))).toBeNull(); }); it("returns null for non-JSON input", () => { expect(parseChunkEnvelope("not json at all")).toBeNull(); }); it("returns null for non-Peach JSON", () => { expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull(); }); it("returns null when version is missing", () => { expect( parseChunkEnvelope( JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }), ), ).toBeNull(); }); it("returns null when fields have the wrong types", () => { expect( parseChunkEnvelope( JSON.stringify({ v: 3, c: "PX", s: 0, S: 1, i: "0", // should be number t: 5, p: false, d: "A", h: "A", }), ), ).toBeNull(); }); it("returns null for v=1 envelopes", () => { expect( parseChunkEnvelope( JSON.stringify({ v: 1, c: "PX", i: 0, t: 5, p: false, d: "A", h: "A", }), ), ).toBeNull(); }); }); // ── cellRectFor ───────────────────────────────────────────────────────────── describe("cellRectFor", () => { // pdf.js renders at scale 2.0; mirror that here so the test exercises the // same dimensions the importer sees in production. const SCALE = 2.0; const canvasW = Math.ceil(PAGE_WIDTH * SCALE); const canvasH = Math.ceil(PAGE_HEIGHT * SCALE); it("covers every cell of the printed 6×6 grid", () => { for (let row = 0; row < 6; row++) { for (let col = 0; col < 6; col++) { const rect = cellRectFor(canvasW, canvasH, row, col); // The crop must enclose the full code rectangle the generator draws. const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE; const codeXMax = codeXMin + CODE_SIZE * SCALE; const codeYMin = (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE; const codeYMax = codeYMin + CODE_SIZE * SCALE; expect(rect.sx).toBeLessThanOrEqual(codeXMin); expect(rect.sy).toBeLessThanOrEqual(codeYMin); expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax); expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax); } } }); it("places adjacent columns far enough apart that crops do not overlap into the next code", () => { // Each crop may include a quiet-zone halo, but it must never cross into // the neighbour's printed code rectangle. for (let row = 0; row < 6; row++) { for (let col = 0; col < 5; col++) { const left = cellRectFor(canvasW, canvasH, row, col); const rightCodeXMin = (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE; expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin); } } }); it("clamps to canvas bounds", () => { const rect = cellRectFor(canvasW, canvasH, 5, 5); expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW); expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH); }); }); // ── decodeAztecFromArgb ───────────────────────────────────────────────────── const WHITE = -1; const BLACK = (0xff << 24) | 0; /** * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size * with a `padModules` quiet zone on every side. Returns the buffer plus its * pixel dimensions. * * Used by tests to synthesize the same pixel format the importer would see * after pdf.js renders + the cell canvas paints, without needing a real DOM. */ function renderMatrixToArgb( matrix: BitMatrix, moduleSize: number, padModules: number, ): { argb: Int32Array; width: number; height: number } { const modules = matrix.getWidth(); const pad = padModules * moduleSize; const size = modules * moduleSize + pad * 2; const argb = new Int32Array(size * size).fill(WHITE); for (let y = 0; y < modules; y++) { for (let x = 0; x < modules; x++) { if (matrix.get(x, y)) { const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize; for (let dy = 0; dy < moduleSize; dy++) { for (let dx = 0; dx < moduleSize; dx++) { argb[baseRow + dy * size + dx] = BLACK; } } } } } return { argb, width: size, height: size }; } /** * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose * a synthetic full-page buffer from per-code Aztec renderings. */ function blitArgb( src: Int32Array, srcW: number, srcH: number, dst: Int32Array, dstW: number, dx: number, dy: number, ): void { for (let y = 0; y < srcH; y++) { const dstRow = (dy + y) * dstW + dx; const srcRow = y * srcW; for (let x = 0; x < srcW; x++) { dst[dstRow + x] = src[srcRow + x]!; } } } describe("decodeAztecFromArgb", () => { it("round-trips a real envelope JSON through the decode path", async () => { // A real-shaped envelope (v3) so the test exercises payload sizes the // live importer will actually see. const envelope = await buildChunkEnvelope( new TextEncoder().encode("hello world".repeat(20)), 0, 36, false, "PX-TEST-1234", 0, 1, ); const payload = JSON.stringify(envelope); const matrix = buildAztecMatrix(payload); const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2); expect(decodeAztecFromArgb(argb, width, height)).toBe(payload); }); it("returns null for an all-white buffer", () => { const w = 100; const h = 100; const argb = new Int32Array(w * h).fill(WHITE); expect(decodeAztecFromArgb(argb, w, h)).toBeNull(); }); it("returns null for an all-black buffer", () => { const w = 100; const h = 100; const argb = new Int32Array(w * h).fill(BLACK); expect(decodeAztecFromArgb(argb, w, h)).toBeNull(); }); it("returns null for random noise", () => { const w = 200; const h = 200; const argb = new Int32Array(w * h); // Deterministic pseudo-noise so the test never flakes. let s = 0x12345678; for (let i = 0; i < argb.length; i++) { s = (s * 1664525 + 1013904223) | 0; argb[i] = s & 1 ? BLACK : WHITE; } expect(decodeAztecFromArgb(argb, w, h)).toBeNull(); }); }); // ── End-to-end page-grid integration ──────────────────────────────────────── describe("page-grid integration", () => { it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => { const SCALE = 2.0; const canvasW = Math.ceil(PAGE_WIDTH * SCALE); const canvasH = Math.ceil(PAGE_HEIGHT * SCALE); const page = new Int32Array(canvasW * canvasH).fill(WHITE); // Build 36 envelopes — exactly one full page worth. Vary the data so each // produces a distinct matrix, catching any "decoded the same code twice" // confusion in the importer. const envelopes = await Promise.all( Array.from({ length: 36 }, (_, i) => { const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x")); return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1); }), ); // Render each envelope's Aztec at the exact pixel position the generator // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE // pixels with a ~2-module quiet zone, matching the live PNG layout. const codeBoxPx = Math.floor(CODE_SIZE * SCALE); for (let i = 0; i < envelopes.length; i++) { const env = envelopes[i]!; const matrix = buildAztecMatrix(JSON.stringify(env)); const modules = matrix.getWidth(); // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure // the rendered code fits within the box and never spills over. const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4))); const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2); const row = Math.floor(i / 6); const col = i % 6; const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE); const dy = Math.round( (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE, ); blitArgb(argb, width, height, page, canvasW, dx, dy); } // Walk the importer's grid math and decode each cell. Every envelope // must reappear, in the right index, with the original JSON intact. const recovered: string[] = []; for (let row = 0; row < 6; row++) { for (let col = 0; col < 6; col++) { const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col); const cell = new Int32Array(sw * sh); for (let y = 0; y < sh; y++) { const srcRow = (sy + y) * canvasW + sx; const dstRow = y * sw; for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!; } const text = decodeAztecFromArgb(cell, sw, sh); expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull(); recovered.push(text!); } } expect(recovered).toHaveLength(36); for (let i = 0; i < 36; i++) { const parsed = parseChunkEnvelope(recovered[i]!); expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull(); expect(parsed!.i).toBe(envelopes[i]!.i); expect(parsed!.h).toBe(envelopes[i]!.h); expect(parsed!.d).toBe(envelopes[i]!.d); } }, 30_000); it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => { const { importCodexPDF } = await import("./pdf-import"); const { IMPORT_LIMITS } = await import("../../shared/import-limits"); const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); const fakeFile = { size: IMPORT_LIMITS.MAX_PDF_BYTES + 1, arrayBuffer, } as unknown as File; const result = await importCodexPDF(fakeFile); expect(result.chunksFound).toBe(0); expect(result.envelopes).toEqual([]); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatch(/limited to/); // Never read into memory — the gate fires before file.arrayBuffer(). expect(arrayBuffer).not.toHaveBeenCalled(); }); });