pdf-import.test.ts

13.5 KB · raw

1/**2 * pdf-import.test.ts — coverage for the testable surface of the PDF import3 * path.4 *5 * What is tested here:6 *   1. `parseChunkEnvelope` — defensive parser for raw decoded text. Must7 *      accept the v3 envelopes the live generator emits (earlier revisions8 *      only accepted v2, silently rejecting every current Codex).9 *   2. `cellRectFor` — pixel geometry of each cell on a rendered page. Must10 *      produce rectangles that cover the regions the generator draws into;11 *      an off-by-one or off-by-margin here clips the Aztec and decoding12 *      fails. The earlier regression that motivated this file was an even13 *      `canvas / 6` slicer that ignored margins entirely.14 *   3. `decodeAztecFromArgb` — DOM-free decode path. Must round-trip a real15 *      Aztec built from a real envelope. The earlier regression here was16 *      `BrowserAztecCodeReader.decode(canvas)` silently no-opping on every17 *      canvas — this test catches a similar regression because we no longer18 *      go through that API at all.19 *   4. Page-grid integration — places 36 Aztec codes at the *exact* PDF20 *      grid positions onto a synthetic page buffer, then extracts each cell21 *      via `cellRectFor` and decodes it. End-to-end proof that the geometry22 *      and decode path agree with the generator. Adding a third bug in23 *      either layer would fail this test.24 *25 * The `importCodexPDF` entry point itself needs pdf.js + an `HTMLCanvasElement`26 * and is exercised only in the manual reliability matrix27 * (peach_codex_reliability.md §5.2). Everything else this module ships now28 * has automated coverage.29 */3031import { describe, expect, it, vi } from "vitest";3233import type { BitMatrix } from "@zxing/library";34import { buildAztecMatrix } from "./aztec";35import { buildChunkEnvelope } from "./helpers";36import {37  cellRectFor,38  decodeAztecFromArgb,39  parseChunkEnvelope,40} from "./pdf-import";41import {42  CODE_SIZE,43  COL_GAP,44  HEADER_GAP,45  HEADER_HEIGHT,46  MARGIN,47  PAGE_HEIGHT,48  PAGE_WIDTH,49  ROW_STEP,50} from "./pdf-layout";5152// ── parseChunkEnvelope ──────────────────────────────────────────────────────5354describe("parseChunkEnvelope", () => {55  const v3 = {56    v: 3 as const,57    c: "PX-TEST-1234",58    s: 0,59    S: 1,60    i: 0,61    t: 15,62    p: false,63    d: "AAAA",64    h: "ABCDEF1234567890ABCDEF1234567890",65  };6667  it("parses a v3 envelope (current generator format)", () => {68    expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3);69  });7071  it("rejects a v2 envelope", () => {72    expect(parseChunkEnvelope(JSON.stringify({ v: 2, c: "PX-TEST-1234" }))).toBeNull();73  });7475  it("rejects a v3 envelope missing stripe metadata", () => {76    const broken = { ...v3, s: undefined, S: undefined };77    expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull();78  });7980  // PCH-01-012: non-integer indices/stripe fields must be rejected at parse time.81  it("parse_chunk_envelope_rejects_non_integer_stripe_fields", () => {82    expect(parseChunkEnvelope(JSON.stringify({ ...v3, s: 0.5 }))).toBeNull();83    expect(parseChunkEnvelope(JSON.stringify({ ...v3, S: 1.5 }))).toBeNull();84    expect(parseChunkEnvelope(JSON.stringify({ ...v3, i: 1.5 }))).toBeNull();85    expect(parseChunkEnvelope(JSON.stringify({ ...v3, t: 15.5 }))).toBeNull();86  });8788  it("returns null for non-JSON input", () => {89    expect(parseChunkEnvelope("not json at all")).toBeNull();90  });9192  it("returns null for non-Peach JSON", () => {93    expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();94  });9596  it("returns null when version is missing", () => {97    expect(98      parseChunkEnvelope(99        JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),100      ),101    ).toBeNull();102  });103104  it("returns null when fields have the wrong types", () => {105    expect(106      parseChunkEnvelope(107        JSON.stringify({108          v: 3,109          c: "PX",110          s: 0,111          S: 1,112          i: "0", // should be number113          t: 5,114          p: false,115          d: "A",116          h: "A",117        }),118      ),119    ).toBeNull();120  });121122  it("returns null for v=1 envelopes", () => {123    expect(124      parseChunkEnvelope(125        JSON.stringify({126          v: 1,127          c: "PX",128          i: 0,129          t: 5,130          p: false,131          d: "A",132          h: "A",133        }),134      ),135    ).toBeNull();136  });137});138139// ── cellRectFor ─────────────────────────────────────────────────────────────140141describe("cellRectFor", () => {142  // pdf.js renders at scale 2.0; mirror that here so the test exercises the143  // same dimensions the importer sees in production.144  const SCALE = 2.0;145  const canvasW = Math.ceil(PAGE_WIDTH * SCALE);146  const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);147148  it("covers every cell of the printed 6×6 grid", () => {149    for (let row = 0; row < 6; row++) {150      for (let col = 0; col < 6; col++) {151        const rect = cellRectFor(canvasW, canvasH, row, col);152        // The crop must enclose the full code rectangle the generator draws.153        const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;154        const codeXMax = codeXMin + CODE_SIZE * SCALE;155        const codeYMin =156          (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;157        const codeYMax = codeYMin + CODE_SIZE * SCALE;158        expect(rect.sx).toBeLessThanOrEqual(codeXMin);159        expect(rect.sy).toBeLessThanOrEqual(codeYMin);160        expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);161        expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);162      }163    }164  });165166  it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {167    // Each crop may include a quiet-zone halo, but it must never cross into168    // the neighbour's printed code rectangle.169    for (let row = 0; row < 6; row++) {170      for (let col = 0; col < 5; col++) {171        const left = cellRectFor(canvasW, canvasH, row, col);172        const rightCodeXMin =173          (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;174        expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);175      }176    }177  });178179  it("clamps to canvas bounds", () => {180    const rect = cellRectFor(canvasW, canvasH, 5, 5);181    expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);182    expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);183  });184});185186// ── decodeAztecFromArgb ─────────────────────────────────────────────────────187188const WHITE = -1;189const BLACK = (0xff << 24) | 0;190191/**192 * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size193 * with a `padModules` quiet zone on every side. Returns the buffer plus its194 * pixel dimensions.195 *196 * Used by tests to synthesize the same pixel format the importer would see197 * after pdf.js renders + the cell canvas paints, without needing a real DOM.198 */199function renderMatrixToArgb(200  matrix: BitMatrix,201  moduleSize: number,202  padModules: number,203): { argb: Int32Array; width: number; height: number } {204  const modules = matrix.getWidth();205  const pad = padModules * moduleSize;206  const size = modules * moduleSize + pad * 2;207  const argb = new Int32Array(size * size).fill(WHITE);208  for (let y = 0; y < modules; y++) {209    for (let x = 0; x < modules; x++) {210      if (matrix.get(x, y)) {211        const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;212        for (let dy = 0; dy < moduleSize; dy++) {213          for (let dx = 0; dx < moduleSize; dx++) {214            argb[baseRow + dy * size + dx] = BLACK;215          }216        }217      }218    }219  }220  return { argb, width: size, height: size };221}222223/**224 * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose225 * a synthetic full-page buffer from per-code Aztec renderings.226 */227function blitArgb(228  src: Int32Array,229  srcW: number,230  srcH: number,231  dst: Int32Array,232  dstW: number,233  dx: number,234  dy: number,235): void {236  for (let y = 0; y < srcH; y++) {237    const dstRow = (dy + y) * dstW + dx;238    const srcRow = y * srcW;239    for (let x = 0; x < srcW; x++) {240      dst[dstRow + x] = src[srcRow + x]!;241    }242  }243}244245describe("decodeAztecFromArgb", () => {246  it("round-trips a real envelope JSON through the decode path", async () => {247    // A real-shaped envelope (v3) so the test exercises payload sizes the248    // live importer will actually see.249    const envelope = await buildChunkEnvelope(250      new TextEncoder().encode("hello world".repeat(20)),251      0,252      36,253      false,254      "PX-TEST-1234",255      0,256      1,257    );258    const payload = JSON.stringify(envelope);259260    const matrix = buildAztecMatrix(payload);261    const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);262263    expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);264  });265266  it("returns null for an all-white buffer", () => {267    const w = 100;268    const h = 100;269    const argb = new Int32Array(w * h).fill(WHITE);270    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();271  });272273  it("returns null for an all-black buffer", () => {274    const w = 100;275    const h = 100;276    const argb = new Int32Array(w * h).fill(BLACK);277    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();278  });279280  it("returns null for random noise", () => {281    const w = 200;282    const h = 200;283    const argb = new Int32Array(w * h);284    // Deterministic pseudo-noise so the test never flakes.285    let s = 0x12345678;286    for (let i = 0; i < argb.length; i++) {287      s = (s * 1664525 + 1013904223) | 0;288      argb[i] = s & 1 ? BLACK : WHITE;289    }290    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();291  });292});293294// ── End-to-end page-grid integration ────────────────────────────────────────295296describe("page-grid integration", () => {297  it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {298    const SCALE = 2.0;299    const canvasW = Math.ceil(PAGE_WIDTH * SCALE);300    const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);301    const page = new Int32Array(canvasW * canvasH).fill(WHITE);302303    // Build 36 envelopes — exactly one full page worth. Vary the data so each304    // produces a distinct matrix, catching any "decoded the same code twice"305    // confusion in the importer.306    const envelopes = await Promise.all(307      Array.from({ length: 36 }, (_, i) => {308        const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));309        return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);310      }),311    );312313    // Render each envelope's Aztec at the exact pixel position the generator314    // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE315    // pixels with a ~2-module quiet zone, matching the live PNG layout.316    const codeBoxPx = Math.floor(CODE_SIZE * SCALE);317    for (let i = 0; i < envelopes.length; i++) {318      const env = envelopes[i]!;319      const matrix = buildAztecMatrix(JSON.stringify(env));320      const modules = matrix.getWidth();321      // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure322      // the rendered code fits within the box and never spills over.323      const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));324      const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);325      const row = Math.floor(i / 6);326      const col = i % 6;327      const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);328      const dy = Math.round(329        (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,330      );331      blitArgb(argb, width, height, page, canvasW, dx, dy);332    }333334    // Walk the importer's grid math and decode each cell. Every envelope335    // must reappear, in the right index, with the original JSON intact.336    const recovered: string[] = [];337    for (let row = 0; row < 6; row++) {338      for (let col = 0; col < 6; col++) {339        const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);340        const cell = new Int32Array(sw * sh);341        for (let y = 0; y < sh; y++) {342          const srcRow = (sy + y) * canvasW + sx;343          const dstRow = y * sw;344          for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;345        }346        const text = decodeAztecFromArgb(cell, sw, sh);347        expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();348        recovered.push(text!);349      }350    }351352    expect(recovered).toHaveLength(36);353    for (let i = 0; i < 36; i++) {354      const parsed = parseChunkEnvelope(recovered[i]!);355      expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();356      expect(parsed!.i).toBe(envelopes[i]!.i);357      expect(parsed!.h).toBe(envelopes[i]!.h);358      expect(parsed!.d).toBe(envelopes[i]!.d);359    }360  }, 30_000);361362  it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => {363    const { importCodexPDF } = await import("./pdf-import");364    const { IMPORT_LIMITS } = await import("../../shared/import-limits");365    const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));366    const fakeFile = {367      size: IMPORT_LIMITS.MAX_PDF_BYTES + 1,368      arrayBuffer,369    } as unknown as File;370371    const result = await importCodexPDF(fakeFile);372373    expect(result.chunksFound).toBe(0);374    expect(result.envelopes).toEqual([]);375    expect(result.errors).toHaveLength(1);376    expect(result.errors[0]).toMatch(/limited to/);377    // Never read into memory — the gate fires before file.arrayBuffer().378    expect(arrayBuffer).not.toHaveBeenCalled();379  });380});