diff options
Diffstat (limited to 'tools/tests')
| -rw-r--r-- | tools/tests/conftest.py | 25 | ||||
| -rw-r--r-- | tools/tests/test_check_budgets.py | 45 | ||||
| -rw-r--r-- | tools/tests/test_check_docs.py | 68 | ||||
| -rw-r--r-- | tools/tests/test_ciq_test_report.py | 99 | ||||
| -rw-r--r-- | tools/tests/test_lint_project.py | 81 | ||||
| -rw-r--r-- | tools/tests/test_make_icon.py | 25 |
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) |
