mayan.test.ts

9.7 KB · raw

1import { describe, it, expect } from "vitest";2import {3  MAYAN_MAGIC,4  MAYAN_V2,5  MAX_PAYLOAD_BYTES,6  RUNE_ZONE,7  QUIET_ZONE,8  ANCHOR_FOOTPRINT,9  RS_DATA,10  RS_BLOCK,11  HEADER_BYTES,12  calculateSymbolSize,13  encodeMayanMatrix,14  encodeMayan,15  decodeMatrix,16  decodeMayan,17  isReserved,18} from "./mayan";1920// Synthesize an ImageData from a boolean module matrix at a given module pixel21// size. Pure black/white, no border padding.22function matrixToImageData(23  matrix: boolean[][],24  size: number,25  modulePx: number,26): ImageData {27  const W = size * modulePx;28  const H = size * modulePx;29  const data = new Uint8ClampedArray(W * H * 4);30  for (let r = 0; r < size; r++) {31    for (let c = 0; c < size; c++) {32      const dark = matrix[r][c];33      const v = dark ? 0 : 255;34      for (let dy = 0; dy < modulePx; dy++) {35        for (let dx = 0; dx < modulePx; dx++) {36          const px = c * modulePx + dx;37          const py = r * modulePx + dy;38          const i = (py * W + px) * 4;39          data[i] = v;40          data[i + 1] = v;41          data[i + 2] = v;42          data[i + 3] = 255;43        }44      }45    }46  }47  return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;48}4950// Deterministic PRNG — Mulberry32 — so tests are reproducible.51function mulberry32(seed: number) {52  let state = seed >>> 0;53  return () => {54    state = (state + 0x6d2b79f5) >>> 0;55    let t = state;56    t = Math.imul(t ^ (t >>> 15), t | 1);57    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);58    return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;59  };60}6162function randomPayload(n: number, seed = 1): Uint8Array {63  const rng = mulberry32(seed);64  const out = new Uint8Array(n);65  for (let i = 0; i < n; i++) out[i] = (rng() * 256) | 0;66  return out;67}6869function flipRandomDataBits(70  matrix: boolean[][],71  size: number,72  fraction: number,73  seed = 1,74): boolean[][] {75  const dataPositions: { r: number; c: number }[] = [];76  for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {77    for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {78      if (!isReserved(r, c, size)) dataPositions.push({ r, c });79    }80  }81  const rng = mulberry32(seed);82  const out = matrix.map((row) => row.slice());83  const toFlip = Math.floor(dataPositions.length * fraction);84  for (let i = 0; i < toFlip; i++) {85    const j = i + Math.floor(rng() * (dataPositions.length - i));86    [dataPositions[i], dataPositions[j]] = [dataPositions[j], dataPositions[i]];87    const { r, c } = dataPositions[i];88    out[r][c] = !out[r][c];89  }90  return out;91}9293describe("Mayan v2 format", () => {94  it("constants are coherent", () => {95    expect(MAYAN_MAGIC).toBe(0x4d);96    expect(MAYAN_V2).toBe(0x02);97    expect(RUNE_ZONE).toBe(27);98    expect(QUIET_ZONE).toBe(2);99    expect(ANCHOR_FOOTPRINT).toBe(9);100    expect(RS_DATA).toBe(204);101    expect(RS_BLOCK).toBe(255);102    expect(HEADER_BYTES).toBe(6);103  });104105  it("calculateSymbolSize returns >=64 for tiny payloads", () => {106    expect(calculateSymbolSize(0)).toBeGreaterThanOrEqual(64);107    expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);108  });109110  it("calculateSymbolSize grows with payload", () => {111    const small = calculateSymbolSize(100);112    const big = calculateSymbolSize(50_000);113    expect(big).toBeGreaterThan(small);114  });115116  it("rejects payloads larger than MAX_PAYLOAD_BYTES", () => {117    const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);118    expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);119  });120121  it("isReserved marks anchor footprints as reserved", () => {122    const size = 128;123    // Top-left anchor footprint: rows/cols [QUIET_ZONE..QUIET_ZONE+8]124    expect(isReserved(QUIET_ZONE, QUIET_ZONE, size)).toBe(true);125    expect(isReserved(QUIET_ZONE + 5, QUIET_ZONE + 5, size)).toBe(true);126    expect(isReserved(QUIET_ZONE + 8, QUIET_ZONE + 8, size)).toBe(true);127    // Just outside the anchor footprint should be data area (assuming the128    // timing strip and rune zone don't claim it)129    const safeR = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;130    const safeC = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;131    // The corner (anchorEnd, anchorEnd) lies at the timing-strip intersection,132    // so step one further in133    expect(isReserved(safeR, safeC, size)).toBe(false);134  });135136  it("isReserved marks size-bit row positions as reserved", () => {137    const size = 128;138    const row = QUIET_ZONE;139    const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;140    for (let i = 0; i < 16; i++) {141      expect(isReserved(row, colStart + i, size)).toBe(true);142    }143  });144145  it("encodes the symbol size bits round-trippably", () => {146    const payload = randomPayload(50);147    const { matrix, size } = encodeMayanMatrix(payload);148    const row = QUIET_ZONE;149    const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;150    let v = 0;151    for (let i = 0; i < 16; i++) {152      if (matrix[row][colStart + i]) v |= 1 << (15 - i);153    }154    expect(v).toBe(size);155  });156157  // encodeMayan() requires DOM canvas (only available in browser/jsdom).158  // Paper-size tagging is exercised end-to-end via the Export view.159});160161describe("Mayan v2 round-trip (matrix)", () => {162  it("round-trips a 5-byte payload", () => {163    const payload = new Uint8Array([1, 2, 3, 4, 5]);164    const { matrix, size } = encodeMayanMatrix(payload);165    const decoded = decodeMatrix(matrix, size);166    expect(decoded).not.toBeNull();167    expect(Array.from(decoded!)).toEqual(Array.from(payload));168  });169170  it("round-trips a 100-byte random payload", () => {171    const payload = randomPayload(100);172    const { matrix, size } = encodeMayanMatrix(payload);173    const decoded = decodeMatrix(matrix, size);174    expect(decoded).not.toBeNull();175    expect(Array.from(decoded!)).toEqual(Array.from(payload));176  });177178  it("round-trips a 500-byte payload (multi-block RS)", () => {179    const payload = randomPayload(500);180    const { matrix, size } = encodeMayanMatrix(payload);181    const decoded = decodeMatrix(matrix, size);182    expect(decoded).not.toBeNull();183    expect(Array.from(decoded!)).toEqual(Array.from(payload));184  });185186  it("round-trips a 2000-byte payload", () => {187    const payload = randomPayload(2000);188    const { matrix, size } = encodeMayanMatrix(payload);189    const decoded = decodeMatrix(matrix, size);190    expect(decoded).not.toBeNull();191    expect(Array.from(decoded!)).toEqual(Array.from(payload));192  });193194  it("round-trips a 20000-byte payload (realistic vault size)", () => {195    const payload = randomPayload(20000);196    const { matrix, size } = encodeMayanMatrix(payload);197    const decoded = decodeMatrix(matrix, size);198    expect(decoded).not.toBeNull();199    expect(Array.from(decoded!)).toEqual(Array.from(payload));200  });201202  it("rejects a matrix with no Mayan magic byte", () => {203    const fake: boolean[][] = Array.from({ length: 64 }, () =>204      new Array<boolean>(64).fill(false),205    );206    const decoded = decodeMatrix(fake, 64);207    expect(decoded).toBeNull();208  });209});210211describe("Mayan v2 image pipeline", () => {212  it("decodes a 5-byte payload at modulePx=3", () => {213    const payload = new Uint8Array([1, 2, 3, 4, 5]);214    const { matrix, size } = encodeMayanMatrix(payload);215    const imgData = matrixToImageData(matrix, size, 3);216    const decoded = decodeMayan(imgData);217    expect(decoded).not.toBeNull();218    expect(Array.from(decoded!)).toEqual(Array.from(payload));219  });220221  it("decodes a 100-byte payload at modulePx=3", () => {222    const payload = randomPayload(100);223    const { matrix, size } = encodeMayanMatrix(payload);224    const imgData = matrixToImageData(matrix, size, 3);225    const decoded = decodeMayan(imgData);226    expect(decoded).not.toBeNull();227    expect(Array.from(decoded!)).toEqual(Array.from(payload));228  });229230  it("decodes a 100-byte payload at modulePx=4", () => {231    const payload = randomPayload(100);232    const { matrix, size } = encodeMayanMatrix(payload);233    const imgData = matrixToImageData(matrix, size, 4);234    const decoded = decodeMayan(imgData);235    expect(decoded).not.toBeNull();236    expect(Array.from(decoded!)).toEqual(Array.from(payload));237  });238239  it("decodes a 500-byte payload at modulePx=3", () => {240    const payload = randomPayload(500);241    const { matrix, size } = encodeMayanMatrix(payload);242    const imgData = matrixToImageData(matrix, size, 3);243    const decoded = decodeMayan(imgData);244    expect(decoded).not.toBeNull();245    expect(Array.from(decoded!)).toEqual(Array.from(payload));246  });247248  it("decodes a 5000-byte payload at modulePx=4", () => {249    const payload = randomPayload(5000);250    const { matrix, size } = encodeMayanMatrix(payload);251    const imgData = matrixToImageData(matrix, size, 4);252    const decoded = decodeMayan(imgData);253    expect(decoded).not.toBeNull();254    expect(Array.from(decoded!)).toEqual(Array.from(payload));255  });256});257258describe("Mayan v2 RS recovery", () => {259  it("recovers from 0.5% random bit flips in the data area", () => {260    const payload = randomPayload(50);261    const { matrix, size } = encodeMayanMatrix(payload);262    const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);263    const decoded = decodeMatrix(corrupted, size);264    expect(decoded).not.toBeNull();265    expect(Array.from(decoded!)).toEqual(Array.from(payload));266  });267268  it("recovers from 1% random bit flips on a multi-block payload", () => {269    // RS(255,204) corrects up to 25 byte errors per block. With interleaving270    // a 1% bit flip rate translates to ~7 byte errors per block — comfortably271    // within capacity. Higher rates approach the per-block limit and are272    // exercised in stress benchmarks separately.273    const payload = randomPayload(500);274    const { matrix, size } = encodeMayanMatrix(payload);275    const corrupted = flipRandomDataBits(matrix, size, 0.01, 7);276    const decoded = decodeMatrix(corrupted, size);277    expect(decoded).not.toBeNull();278    expect(Array.from(decoded!)).toEqual(Array.from(payload));279  });280});281