From fa9c695256de1c7fe007ba5d6bb9b28b1deb5a5a Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 02:43:08 +0200 Subject: test(codes): compare with zxing-cpp reference rows and decode encoder output and screenshots Co-Authored-By: Claude Opus 5.5 --- tools/barcode_ref.py | 97 +++++++++++++++++++++++++++++ tools/check_codes.py | 60 ++++++++++++++++++ tools/gen_code_vectors.py | 124 ++++++++++++++++++++++++++++++++++++++ tools/tests/test_barcode_tools.py | 65 ++++++++++++++++++++ tools/ui_flow.py | 29 ++++++++- 5 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 tools/barcode_ref.py create mode 100644 tools/check_codes.py create mode 100644 tools/gen_code_vectors.py create mode 100644 tools/tests/test_barcode_tools.py (limited to 'tools') diff --git a/tools/barcode_ref.py b/tools/barcode_ref.py new file mode 100644 index 0000000..2e58b5e --- /dev/null +++ b/tools/barcode_ref.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Shared barcode helpers for the reference tools: zxing-cpp module rows, rendering, decoding, check digits. +"""Helpers shared by gen_code_vectors.py and check_codes.py. + +Symbology numbers match source/codes/Codes.mc. zxing-cpp is both the +reference encoder (module rows) and the decoder (README 12.4). +""" + +from __future__ import annotations + +from typing import Any, cast + +import zxingcpp +from PIL import Image + +QR, CODE128, CODE39, EAN13, EAN8, UPCA, UPCE, ITF, CODABAR = range(9) +FORMATS = { + CODE128: zxingcpp.BarcodeFormat.Code128, + CODE39: zxingcpp.BarcodeFormat.Code39, + EAN13: zxingcpp.BarcodeFormat.EAN13, + EAN8: zxingcpp.BarcodeFormat.EAN8, + UPCA: zxingcpp.BarcodeFormat.UPCA, + UPCE: zxingcpp.BarcodeFormat.UPCE, + ITF: zxingcpp.BarcodeFormat.ITF, + CODABAR: zxingcpp.BarcodeFormat.Codabar, +} +CODE39_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. $/+%" +LINEAR_QUIET_MODULES = 10 # README 12.2 + + +def reference_modules(symbology: int, text: str) -> str: + """zxing-cpp's module row for text, without quiet zones, as '0'/'1'.""" + barcode = zxingcpp.create_barcode(text, FORMATS[symbology]) + image = barcode.to_image(scale=1, add_hrt=False, add_quiet_zones=False) + # zxing-cpp images support the buffer protocol; its type stubs don't say so. + row = bytes(memoryview(cast(Any, image)))[: image.shape[1]] + return "".join("1" if v < 128 else "0" for v in row) + + +def render_linear(modules: str, scale: int, height: int = 40) -> Image.Image: + """Black bars on white with the quiet zone, `scale` pixels per module.""" + quiet = LINEAR_QUIET_MODULES * scale + image = Image.new("L", (len(modules) * scale + 2 * quiet, height), 255) + for i, module in enumerate(modules): + if module == "1": + image.paste(0, (quiet + i * scale, 0, quiet + (i + 1) * scale, height)) + return image + + +# What to read each symbology as. Code 39 is read as standard Code 39: by +# default zxing-cpp also tries full-ASCII (extended) Code 39, which turns +# pairs like "+S" into "s" (the app doesn't encode extended Code 39). +READ_FORMATS = {**FORMATS, CODE39: zxingcpp.BarcodeFormat.Code39Std, UPCA: zxingcpp.BarcodeFormat.EAN13} + + +def decode(image: Image.Image, symbology: int | None = None) -> list[str]: + """Texts zxing-cpp reads in image (only as `symbology` when given).""" + if symbology is None: + return [result.text for result in zxingcpp.read_barcodes(image)] + # One format works as a format set at runtime; the stubs only allow the set type. + formats = cast(zxingcpp.BarcodeFormats, READ_FORMATS[symbology]) + return [result.text for result in zxingcpp.read_barcodes(image, formats=formats)] + + +def gs1_check(digits: str) -> int: + """GS1 mod-10 check digit of the data digits.""" + total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(digits))) + return (10 - total % 10) % 10 + + +def code39_check(text: str) -> str: + return CODE39_ALPHABET[sum(CODE39_ALPHABET.index(c) for c in text) % 43] + + +def upce_expand(code: str) -> str: + """UPC-E 'NS dddddd C' (8 digits) as the 13-digit EAN form zxing reads.""" + ns, d, check = code[0], code[1:7], code[7] + last = d[5] + if last in "012": + body = d[0:2] + last + "0000" + d[2:5] + elif last == "3": + body = d[0:3] + "00000" + d[3:5] + elif last == "4": + body = d[0:4] + "00000" + d[4] + else: + body = d[0:5] + "0000" + last + return "0" + ns + body + check + + +def expected_reading(symbology: int, readable: str) -> str: + """What zxing-cpp reports when it decodes a code whose human text is `readable`.""" + if symbology == UPCA: + return "0" + readable + if symbology == UPCE: + return upce_expand(readable) + return readable diff --git a/tools/check_codes.py b/tools/check_codes.py new file mode 100644 index 0000000..22d3033 --- /dev/null +++ b/tools/check_codes.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Decode the barcodes the watch's encoders produced in a unit-test run with zxing-cpp (README 12.4 check 2). +"""Decode-check the CODEDUMP lines of a Monkey C test log. + +Each line is "CODEDUMP ". Every +code is rendered with its quiet zone at 1, 2 and 3 pixels per module and +must decode to the expected text at each size. + +Usage: check_codes.py LOG [LOG ...] Exit: 0 all decoded, 1 a failure, 2 no dump found. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import barcode_ref as ref + +DUMP = re.compile(r"CODEDUMP (\d+) ([0-9a-f]*) ([01]+)") +SCALES = (1, 2, 3) + + +def check_log(path: Path) -> tuple[int, list[str]]: + """(codes checked, failure messages) for one log.""" + checked = 0 + failures: list[str] = [] + for match in DUMP.finditer(path.read_text(encoding="utf-8", errors="replace")): + symbology = int(match.group(1)) + text = bytes.fromhex(match.group(2)).decode("utf-8") + modules = match.group(3) + expected = ref.expected_reading(symbology, text) + for scale in SCALES: + got = ref.decode(ref.render_linear(modules, scale), symbology) + if expected not in got: + failures.append(f"symbology {symbology} '{text}' at {scale} px/module: read {got}") + checked += 1 + return checked, failures + + +def main(argv: list[str] | None = None) -> int: + paths = [Path(p) for p in (argv if argv is not None else sys.argv[1:])] + total = 0 + failed = False + for path in paths: + checked, failures = check_log(path) + total += checked + for failure in failures[:10]: + print(f"[{path.stem}] {failure}") + failed = failed or bool(failures) + print(f"[{path.stem}] decoded {checked} barcodes at {len(SCALES)} sizes: {'FAILED' if failures else 'ok'}") + if total == 0: + print("no CODEDUMP lines found") + return 2 + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gen_code_vectors.py b/tools/gen_code_vectors.py new file mode 100644 index 0000000..970ca06 --- /dev/null +++ b/tools/gen_code_vectors.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Generate test/data/CodeVectors.mc: zxing-cpp reference module rows for the linear barcode encoders. +"""Reference module rows for README 12.4 check 1 (all fake payloads). + +For every linear symbology: fixed edge cases plus seeded random payloads, +each with the options the watch uses and zxing-cpp's module row. Where the +watch adds something zxing doesn't (a Code 39 or ITF check character, Codabar +start/stop characters), the reference is made from the completed text. + + gen_code_vectors.py [--seed 1212] [--count 25] [--out test/data/CodeVectors.mc] +""" + +from __future__ import annotations + +import argparse +import random +import string +from pathlib import Path + +import barcode_ref as ref + +CHECK_ADD, CHECK_FIX, CHECK_OFF = 0, 1, 2 +TEXT_ON = 0x10 + + +def options(check: int) -> int: + return TEXT_ON | (check << 2) | 1 + + +def digits(rng: random.Random, n: int) -> str: + return "".join(rng.choice(string.digits) for _ in range(n)) + + +def valid_upce(rng: random.Random) -> str: + """Six UPC-E digits that are a canonical zero suppression.""" + while True: + d = digits(rng, 6) + last = d[5] + if (last == "3" and d[2] in "012") or (last == "4" and d[3] == "0") or (last in "56789" and d[4] == "0"): + continue + return d + + +def cases(rng: random.Random, count: int) -> list[tuple[int, str, int, str]]: + """(symbology, payload, options, zxing text) tuples.""" + out: list[tuple[int, str, int, str]] = [] + fixed = [ + (ref.EAN13, "590123412345", options(CHECK_ADD), "590123412345"), + (ref.EAN13, "5901234123457", options(CHECK_ADD), "5901234123457"), + (ref.EAN8, "1234567", options(CHECK_ADD), "1234567"), + (ref.UPCA, "01234567890", options(CHECK_ADD), "01234567890"), + (ref.UPCE, "0425261", options(CHECK_ADD), "0425261"), + (ref.UPCE, "1234565", options(CHECK_ADD), "1234565"), + (ref.CODE128, "1234", options(CHECK_ADD), "1234"), + (ref.CODE128, "123456789", options(CHECK_ADD), "123456789"), + (ref.CODE128, "AB12345678", options(CHECK_ADD), "AB12345678"), + (ref.CODE128, "A1234B12345", options(CHECK_ADD), "A1234B12345"), + (ref.CODE128, " ~", options(CHECK_ADD), " ~"), + (ref.CODE39, "CODE 39", options(CHECK_OFF), "CODE 39"), + (ref.CODE39, "CODE-39", options(CHECK_ADD), "CODE-39" + ref.code39_check("CODE-39")), + (ref.ITF, "1234", options(CHECK_OFF), "1234"), + (ref.ITF, "123", options(CHECK_ADD), "123" + str(ref.gs1_check("123"))), + (ref.CODABAR, "A40156B", options(CHECK_ADD), "A40156B"), + (ref.CODABAR, "31117013206375", options(CHECK_ADD), "A31117013206375A"), + ] + out.extend(fixed) + printable = string.ascii_letters + string.digits + " !#$%&()*+,-./:;<=>?@[]^_{|}~" + for _ in range(count): + out.append((ref.EAN13, digits(rng, 12), options(CHECK_ADD), "")) + out.append((ref.EAN8, digits(rng, 7), options(CHECK_ADD), "")) + out.append((ref.UPCA, digits(rng, 11), options(CHECK_ADD), "")) + out.append((ref.UPCE, rng.choice("01") + valid_upce(rng), options(CHECK_ADD), "")) + n = rng.randint(1, 20) + mixed = "".join(rng.choice(string.digits) if rng.random() < 0.5 else rng.choice(printable) for _ in range(n)) + out.append((ref.CODE128, mixed, options(CHECK_ADD), mixed)) + c39 = "".join(rng.choice(ref.CODE39_ALPHABET) for _ in range(rng.randint(1, 12))) + with_check = rng.random() < 0.5 + out.append( + ( + ref.CODE39, + c39, + options(CHECK_ADD if with_check else CHECK_OFF), + c39 + ref.code39_check(c39) if with_check else c39, + ) + ) + itf = digits(rng, 2 * rng.randint(1, 8)) + out.append((ref.ITF, itf, options(CHECK_OFF), itf)) + inner = "".join(rng.choice("0123456789-$:/.+") for _ in range(rng.randint(1, 12))) + guard = rng.choice("ABCD") + inner + rng.choice("ABCD") + out.append((ref.CODABAR, guard, options(CHECK_ADD), guard)) + return out + + +def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: + lines = [] + for symbology, payload, opts, text in rows: + modules = ref.reference_modules(symbology, text or payload) + escaped = payload.replace("\\", "\\\\").replace('"', '\\"') + lines.append(f' [{symbology}, "{escaped}", {opts}, "{modules}"]') + body = ",\n".join(lines) + return ( + "// Author: cirakg & claude ai\n" + "// Summary: zxing-cpp reference module rows for the linear barcode encoders (generated; do not edit).\n" + f"//\n// Generated by tools/gen_code_vectors.py, seed {seed}. Rows: [symbology, payload, options,\n" + "// expected modules without quiet zones]. Fake payloads only.\n\n" + "import Toybox.Lang;\n\n(:test)\nmodule CodeVectors {\n const LINEAR = [\n" + body + "\n ];\n}\n" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--seed", type=int, default=1212) + parser.add_argument("--count", type=int, default=25) + parser.add_argument("--out", type=Path, default=Path("test/data/CodeVectors.mc")) + args = parser.parse_args(argv) + rows = cases(random.Random(args.seed), args.count) + args.out.write_text(render(rows, args.seed), encoding="utf-8") + print(f"wrote {len(rows)} reference rows to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_barcode_tools.py b/tools/tests/test_barcode_tools.py new file mode 100644 index 0000000..7be1f18 --- /dev/null +++ b/tools/tests/test_barcode_tools.py @@ -0,0 +1,65 @@ +# Author: cirakg & claude ai +# Summary: Tests for the barcode reference tools: zxing-cpp rows, rendering, decoding, check digits, generator, checker. +from __future__ import annotations + +import random +from pathlib import Path + +import barcode_ref as ref +import check_codes +import gen_code_vectors + +ROOT = Path(__file__).resolve().parents[2] + + +def test_reference_rows_have_the_standard_lengths() -> None: + assert ref.reference_modules(ref.EAN13, "590123412345") == ( + "10100010110100111011001100100110111101001110101010110011011011001000010101110010011101000100101" + ) + assert len(ref.reference_modules(ref.EAN8, "1234567")) == 67 + assert len(ref.reference_modules(ref.UPCE, "0425261")) == 51 + + +def test_rendered_codes_decode() -> None: + modules = ref.reference_modules(ref.CODE39, "ABC") + for scale in (1, 2, 3): + assert ref.decode(ref.render_linear(modules, scale), ref.CODE39) == ["ABC"] + + +def test_check_digits() -> None: + assert ref.gs1_check("590123412345") == 7 + assert ref.gs1_check("1234567") == 0 + assert ref.code39_check("CODE 39") == "R" # (12+24+13+14+38+3+9) % 43 = 27 + + +def test_expected_reading_normalises_upc() -> None: + assert ref.expected_reading(ref.UPCA, "012345678905") == "0012345678905" + assert ref.upce_expand("01234565") == "0012345000065" + + +def test_generator_only_makes_canonical_upce() -> None: + rng = random.Random(5) + for _ in range(200): + d = gen_code_vectors.valid_upce(rng) + ref.reference_modules(ref.UPCE, "0" + d) # zxing-cpp rejects non-canonical ones + + +def test_committed_vectors_match_the_generator() -> None: + rows = gen_code_vectors.cases(random.Random(1212), 25) + committed = (ROOT / "test" / "data" / "CodeVectors.mc").read_text(encoding="utf-8") + assert committed == gen_code_vectors.render(rows, 1212) + + +def test_check_codes_reads_dump_lines(tmp_path: Path) -> None: + good = ref.reference_modules(ref.EAN8, "1234567") + log = tmp_path / "dev.log" + log.write_text( + f"CODEDUMP {ref.EAN8} {b'12345670'.hex()} {good}\nCODEDUMP {ref.EAN8} {b'12345670'.hex()} {good[:-5]}00000\n", + encoding="utf-8", + ) + checked, failures = check_codes.check_log(log) + assert checked == 2 and len(failures) == 3 # the damaged code fails at all three sizes + assert check_codes.main([str(log)]) == 1 + empty = tmp_path / "empty.log" + empty.write_text("nothing\n", encoding="utf-8") + assert check_codes.main([str(empty)]) == 2 diff --git a/tools/ui_flow.py b/tools/ui_flow.py index 3e82231..82e5d65 100644 --- a/tools/ui_flow.py +++ b/tools/ui_flow.py @@ -23,7 +23,8 @@ The flow covers: the results of an Aegis import sent through the settings form (the fixture sets the form, as the phone would), the list, a TOTP entry, a HOTP entry and its next code, the PIN screen, a wrong PIN, a correct PIN opening a locked entry, auto-lock closing it, the menu, the About screen (watches with buttons; touch-only -menus have no focus to move) and the lockout after 5 wrong PINs. +menus have no focus to move), the lockout after 5 wrong PINs, and an EAN-13 +code whose screenshot must decode with zxing-cpp (README 12.4). Each screenshot is compared with test/golden//.png like sim_screenshot.py does (or saved there with --approve). @@ -43,6 +44,7 @@ from dataclasses import dataclass from pathlib import Path import sim_screenshot as shots +import zxingcpp EXIT_OK, EXIT_DIFFERENT, EXIT_FLOW = 0, 1, 2 SCREEN_TIMEOUT_S = 20.0 @@ -57,6 +59,7 @@ FIXTURE_PIN = "123456" # source/app/Fixtures.mc WRONG_PIN = "000000" FAILURES_UNTIL_WAIT = 5 # source/storage/Lockout.mc: the 5th failure starts a delay SELECTOR_OK = 10 # source/ui/PinView.mc: selector positions 0-9, then OK +GYM_CARD = "5901234123457" # source/app/Fixtures.mc: EAN-13 with its check digit class FlowError(Exception): @@ -295,7 +298,7 @@ class Flow: """Call before an input whose result `expect` should wait for.""" self.log.set_mark() - def shot(self, name: str) -> None: + def shot(self, name: str, expect_code: str | None = None) -> None: raw = self.sim.capture(name) out = self.out / f"{name}.png" label = f"{self.sim.device_dir.name}/{name}" @@ -305,6 +308,14 @@ class Flow: print(f"[{label}] {err}") self.results[name] = EXIT_DIFFERENT return + if expect_code is not None: + # README 12.4 check 3: the code on the screenshot must decode. + read = [r.text for r in zxingcpp.read_barcodes(screen)] + if expect_code not in read: + print(f"[{label}] the barcode didn't decode to {expect_code!r}: read {read}") + self.results[name] = EXIT_DIFFERENT + return + print(f"[{label}] barcode decodes to {expect_code!r}") golden = self.golden / f"{name}.png" if self.approve: shots.save_png(screen, golden, f"Approved simulator screen '{name}' of {self.sim.device_dir.name}") @@ -414,6 +425,19 @@ def flow_lockout(f: Flow) -> None: f.shot("pin_wait") +def flow_code(f: Flow) -> None: + """The EAN-13 gym card (index 6): its screenshot must decode.""" + f.act() + f.back() # leave the waiting PIN screen + f.expect("list") + for _ in range(4): # Bank (2) to the gym card (6) + f.next() + f.act() + f.open_focused_row() + f.expect("code") + f.shot("code", expect_code=GYM_CARD) + + def run_flow(f: Flow) -> None: """The scripted session; see the module docstring.""" flow_import(f) @@ -421,6 +445,7 @@ def run_flow(f: Flow) -> None: flow_locked_entry(f) flow_menu(f) flow_lockout(f) + flow_code(f) def find_key(data: object, key: str) -> object: -- cgit v1.2.3