pdf-import.test.ts

13.9 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  claimPDFRenderPixels,39  createPDFRenderBudget,40  decodeAztecFromArgb,41  parseChunkEnvelope,42} from "./pdf-import";43import {44  CODE_SIZE,45  COL_GAP,46  HEADER_GAP,47  HEADER_HEIGHT,48  MARGIN,49  PAGE_HEIGHT,50  PAGE_WIDTH,51  ROW_STEP,52} from "./pdf-layout";5354describe("PB-26-704 PDF aggregate render budget", () => {55  it("rejects cumulative near-limit pages before another canvas allocation", () => {56    const budget = createPDFRenderBudget(100);57    claimPDFRenderPixels(budget, 10, 5);58    claimPDFRenderPixels(budget, 5, 10);59    expect(() => claimPDFRenderPixels(budget, 1, 1)).toThrow(/aggregate render limit/i);60  });61});6263// ── parseChunkEnvelope ──────────────────────────────────────────────────────6465describe("parseChunkEnvelope", () => {66  const v3 = {67    v: 3 as const,68    c: "PX-TEST-1234",69    s: 0,70    S: 1,71    i: 0,72    t: 15,73    p: false,74    d: "AAAA",75    h: "ABCDEF1234567890ABCDEF1234567890",76  };7778  it("parses a v3 envelope (current generator format)", () => {79    expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3);80  });8182  it("rejects a v2 envelope", () => {83    expect(parseChunkEnvelope(JSON.stringify({ v: 2, c: "PX-TEST-1234" }))).toBeNull();84  });8586  it("rejects a v3 envelope missing stripe metadata", () => {87    const broken = { ...v3, s: undefined, S: undefined };88    expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull();89  });9091  // PCH-01-012: non-integer indices/stripe fields must be rejected at parse time.92  it("parse_chunk_envelope_rejects_non_integer_stripe_fields", () => {93    expect(parseChunkEnvelope(JSON.stringify({ ...v3, s: 0.5 }))).toBeNull();94    expect(parseChunkEnvelope(JSON.stringify({ ...v3, S: 1.5 }))).toBeNull();95    expect(parseChunkEnvelope(JSON.stringify({ ...v3, i: 1.5 }))).toBeNull();96    expect(parseChunkEnvelope(JSON.stringify({ ...v3, t: 15.5 }))).toBeNull();97  });9899  it("returns null for non-JSON input", () => {100    expect(parseChunkEnvelope("not json at all")).toBeNull();101  });102103  it("returns null for non-Peach JSON", () => {104    expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();105  });106107  it("returns null when version is missing", () => {108    expect(109      parseChunkEnvelope(110        JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),111      ),112    ).toBeNull();113  });114115  it("returns null when fields have the wrong types", () => {116    expect(117      parseChunkEnvelope(118        JSON.stringify({119          v: 3,120          c: "PX",121          s: 0,122          S: 1,123          i: "0", // should be number124          t: 5,125          p: false,126          d: "A",127          h: "A",128        }),129      ),130    ).toBeNull();131  });132133  it("returns null for v=1 envelopes", () => {134    expect(135      parseChunkEnvelope(136        JSON.stringify({137          v: 1,138          c: "PX",139          i: 0,140          t: 5,141          p: false,142          d: "A",143          h: "A",144        }),145      ),146    ).toBeNull();147  });148});149150// ── cellRectFor ─────────────────────────────────────────────────────────────151152describe("cellRectFor", () => {153  // pdf.js renders at scale 2.0; mirror that here so the test exercises the154  // same dimensions the importer sees in production.155  const SCALE = 2.0;156  const canvasW = Math.ceil(PAGE_WIDTH * SCALE);157  const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);158159  it("covers every cell of the printed 6×6 grid", () => {160    for (let row = 0; row < 6; row++) {161      for (let col = 0; col < 6; col++) {162        const rect = cellRectFor(canvasW, canvasH, row, col);163        // The crop must enclose the full code rectangle the generator draws.164        const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;165        const codeXMax = codeXMin + CODE_SIZE * SCALE;166        const codeYMin =167          (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;168        const codeYMax = codeYMin + CODE_SIZE * SCALE;169        expect(rect.sx).toBeLessThanOrEqual(codeXMin);170        expect(rect.sy).toBeLessThanOrEqual(codeYMin);171        expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);172        expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);173      }174    }175  });176177  it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {178    // Each crop may include a quiet-zone halo, but it must never cross into179    // the neighbour's printed code rectangle.180    for (let row = 0; row < 6; row++) {181      for (let col = 0; col < 5; col++) {182        const left = cellRectFor(canvasW, canvasH, row, col);183        const rightCodeXMin =184          (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;185        expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);186      }187    }188  });189190  it("clamps to canvas bounds", () => {191    const rect = cellRectFor(canvasW, canvasH, 5, 5);192    expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);193    expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);194  });195});196197// ── decodeAztecFromArgb ─────────────────────────────────────────────────────198199const WHITE = -1;200const BLACK = (0xff << 24) | 0;201202/**203 * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size204 * with a `padModules` quiet zone on every side. Returns the buffer plus its205 * pixel dimensions.206 *207 * Used by tests to synthesize the same pixel format the importer would see208 * after pdf.js renders + the cell canvas paints, without needing a real DOM.209 */210function renderMatrixToArgb(211  matrix: BitMatrix,212  moduleSize: number,213  padModules: number,214): { argb: Int32Array; width: number; height: number } {215  const modules = matrix.getWidth();216  const pad = padModules * moduleSize;217  const size = modules * moduleSize + pad * 2;218  const argb = new Int32Array(size * size).fill(WHITE);219  for (let y = 0; y < modules; y++) {220    for (let x = 0; x < modules; x++) {221      if (matrix.get(x, y)) {222        const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;223        for (let dy = 0; dy < moduleSize; dy++) {224          for (let dx = 0; dx < moduleSize; dx++) {225            argb[baseRow + dy * size + dx] = BLACK;226          }227        }228      }229    }230  }231  return { argb, width: size, height: size };232}233234/**235 * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose236 * a synthetic full-page buffer from per-code Aztec renderings.237 */238function blitArgb(239  src: Int32Array,240  srcW: number,241  srcH: number,242  dst: Int32Array,243  dstW: number,244  dx: number,245  dy: number,246): void {247  for (let y = 0; y < srcH; y++) {248    const dstRow = (dy + y) * dstW + dx;249    const srcRow = y * srcW;250    for (let x = 0; x < srcW; x++) {251      dst[dstRow + x] = src[srcRow + x]!;252    }253  }254}255256describe("decodeAztecFromArgb", () => {257  it("round-trips a real envelope JSON through the decode path", async () => {258    // A real-shaped envelope (v3) so the test exercises payload sizes the259    // live importer will actually see.260    const envelope = await buildChunkEnvelope(261      new TextEncoder().encode("hello world".repeat(20)),262      0,263      36,264      false,265      "PX-TEST-1234",266      0,267      1,268    );269    const payload = JSON.stringify(envelope);270271    const matrix = buildAztecMatrix(payload);272    const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);273274    expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);275  });276277  it("returns null for an all-white buffer", () => {278    const w = 100;279    const h = 100;280    const argb = new Int32Array(w * h).fill(WHITE);281    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();282  });283284  it("returns null for an all-black buffer", () => {285    const w = 100;286    const h = 100;287    const argb = new Int32Array(w * h).fill(BLACK);288    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();289  });290291  it("returns null for random noise", () => {292    const w = 200;293    const h = 200;294    const argb = new Int32Array(w * h);295    // Deterministic pseudo-noise so the test never flakes.296    let s = 0x12345678;297    for (let i = 0; i < argb.length; i++) {298      s = (s * 1664525 + 1013904223) | 0;299      argb[i] = s & 1 ? BLACK : WHITE;300    }301    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();302  });303});304305// ── End-to-end page-grid integration ────────────────────────────────────────306307describe("page-grid integration", () => {308  it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {309    const SCALE = 2.0;310    const canvasW = Math.ceil(PAGE_WIDTH * SCALE);311    const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);312    const page = new Int32Array(canvasW * canvasH).fill(WHITE);313314    // Build 36 envelopes — exactly one full page worth. Vary the data so each315    // produces a distinct matrix, catching any "decoded the same code twice"316    // confusion in the importer.317    const envelopes = await Promise.all(318      Array.from({ length: 36 }, (_, i) => {319        const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));320        return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);321      }),322    );323324    // Render each envelope's Aztec at the exact pixel position the generator325    // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE326    // pixels with a ~2-module quiet zone, matching the live PNG layout.327    const codeBoxPx = Math.floor(CODE_SIZE * SCALE);328    for (let i = 0; i < envelopes.length; i++) {329      const env = envelopes[i]!;330      const matrix = buildAztecMatrix(JSON.stringify(env));331      const modules = matrix.getWidth();332      // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure333      // the rendered code fits within the box and never spills over.334      const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));335      const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);336      const row = Math.floor(i / 6);337      const col = i % 6;338      const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);339      const dy = Math.round(340        (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,341      );342      blitArgb(argb, width, height, page, canvasW, dx, dy);343    }344345    // Walk the importer's grid math and decode each cell. Every envelope346    // must reappear, in the right index, with the original JSON intact.347    const recovered: string[] = [];348    for (let row = 0; row < 6; row++) {349      for (let col = 0; col < 6; col++) {350        const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);351        const cell = new Int32Array(sw * sh);352        for (let y = 0; y < sh; y++) {353          const srcRow = (sy + y) * canvasW + sx;354          const dstRow = y * sw;355          for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;356        }357        const text = decodeAztecFromArgb(cell, sw, sh);358        expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();359        recovered.push(text!);360      }361    }362363    expect(recovered).toHaveLength(36);364    for (let i = 0; i < 36; i++) {365      const parsed = parseChunkEnvelope(recovered[i]!);366      expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();367      expect(parsed!.i).toBe(envelopes[i]!.i);368      expect(parsed!.h).toBe(envelopes[i]!.h);369      expect(parsed!.d).toBe(envelopes[i]!.d);370    }371  }, 30_000);372373  it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => {374    const { importCodexPDF } = await import("./pdf-import");375    const { IMPORT_LIMITS } = await import("../../shared/import-limits");376    const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));377    const fakeFile = {378      size: IMPORT_LIMITS.MAX_PDF_BYTES + 1,379      arrayBuffer,380    } as unknown as File;381382    const result = await importCodexPDF(fakeFile);383384    expect(result.chunksFound).toBe(0);385    expect(result.envelopes).toEqual([]);386    expect(result.errors).toHaveLength(1);387    expect(result.errors[0]).toMatch(/limited to/);388    // Never read into memory — the gate fires before file.arrayBuffer().389    expect(arrayBuffer).not.toHaveBeenCalled();390  });391});