aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 02:43:08 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 02:43:08 +0200
commitfa9c695256de1c7fe007ba5d6bb9b28b1deb5a5a (patch)
treebd418bc860349833d5a19c4af6bb8a61666477ca /tools
parent59255b1a55a81ba2b5de967eefced11910597b48 (diff)
test(codes): compare with zxing-cpp reference rows and decode encoder output and screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools')
-rw-r--r--tools/barcode_ref.py97
-rw-r--r--tools/check_codes.py60
-rw-r--r--tools/gen_code_vectors.py124
-rw-r--r--tools/tests/test_barcode_tools.py65
-rw-r--r--tools/ui_flow.py29
5 files changed, 373 insertions, 2 deletions
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 <symbology> <readable text as hex> <modules>". 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/<device>/<screen>.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: