pdf-import.test.ts

12.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 } 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  it("returns null for non-JSON input", () => {90    expect(parseChunkEnvelope("not json at all")).toBeNull();91  });9293  it("returns null for non-Peach JSON", () => {94    expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();95  });9697  it("returns null when version is missing", () => {98    expect(99      parseChunkEnvelope(100        JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),101      ),102    ).toBeNull();103  });104105  it("returns null when fields have the wrong types", () => {106    expect(107      parseChunkEnvelope(108        JSON.stringify({109          v: 3,110          c: "PX",111          s: 0,112          S: 1,113          i: "0", // should be number114          t: 5,115          p: false,116          d: "A",117          h: "A",118        }),119      ),120    ).toBeNull();121  });122123  it("returns null for v=1 envelopes", () => {124    expect(125      parseChunkEnvelope(126        JSON.stringify({127          v: 1,128          c: "PX",129          i: 0,130          t: 5,131          p: false,132          d: "A",133          h: "A",134        }),135      ),136    ).toBeNull();137  });138});139140// ── cellRectFor ─────────────────────────────────────────────────────────────141142describe("cellRectFor", () => {143  // pdf.js renders at scale 2.0; mirror that here so the test exercises the144  // same dimensions the importer sees in production.145  const SCALE = 2.0;146  const canvasW = Math.ceil(PAGE_WIDTH * SCALE);147  const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);148149  it("covers every cell of the printed 6×6 grid", () => {150    for (let row = 0; row < 6; row++) {151      for (let col = 0; col < 6; col++) {152        const rect = cellRectFor(canvasW, canvasH, row, col);153        // The crop must enclose the full code rectangle the generator draws.154        const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;155        const codeXMax = codeXMin + CODE_SIZE * SCALE;156        const codeYMin =157          (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;158        const codeYMax = codeYMin + CODE_SIZE * SCALE;159        expect(rect.sx).toBeLessThanOrEqual(codeXMin);160        expect(rect.sy).toBeLessThanOrEqual(codeYMin);161        expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);162        expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);163      }164    }165  });166167  it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {168    // Each crop may include a quiet-zone halo, but it must never cross into169    // the neighbour's printed code rectangle.170    for (let row = 0; row < 6; row++) {171      for (let col = 0; col < 5; col++) {172        const left = cellRectFor(canvasW, canvasH, row, col);173        const rightCodeXMin =174          (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;175        expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);176      }177    }178  });179180  it("clamps to canvas bounds", () => {181    const rect = cellRectFor(canvasW, canvasH, 5, 5);182    expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);183    expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);184  });185});186187// ── decodeAztecFromArgb ─────────────────────────────────────────────────────188189const WHITE = -1;190const BLACK = (0xff << 24) | 0;191192/**193 * Render a ZXing BitMatrix into an ARGB Int32Array at the given module size194 * with a `padModules` quiet zone on every side. Returns the buffer plus its195 * pixel dimensions.196 *197 * Used by tests to synthesize the same pixel format the importer would see198 * after pdf.js renders + the cell canvas paints, without needing a real DOM.199 */200function renderMatrixToArgb(201  matrix: BitMatrix,202  moduleSize: number,203  padModules: number,204): { argb: Int32Array; width: number; height: number } {205  const modules = matrix.getWidth();206  const pad = padModules * moduleSize;207  const size = modules * moduleSize + pad * 2;208  const argb = new Int32Array(size * size).fill(WHITE);209  for (let y = 0; y < modules; y++) {210    for (let x = 0; x < modules; x++) {211      if (matrix.get(x, y)) {212        const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;213        for (let dy = 0; dy < moduleSize; dy++) {214          for (let dx = 0; dx < moduleSize; dx++) {215            argb[baseRow + dy * size + dx] = BLACK;216          }217        }218      }219    }220  }221  return { argb, width: size, height: size };222}223224/**225 * Blit a smaller ARGB buffer onto a larger one at (dx, dy). Used to compose226 * a synthetic full-page buffer from per-code Aztec renderings.227 */228function blitArgb(229  src: Int32Array,230  srcW: number,231  srcH: number,232  dst: Int32Array,233  dstW: number,234  dx: number,235  dy: number,236): void {237  for (let y = 0; y < srcH; y++) {238    const dstRow = (dy + y) * dstW + dx;239    const srcRow = y * srcW;240    for (let x = 0; x < srcW; x++) {241      dst[dstRow + x] = src[srcRow + x]!;242    }243  }244}245246describe("decodeAztecFromArgb", () => {247  it("round-trips a real envelope JSON through the decode path", async () => {248    // A real-shaped envelope (v3) so the test exercises payload sizes the249    // live importer will actually see.250    const envelope = await buildChunkEnvelope(251      new TextEncoder().encode("hello world".repeat(20)),252      0,253      36,254      false,255      "PX-TEST-1234",256      0,257      1,258    );259    const payload = JSON.stringify(envelope);260261    const matrix = buildAztecMatrix(payload);262    const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);263264    expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);265  });266267  it("returns null for an all-white buffer", () => {268    const w = 100;269    const h = 100;270    const argb = new Int32Array(w * h).fill(WHITE);271    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();272  });273274  it("returns null for an all-black buffer", () => {275    const w = 100;276    const h = 100;277    const argb = new Int32Array(w * h).fill(BLACK);278    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();279  });280281  it("returns null for random noise", () => {282    const w = 200;283    const h = 200;284    const argb = new Int32Array(w * h);285    // Deterministic pseudo-noise so the test never flakes.286    let s = 0x12345678;287    for (let i = 0; i < argb.length; i++) {288      s = (s * 1664525 + 1013904223) | 0;289      argb[i] = s & 1 ? BLACK : WHITE;290    }291    expect(decodeAztecFromArgb(argb, w, h)).toBeNull();292  });293});294295// ── End-to-end page-grid integration ────────────────────────────────────────296297describe("page-grid integration", () => {298  it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {299    const SCALE = 2.0;300    const canvasW = Math.ceil(PAGE_WIDTH * SCALE);301    const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);302    const page = new Int32Array(canvasW * canvasH).fill(WHITE);303304    // Build 36 envelopes — exactly one full page worth. Vary the data so each305    // produces a distinct matrix, catching any "decoded the same code twice"306    // confusion in the importer.307    const envelopes = await Promise.all(308      Array.from({ length: 36 }, (_, i) => {309        const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));310        return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);311      }),312    );313314    // Render each envelope's Aztec at the exact pixel position the generator315    // would draw it. Module size is chosen to fit within CODE_SIZE * SCALE316    // pixels with a ~2-module quiet zone, matching the live PNG layout.317    const codeBoxPx = Math.floor(CODE_SIZE * SCALE);318    for (let i = 0; i < envelopes.length; i++) {319      const env = envelopes[i]!;320      const matrix = buildAztecMatrix(JSON.stringify(env));321      const modules = matrix.getWidth();322      // Fit modules+4 (=quiet zone) into codeBoxPx pixels. Floor to ensure323      // the rendered code fits within the box and never spills over.324      const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));325      const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);326      const row = Math.floor(i / 6);327      const col = i % 6;328      const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);329      const dy = Math.round(330        (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,331      );332      blitArgb(argb, width, height, page, canvasW, dx, dy);333    }334335    // Walk the importer's grid math and decode each cell. Every envelope336    // must reappear, in the right index, with the original JSON intact.337    const recovered: string[] = [];338    for (let row = 0; row < 6; row++) {339      for (let col = 0; col < 6; col++) {340        const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);341        const cell = new Int32Array(sw * sh);342        for (let y = 0; y < sh; y++) {343          const srcRow = (sy + y) * canvasW + sx;344          const dstRow = y * sw;345          for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;346        }347        const text = decodeAztecFromArgb(cell, sw, sh);348        expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();349        recovered.push(text!);350      }351    }352353    expect(recovered).toHaveLength(36);354    for (let i = 0; i < 36; i++) {355      const parsed = parseChunkEnvelope(recovered[i]!);356      expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();357      expect(parsed!.i).toBe(envelopes[i]!.i);358      expect(parsed!.h).toBe(envelopes[i]!.h);359      expect(parsed!.d).toBe(envelopes[i]!.d);360    }361  }, 30_000);362});