From 2292bf700d28ade39cf2d64b45509e6db21034bc Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 18 Oct 2026 21:10:37 +0200 Subject: test(tools): add screenshot, budget and product checks; enforce file headers - sim_screenshot.py finds the device drawing in the simulator window, crops the screen and compares it with approved golden images. - check_budgets.py reads key=value DIAG/HARNESS lines (the simulator console escapes quotes, so JSON was dropped). - ciq_test_report.py accepts SDK 9.2.0's real summary line `PASSED (passed=N, failed=N, errors=N)`; a captured log is a fixture. - gen_products.py checks all manifest products are watches with API 5.0+ and at least 512 KB for apps. - lint_project.py rule HDR001: every file carries the Author/Summary header (JSON keys, PNG text chunks; generated package-lock exempt). - make_icon.py --all draws one icon per launcher size in use. - Golden screenshots for fr255s, venusq2, fr70 and fr965. - 44 tests, warnings as errors, ruff and mypy --strict clean. Co-Authored-By: Claude Opus 5.5 --- tools/sim_screenshot.py | 217 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tools/sim_screenshot.py (limited to 'tools/sim_screenshot.py') diff --git a/tools/sim_screenshot.py b/tools/sim_screenshot.py new file mode 100644 index 0000000..81b41b6 --- /dev/null +++ b/tools/sim_screenshot.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Cut the watch screen out of a simulator window screenshot and compare it with the approved golden image. +"""Cut the watch screen out of a Connect IQ simulator screenshot. + +Usage: + sim_screenshot.py --raw RAW.png --device-dir DIR --out SCREEN.png + [--golden GOLDEN.png] [--approve] + +Steps: + 1. Find where the device drawing (DIR/device.png) sits in the window + screenshot. The window adds a menu bar and borders, so the offset is + searched for, comparing only the bezel: the display area is masked out, + because the app draws there. + 2. Crop the display rectangle from DIR/simulator.json (display.location) and + black out the corners of round displays, which show bezel, not screen. + 3. Fail if the screen is blank (the app drew nothing). + 4. With --golden, compare with the approved image: the check fails when more + than 0.5 % of the pixels differ by more than 24 levels in any channel. A + diff image (differences in red) is written next to --out. + 5. With --approve, save the screen as the new golden image instead. + +Exit: 0 ok, 1 differs from the golden image, 2 error, 3 no golden image yet. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageStat +from PIL.PngImagePlugin import PngInfo + +EXIT_OK, EXIT_DIFFERENT, EXIT_ERROR, EXIT_NO_GOLDEN = 0, 1, 2, 3 +AUTHOR = "cirakg & claude ai" +MAX_OFFSET_X, MAX_OFFSET_Y = 16, 64 # search range for the device drawing +MIN_OVERLAP = 0.9 # the window may clip the drawing's edge, but not more than this +MAX_BEZEL_DIFF = 6.0 # mean absolute difference per channel at the best offset +PIXEL_TOLERANCE = 24 # per-channel difference that still counts as equal +MAX_DIFF_FRACTION = 0.005 # share of pixels allowed to differ +MIN_SCREEN_STDDEV = 2.0 # below this the screen counts as blank + + +@dataclass(frozen=True) +class Display: + """The watch screen inside the device drawing.""" + + x: int + y: int + width: int + height: int + shape: str # round, rectangle, semi-octagon, ... + image: str # file name of the device drawing, e.g. device.png or fr255s.png + + +def open_image(path: Path) -> Image.Image: + """Load an image completely and close its file straight away (no open handles left behind).""" + with Image.open(path) as img: + img.load() + return img + + +def load_display(device_dir: Path) -> Display: + """Display rectangle, shape and drawing file from the device's simulator.json.""" + sim = json.loads((device_dir / "simulator.json").read_text(encoding="utf-8")) + loc = sim["display"]["location"] + return Display( + int(loc["x"]), + int(loc["y"]), + int(loc["width"]), + int(loc["height"]), + str(sim["display"]["shape"]), + str(sim.get("image", "device.png")), + ) + + +def bezel_only(device: Image.Image, display: Display) -> Image.Image: + """The device drawing with the display area made transparent.""" + masked = device.convert("RGBA") + ImageDraw.Draw(masked).rectangle( + (display.x, display.y, display.x + display.width - 1, display.y + display.height - 1), fill=(0, 0, 0, 0) + ) + return masked + + +def find_offset(raw: Image.Image, bezel: Image.Image) -> tuple[int, int, float]: + """Best (dx, dy) placement of the bezel in the screenshot, and its mean difference. + + When the window is exactly as wide as the drawing, the 1 px window border + pushes the drawing's last column out of the window, so only the part that + overlaps the screenshot is compared. + """ + raw_rgba = raw.convert("RGBA") + best = (0, 0, float("inf")) + for dy in range(MAX_OFFSET_Y + 1): + for dx in range(MAX_OFFSET_X + 1): + width = min(bezel.width, raw.width - dx) + height = min(bezel.height, raw.height - dy) + if width < bezel.width * MIN_OVERLAP or height < bezel.height * MIN_OVERLAP: + continue + region = raw_rgba.crop((dx, dy, dx + width, dy + height)) + expected = region.copy() + expected.alpha_composite(bezel.crop((0, 0, width, height))) + diff = ImageChops.difference(expected.convert("RGB"), region.convert("RGB")) + score = sum(ImageStat.Stat(diff).mean) / 3 + if score < best[2]: + best = (dx, dy, score) + return best + + +def crop_screen(raw: Image.Image, offset: tuple[int, int], display: Display) -> Image.Image: + """The display area, with the corners of round screens blacked out.""" + dx, dy = offset + left, top = dx + display.x, dy + display.y + screen = raw.convert("RGB").crop((left, top, left + display.width, top + display.height)) + if display.shape == "round": + mask = Image.new("L", screen.size, 0) + ImageDraw.Draw(mask).ellipse((0, 0, display.width - 1, display.height - 1), fill=255) + black = Image.new("RGB", screen.size, (0, 0, 0)) + screen = Image.composite(screen, black, mask) + return screen + + +def is_blank(screen: Image.Image) -> bool: + return max(ImageStat.Stat(screen).stddev) < MIN_SCREEN_STDDEV + + +def compare(screen: Image.Image, golden: Image.Image) -> tuple[float, Image.Image]: + """Share of differing pixels and a diff image (differences in red).""" + if screen.size != golden.size: + return 1.0, Image.new("RGB", screen.size, (255, 0, 0)) + diff = ImageChops.difference(screen.convert("RGB"), golden.convert("RGB")) + # A pixel differs when any channel differs by more than the tolerance. + channels = [band.point(lambda v: 255 if v > PIXEL_TOLERANCE else 0) for band in diff.split()] + differing = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2]) + count = sum(differing.histogram()[1:]) # every non-zero pixel + overlay = Image.composite(Image.new("RGB", screen.size, (255, 0, 0)), screen.convert("RGB"), differing) + return count / (screen.width * screen.height), overlay + + +def save_png(img: Image.Image, path: Path, description: str) -> None: + info = PngInfo() + info.add_text("Author", AUTHOR) + info.add_text("Description", description) + path.parent.mkdir(parents=True, exist_ok=True) + img.save(path, format="PNG", optimize=True, pnginfo=info) + + +class ScreenshotError(Exception): + """A problem that makes the screenshot unusable; the message is shown to the user.""" + + +def extract_screen(raw_path: Path, device_dir: Path, out: Path) -> Image.Image: + """Find, crop, save and sanity-check the screen; raises ScreenshotError.""" + name = device_dir.name + try: + raw = open_image(raw_path) + display = load_display(device_dir) + device = open_image(device_dir / display.image) + except (OSError, KeyError, ValueError) as err: + raise ScreenshotError(f"cannot read the screenshot or the device files: {err}") from err + dx, dy, score = find_offset(raw, bezel_only(device, display)) + if score > MAX_BEZEL_DIFF: + raise ScreenshotError(f"device drawing not found in the screenshot (best difference {score:.1f})") + print(f"[{name}] device drawing found at window offset ({dx}, {dy}), bezel difference {score:.2f}") + screen = crop_screen(raw, (dx, dy), display) + save_png(screen, out, f"Simulator screen of {name}") + if is_blank(screen): + raise ScreenshotError("the screen is blank: the app drew nothing") + return screen + + +def check_golden(screen: Image.Image, name: str, out: Path, golden: Path) -> int: + """Compare with the approved image; writes a diff image when they differ.""" + if not golden.exists(): + print(f"[{name}] no golden image yet: review {out}, then run 'make screenshots-approve'") + return EXIT_NO_GOLDEN + fraction, overlay = compare(screen, open_image(golden)) + if fraction > MAX_DIFF_FRACTION: + diff_path = out.with_name(out.stem + "-diff.png") + save_png(overlay, diff_path, f"Differences from the golden image on {name}") + print(f"[{name}] differs from the golden image: {fraction:.2%} of pixels (see {diff_path})") + return EXIT_DIFFERENT + print(f"[{name}] matches the golden image ({fraction:.2%} of pixels differ)") + return EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Crop and check a simulator screenshot.") + parser.add_argument("--raw", type=Path, required=True, help="screenshot of the simulator window") + parser.add_argument("--device-dir", type=Path, required=True, help="the device's folder in ~/.Garmin") + parser.add_argument("--out", type=Path, required=True, help="where to write the cropped screen") + parser.add_argument("--golden", type=Path, help="approved image to compare with (or to write with --approve)") + parser.add_argument("--approve", action="store_true", help="save the screen as the new golden image") + args = parser.parse_args(argv) + name = args.device_dir.name + + try: + screen = extract_screen(args.raw, args.device_dir, args.out) + except ScreenshotError as err: + print(f"[{name}] {err}") + return EXIT_ERROR + if args.golden is None: + print(f"[{name}] screen saved to {args.out}") + return EXIT_OK + if args.approve: + save_png(screen, args.golden, f"Approved simulator screen of {name} (golden image for make screenshots)") + print(f"[{name}] approved as {args.golden}") + return EXIT_OK + return check_golden(screen, name, args.out, args.golden) + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3