aboutsummaryrefslogtreecommitdiff
path: root/tools/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tools/tests')
-rw-r--r--tools/tests/conftest.py25
-rw-r--r--tools/tests/test_check_budgets.py45
-rw-r--r--tools/tests/test_check_docs.py68
-rw-r--r--tools/tests/test_ciq_test_report.py99
-rw-r--r--tools/tests/test_lint_project.py81
-rw-r--r--tools/tests/test_make_icon.py25
6 files changed, 343 insertions, 0 deletions
diff --git a/tools/tests/conftest.py b/tools/tests/conftest.py
new file mode 100644
index 0000000..7054ecb
--- /dev/null
+++ b/tools/tests/conftest.py
@@ -0,0 +1,25 @@
+"""Shared pytest setup for the tool tests.
+
+* Makes the modules in tools/ importable as top-level modules.
+* Keeps Hypothesis' example database in build/ (git-ignored) and disables its
+ per-example deadline, which is flaky on a busy machine.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from hypothesis import settings
+from hypothesis.database import DirectoryBasedExampleDatabase
+
+TOOLS_DIR = Path(__file__).resolve().parents[1]
+PROJECT_ROOT = TOOLS_DIR.parent
+sys.path.insert(0, str(TOOLS_DIR))
+
+settings.register_profile(
+ "project",
+ database=DirectoryBasedExampleDatabase(str(PROJECT_ROOT / "build" / ".hypothesis")),
+ deadline=None,
+)
+settings.load_profile("project")
diff --git a/tools/tests/test_check_budgets.py b/tools/tests/test_check_budgets.py
new file mode 100644
index 0000000..35cc2f8
--- /dev/null
+++ b/tools/tests/test_check_budgets.py
@@ -0,0 +1,45 @@
+"""Tests for tools/check_budgets.py (perf and memory budgets)."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import check_budgets as cb
+
+FRAME = 'DIAG {"event":"first_frame","startup_ms":250,"mem_used":100000,"mem_total":524288}'
+
+
+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}
+ ]
+
+
+def test_measure_perf_and_memory() -> None:
+ events = cb.read_events(FRAME)
+ assert cb.measure("perf", events) == {"startup_ms": 250.0}
+ assert cb.measure("mem", events) == {"mem_used_fraction": 100000 / 524288}
+ assert cb.measure("perf", []) == {}
+
+
+def run(tmp_path: Path, kind: str, log_text: str, budgets: dict[str, dict[str, float]]) -> int:
+ log = tmp_path / "fr255s.log"
+ log.write_text(log_text, encoding="utf-8")
+ budget_file = tmp_path / "budgets.json"
+ budget_file.write_text(json.dumps(budgets), encoding="utf-8")
+ return cb.main([kind, str(log), "--budgets", str(budget_file), "--summary", str(tmp_path / "s.json")])
+
+
+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}}
+
+
+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_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
new file mode 100644
index 0000000..67404c9
--- /dev/null
+++ b/tools/tests/test_check_docs.py
@@ -0,0 +1,68 @@
+"""Tests for tools/check_docs.py, the offline Markdown link checker."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import check_docs as cd
+import pytest
+from conftest import PROJECT_ROOT
+from hypothesis import given
+from hypothesis import strategies as st
+
+
+@pytest.mark.parametrize(
+ ("heading", "anchor"),
+ [
+ ("12.4 How each symbology is proven scannable", "124-how-each-symbology-is-proven-scannable"),
+ ("3e. Writing values back: unknown", "3e-writing-values-back-unknown"),
+ ("1. Scope", "1-scope"),
+ ("`make sim` and noVNC", "make-sim-and-novnc"),
+ ("A — B", "a--b"),
+ ("[Link](x.md) text", "link-text"),
+ ],
+)
+def test_slugify_matches_github(heading: str, anchor: str) -> None:
+ assert cd.slugify(heading) == anchor
+
+
+@given(st.text())
+def test_slugify_output_is_anchor_safe(heading: str) -> None:
+ assert re.fullmatch(r"[\w\-]*", cd.slugify(heading))
+
+
+def write(tmp_path: Path, name: str, text: str) -> Path:
+ path = tmp_path / name
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+def test_repeated_headings_get_numbered_anchors(tmp_path: Path) -> None:
+ doc = write(tmp_path, "a.md", "# Notes\n\n## Notes\n\n```\n# not a heading\n```\n")
+ assert cd.anchors_of(doc) == {"notes", "notes-1"}
+
+
+def test_broken_links_are_reported(tmp_path: Path) -> None:
+ write(tmp_path, "other.md", "# Present\n")
+ doc = write(
+ tmp_path,
+ "a.md",
+ "# Top\n"
+ "[ok](#top) [ok2](other.md#present) [web](https://example.com/x)\n"
+ "[missing file](nope.md)\n"
+ "[missing anchor](other.md#absent)\n"
+ "`[inline code](ignored.md)`\n"
+ "```\n[fenced](ignored.md)\n```\n",
+ )
+ problems = cd.check_file(doc, tmp_path, {})
+ assert [(p.line, p.message) for p in problems] == [
+ (3, "missing file 'nope.md'"),
+ (4, "no heading for anchor '#absent' in other.md"),
+ ]
+
+
+def test_project_docs_have_no_broken_links() -> None:
+ cache: dict[Path, set[str]] = {}
+ problems = [p for f in cd.markdown_files(PROJECT_ROOT) for p in cd.check_file(f, PROJECT_ROOT, cache)]
+ assert problems == [], "\n".join(map(str, problems))
diff --git a/tools/tests/test_ciq_test_report.py b/tools/tests/test_ciq_test_report.py
new file mode 100644
index 0000000..cdd3901
--- /dev/null
+++ b/tools/tests/test_ciq_test_report.py
@@ -0,0 +1,99 @@
+"""Tests for tools/ciq_test_report.py, the judge of Connect IQ test logs."""
+
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+import ciq_test_report as rep
+from hypothesis import given
+from hypothesis import strategies as st
+
+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
+PASS
+------------------------------------------------------------------------------
+Executing test otherTest...
+PASS
+==============================================================================
+RESULTS
+Test: Status:
+smokeVersionIsSemver PASS
+otherTest PASS
+Ran 2 tests
+PASSED (failures=0, errors=0)
+Connection Finished
+Closing shell and port
+"""
+
+
+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_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"
+ )
+ code, reasons = rep.parse(text).problems(min_tests=1)
+ assert code == rep.EXIT_FAIL
+ assert "otherTest: FAIL" in reasons
+
+
+def test_error_row_fails_even_if_summary_claims_pass() -> None:
+ 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
+
+
+def test_log_without_results_is_incomplete() -> None:
+ truncated = PASSING.partition("RESULTS")[0]
+ assert rep.parse(truncated).problems(min_tests=1)[0] == rep.EXIT_INCOMPLETE
+
+
+def test_too_few_tests_fails() -> None:
+ code, reasons = rep.parse(PASSING).problems(min_tests=3)
+ assert code == rep.EXIT_FAIL
+ assert any("expected at least 3" in r for r in reasons)
+
+
+def test_ran_count_must_match_rows() -> None:
+ code, reasons = rep.parse(PASSING.replace("Ran 2 tests", "Ran 3 tests")).problems(min_tests=1)
+ assert code == rep.EXIT_FAIL
+ assert any("'Ran 3'" in r for r in reasons)
+
+
+def test_junit_output_is_valid(tmp_path: Path) -> None:
+ log = tmp_path / "fr70.log"
+ 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()
+ assert suite.get("tests") == "2"
+ assert suite.get("failures") == "1"
+ assert [c.get("name") for c in suite.iter("testcase")] == ["smokeVersionIsSemver", "otherTest"]
+
+
+def test_missing_log_is_incomplete(tmp_path: Path) -> None:
+ assert rep.main([str(tmp_path / "nope.log"), "--device", "x"]) == rep.EXIT_INCOMPLETE
+
+
+@given(st.text())
+def test_parser_never_crashes(text: str) -> None:
+ code, _ = rep.parse(text).problems(min_tests=1)
+ assert code in (rep.EXIT_PASS, rep.EXIT_FAIL, rep.EXIT_INCOMPLETE)
diff --git a/tools/tests/test_lint_project.py b/tools/tests/test_lint_project.py
new file mode 100644
index 0000000..be0151e
--- /dev/null
+++ b/tools/tests/test_lint_project.py
@@ -0,0 +1,81 @@
+"""Tests for tools/lint_project.py, the project policy linter."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import lint_project as lp
+from conftest import PROJECT_ROOT
+
+GOOD_MANIFEST = "<iq:manifest><iq:permissions/></iq:manifest>\n"
+
+
+def make_project(tmp_path: Path, files: dict[str, str]) -> Path:
+ for name, text in {"manifest.xml": GOOD_MANIFEST, **files}.items():
+ path = tmp_path / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return tmp_path
+
+
+def codes(root: Path) -> list[tuple[str, str, int]]:
+ return [(str(p.path), p.code, p.line) for p in lp.lint(root)]
+
+
+def test_clean_project_passes(tmp_path: Path) -> None:
+ root = make_project(
+ tmp_path,
+ {
+ "source/util/Diag.mc": '// Diag\nSystem.println("DIAG {}");\n',
+ "source/otp/Base32.mc": (
+ '// Base32\nconst ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // lint: allow-literal\n'
+ ),
+ "test/T.mc": (
+ "// T\n"
+ 'System.println("debug output in tests is allowed");\n'
+ 'const V = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";\n'
+ ),
+ },
+ )
+ assert codes(root) == []
+
+
+def test_each_rule_fires(tmp_path: Path) -> None:
+ root = make_project(
+ tmp_path,
+ {
+ "source/app/NoHeader.mc": "import Toybox.Lang;\n",
+ "source/app/Log.mc": '// Log\nSystem.println("secret?");\n',
+ "source/app/Todo.mc": "// Todo\nvar x = 1; // TODO finish\n",
+ "source/app/Secret.mc": (
+ '// Secret\nconst S = "JBSWY3DPEHPK3PXPJBSWY3DP";\nconst H = "0123456789abcdef0123456789abcdef";\n'
+ ),
+ "source/app/Net.mc": "// Net\nimport Toybox.Communications;\n",
+ },
+ )
+ assert codes(root) == [
+ ("source/app/Log.mc", "MC002", 2),
+ ("source/app/Net.mc", "MC005", 2),
+ ("source/app/NoHeader.mc", "MC001", 1),
+ ("source/app/Secret.mc", "MC004", 2),
+ ("source/app/Secret.mc", "MC004", 3),
+ ("source/app/Todo.mc", "MC003", 2),
+ ]
+
+
+def test_println_in_comment_is_ignored(tmp_path: Path) -> None:
+ root = make_project(tmp_path, {"source/app/A.mc": "// A\nvar a = 1; // System.println(a)\n"})
+ assert codes(root) == []
+
+
+def test_manifest_permissions_are_rejected(tmp_path: Path) -> None:
+ root = make_project(
+ tmp_path,
+ {"manifest.xml": '<iq:permissions>\n<iq:uses-permission id="Communications"/>\n</iq:permissions>\n'},
+ )
+ assert codes(root) == [("manifest.xml", "MC005", 2)]
+
+
+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
new file mode 100644
index 0000000..66128c5
--- /dev/null
+++ b/tools/tests/test_make_icon.py
@@ -0,0 +1,25 @@
+"""Tests for tools/make_icon.py (launcher icon generator)."""
+
+from __future__ import annotations
+
+import make_icon
+from PIL import Image
+
+
+def rgba(img: Image.Image, xy: tuple[int, int]) -> tuple[int, ...]:
+ """One RGBA pixel as a tuple (Pillow's getpixel returns a wider union type)."""
+ value = img.getpixel(xy)
+ assert isinstance(value, tuple)
+ return value
+
+
+def test_icon_size_transparency_and_colours() -> None:
+ img = make_icon.draw_icon(60)
+ assert img.size == (60, 60)
+ assert img.mode == "RGBA"
+ # Corners are outside the disc, so fully transparent.
+ assert rgba(img, (0, 0))[3] == 0
+ # The disc edge is teal, the lock body in the middle is white.
+ r, g, b, a = rgba(img, (30, 4))
+ assert a > 200 and g > r and b > r
+ assert rgba(img, (22, 40))[:3] == (255, 255, 255)