mayan.test.ts

7.1 KB · raw

1import { describe, it, expect } from "vitest";2import {3  MAYAN_MAGIC,4  MAYAN_VERSION,5  MAX_PAYLOAD_BYTES,6  RUNE_ZONE,7  QUIET_ZONE,8  calculateSymbolSize,9  encodeMayanMatrix,10  decodeMatrix,11  decodeMayan,12  isReserved,13} from "./mayan";1415// Synthesize an ImageData from a boolean module matrix at a given module pixel16// size. Pure black/white, no border padding. Useful for exercising the full17// image-decode pipeline without a canvas.18function matrixToImageData(19  matrix: boolean[][],20  size: number,21  modulePx: number,22): ImageData {23  const W = size * modulePx;24  const H = size * modulePx;25  const data = new Uint8ClampedArray(W * H * 4);26  for (let r = 0; r < size; r++) {27    for (let c = 0; c < size; c++) {28      const dark = matrix[r][c];29      const v = dark ? 0 : 255;30      for (let dy = 0; dy < modulePx; dy++) {31        for (let dx = 0; dx < modulePx; dx++) {32          const px = c * modulePx + dx;33          const py = r * modulePx + dy;34          const i = (py * W + px) * 4;35          data[i] = v;36          data[i + 1] = v;37          data[i + 2] = v;38          data[i + 3] = 255;39        }40      }41    }42  }43  return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;44}4546// Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.47function mulberry32(seed: number) {48  let state = seed >>> 0;49  return () => {50    state = (state + 0x6d2b79f5) >>> 0;51    let t = state;52    t = Math.imul(t ^ (t >>> 15), t | 1);53    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);54    return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;55  };56}5758function randomPayload(n: number, seed = 1): Uint8Array {59  const rng = mulberry32(seed);60  const out = new Uint8Array(n);61  for (let i = 0; i < n; i++) out[i] = (rng() * 256) | 0;62  return out;63}6465function flipRandomDataBits(66  matrix: boolean[][],67  size: number,68  fraction: number,69  seed = 1,70): boolean[][] {71  const center = Math.floor(size / 2);72  const dataPositions: { r: number; c: number }[] = [];73  for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {74    for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {75      if (!isReserved(r, c, center, size)) dataPositions.push({ r, c });76    }77  }78  const rng = mulberry32(seed);79  const out = matrix.map((row) => row.slice());80  const toFlip = Math.floor(dataPositions.length * fraction);81  for (let i = 0; i < toFlip; i++) {82    const j = i + Math.floor(rng() * (dataPositions.length - i));83    [dataPositions[i], dataPositions[j]] = [dataPositions[j], dataPositions[i]];84    const { r, c } = dataPositions[i];85    out[r][c] = !out[r][c];86  }87  return out;88}8990describe("Mayan format", () => {91  it("constants are coherent", () => {92    expect(MAYAN_MAGIC).toBe(0x4d);93    expect(MAYAN_VERSION).toBe(0x01);94    expect(RUNE_ZONE).toBe(27);95    expect(QUIET_ZONE).toBe(2);96  });9798  it("calculateSymbolSize returns >=64 for tiny payloads", () => {99    expect(calculateSymbolSize(0)).toBe(64);100    expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);101  });102103  it("calculateSymbolSize grows with payload", () => {104    const small = calculateSymbolSize(100);105    const big = calculateSymbolSize(5000);106    expect(big).toBeGreaterThan(small);107  });108109  it("rejects payloads larger than MAX_PAYLOAD_BYTES", () => {110    const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);111    expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);112  });113});114115describe("Mayan round-trip", () => {116  it("round-trips a 5-byte payload", () => {117    const payload = new Uint8Array([1, 2, 3, 4, 5]);118    const { matrix, size } = encodeMayanMatrix(payload);119    const decoded = decodeMatrix(matrix, size);120    expect(decoded).not.toBeNull();121    expect(Array.from(decoded!)).toEqual(Array.from(payload));122  });123124  it("round-trips a 100-byte random payload", () => {125    const payload = randomPayload(100);126    const { matrix, size } = encodeMayanMatrix(payload);127    const decoded = decodeMatrix(matrix, size);128    expect(decoded).not.toBeNull();129    expect(Array.from(decoded!)).toEqual(Array.from(payload));130  });131132  it("round-trips a 500-byte payload (multi-block RS)", () => {133    const payload = randomPayload(500);134    const { matrix, size } = encodeMayanMatrix(payload);135    const decoded = decodeMatrix(matrix, size);136    expect(decoded).not.toBeNull();137    expect(Array.from(decoded!)).toEqual(Array.from(payload));138  });139140  it("round-trips a 2000-byte payload", () => {141    const payload = randomPayload(2000);142    const { matrix, size } = encodeMayanMatrix(payload);143    const decoded = decodeMatrix(matrix, size);144    expect(decoded).not.toBeNull();145    expect(Array.from(decoded!)).toEqual(Array.from(payload));146  });147148  it("rejects a matrix with no Mayan magic byte", () => {149    const fake: boolean[][] = Array.from({ length: 64 }, () =>150      new Array<boolean>(64).fill(false),151    );152    const decoded = decodeMatrix(fake, 64);153    expect(decoded).toBeNull();154  });155});156157describe("Mayan image pipeline", () => {158  it("decodes a 5-byte payload via the full image pipeline at modulePx=3", () => {159    const payload = new Uint8Array([1, 2, 3, 4, 5]);160    const { matrix, size } = encodeMayanMatrix(payload);161    const imgData = matrixToImageData(matrix, size, 3);162    const decoded = decodeMayan(imgData);163    expect(decoded).not.toBeNull();164    expect(Array.from(decoded!)).toEqual(Array.from(payload));165  });166167  it("decodes a 100-byte payload via the full image pipeline at modulePx=3", () => {168    const payload = randomPayload(100);169    const { matrix, size } = encodeMayanMatrix(payload);170    const imgData = matrixToImageData(matrix, size, 3);171    const decoded = decodeMayan(imgData);172    expect(decoded).not.toBeNull();173    expect(Array.from(decoded!)).toEqual(Array.from(payload));174  });175176  it("decodes a 100-byte payload via the full image pipeline at modulePx=4", () => {177    const payload = randomPayload(100);178    const { matrix, size } = encodeMayanMatrix(payload);179    const imgData = matrixToImageData(matrix, size, 4);180    const decoded = decodeMayan(imgData);181    expect(decoded).not.toBeNull();182    expect(Array.from(decoded!)).toEqual(Array.from(payload));183  });184185  it("decodes a 500-byte payload via the full image pipeline at modulePx=3", () => {186    const payload = randomPayload(500);187    const { matrix, size } = encodeMayanMatrix(payload);188    const imgData = matrixToImageData(matrix, size, 3);189    const decoded = decodeMayan(imgData);190    expect(decoded).not.toBeNull();191    expect(Array.from(decoded!)).toEqual(Array.from(payload));192  });193});194195describe("Mayan RS recovery", () => {196  // Recoverable threshold for RS(255,178): up to 38 byte errors per block.197  // Each byte holds 8 bits, so the bit-flip equivalent is bounded by the198  // byte coverage P(byte has ≥1 flip) = 1 - (1-p)^8. For 30% byte errors199  // (within RS limit), p ≈ 0.043 — so 1% bit flips is comfortably inside.200  it("recovers from 0.5% random bit flips in data area", () => {201    const payload = randomPayload(50);202    const { matrix, size } = encodeMayanMatrix(payload);203    const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);204    const decoded = decodeMatrix(corrupted, size);205    expect(decoded).not.toBeNull();206    expect(Array.from(decoded!)).toEqual(Array.from(payload));207  });208});