Files
kokkoja-rel/tests/test_qr_code.py
T

85 lines
3.5 KiB
Python
Raw Normal View History

"""The client's QR encoder against Nayuki's reference encoder (qrcodegen).
Every version from 1 to 40 at every level, and the invitation link's
shape, must give the same modules, mask choice included. Needs node
(the type checker's) and qrcodegen (tests/requirements.txt). Two other
encoders were tried as the reference: python-qrcode agrees with ours at
a given mask but chooses masks by its own reading of the penalty rules,
and segno's modules differ even at the same mask.
"""
import json
import os
import subprocess
import unittest
from qrcodegen import QrCode, QrSegment
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
LEVELS = {"L": QrCode.Ecc.LOW, "M": QrCode.Ecc.MEDIUM, "Q": QrCode.Ecc.QUARTILE, "H": QrCode.Ecc.HIGH}
HARNESS = """
import { qrModules } from %s;
const texts = JSON.parse(await new Promise((resolve) => {
let data = '';
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
}));
console.log(JSON.stringify(texts.map(([text, level]) =>
qrModules(text, level).map((row) => row.map((dark) => dark ? 1 : 0)))));
"""
def encode_in_client(texts):
"""[(text, level)] -> list of module matrices (lists of 0/1 rows)."""
harness = HARNESS % json.dumps(os.path.join(CLIENT, "qr_code.js"))
result = subprocess.run(["node", "--input-type=module", "-e", harness],
input=json.dumps(texts), capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def reference(data, level):
"""Nayuki's code for those bytes in byte mode: (version, mask, matrix)."""
code = QrCode.encode_segments([QrSegment.make_bytes(data)], LEVELS[level], boostecl=False)
size = code.get_size()
matrix = [[1 if code.get_module(x, y) else 0 for x in range(size)] for y in range(size)]
return code.get_version(), code.get_mask(), matrix
def longest_text(version, level):
"""The longest text of that version at that level in byte mode."""
bits = QrCode._get_num_data_codewords(version, LEVELS[level]) * 8
length = (bits - 4 - (8 if version < 10 else 16)) // 8
assert reference(b"x" * length, level)[0] == version
return "".join(chr(33 + (i * 7) % 90) for i in range(length))
class QrCodeTest(unittest.TestCase):
def check(self, texts):
matrices = encode_in_client(texts)
for (text, level), matrix in zip(texts, matrices):
version, mask, expected = reference(text.encode("utf-8"), level)
with self.subTest(level=level, length=len(text.encode("utf-8")), version=version, mask=mask):
self.assertEqual(matrix, expected)
def test_invitation_link(self):
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23"
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
("Übung 1: äöü", "M")])
def test_every_version(self):
texts = []
for level in "LMQH":
for version in range(1, 41):
# The longest text of that version, so that the block
# structure is filled, and a short one with much padding.
full = longest_text(version, level)
texts.append((full, level))
texts.append((full[: len(full) // 2 + 1], level))
self.check(texts)
if __name__ == "__main__":
unittest.main()