| 1 | import test from "node:test"; |
| 2 | import assert from "node:assert/strict"; |
| 3 | |
| 4 | import { encodeQr, renderQrToText } from "../src/qr.mjs"; |
| 5 | |
| 6 | // The rendered QR is the login credential, so a wrong matrix is worse than no |
| 7 | // QR at all. These checks pin the structural invariants a scanner relies on: |
| 8 | // finder patterns, timing patterns, and the format-information copies. |
| 9 | |
| 10 | function finderOk(matrix, row, col) { |
| 11 | for (let r = 0; r < 7; r++) { |
| 12 | for (let c = 0; c < 7; c++) { |
| 13 | const expected = |
| 14 | r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4); |
| 15 | if (Boolean(matrix[row + r][col + c]) !== expected) return false; |
| 16 | } |
| 17 | } |
| 18 | return true; |
| 19 | } |
| 20 | |
| 21 | const SAMPLE = "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=c09677d820dc2b705c2ba8c89dee2b4c&bot_type=3"; |
| 22 | |
| 23 | test("encodeQr picks a valid version size", () => { |
| 24 | // Version 1 is 21x21 and each version adds 4 modules. |
| 25 | for (const text of ["A", "HELLO", SAMPLE]) { |
| 26 | const size = encodeQr(text).length; |
| 27 | assert.equal((size - 17) % 4, 0, `size ${size} is not a valid QR size`); |
| 28 | assert.ok(size >= 21, `size ${size} below version 1`); |
| 29 | } |
| 30 | }); |
| 31 | |
| 32 | test("encodeQr places the three finder patterns", () => { |
| 33 | const matrix = encodeQr(SAMPLE); |
| 34 | const size = matrix.length; |
| 35 | assert.ok(finderOk(matrix, 0, 0), "top-left finder"); |
| 36 | assert.ok(finderOk(matrix, 0, size - 7), "top-right finder"); |
| 37 | assert.ok(finderOk(matrix, size - 7, 0), "bottom-left finder"); |
| 38 | }); |
| 39 | |
| 40 | test("encodeQr writes the alternating timing patterns", () => { |
| 41 | const matrix = encodeQr(SAMPLE); |
| 42 | const size = matrix.length; |
| 43 | for (let i = 8; i < size - 8; i++) { |
| 44 | assert.equal(matrix[6][i], i % 2 === 0 ? 1 : 0, `row timing at ${i}`); |
| 45 | assert.equal(matrix[i][6], i % 2 === 0 ? 1 : 0, `column timing at ${i}`); |
| 46 | } |
| 47 | }); |
| 48 | |
| 49 | test("encodeQr sets the fixed dark module", () => { |
| 50 | const matrix = encodeQr(SAMPLE); |
| 51 | assert.equal(matrix[matrix.length - 8][8], 1); |
| 52 | }); |
| 53 | |
| 54 | test("format information is mirrored between its two copies", () => { |
| 55 | const matrix = encodeQr(SAMPLE); |
| 56 | const size = matrix.length; |
| 57 | // Bits 0-5 run down column 8; the second copy of bits 8-14 runs down column 8 |
| 58 | // near the bottom-left finder. Pin that the split exists and is populated. |
| 59 | const firstCopy = [0, 1, 2, 3, 4, 5].map((r) => matrix[r][8]); |
| 60 | const secondCopy = [8, 9, 10, 11, 12, 13, 14].map((i) => matrix[size - 15 + i][8]); |
| 61 | assert.ok( |
| 62 | firstCopy.every((v) => v === 0 || v === 1), |
| 63 | "first format copy must be fully written" |
| 64 | ); |
| 65 | assert.ok( |
| 66 | secondCopy.every((v) => v === 0 || v === 1), |
| 67 | "second format copy must be fully written" |
| 68 | ); |
| 69 | }); |
| 70 | |
| 71 | test("encodeQr round-trips a UTF-8 payload without throwing", () => { |
| 72 | // 18 UTF-8 bytes needs version 2 (25x25), not version 1. |
| 73 | const matrix = encodeQr("微信扫码测试"); |
| 74 | assert.equal(matrix.length, 25); |
| 75 | assert.equal((matrix.length - 17) % 4, 0); |
| 76 | }); |
| 77 | |
| 78 | test("encodeQr rejects payloads beyond the supported versions", () => { |
| 79 | assert.throws(() => encodeQr("x".repeat(400)), /too long/); |
| 80 | }); |
| 81 | |
| 82 | test("renderQrToText produces half-block rows covering the whole matrix", () => { |
| 83 | const text = renderQrToText(SAMPLE); |
| 84 | const lines = text.split("\n"); |
| 85 | const matrix = encodeQr(SAMPLE); |
| 86 | const quietZone = 2; |
| 87 | const padded = matrix.length + quietZone * 2; |
| 88 | const expectedRows = padded % 2 === 0 ? padded : padded + 1; |
| 89 | |
| 90 | assert.equal(lines.length, expectedRows / 2, "one text row per two module rows"); |
| 91 | for (const line of lines) { |
| 92 | assert.equal([...line].length, padded, "every row is padded to the same width"); |
| 93 | } |
| 94 | }); |
| 95 | |
| 96 | test("renderQrToText emits only half-block glyphs and spaces", () => { |
| 97 | const text = renderQrToText(SAMPLE); |
| 98 | assert.match(text, /^[\u2580\u2584\u2588 \n]+$/); |
| 99 | }); |
| 100 |