4"""ereader_golden.py -- golden-image regression gate for the e-reader chrome.
6The ``ereader_ui`` example (issue #80) paints its Library and Reading screens
7into the GLCDC framebuffer. ``tools/ra8_emulator`` renders that firmware
8framebuffer deterministically, so we can pin the chrome with checked-in golden
9images and fail CI (or the local ``just apps::emulator::golden`` recipe) when an unrelated
12ra8_emulator's ``--ppm`` snapshot is the panel framebuffer PLUS a fixed-width debug
13sidebar on the right (LED / USB / IRQ state). The chrome golden must test the
14*firmware* output, not ra8_emulator's overlay, so every snapshot is cropped to the
15panel region (total width minus the sidebar) before it is hashed or stored. The
16golden bytes are gzipped (the flat 16-level-grayscale chrome compresses ~30x).
20 ereader_golden.py check --elf E --emulator B --golden-dir D [--out-dir O]
21 ereader_golden.py update --elf E --emulator B --golden-dir D
23``check`` renders each screen, crops it, and compares against
24``<golden-dir>/<name>.ppm.gz``; mismatches are reported (with the actual image
25written to ``--out-dir`` for inspection) and exit status is non-zero.
26``update`` regenerates the goldens in place.
28The script is stdlib-only (gzip / subprocess / argparse) so it runs anywhere the
29ra8_emulator binary and the cross-built ``.elf`` exist.
32from __future__
import annotations
39from pathlib
import Path
42PPM_HEADER_TOKEN_COUNT = 3
54SCREENS: tuple[tuple[str, tuple[str, ...]], ...] = (
56 (
"reading", (
"--click",
"250",
"250")),
57 (
"keyboard", (
"--click",
"200",
"100")),
58 (
"battery_low", (
"--battery",
"8")),
62def read_ppm(path: Path) -> tuple[int, int, int, bytes]:
63 """Parse a binary (P6) PPM into (width, height, maxval, pixel-bytes)."""
64 data = path.read_bytes()
66 msg = f
"{path}: not a P6 PPM"
69 tokens: list[int] = []
70 while len(tokens) < PPM_HEADER_TOKEN_COUNT:
71 while idx < len(data)
and data[idx]
in b
" \t\n\r":
74 while idx < len(data)
and data[idx]
not in b
" \t\n\r":
76 tokens.append(int(data[start:idx]))
78 width, height, maxval = tokens
79 pixels = data[idx : idx + width * height * 3]
80 if len(pixels) != width * height * 3:
81 msg = f
"{path}: truncated raster"
83 return width, height, maxval, pixels
86def crop_panel(width: int, height: int, maxval: int, pixels: bytes) -> bytes:
87 """Return a P6 PPM of the panel region (left ``width - SIDEBAR_W`` columns)."""
88 panel_w = width - SIDEBAR_W
90 msg = f
"width {width} <= sidebar {SIDEBAR_W}"
93 for row
in range(height):
95 out += pixels[off : off + panel_w * 3]
96 header = b
"P6\n%d %d\n%d\n" % (panel_w, height, maxval)
97 return header + bytes(out)
100def render_panel(emulator: Path, elf: Path, extra: tuple[str, ...]) -> bytes:
101 """Run ra8_emulator for one screen and return its cropped-panel PPM bytes."""
102 with tempfile.NamedTemporaryFile(suffix=
".ppm")
as tmp:
103 cmd = [str(emulator), str(elf), *extra,
"--ppm", tmp.name]
107 proc = subprocess.run(cmd, capture_output=
True, check=
False)
108 if proc.returncode != 0:
109 err = proc.stderr.decode(
"utf-8",
"replace")
110 msg = f
"ra8_emulator failed: {' '.join(cmd)}\n{err}"
111 raise RuntimeError(msg)
112 return crop_panel(*read_ppm(Path(tmp.name)))
115def golden_path(golden_dir: Path, name: str) -> Path:
116 """Path of one screen's golden image, gzip-compressed.
118 One naming rule shared by the update and check paths, so the two can never
119 disagree about which file a screen owns.
121 return golden_dir / f
"{name}.ppm.gz"
124def do_update(args: argparse.Namespace) -> int:
125 """Re-render every screen and overwrite its golden image.
127 Accepts whatever the emulator currently produces as correct, so it must
128 only be run when the change in output is understood and intended -- this
129 is the operation that can silently bless a rendering regression.
133 args.golden_dir.mkdir(parents=
True, exist_ok=
True)
134 for name, extra
in SCREENS:
135 panel = render_panel(args.emulator, args.elf, extra)
136 golden_path(args.golden_dir, name).write_bytes(gzip.compress(panel, 9))
137 print(f
" updated {name}.ppm.gz ({len(panel)} bytes -> gz)")
141def do_check(args: argparse.Namespace) -> int:
142 """Re-render every screen and compare it against its golden image.
144 A MISSING golden counts as a failure rather than being created on the fly;
145 silently generating it would make the first run of a new screen pass
146 against no reference at all.
148 Returns the number of failing screens (0 when every screen matches).
151 for name, extra
in SCREENS:
152 gpath = golden_path(args.golden_dir, name)
153 if not gpath.exists():
154 print(f
" [FAIL] {name}: golden missing ({gpath}); run 'update'")
157 actual = render_panel(args.emulator, args.elf, extra)
158 expected = gzip.decompress(gpath.read_bytes())
159 if actual == expected:
160 print(f
" [PASS] {name}: chrome matches golden")
163 msg =
"size differs" if len(actual) != len(expected)
else "pixels differ"
164 print(f
" [FAIL] {name}: {msg} (actual {len(actual)} vs golden {len(expected)})")
165 if args.out_dir
is not None:
166 args.out_dir.mkdir(parents=
True, exist_ok=
True)
167 out = args.out_dir / f
"{name}.actual.ppm"
168 out.write_bytes(actual)
169 print(f
" wrote {out} for inspection")
171 print(f
"[FAIL] ereader chrome golden: {failures} screen(s) drifted.")
173 print(
"[PASS] ereader chrome golden: all screens match.")
178 """Dispatch to the golden-image check or update mode.
180 The mode is a required positional with no default, deliberately: defaulting
181 to ``update`` would let a careless invocation overwrite the references it
182 was meant to test against.
184 parser = argparse.ArgumentParser(description=__doc__)
185 parser.add_argument(
"mode", choices=(
"check",
"update"))
186 parser.add_argument(
"--elf", type=Path, required=
True, help=
"cross-built ereader_ui.elf")
187 parser.add_argument(
"--emulator", type=Path, required=
True, help=
"ra8_emulator binary")
188 parser.add_argument(
"--golden-dir", type=Path, required=
True, help=
"golden image directory")
189 parser.add_argument(
"--out-dir", type=Path, default=
None, help=
"where to dump mismatches")
190 args = parser.parse_args()
192 if not args.elf.exists():
193 print(f
"error: elf not found: {args.elf}", file=sys.stderr)
195 if not args.emulator.exists():
196 print(f
"error: ra8_emulator not found: {args.emulator}", file=sys.stderr)
199 return do_update(args)
if args.mode ==
"update" else do_check(args)
202if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.