aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
commit2292bf700d28ade39cf2d64b45509e6db21034bc (patch)
treeef455320d39ebe51db236219dbbda2b148dcbe18 /tools
parent85f1ecabd7f58f306b2a042376209ac8cf49ab9b (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.json7
-rw-r--r--tools/check_budgets.py65
-rw-r--r--tools/check_docs.py2
-rw-r--r--tools/ciq_test_report.py39
-rw-r--r--tools/gen_products.py104
-rw-r--r--tools/lint_project.py74
-rw-r--r--tools/make_icon.py118
-rw-r--r--tools/sim_screenshot.py217
-rw-r--r--tools/tests/conftest.py2
-rw-r--r--tools/tests/data/runnoevil_pass.log14
-rw-r--r--tools/tests/test_check_budgets.py36
-rw-r--r--tools/tests/test_check_docs.py2
-rw-r--r--tools/tests/test_ciq_test_report.py43
-rw-r--r--tools/tests/test_gen_products.py63
-rw-r--r--tools/tests/test_lint_project.py43
-rw-r--r--tools/tests/test_make_icon.py2
-rw-r--r--tools/tests/test_sim_screenshot.py94
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