115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { QrCode, type QrEcLevel, renderQrHalfBlocks } from "@oh-my-pi/pi-coding-agent/utils/qrcode";
|
|
|
|
function matrixFingerprint(qr: QrCode): string {
|
|
let bits = "";
|
|
for (let y = 0; y < qr.size; y++) for (let x = 0; x < qr.size; x++) bits += qr.module(x, y) ? "1" : "0";
|
|
return new Bun.CryptoHasher("sha256").update(bits).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
describe("QR encoder", () => {
|
|
// Golden vectors captured from the encoder after byte-for-byte cross-validation
|
|
// against the `qrcode` reference library (all versions/EC levels/masks) and a
|
|
// real jsQR decode. A changed hash means the symbol bytes drifted.
|
|
const vectors: ReadonlyArray<{
|
|
text: string;
|
|
ecl: QrEcLevel;
|
|
mask: number;
|
|
version: number;
|
|
size: number;
|
|
hash: string;
|
|
}> = [
|
|
{ text: "HELLO WORLD", ecl: "M", mask: 0, version: 1, size: 21, hash: "a28227450c6dd5ab" },
|
|
{
|
|
text: "https://my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU",
|
|
ecl: "M",
|
|
mask: 4,
|
|
version: 4,
|
|
size: 33,
|
|
hash: "4af2f66e1b06a5b1",
|
|
},
|
|
{
|
|
text: "https://web.example/collab/#relay.example.com:8443/r/AbCdEfGhIjKlMnOp.0123456789abcdef",
|
|
ecl: "Q",
|
|
mask: 6,
|
|
version: 7,
|
|
size: 45,
|
|
hash: "2ca4a51e3cba1adf",
|
|
},
|
|
{ text: "x", ecl: "L", mask: 1, version: 1, size: 21, hash: "d1330f755ac63f88" },
|
|
];
|
|
|
|
for (const v of vectors) {
|
|
it(`produces a stable symbol for ${JSON.stringify(v.text).slice(0, 32)} (${v.ecl})`, () => {
|
|
const qr = QrCode.encodeText(v.text, v.ecl, { mask: v.mask });
|
|
expect(qr.version).toBe(v.version);
|
|
expect(qr.size).toBe(v.size);
|
|
expect(qr.mask).toBe(v.mask);
|
|
expect(matrixFingerprint(qr)).toBe(v.hash);
|
|
});
|
|
}
|
|
|
|
it("auto-selects the smallest version that fits the byte payload", () => {
|
|
// Byte-mode, EC level M capacities: v1=14, v2=26, v3=42 data bytes.
|
|
expect(QrCode.encodeText("a".repeat(14), "M").version).toBe(1);
|
|
expect(QrCode.encodeText("a".repeat(15), "M").version).toBe(2);
|
|
expect(QrCode.encodeText("a".repeat(26), "M").version).toBe(2);
|
|
expect(QrCode.encodeText("a".repeat(27), "M").version).toBe(3);
|
|
});
|
|
|
|
it("sizes by UTF-8 byte length, not string length", () => {
|
|
// "日" is 3 UTF-8 bytes; five of them = 15 bytes, just over v1-M's 14.
|
|
expect(QrCode.encodeText("日".repeat(5), "M").version).toBe(2);
|
|
});
|
|
|
|
it("deterministically selects a penalty-minimizing mask when none is forced", () => {
|
|
// Auto mask is the lowest-penalty choice; locking it guards the penalty rules.
|
|
const qr = QrCode.encodeText("https://my.omp.sh/#demo", "M");
|
|
expect(qr.mask).toBe(1);
|
|
expect(matrixFingerprint(qr)).toBe("ee820c588fe36d99");
|
|
});
|
|
|
|
it("throws when the payload exceeds version 40 at the chosen EC level", () => {
|
|
// v40-H holds 1273 data bytes.
|
|
expect(() => QrCode.encodeText("a".repeat(1274), "H")).toThrow(/too long/);
|
|
});
|
|
|
|
it("places the three finder patterns at the symbol corners", () => {
|
|
const qr = QrCode.encodeText("finder", "M");
|
|
// A finder is a 7x7 dark ring: the outer corner is dark and the module one
|
|
// step diagonally inward (the separating gap) is light.
|
|
const finderCorner = (ox: number, oy: number): boolean =>
|
|
qr.module(ox, oy) && !qr.module(ox + (ox === 0 ? 1 : -1), oy + (oy === 0 ? 1 : -1));
|
|
expect(finderCorner(0, 0)).toBe(true);
|
|
expect(finderCorner(qr.size - 1, 0)).toBe(true);
|
|
expect(finderCorner(0, qr.size - 1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("renderQrHalfBlocks", () => {
|
|
it("frames the symbol in a light quiet zone wide enough for the margin", () => {
|
|
const qr = QrCode.encodeText("https://omp.sh/#demo", "M");
|
|
const margin = 3;
|
|
const lines = renderQrHalfBlocks(qr, { margin });
|
|
// Visible cell width = symbol + both margins.
|
|
const stripped = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
expect(stripped[0]!.length).toBe(qr.size + margin * 2);
|
|
// Top quiet rows (margin/2 paired rows) carry no dark half-blocks.
|
|
const quietRows = Math.floor(margin / 2);
|
|
for (let i = 0; i < quietRows; i++) {
|
|
expect(stripped[i]).toBe(" ".repeat(qr.size + margin * 2));
|
|
}
|
|
// Body rows use half-block glyphs and a white-background/black-foreground prefix.
|
|
const body = lines[quietRows + 2]!;
|
|
expect(body).toContain("\x1b[47m");
|
|
expect(body).toMatch(/[▀▄█]/);
|
|
});
|
|
|
|
it("emits ceil((size + 2*margin)/2) rows", () => {
|
|
const qr = QrCode.encodeText("rows", "M");
|
|
const margin = 4;
|
|
const lines = renderQrHalfBlocks(qr, { margin });
|
|
expect(lines.length).toBe(Math.ceil((qr.size + margin * 2) / 2));
|
|
});
|
|
});
|