diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-18 21:10:37 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-18 21:10:37 +0200 |
| commit | 2292bf700d28ade39cf2d64b45509e6db21034bc (patch) | |
| tree | ef455320d39ebe51db236219dbbda2b148dcbe18 /tools | |
| parent | 85f1ecabd7f58f306b2a042376209ac8cf49ab9b (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/budgets.json | 7 | ||||
| -rw-r--r-- | tools/check_budgets.py | 65 | ||||
| -rw-r--r-- | tools/check_docs.py | 2 | ||||
| -rw-r--r-- | tools/ciq_test_report.py | 39 | ||||
| -rw-r--r-- | tools/gen_products.py | 104 | ||||
| -rw-r--r-- | tools/lint_project.py | 74 | ||||
| -rw-r--r-- | tools/make_icon.py | 118 | ||||
| -rw-r--r-- | tools/sim_screenshot.py | 217 | ||||
| -rw-r--r-- | tools/tests/conftest.py | 2 | ||||
| -rw-r--r-- | tools/tests/data/runnoevil_pass.log | 14 | ||||
| -rw-r--r-- | tools/tests/test_check_budgets.py | 36 | ||||
| -rw-r--r-- | tools/tests/test_check_docs.py | 2 | ||||
| -rw-r--r-- | tools/tests/test_ciq_test_report.py | 43 | ||||
| -rw-r--r-- | tools/tests/test_gen_products.py | 63 | ||||
| -rw-r--r-- | tools/tests/test_lint_project.py | 43 | ||||
| -rw-r--r-- | tools/tests/test_make_icon.py | 2 | ||||
| -rw-r--r-- | tools/tests/test_sim_screenshot.py | 94 |
17 files changed, 844 insertions, 81 deletions
diff --git a/tools/budgets.json b/tools/budgets.json index 615cd74..93e9a2b 100644 --- a/tools/budgets.json +++ b/tools/budgets.json @@ -1,7 +1,10 @@ { - "_about": "Simulator budgets checked by `make perf` and `make memtest` (README section 18). Simulator timings depend on your PC, so perf budgets only catch big regressions; from M4 on they are set to about 2x the measured baseline. Memory is a hard limit: at most 70 % of the watch-app memory of the device.", + "_author": "cirakg & claude ai", + "_summary": "Simulator budgets checked by `make perf` and `make memtest` (README section 18).", + "_about": "Simulator timings depend on your PC and include the JVM start of monkeydo, so perf budgets only catch big regressions; from M4 on they are set to about 2x the measured baseline. Memory is a hard limit: at most 70 % of the watch-app memory of the device.", "perf": { - "startup_ms": 3000 + "startup_ms": 3000, + "first_frame_wall_ms": 15000 }, "mem": { "mem_used_fraction": 0.70 diff --git a/tools/check_budgets.py b/tools/check_budgets.py index 1e26037..8b55a2b 100644 --- a/tools/check_budgets.py +++ b/tools/check_budgets.py @@ -1,10 +1,16 @@ #!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Compare the app's debug diagnostics (start-up time, memory) with the budgets in tools/budgets.json. """Compare the app's debug diagnostics with the budgets in tools/budgets.json. -Debug builds print lines like - DIAG {"event":"first_frame","startup_ms":123,"mem_used":4567,"mem_total":786432} -(source/util/Diag.mc). app-diag.sh runs the app on each test device, saves the -console output to build/diag/<device>.log, then calls this script. +Two kinds of lines are read from build/diag/<device>.log: + + DIAG first_frame startup_ms=1 mem_used=6984 mem_total=781888 + printed by the debug build (source/util/Diag.mc) after its first frame; + HARNESS first_frame wall_ms=2310 + added by app-diag.sh: wall-clock time from launching the app with + monkeydo until that DIAG line appeared, which includes loading the + program (the in-app timer can't see that part). Usage: check_budgets.py {perf,mem} LOG [LOG ...] [--budgets FILE] [--summary OUT.json] Exit: 0 within budget, 1 over budget, 2 missing or unreadable diagnostics. @@ -14,38 +20,39 @@ from __future__ import annotations import argparse import json +import re import sys from pathlib import Path -from typing import Any EXIT_OK, EXIT_OVER, EXIT_MISSING = 0, 1, 2 +EVENT_RE = re.compile(r"\b(DIAG|HARNESS)\s+(\w+)((?:\s+\w+=-?\d+(?:\.\d+)?)+)") +PAIR_RE = re.compile(r"(\w+)=(-?\d+(?:\.\d+)?)") -def read_events(text: str) -> list[dict[str, Any]]: - """Every DIAG event in a console log, in order. Malformed lines are skipped.""" - events = [] - for line in text.splitlines(): - start = line.find("DIAG {") - if start < 0: - continue - try: - event = json.loads(line[start + len("DIAG ") :]) - except json.JSONDecodeError: - continue - if isinstance(event, dict): - events.append(event) + +def read_events(text: str) -> dict[str, dict[str, float]]: + """The first occurrence of each event, keyed as "DIAG.first_frame" etc.""" + events: dict[str, dict[str, float]] = {} + for match in EVENT_RE.finditer(text): + key = f"{match.group(1)}.{match.group(2)}" + if key not in events: + events[key] = {name: float(value) for name, value in PAIR_RE.findall(match.group(3))} return events -def measure(kind: str, events: list[dict[str, Any]]) -> dict[str, float]: - """The values a budget kind looks at, from the first first_frame event.""" - frame = next((e for e in events if e.get("event") == "first_frame"), None) +def measure(kind: str, events: dict[str, dict[str, float]]) -> dict[str, float]: + """The values one budget kind looks at; empty when the app never drew.""" + frame = events.get("DIAG.first_frame") if frame is None: return {} if kind == "perf": - return {"startup_ms": float(frame["startup_ms"])} - total = float(frame["mem_total"]) - return {"mem_used_fraction": float(frame["mem_used"]) / total if total else 1.0} + values = {"startup_ms": frame["startup_ms"]} + harness = events.get("HARNESS.first_frame") + if harness is not None: + values["first_frame_wall_ms"] = harness["wall_ms"] + return values + total = frame["mem_total"] + return {"mem_used_fraction": frame["mem_used"] / total if total else 1.0} def main(argv: list[str] | None = None) -> int: @@ -63,19 +70,23 @@ def main(argv: list[str] | None = None) -> int: device = log.stem try: values = measure(args.kind, read_events(log.read_text(encoding="utf-8", errors="replace"))) - except (OSError, KeyError, ValueError) as err: + except (OSError, KeyError) as err: print(f"[{device}] cannot read diagnostics: {err}") worst = max(worst, EXIT_MISSING) continue if not values: - print(f"[{device}] no DIAG first_frame line (did the app start? is this a debug build?)") + print(f"[{device}] no 'DIAG first_frame' line (did the app start? is this a debug build?)") worst = max(worst, EXIT_MISSING) continue summary[device] = values for name, limit in budgets.items(): + if name not in values: + print(f"[{device}] {name}: not measured") + worst = max(worst, EXIT_MISSING) + continue value = values[name] over = value > limit - print(f"[{device}] {name} = {value:.3g} (budget {limit:.3g}) {'OVER BUDGET' if over else 'ok'}") + print(f"[{device}] {name} = {value:.4g} (budget {limit:.4g}) {'OVER BUDGET' if over else 'ok'}") if over: worst = max(worst, EXIT_OVER) if args.summary: diff --git a/tools/check_docs.py b/tools/check_docs.py index 977bd11..fa5ff5a 100644 --- a/tools/check_docs.py +++ b/tools/check_docs.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Offline checker for broken relative links and heading anchors in the Markdown docs. """Check the project's Markdown files for broken internal links. What it checks, offline (`make docs` runs without network): diff --git a/tools/ciq_test_report.py b/tools/ciq_test_report.py index b35095f..e69b93f 100644 --- a/tools/ciq_test_report.py +++ b/tools/ciq_test_report.py @@ -1,23 +1,30 @@ #!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Decide pass/fail of a Connect IQ unit-test run from its console log, and write JUnit XML. """Turn the console output of a Connect IQ unit-test run into a verdict. -`monkeydo app.prg <device> -t` prints Garmin's "Run No Evil" report: +`monkeydo app.prg <device> -t` prints Garmin's "Run No Evil" report. Captured +from SDK 9.2.0 (tools/tests/data/runnoevil_pass.log): Executing test smokeVersionIsSemver... - DEBUG (14:16): version 0.1.0 + DEBUG (18:41): version 0.1.0 PASS ============================================================================== RESULTS - Test: Status: - smokeVersionIsSemver PASS + Test: Status: + smokeVersionIsSemver PASS Ran 1 test - PASSED (failures=0, errors=0) + PASSED (passed=1, failed=0, errors=0) -monkeydo's exit code is reported to be non-zero even when every test passes, +Garmin's documentation shows the older summary form `PASSED (failures=0, +errors=0)`; both are accepted. + +monkeydo exits with 1 even when every test passes (verified with SDK 9.2.0), so this script decides instead. A run passes only when all of these hold: * the RESULTS section and the final PASSED/FAILED line are present (a crash or a watchdog kill leaves the log incomplete); - * the final line says PASSED with zero failures and zero errors; + * the final line says PASSED with zero failed and zero errors, and its + passed count (when present) equals the number of passing rows; * "Ran N tests" matches the number of per-test rows, and N >= --min-tests; * every per-test row says PASS. @@ -36,7 +43,8 @@ from xml.sax.saxutils import escape, quoteattr EXIT_PASS, EXIT_FAIL, EXIT_INCOMPLETE = 0, 1, 2 -SUMMARY_RE = re.compile(r"^(PASSED|FAILED)\s*\(\s*failures\s*=\s*(\d+)\s*,\s*errors\s*=\s*(\d+)\s*\)", re.I) +SUMMARY_RE = re.compile(r"^(PASSED|FAILED)\s*\(([^)]*)\)\s*$", re.I) +COUNT_RE = re.compile(r"(\w+)\s*=\s*(\d+)") RAN_RE = re.compile(r"^Ran\s+(\d+)\s+tests?\b", re.I) ROW_RE = re.compile(r"^(\S+)\s+(PASS|FAIL|ERROR)\s*$", re.I) EXECUTING_RE = re.compile(r"^Executing test\s+(\S+?)(?:\.\.\.|…)?\s*$") @@ -50,7 +58,8 @@ class Report: output: dict[str, list[str]] = field(default_factory=dict) # test name -> its console lines ran: int | None = None summary: str | None = None # PASSED or FAILED - failures: int = 0 + passed: int | None = None # absent in the older summary format + failed: int = 0 errors: int = 0 def problems(self, min_tests: int) -> tuple[int, list[str]]: @@ -58,8 +67,11 @@ class Report: if self.summary is None or self.ran is None: return EXIT_INCOMPLETE, ["log has no complete RESULTS section (crash, timeout or watchdog?)"] reasons = [] - if self.summary != "PASSED" or self.failures or self.errors: - reasons.append(f"summary says {self.summary} (failures={self.failures}, errors={self.errors})") + if self.summary != "PASSED" or self.failed or self.errors: + reasons.append(f"summary says {self.summary} (failed={self.failed}, errors={self.errors})") + passing_rows = sum(1 for status in self.results.values() if status == "PASS") + if self.passed is not None and self.passed != passing_rows: + reasons.append(f"summary says passed={self.passed} but {passing_rows} rows say PASS") if self.ran != len(self.results): reasons.append(f"'Ran {self.ran}' but {len(self.results)} result rows") if self.ran < min_tests: @@ -91,7 +103,10 @@ def parse(text: str) -> Report: report.ran = int(match.group(1)) elif match := SUMMARY_RE.match(line): report.summary = match.group(1).upper() - report.failures, report.errors = int(match.group(2)), int(match.group(3)) + counts = {key.lower(): int(value) for key, value in COUNT_RE.findall(match.group(2))} + report.passed = counts.get("passed") + report.failed = counts.get("failed", counts.get("failures", 0)) + report.errors = counts.get("errors", 0) return report diff --git a/tools/gen_products.py b/tools/gen_products.py new file mode 100644 index 0000000..7504287 --- /dev/null +++ b/tools/gen_products.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Check that every product in manifest.xml is a watch with API 5.0+ and at least 512 KB for watch apps. +"""Check the manifest's product list against the downloaded device files. + +The target rule (README section 14, decision D4): a product belongs in +manifest.xml when it is in Garmin's "Watches/Wearables" group, every firmware +variant supports Connect IQ API 5.0.0 or newer, and watch apps get at least +512 KB of memory. The data comes from each device's compiler.json, which +`make sdk` downloads for exactly the products in the manifest. + +Usage: gen_products.py --check [--manifest manifest.xml] [--devices DIR] +Exit: 0 every product qualifies, 1 some don't, 2 device files missing. + +Finding devices Garmin releases later needs the device catalogue (a login); +re-run `make sdk` after adding a new product to the manifest. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + +EXIT_OK, EXIT_UNQUALIFIED, EXIT_MISSING = 0, 1, 2 +IQ_NS = "{http://www.garmin.com/xml/connectiq}" +MIN_API = (5, 0, 0) +MIN_WATCH_APP_MEMORY = 512 * 1024 +WATCH_GROUP = "Watches/Wearables" +DEFAULT_DEVICES = Path.home() / ".Garmin" / "ConnectIQ" / "Devices" + + +@dataclass(frozen=True) +class Device: + """What the target rule needs from one compiler.json.""" + + product: str + name: str + group: str + min_api: tuple[int, ...] + watch_app_memory: int + + def problems(self) -> list[str]: + reasons = [] + if self.group != WATCH_GROUP: + reasons.append(f"group is '{self.group}', not '{WATCH_GROUP}'") + if self.min_api < MIN_API: + reasons.append(f"API {'.'.join(map(str, self.min_api))} is below 5.0.0") + if self.watch_app_memory < MIN_WATCH_APP_MEMORY: + reasons.append(f"watch apps get {self.watch_app_memory // 1024} KB, below 512 KB") + return reasons + + +def parse_version(text: str) -> tuple[int, ...]: + return tuple(int(part) for part in text.split(".")) + + +def load_device(devices: Path, product: str) -> Device: + compiler = json.loads((devices / product / "compiler.json").read_text(encoding="utf-8")) + memory = {app["type"]: int(app["memoryLimit"]) for app in compiler["appTypes"]} + return Device( + product=product, + name=str(compiler.get("displayName", product)), + group=str(compiler.get("webDocDeviceGroup", "")), + min_api=min(parse_version(p["connectIQVersion"]) for p in compiler["partNumbers"]), + watch_app_memory=memory.get("watchApp", 0), + ) + + +def manifest_products(manifest: Path) -> list[str]: + root = ET.parse(manifest).getroot() + return [p.attrib["id"] for p in root.iter(f"{IQ_NS}product")] + + +def check(manifest: Path, devices: Path) -> int: + products = manifest_products(manifest) + worst = EXIT_OK + for product in products: + if not (devices / product / "compiler.json").exists(): + print(f"{product}: no device files (run 'make sdk')") + worst = max(worst, EXIT_MISSING) + continue + device = load_device(devices, product) + for reason in device.problems(): + print(f"{product} ({device.name}): {reason}") + worst = max(worst, EXIT_UNQUALIFIED) + print(f"gen_products: {len(products)} products checked") + return worst + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Check the manifest's products against the target rule.") + parser.add_argument("--check", action="store_true", required=True) + parser.add_argument("--manifest", type=Path, default=Path("manifest.xml")) + parser.add_argument("--devices", type=Path, default=DEFAULT_DEVICES) + args = parser.parse_args(argv) + return check(args.manifest, args.devices) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/lint_project.py b/tools/lint_project.py index f11d35e..e02215b 100644 --- a/tools/lint_project.py +++ b/tools/lint_project.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""Project-specific lint rules for the Monkey C sources and the manifest. +# Author: cirakg & claude ai +# Summary: Project-specific lint rules for the Monkey C sources, the manifest and the file headers. +"""Project-specific lint rules. The Monkey C compiler (strict type checking, `make lint`) catches language errors. These rules catch project policy violations the compiler can't see: @@ -14,6 +16,11 @@ errors. These rules catch project policy violations the compiler can't see: can be allowed with the comment `// lint: allow-literal` on its line. MC005 no network: manifest.xml declares no permissions and no source file imports or uses Toybox.Communications + HDR001 every project file names its author and summarises itself: + "Author: cirakg & claude ai" and "Summary: ..." in its first lines; + JSON files use "author"/"_author" and "description"/"_summary" keys; + PNG images use Author and Description text chunks. Files that a tool + regenerates (GENERATED below) are exempt. Usage: lint_project.py [ROOT] Exit: 0 clean, 1 problems found (printed as file:line: CODE message). @@ -21,13 +28,24 @@ Exit: 0 clean, 1 problems found (printed as file:line: CODE message). from __future__ import annotations +import json +import os import re import sys from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path +from PIL import Image + +AUTHOR = "cirakg & claude ai" +HEADER_LINES = 15 DIAG_FILE = Path("source/util/Diag.mc") +# Folders that hold local tool state or generated output, never project files. +SKIP_DIRS = {".git", ".cache", "build", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +# Written by a tool that would drop a hand-added header on the next run. +GENERATED = {Path("docker/docs/package-lock.json")} + BASE32_SECRET_RE = re.compile(r'"[^"\n]*[A-Z2-7]{16,}[^"\n]*"') HEX_SECRET_RE = re.compile(r'"[^"\n]*[0-9a-fA-F]{32,}[^"\n]*"') MARKER_RE = re.compile(r"\b(TODO|FIXME|XXX)\b") @@ -35,6 +53,8 @@ PRINTLN_RE = re.compile(r"\bSystem\.println\s*\(") COMMUNICATIONS_RE = re.compile(r"\bToybox\.Communications\b|\bCommunications\.") PERMISSION_RE = re.compile(r"<iq:uses-permission\b") ALLOW_LITERAL = "lint: allow-literal" +AUTHOR_RE = re.compile(r"Author:\s+" + re.escape(AUTHOR)) +SUMMARY_RE = re.compile(r"Summary:\s+\S") @dataclass(frozen=True, order=True) @@ -86,7 +106,8 @@ def check_manifest(path: Path, rel: Path) -> Iterable[Problem]: yield Problem(rel, number, "MC005", "manifest must not request permissions") -def lint(root: Path) -> list[Problem]: +def lint_code(root: Path) -> list[Problem]: + """The MC rules: Monkey C sources, tests and the manifest.""" problems: list[Problem] = [] for folder in ("source", "test"): for path in sorted((root / folder).rglob("*.mc")): @@ -96,7 +117,54 @@ def lint(root: Path) -> list[Problem]: problems += check_manifest(manifest, manifest.relative_to(root)) else: problems.append(Problem(Path("manifest.xml"), 1, "MC005", "manifest.xml is missing")) - return sorted(problems) + return problems + + +def project_files(root: Path) -> list[Path]: + """Every file of the project, skipping tool state and build output.""" + files = [] + for folder, dirs, names in os.walk(root): + dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS) + files += [Path(folder) / name for name in sorted(names)] + return files + + +def check_header(path: Path, rel: Path) -> Iterable[Problem]: + """HDR001 for one file, by file type.""" + if rel in GENERATED: + return + suffix = path.suffix.lower() + if suffix == ".png": + with Image.open(path) as img: + info = img.info + if info.get("Author") != AUTHOR or not info.get("Description"): + yield Problem(rel, 1, "HDR001", f"PNG needs Author '{AUTHOR}' and Description text chunks") + return + if suffix == ".json": + data = json.loads(path.read_text(encoding="utf-8")) + meta = data if isinstance(data, dict) else {} + if meta.get("author", meta.get("_author")) != AUTHOR or not meta.get("description", meta.get("_summary")): + yield Problem(rel, 1, "HDR001", 'JSON needs "author"/"_author" and "description"/"_summary" keys') + return + try: + head = "\n".join(path.read_text(encoding="utf-8").splitlines()[:HEADER_LINES]) + except UnicodeDecodeError: + yield Problem(rel, 1, "HDR001", "binary file type without a header convention") + return + if not AUTHOR_RE.search(head) or not SUMMARY_RE.search(head): + message = f"needs 'Author: {AUTHOR}' and 'Summary: ...' in its first {HEADER_LINES} lines" + yield Problem(rel, 1, "HDR001", message) + + +def lint_headers(root: Path) -> list[Problem]: + problems: list[Problem] = [] + for path in project_files(root): + problems += check_header(path, path.relative_to(root)) + return problems + + +def lint(root: Path) -> list[Problem]: + return sorted(lint_code(root) + lint_headers(root)) def main(argv: list[str]) -> int: diff --git a/tools/make_icon.py b/tools/make_icon.py index 6bb1cbf..f05d0c9 100644 --- a/tools/make_icon.py +++ b/tools/make_icon.py @@ -1,28 +1,50 @@ #!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Draw the launcher icon at every size the target watches need, plus the jungle that maps devices to sizes. """Draw the app's launcher icon: a white padlock with three "code" dots on a teal disc, on a transparent background. The icon is drawn at 8x the target size and scaled down, which gives smooth -edges at the small sizes watches use (35 to 70 px). +edges at the small sizes watches use (38 to 70 px). Every PNG carries the +project's Author and a Description in PNG text chunks. -Usage: make_icon.py --size 60 --out resources/drawables/launcher_icon.png +Two modes: + + make_icon.py --size 60 --out FILE.png + One icon of the given size. + + make_icon.py --all --devices DIR --manifest manifest.xml + One icon per launcher-icon size used by the products in manifest.xml + (read from each device's compiler.json), written to + resources-icons/<W>x<H>/, plus icons.jungle, which points each product + at its folder. The Monkey C compiler warns when it has to scale an + icon, and warnings fail the build, so every device needs an exact size. """ from __future__ import annotations import argparse +import json +import xml.etree.ElementTree as ET +from collections import defaultdict from pathlib import Path from PIL import Image, ImageDraw +from PIL.PngImagePlugin import PngInfo +AUTHOR = "cirakg & claude ai" +DESCRIPTION = "OTP Wallet launcher icon: a padlock with a one-time code, on a teal disc." TEAL = (14, 124, 134, 255) WHITE = (255, 255, 255, 255) SUPERSAMPLE = 8 +IQ_NS = "{http://www.garmin.com/xml/connectiq}" -def draw_icon(size: int) -> Image.Image: - """Return the icon as an RGBA image of size x size pixels.""" - s = size * SUPERSAMPLE +def draw_icon(width: int, height: int | None = None) -> Image.Image: + """Return the icon as an RGBA image; non-square sizes get a centred square icon.""" + height = width if height is None else height + side = min(width, height) + s = side * SUPERSAMPLE img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(img) @@ -49,18 +71,92 @@ def draw_icon(size: int) -> Image.Image: center_x = s * (0.38 + 0.12 * i) d.ellipse((center_x - radius, center_y - radius, center_x + radius, center_y + radius), fill=TEAL) - return img.resize((size, size), Image.Resampling.LANCZOS) + icon = img.resize((side, side), Image.Resampling.LANCZOS) + if (width, height) == (side, side): + return icon + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + canvas.paste(icon, ((width - side) // 2, (height - side) // 2)) + return canvas + + +def save_png(img: Image.Image, path: Path) -> None: + """Save with the project's Author and Description text chunks.""" + 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) + + +def manifest_products(manifest: Path) -> list[str]: + """Product IDs listed in manifest.xml.""" + root = ET.parse(manifest).getroot() + return [p.attrib["id"] for p in root.iter(f"{IQ_NS}product")] + + +def icon_sizes(devices: Path, products: list[str]) -> dict[tuple[int, int], list[str]]: + """Group products by the launcher-icon size in their compiler.json.""" + groups: dict[tuple[int, int], list[str]] = defaultdict(list) + for product in products: + compiler = json.loads((devices / product / "compiler.json").read_text(encoding="utf-8")) + icon = compiler["launcherIcon"] + groups[(int(icon["width"]), int(icon["height"]))].append(product) + return dict(sorted(groups.items())) + + +DRAWABLES_XML = """\ +<!-- + Author: cirakg & claude ai + Summary: Launcher icon at {w}x{h} px for the devices listed in icons.jungle (generated by tools/make_icon.py). +--> +<drawables> + <bitmap id="LauncherIcon" filename="launcher_icon.png" /> +</drawables> +""" + +JUNGLE_HEADER = """\ +# Author: cirakg & claude ai +# Summary: Per-device launcher-icon resources; generated by tools/make_icon.py --all (do not edit). +# +# Each product gets the icon folder matching the launcher-icon size in its +# device definition, so the compiler never has to scale (and warn). +""" + + +def write_all(devices: Path, manifest: Path, out_dir: Path, jungle: Path) -> None: + groups = icon_sizes(devices, manifest_products(manifest)) + lines = [JUNGLE_HEADER] + for (w, h), products in groups.items(): + folder = out_dir / f"{w}x{h}" + save_png(draw_icon(w, h), folder / "launcher_icon.png") + (folder / "drawables.xml").write_text(DRAWABLES_XML.format(w=w, h=h), encoding="utf-8") + lines += [f"{p}.resourcePath = $({p}.resourcePath);{folder.as_posix()}" for p in sorted(products)] + print(f"{w}x{h}: {len(products)} devices") + jungle.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {jungle} and {len(groups)} icon folders under {out_dir}") def main() -> None: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--size", type=int, default=60, help="icon width and height in pixels") - parser.add_argument("--out", type=Path, required=True, help="PNG file to write") + parser = argparse.ArgumentParser(description="Draw the OTP Wallet launcher icon(s).") + parser.add_argument("--size", type=int, default=60, help="single icon: width and height in pixels") + parser.add_argument("--out", type=Path, help="single icon: PNG file to write") + parser.add_argument("--all", action="store_true", help="every size used by the manifest's products") + parser.add_argument("--devices", type=Path, help="device definitions (~/.Garmin/ConnectIQ/Devices)") + parser.add_argument("--manifest", type=Path, default=Path("manifest.xml")) + parser.add_argument("--out-dir", type=Path, default=Path("resources-icons")) + parser.add_argument("--jungle", type=Path, default=Path("icons.jungle")) args = parser.parse_args() + + if args.all: + if args.devices is None: + parser.error("--all needs --devices") + write_all(args.devices, args.manifest, args.out_dir, args.jungle) + return + if args.out is None: + parser.error("give --out (single icon) or --all") if not 16 <= args.size <= 256: parser.error("--size must be between 16 and 256") - args.out.parent.mkdir(parents=True, exist_ok=True) - draw_icon(args.size).save(args.out, format="PNG", optimize=True) + save_png(draw_icon(args.size), args.out) print(f"wrote {args.out} ({args.size}x{args.size})") 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()) diff --git a/tools/tests/conftest.py b/tools/tests/conftest.py index 7054ecb..d4dae36 100644 --- a/tools/tests/conftest.py +++ b/tools/tests/conftest.py @@ -1,3 +1,5 @@ +# Author: cirakg & claude ai +# Summary: Shared pytest setup for the tool tests (import path, Hypothesis database). """Shared pytest setup for the tool tests. * Makes the modules in tools/ importable as top-level modules. diff --git a/tools/tests/data/runnoevil_pass.log b/tools/tests/data/runnoevil_pass.log new file mode 100644 index 0000000..82c3cf0 --- /dev/null +++ b/tools/tests/data/runnoevil_pass.log @@ -0,0 +1,14 @@ +# Author: cirakg & claude ai +# Summary: Real Run No Evil console output (SDK 9.2.0, fr255s, headless simulator), used as a parser fixture. +------------------------------------------------------------------------------ +Executing test smokeVersionIsSemver... +DEBUG (18:41): version 0.1.0 +PASS + +============================================================================== +RESULTS +Test: Status: +smokeVersionIsSemver PASS +Ran 1 test + +PASSED (passed=1, failed=0, errors=0) diff --git a/tools/tests/test_check_budgets.py b/tools/tests/test_check_budgets.py index 35cc2f8..2262a0b 100644 --- a/tools/tests/test_check_budgets.py +++ b/tools/tests/test_check_budgets.py @@ -1,30 +1,37 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/check_budgets.py (start-up time and memory budgets). """Tests for tools/check_budgets.py (perf and memory budgets).""" from __future__ import annotations import json +from collections.abc import Mapping from pathlib import Path import check_budgets as cb -FRAME = 'DIAG {"event":"first_frame","startup_ms":250,"mem_used":100000,"mem_total":524288}' +FRAME = "DIAG first_frame startup_ms=12 mem_used=100000 mem_total=524288" +WALL = "HARNESS first_frame wall_ms=2310" def test_reads_events_and_skips_noise() -> None: - text = f"Device Version 1\n{FRAME}\nDIAG {{not json\nsome DIAG line\n" - assert cb.read_events(text) == [ - {"event": "first_frame", "startup_ms": 250, "mem_used": 100000, "mem_total": 524288} - ] + text = f"Device Version 1\n{FRAME}\nDIAG broken\n{WALL}\nDIAG first_frame startup_ms=99 mem_used=1 mem_total=2\n" + events = cb.read_events(text) + # Only the first occurrence of each event counts. + assert events == { + "DIAG.first_frame": {"startup_ms": 12.0, "mem_used": 100000.0, "mem_total": 524288.0}, + "HARNESS.first_frame": {"wall_ms": 2310.0}, + } def test_measure_perf_and_memory() -> None: - events = cb.read_events(FRAME) - assert cb.measure("perf", events) == {"startup_ms": 250.0} + events = cb.read_events(f"{FRAME}\n{WALL}") + assert cb.measure("perf", events) == {"startup_ms": 12.0, "first_frame_wall_ms": 2310.0} assert cb.measure("mem", events) == {"mem_used_fraction": 100000 / 524288} - assert cb.measure("perf", []) == {} + assert cb.measure("perf", {}) == {} -def run(tmp_path: Path, kind: str, log_text: str, budgets: dict[str, dict[str, float]]) -> int: +def run(tmp_path: Path, kind: str, log_text: str, budgets: Mapping[str, Mapping[str, float]]) -> int: log = tmp_path / "fr255s.log" log.write_text(log_text, encoding="utf-8") budget_file = tmp_path / "budgets.json" @@ -33,13 +40,20 @@ def run(tmp_path: Path, kind: str, log_text: str, budgets: dict[str, dict[str, f def test_within_budget(tmp_path: Path) -> None: - assert run(tmp_path, "perf", FRAME, {"perf": {"startup_ms": 300}}) == cb.EXIT_OK - assert json.loads((tmp_path / "s.json").read_text()) == {"fr255s": {"startup_ms": 250.0}} + budgets = {"perf": {"startup_ms": 300, "first_frame_wall_ms": 5000}} + assert run(tmp_path, "perf", f"{FRAME}\n{WALL}", budgets) == cb.EXIT_OK + summary = json.loads((tmp_path / "s.json").read_text()) + assert summary == {"fr255s": {"startup_ms": 12.0, "first_frame_wall_ms": 2310.0}} def test_over_budget(tmp_path: Path) -> None: assert run(tmp_path, "mem", FRAME, {"mem": {"mem_used_fraction": 0.1}}) == cb.EXIT_OVER +def test_budgeted_value_not_measured(tmp_path: Path) -> None: + budgets = {"perf": {"first_frame_wall_ms": 5000}} + assert run(tmp_path, "perf", FRAME, budgets) == cb.EXIT_MISSING + + def test_missing_diagnostics(tmp_path: Path) -> None: assert run(tmp_path, "perf", "app crashed\n", {"perf": {"startup_ms": 300}}) == cb.EXIT_MISSING diff --git a/tools/tests/test_check_docs.py b/tools/tests/test_check_docs.py index 67404c9..a5e83d0 100644 --- a/tools/tests/test_check_docs.py +++ b/tools/tests/test_check_docs.py @@ -1,3 +1,5 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/check_docs.py, the Markdown link checker. """Tests for tools/check_docs.py, the offline Markdown link checker.""" from __future__ import annotations diff --git a/tools/tests/test_ciq_test_report.py b/tools/tests/test_ciq_test_report.py index cdd3901..8ed83cc 100644 --- a/tools/tests/test_ciq_test_report.py +++ b/tools/tests/test_ciq_test_report.py @@ -1,3 +1,5 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/ciq_test_report.py, the judge of Connect IQ unit-test logs. """Tests for tools/ciq_test_report.py, the judge of Connect IQ test logs.""" from __future__ import annotations @@ -9,10 +11,9 @@ import ciq_test_report as rep from hypothesis import given from hypothesis import strategies as st +DATA = Path(__file__).parent / "data" + PASSING = """\ -Device Version 0.1.0 -Device id 1 name "A garmin device" -Shell Version 0.1.0 ------------------------------------------------------------------------------ Executing test smokeVersionIsSemver... DEBUG (14:16): version 0.1.0 @@ -20,34 +21,47 @@ PASS ------------------------------------------------------------------------------ Executing test otherTest... PASS + ============================================================================== RESULTS -Test: Status: -smokeVersionIsSemver PASS -otherTest PASS +Test: Status: +smokeVersionIsSemver PASS +otherTest PASS Ran 2 tests -PASSED (failures=0, errors=0) -Connection Finished -Closing shell and port + +PASSED (passed=2, failed=0, errors=0) """ +def test_real_sdk_log_passes() -> None: + report = rep.parse((DATA / "runnoevil_pass.log").read_text(encoding="utf-8")) + assert report.results == {"smokeVersionIsSemver": "PASS"} + assert (report.ran, report.passed, report.failed, report.errors) == (1, 1, 0, 0) + assert report.problems(min_tests=1) == (rep.EXIT_PASS, []) + + def test_passing_log_passes() -> None: report = rep.parse(PASSING) assert report.results == {"smokeVersionIsSemver": "PASS", "otherTest": "PASS"} - assert report.ran == 2 assert report.output["smokeVersionIsSemver"] == ["DEBUG (14:16): version 0.1.0", "PASS"] assert report.problems(min_tests=1) == (rep.EXIT_PASS, []) +def test_documented_older_summary_format_passes() -> None: + text = PASSING.replace("PASSED (passed=2, failed=0, errors=0)", "PASSED (failures=0, errors=0)") + report = rep.parse(text) + assert report.passed is None + assert report.problems(min_tests=1)[0] == rep.EXIT_PASS + + def test_status_words_are_case_insensitive() -> None: text = PASSING.replace("PASS\n", "Pass\n").replace("PASSED (", "Passed (") assert rep.parse(text).problems(min_tests=1)[0] == rep.EXIT_PASS def test_failed_test_fails() -> None: - text = PASSING.replace("otherTest PASS", "otherTest FAIL").replace( - "PASSED (failures=0", "FAILED (failures=1" + text = PASSING.replace("otherTest PASS", "otherTest FAIL").replace( + "PASSED (passed=2, failed=0", "FAILED (passed=1, failed=1" ) code, reasons = rep.parse(text).problems(min_tests=1) assert code == rep.EXIT_FAIL @@ -55,10 +69,11 @@ def test_failed_test_fails() -> None: def test_error_row_fails_even_if_summary_claims_pass() -> None: - text = PASSING.replace("otherTest PASS", "otherTest ERROR") + text = PASSING.replace("otherTest PASS", "otherTest ERROR") code, reasons = rep.parse(text).problems(min_tests=1) assert code == rep.EXIT_FAIL assert "otherTest: ERROR" in reasons + assert any("passed=2 but 1 rows" in r for r in reasons) def test_log_without_results_is_incomplete() -> None: @@ -80,7 +95,7 @@ def test_ran_count_must_match_rows() -> None: def test_junit_output_is_valid(tmp_path: Path) -> None: log = tmp_path / "fr70.log" - log.write_text(PASSING.replace("otherTest PASS", "otherTest FAIL")) + log.write_text(PASSING.replace("otherTest PASS", "otherTest FAIL")) junit = tmp_path / "out" / "fr70.xml" assert rep.main([str(log), "--device", "fr70", "--junit", str(junit)]) == rep.EXIT_FAIL suite = ET.parse(junit).getroot() diff --git a/tools/tests/test_gen_products.py b/tools/tests/test_gen_products.py new file mode 100644 index 0000000..47424d2 --- /dev/null +++ b/tools/tests/test_gen_products.py @@ -0,0 +1,63 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/gen_products.py (the manifest product rule). +"""Tests for tools/gen_products.py, with synthetic device definitions.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import gen_products as gp + +MANIFEST = """<iq:manifest version="3" xmlns:iq="http://www.garmin.com/xml/connectiq"> + <iq:application id="x" type="watch-app"><iq:products>{products}</iq:products></iq:application> +</iq:manifest>""" + + +def add_device(devices: Path, product: str, api: list[str], memory: int, group: str = gp.WATCH_GROUP) -> None: + folder = devices / product + folder.mkdir(parents=True) + compiler = { + "displayName": product.upper(), + "webDocDeviceGroup": group, + "partNumbers": [{"connectIQVersion": v} for v in api], + "appTypes": [{"type": "watchApp", "memoryLimit": memory}, {"type": "glance", "memoryLimit": 65536}], + } + (folder / "compiler.json").write_text(json.dumps(compiler)) + + +def write_manifest(tmp_path: Path, products: list[str]) -> Path: + path = tmp_path / "manifest.xml" + path.write_text(MANIFEST.format(products="".join(f'<iq:product id="{p}"/>' for p in products))) + return path + + +def test_qualifying_products_pass(tmp_path: Path) -> None: + devices = tmp_path / "Devices" + add_device(devices, "fr70", ["6.0.3"], 786432) + add_device(devices, "fr255s", ["5.2.0", "5.2.1"], 524288) + assert gp.check(write_manifest(tmp_path, ["fr70", "fr255s"]), devices) == gp.EXIT_OK + + +def test_each_rule_is_enforced(tmp_path: Path) -> None: + devices = tmp_path / "Devices" + add_device(devices, "old", ["4.2.0", "5.0.0"], 786432) # one firmware variant below 5.0 + add_device(devices, "small", ["6.0.0"], 131072) + add_device(devices, "bike", ["5.1.0"], 786432, group="Edge") + manifest = write_manifest(tmp_path, ["old", "small", "bike"]) + assert gp.check(manifest, devices) == gp.EXIT_UNQUALIFIED + + +def test_missing_device_files(tmp_path: Path) -> None: + devices = tmp_path / "Devices" + devices.mkdir() + assert gp.check(write_manifest(tmp_path, ["ghost"]), devices) == gp.EXIT_MISSING + + +def test_problem_messages() -> None: + device = gp.Device("x", "X", "Edge", (4, 2, 0), 131072) + assert device.problems() == [ + "group is 'Edge', not 'Watches/Wearables'", + "API 4.2.0 is below 5.0.0", + "watch apps get 128 KB, below 512 KB", + ] diff --git a/tools/tests/test_lint_project.py b/tools/tests/test_lint_project.py index be0151e..31f8125 100644 --- a/tools/tests/test_lint_project.py +++ b/tools/tests/test_lint_project.py @@ -1,11 +1,16 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/lint_project.py, the project policy linter. """Tests for tools/lint_project.py, the project policy linter.""" from __future__ import annotations +import json from pathlib import Path import lint_project as lp from conftest import PROJECT_ROOT +from PIL import Image +from PIL.PngImagePlugin import PngInfo GOOD_MANIFEST = "<iq:manifest><iq:permissions/></iq:manifest>\n" @@ -19,7 +24,8 @@ def make_project(tmp_path: Path, files: dict[str, str]) -> Path: def codes(root: Path) -> list[tuple[str, str, int]]: - return [(str(p.path), p.code, p.line) for p in lp.lint(root)] + """Findings of the code rules (MC001-MC005) only.""" + return [(str(p.path), p.code, p.line) for p in sorted(lp.lint_code(root))] def test_clean_project_passes(tmp_path: Path) -> None: @@ -76,6 +82,41 @@ def test_manifest_permissions_are_rejected(tmp_path: Path) -> None: assert codes(root) == [("manifest.xml", "MC005", 2)] +HEADER = "# Author: cirakg & claude ai\n# Summary: A file with a proper header.\n" + + +def header_findings(root: Path) -> list[str]: + return sorted(str(p.path) for p in lp.lint_headers(root)) + + +def save_png(path: Path, author: str | None) -> None: + info = PngInfo() + if author is not None: + info.add_text("Author", author) + info.add_text("Description", "test image") + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (4, 4)).save(path, pnginfo=info) + + +def test_headers_are_required_in_every_file_type(tmp_path: Path) -> None: + files = { + "ok.sh": "#!/bin/sh\n" + HEADER, + "late.txt": "\n" * 20 + HEADER, # too far down + "none.md": "# Title\n", + "data/ok.json": json.dumps({"_author": lp.AUTHOR, "_summary": "x"}), + "data/bad.json": json.dumps({"author": "someone else", "description": "x"}), + "docker/docs/package-lock.json": "{}", # generated, exempt + "build/out.txt": "ignored folder", + } + for name, text in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + save_png(tmp_path / "img/ok.png", lp.AUTHOR) + save_png(tmp_path / "img/anon.png", None) + assert header_findings(tmp_path) == ["data/bad.json", "img/anon.png", "late.txt", "none.md"] + + def test_project_passes_its_own_rules() -> None: problems = lp.lint(PROJECT_ROOT) assert problems == [], "\n".join(map(str, problems)) diff --git a/tools/tests/test_make_icon.py b/tools/tests/test_make_icon.py index 66128c5..93d7c94 100644 --- a/tools/tests/test_make_icon.py +++ b/tools/tests/test_make_icon.py @@ -1,3 +1,5 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/make_icon.py, the launcher icon generator. """Tests for tools/make_icon.py (launcher icon generator).""" from __future__ import annotations diff --git a/tools/tests/test_sim_screenshot.py b/tools/tests/test_sim_screenshot.py new file mode 100644 index 0000000..7a1a087 --- /dev/null +++ b/tools/tests/test_sim_screenshot.py @@ -0,0 +1,94 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/sim_screenshot.py (finding, cropping and comparing simulator screens). +"""Tests for tools/sim_screenshot.py, using synthetic simulator windows.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import sim_screenshot as ss +from PIL import Image, ImageDraw + +DISPLAY = {"x": 20, "y": 30, "width": 60, "height": 60} + + +def make_device(tmp_path: Path, shape: str = "round") -> Path: + """A fake device folder: a striped bezel drawing and its simulator.json.""" + device_dir = tmp_path / "fakewatch" + device_dir.mkdir() + bezel = Image.new("RGBA", (100, 120), (0, 0, 0, 0)) + draw = ImageDraw.Draw(bezel) + for y in range(0, 120, 6): # stripes make the offset unambiguous + draw.rectangle((0, y, 99, y + 2), fill=(120 + y, 60, 200 - y, 255)) + draw.rectangle((DISPLAY["x"], DISPLAY["y"], DISPLAY["x"] + 59, DISPLAY["y"] + 59), fill=(0, 0, 0, 255)) + bezel.save(device_dir / "fake.png") + simulator = {"image": "fake.png", "display": {"location": DISPLAY, "shape": shape}} + (device_dir / "simulator.json").write_text(json.dumps(simulator)) + return device_dir + + +def make_window(device_dir: Path, offset: tuple[int, int], screen: Image.Image) -> Image.Image: + """A simulator window: grey background, the drawing at offset, the app's screen on top.""" + window = Image.new("RGB", (100 + offset[0], 150), (230, 230, 230)) + bezel = ss.open_image(device_dir / "fake.png").convert("RGBA") + window.paste(bezel, offset, bezel) + window.paste(screen, (offset[0] + DISPLAY["x"], offset[1] + DISPLAY["y"])) + return window + + +def app_screen(text_colour: tuple[int, int, int] = (255, 255, 255)) -> Image.Image: + screen = Image.new("RGB", (60, 60), (0, 0, 0)) + ImageDraw.Draw(screen).rectangle((20, 25, 40, 35), fill=text_colour) + return screen + + +def test_finds_offset_and_crops_screen(tmp_path: Path) -> None: + device_dir = make_device(tmp_path) + display = ss.load_display(device_dir) + window = make_window(device_dir, (1, 26), app_screen()) + bezel = ss.bezel_only(ss.open_image(device_dir / display.image), display) + dx, dy, score = ss.find_offset(window, bezel) + assert (dx, dy) == (1, 26) + assert score < 1.0 + screen = ss.crop_screen(window, (dx, dy), display) + assert screen.size == (60, 60) + assert screen.getpixel((30, 30)) == (255, 255, 255) + assert screen.getpixel((0, 0)) == (0, 0, 0) # outside the round display + + +def test_blank_screen_is_detected() -> None: + assert ss.is_blank(Image.new("RGB", (60, 60), (0, 0, 0))) + assert not ss.is_blank(app_screen()) + + +def test_compare_counts_differing_pixels() -> None: + fraction, _ = ss.compare(app_screen(), app_screen()) + assert fraction == 0 + fraction, overlay = ss.compare(app_screen((255, 255, 255)), app_screen((0, 0, 255))) + assert fraction == (21 * 11) / (60 * 60) + assert overlay.getpixel((30, 30)) == (255, 0, 0) + assert ss.compare(app_screen(), Image.new("RGB", (10, 10)))[0] == 1.0 + + +def test_main_flow_approve_then_compare(tmp_path: Path) -> None: + device_dir = make_device(tmp_path) + raw, out, golden = tmp_path / "raw.png", tmp_path / "out.png", tmp_path / "golden" / "fakewatch.png" + make_window(device_dir, (1, 26), app_screen()).save(raw) + args = ["--raw", str(raw), "--device-dir", str(device_dir), "--out", str(out), "--golden", str(golden)] + assert ss.main(args) == ss.EXIT_NO_GOLDEN + assert ss.main([*args, "--approve"]) == ss.EXIT_OK + assert ss.open_image(golden).info["Author"] == "cirakg & claude ai" + assert ss.main(args) == ss.EXIT_OK + + make_window(device_dir, (1, 26), app_screen((0, 255, 0))).save(raw) + assert ss.main(args) == ss.EXIT_DIFFERENT + assert (tmp_path / "out-diff.png").exists() + + +def test_missing_device_files_are_an_error(tmp_path: Path) -> None: + device_dir = make_device(tmp_path) + (device_dir / "fake.png").unlink() + raw = tmp_path / "raw.png" + Image.new("RGB", (100, 150)).save(raw) + assert ss.main(["--raw", str(raw), "--device-dir", str(device_dir), "--out", str(tmp_path / "o.png")]) == 2 |
