pdf-import.test.ts

13.7 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("parses a v2 envelope (legacy single-stripe)", () => {72    const v2 = {73      v: 2,74      c: "PX-TEST-1234",75      i: 0,76      t: 15,77      p: false,78      d: "AAAA",79      h: "ABCDEF1234567890ABCDEF1234567890",80    };81    expect(parseChunkEnvelope(JSON.stringify(v2))).toEqual(v2);82  });8384  it("rejects a v3 envelope missing stripe metadata", () => {85    const broken = { ...v3, s: undefined, S: undefined };86    expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull();87  });8889  // PCH-01-012: non-integer indices/stripe fields must be rejected at parse time.90  it("parse_chunk_envelope_rejects_non_integer_stripe_fields", () => {91    expect(parseChunkEnvelope(JSON.stringify({ ...v3, s: 0.5 }))).toBeNull();92    expect(parseChunkEnvelope(JSON.stringify({ ...v3, S: 1.5 }))).toBeNull();93    expect(parseChunkEnvelope(JSON.stringify({ ...v3, i: 1.5 }))).toBeNull();94    expect(parseChunkEnvelope(JSON.stringify({ ...v3, t: 15.5 }))).toBeNull();95  });9697  it("returns null for non-JSON input", () => {98    expect(parseChunkEnvelope("not json at all")).toBeNull();99  });100101  it("returns null for non-Peach JSON", () => {102    expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();103  });104105  it("returns null when version is missing", () => {106    expect(107      parseChunkEnvelope(108        JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),109      ),110    ).toBeNull();111  });112113  it("returns null when fields have the wrong types", () => {114    expect(115      parseChunkEnvelope(116        JSON.stringify({117          v: 3,118          c: "PX",119          s: 0,120          S: 1,121          i: "0", // should be number122          t: 5,123          p: false,124          d: "A",125          h: "A",126        }),127      ),128    ).toBeNull();129  });130131  it("returns null for v=1 envelopes", () => {132    expect(133      parseChunkEnvelope(134        JSON.stringify({135          v: 1,136          c: "PX",137          i: 0,138          t: 5,139          p: false,140          d: "A",141          h: "A",142        }),143      ),144    ).toBeNull();145  });146});147148// ── cellRectFor ─────────────────────────────────────────────────────────────149150describe("cellRectFor", () => {151  // pdf.js renders at scale 2.0; mirror that here so the test exercises the152  // same dimensions the importer sees in production.153  const SCALE = 2.0;154  const canvasW = Math.ceil(PAGE_WIDTH * SCALE);155  const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);156157  it("covers every cell of the printed 6×6 grid", () => {158    for (let row = 0; row < 6; row++) {159      for (let col = 0; col < 6; col++) {160        const rect = cellRectFor(canvasW, canvasH, row, col);161        // The crop must enclose the full code rectangle the generator draws.162        const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;163        const codeXMax = codeXMin + CODE_SIZE * SCALE;164        const codeYMin =165          (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;166        const codeYMax = codeYMin + CODE_SIZE * SCALE;167        expect(rect.sx).toBeLessThanOrEqual(codeXMin);168        expect(rect.sy).toBeLessThanOrEqual(codeYMin);169        expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);170        expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);171      }172    }173  });174175  it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {176    // Each crop may include a quiet-zone halo, but it must never cross into177    // the neighbour's printed code rectangle.178    for (let row = 0; row < 6; row++) {179      for (let col = 0; col < 5; col++) {180        const left = cellRectFor(canvasW, canvasH, row, col);181        const rightCodeXMin =182          (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;183        expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);184      }185    }186  });187188  it("clamps to canvas bounds", () => {189    const rect = cellRectFor(canvasW, canvasH, 5, 5);190    expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);191    expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);192  });193});194195// ── decodeAztecFromArgb ─────────────────────────────────────────────────────196197const WHITE = -1;198const BLACK = (0xff << 24) | 0;199200/**201 * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size202 * with a `padModules` quiet zone on every side. Returns the buffer plus its203 * pixel dimensions.204 *205 * Used by tests to synthesize the same pixel format the importer would see206 * after pdf.js renders + the cell canvas paints, without needing a real DOM.207 */208function renderMatrixToArgb(209  matrix: BitMatrix,210  moduleSize: number,211  padModules: number,212): { argb: Int32Array; width: number; height: number } {213  const modules = matrix.getWidth();214  const pad = padModules * moduleSize;215  const size = modules * moduleSize + pad * 2;216  const argb = new Int32Array(size * size).fill(WHITE);217  for (let y = 0; y < modules; y++) {218    for (let x = 0; x < modules; x++) {219      if (matrix.get(x, y)) {220        const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;221        for (let dy = 0; dy < moduleSize; dy++) {222          for (let dx = 0; dx < moduleSize; dx++) {223            argb[baseRow + dy * size + dx] = BLACK;224          }225        }226      }227    }228  }229  return { argb, width: size, height: size };230}231232/**233 * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose234 * a synthetic full-page buffer from per-code Aztec renderings.235 */236function blitArgb(237  src: Int32Array,238  srcW: number,239  srcH: number,240  dst: Int32Array,241  dstW: number,242  dx: number,243  dy: number,244): void {245  for (let y = 0; y < srcH; y++) {246    const dstRow = (dy + y) * dstW + dx;247    const srcRow = y * srcW;248    for (let x = 0; x < srcW; x++) {249      dst[dstRow + x] = src[srcRow + x]!;250    }251  }252}253254describe("decodeAztecFromArgb", () => {255  it("round-trips a real envelope JSON through the decode path", async () => {256    // A real-shaped envelope (v3) so the test exercises payload sizes the257    // live importer will actually see.258    const envelope = await buildChunkEnvelope(259      new TextEncoder().encode("hello world".repeat(20)),260      0,261      36,262      false,263      "PX-TEST-1234",264      0,265      1,266    );267    const payload = JSON.stringify(envelope);268269    const matrix = buildAztecMatrix(payload);270    const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);271272    expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);273  });274275  it("returns null for an all-white buffer", () => {276    const w = 100;277    const h = 100;278    const argb = new Int32Array(w * h).fill(WHITE);279    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();280  });281282  it("returns null for an all-black buffer", () => {283    const w = 100;284    const h = 100;285    const argb = new Int32Array(w * h).fill(BLACK);286    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();287  });288289  it("returns null for random noise", () => {290    const w = 200;291    const h = 200;292    const argb = new Int32Array(w * h);293    // Deterministic pseudo-noise so the test never flakes.294    let s = 0x12345678;295    for (let i = 0; i < argb.length; i++) {296      s = (s * 1664525 + 1013904223) | 0;297      argb[i] = s & 1 ? BLACK : WHITE;298    }299    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();300  });301});302303// ── End-to-end page-grid integration ────────────────────────────────────────304305describe("page-grid integration", () => {306  it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {307    const SCALE = 2.0;308    const canvasW = Math.ceil(PAGE_WIDTH * SCALE);309    const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);310    const page = new Int32Array(canvasW * canvasH).fill(WHITE);311312    // Build 36 envelopes — exactly one full page worth. Vary the data so each313    // produces a distinct matrix, catching any "decoded the same code twice"314    // confusion in the importer.315    const envelopes = await Promise.all(316      Array.from({ length: 36 }, (_, i) => {317        const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));318        return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);319      }),320    );321322    // Render each envelope's Aztec at the exact pixel position the generator323    // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE324    // pixels with a ~2-module quiet zone, matching the live PNG layout.325    const codeBoxPx = Math.floor(CODE_SIZE * SCALE);326    for (let i = 0; i < envelopes.length; i++) {327      const env = envelopes[i]!;328      const matrix = buildAztecMatrix(JSON.stringify(env));329      const modules = matrix.getWidth();330      // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure331      // the rendered code fits within the box and never spills over.332      const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));333      const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);334      const row = Math.floor(i / 6);335      const col = i % 6;336      const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);337      const dy = Math.round(338        (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,339      );340      blitArgb(argb, width, height, page, canvasW, dx, dy);341    }342343    // Walk the importer's grid math and decode each cell. Every envelope344    // must reappear, in the right index, with the original JSON intact.345    const recovered: string[] = [];346    for (let row = 0; row < 6; row++) {347      for (let col = 0; col < 6; col++) {348        const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);349        const cell = new Int32Array(sw * sh);350        for (let y = 0; y < sh; y++) {351          const srcRow = (sy + y) * canvasW + sx;352          const dstRow = y * sw;353          for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;354        }355        const text = decodeAztecFromArgb(cell, sw, sh);356        expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();357        recovered.push(text!);358      }359    }360361    expect(recovered).toHaveLength(36);362    for (let i = 0; i < 36; i++) {363      const parsed = parseChunkEnvelope(recovered[i]!);364      expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();365      expect(parsed!.i).toBe(envelopes[i]!.i);366      expect(parsed!.h).toBe(envelopes[i]!.h);367      expect(parsed!.d).toBe(envelopes[i]!.d);368    }369  }, 30_000);370371  it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => {372    const { importCodexPDF } = await import("./pdf-import");373    const { IMPORT_LIMITS } = await import("../../shared/import-limits");374    const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));375    const fakeFile = {376      size: IMPORT_LIMITS.MAX_PDF_BYTES + 1,377      arrayBuffer,378    } as unknown as File;379380    const result = await importCodexPDF(fakeFile);381382    expect(result.chunksFound).toBe(0);383    expect(result.envelopes).toEqual([]);384    expect(result.errors).toHaveLength(1);385    expect(result.errors[0]).toMatch(/limited to/);386    // Never read into memory — the gate fires before file.arrayBuffer().387    expect(arrayBuffer).not.toHaveBeenCalled();388  });389});