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("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 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", 122 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});147148149150describe("cellRectFor", () => {151 152 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 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 177 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});194195196197const WHITE = -1;198const BLACK = (0xff << 24) | 0;199200201202203204205206207208function 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}231232233234235236function 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 257 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 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});302303304305describe("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 313 314 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 323 324 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 331 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 344 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 387 expect(arrayBuffer).not.toHaveBeenCalled();388 });389});