| 1 | // The QR encoder, against two implementations that are not ours. |
| 2 | // |
| 3 | // This is the artifact that has to be right years after it was printed, on a |
| 4 | // phone we have never seen, when the person holding it has already lost |
| 5 | // everything else. So it is checked twice on every run: |
| 6 | // |
| 7 | // 1. ZXing decodes each symbol back to its payload — "will it scan?" |
| 8 | // 2. Python's `qrcode` produces the same matrix, bit-for-bit, at the same |
| 9 | // version, EC level and mask — "is it the RIGHT symbol?" |
| 10 | // |
| 11 | // The second question is the one decoding cannot answer. Error correction |
| 12 | // exists to make a damaged symbol readable, which means a symbol with a real |
| 13 | // mistake in it can still scan — and would, right up until the day somebody's |
| 14 | // print was slightly faded too. Reed–Solomon would hide our bug behind its |
| 15 | // own redundancy. |
| 16 | // |
| 17 | // Needs python3 with zxing-cpp, qrcode, pillow and numpy: |
| 18 | // |
| 19 | // pip install --user zxing-cpp qrcode pillow numpy |
| 20 | // |
| 21 | // A HARD gate. If the oracle is missing this fails rather than skipping: a |
| 22 | // skip on this test is indistinguishable from a pass, on the one artifact |
| 23 | // where being wrong is unrecoverable. |
| 24 | |
| 25 | import { test } from "node:test"; |
| 26 | import assert from "node:assert/strict"; |
| 27 | import { fileURLToPath } from "node:url"; |
| 28 | import { dirname, join } from "node:path"; |
| 29 | import { run as runOracle } from "./oracle.mjs"; |
| 30 | |
| 31 | const here = dirname(fileURLToPath(import.meta.url)); |
| 32 | const { encode, smallestVersion } = await import( |
| 33 | join(here, "..", "internal", "contra", "static", "crypto", "qr.js")); |
| 34 | |
| 35 | // Real printed forms, the extremes of the alphabet, and the boundaries where |
| 36 | // a version rolls over — which is where a capacity table is wrong if it is |
| 37 | // wrong anywhere. |
| 38 | const PAYLOADS = [ |
| 39 | "C1-E3D5Z-Z4R6M-8BQ93-740R9-8TWYX-MHGE0", |
| 40 | "A1-YR7V5-0F8RW-QPE9D-DTK5X-83KV1-0GJC8", |
| 41 | "A1-00000-00000-00000-00000-00000-00000", |
| 42 | "C1-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ", |
| 43 | "0", |
| 44 | "HELLO WORLD", |
| 45 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:", |
| 46 | ]; |
| 47 | |
| 48 | function ask(cases) { |
| 49 | return JSON.parse(runOracle("qroracle.py", cases)); |
| 50 | } |
| 51 | |
| 52 | test("every symbol scans, and is the symbol another encoder would have made", () => { |
| 53 | const cases = []; |
| 54 | for (const text of PAYLOADS) { |
| 55 | for (const ec of ["L", "M", "Q", "H"]) { |
| 56 | for (let mask = 0; mask < 8; mask++) { |
| 57 | const r = encode(text, { ec, mask }); |
| 58 | cases.push({ text, ec, mask, version: r.version, modules: r.modules }); |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | const verdicts = ask(cases); |
| 64 | assert.equal(verdicts.length, cases.length); |
| 65 | |
| 66 | const bad = verdicts.filter((v) => !v.ok); |
| 67 | const describe = (v) => |
| 68 | `${v.ec} mask=${v.mask} v${v.version} ${JSON.stringify(v.text).slice(0, 30)}: ` + |
| 69 | `scans=${v.scans} identical=${v.identical}` + |
| 70 | (v.differing_modules ? ` differing=${v.differing_modules}` : "") + |
| 71 | (v.reference_error ? ` refErr=${v.reference_error}` : ""); |
| 72 | assert.deepEqual(bad.map(describe), [], `${bad.length} of ${cases.length} symbols wrong`); |
| 73 | }); |
| 74 | |
| 75 | // The mask the encoder picks on its own must also be right — the sweep above |
| 76 | // forces every mask, which would not catch a broken penalty score. |
| 77 | test("the automatically chosen mask still scans", () => { |
| 78 | const cases = PAYLOADS.map((text) => { |
| 79 | const r = encode(text, { ec: "Q" }); |
| 80 | return { text, ec: "Q", mask: r.mask, version: r.version, modules: r.modules }; |
| 81 | }); |
| 82 | for (const v of ask(cases)) { |
| 83 | assert.ok(v.scans, `${JSON.stringify(v.text)} did not scan at its chosen mask`); |
| 84 | assert.ok(v.identical, `${JSON.stringify(v.text)} differs from the reference`); |
| 85 | } |
| 86 | }); |
| 87 | |
| 88 | // A CONTRA code at the EC level the kit prints at. Pinned so a change to |
| 89 | // either is a deliberate one. |
| 90 | test("a printed code is a version 3 symbol at EC-Q", () => { |
| 91 | const code = "C1-E3D5Z-Z4R6M-8BQ93-740R9-8TWYX-MHGE0"; |
| 92 | assert.equal(code.length, 38); |
| 93 | assert.equal(smallestVersion(code, "Q"), 3); |
| 94 | const r = encode(code, { ec: "Q" }); |
| 95 | assert.equal(r.version, 3); |
| 96 | assert.equal(r.modules.length, 29, "a version 3 symbol is 4*3+17 = 29 modules across"); |
| 97 | }); |
| 98 | |
| 99 | // The encoder refuses rather than guessing, on both edges. |
| 100 | test("it refuses what it cannot encode", () => { |
| 101 | assert.throws(() => encode("lower case"), /not alphanumeric/); |
| 102 | assert.throws(() => encode("héllo"), /not alphanumeric/); |
| 103 | assert.throws(() => encode("Z".repeat(200), { ec: "H" }), /version above 6/); |
| 104 | assert.throws(() => encode("SHORT", { ec: "X" }), /unknown EC level/); |
| 105 | }); |
| 106 | |