12345678910111213141516171819202122232425262728293031import { 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";51525354describe("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 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", 113 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});138139140141describe("cellRectFor", () => {142 143 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 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 168 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});185186187188const WHITE = -1;189const BLACK = (0xff << 24) | 0;190191192193194195196197198199function 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}222223224225226227function 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 248 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 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});293294295296describe("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 304 305 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 314 315 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 322 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 335 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 378 expect(arrayBuffer).not.toHaveBeenCalled();379 });380});