mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
Rewrites the roadmap in docs/SPORTS_UNIFICATION.md for the reconcile-then-promote decision (stages 0-3 recorded as done), and adds the sports drift report script with a report-only CI job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
6.3 KiB
Python
161 lines
6.3 KiB
Python
"""scripts/sports_drift_report.py counts what it says it counts.
|
|
|
|
The report is the progress measure for the reconcile-then-promote stages in
|
|
docs/SPORTS_UNIFICATION.md, so a wrong count is a wrong roadmap. These build a
|
|
tiny plugin tree by hand and check each definition: families across classes,
|
|
variants per class, what is ignored (docstrings, comments, annotations), what
|
|
folding does, and that a missing checkout is a report, not a failure.
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "sports_drift_report.py"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def drift():
|
|
spec = importlib.util.spec_from_file_location("sports_drift_report", SCRIPT)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _write(root: Path, sport: str, fname: str, body: str) -> None:
|
|
d = root / "plugins" / f"{sport}-scoreboard"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / fname).write_text(textwrap.dedent(body), encoding="utf-8")
|
|
|
|
|
|
SAME = '''
|
|
class SportsLive:
|
|
def update(self):
|
|
"""Docstrings are not behaviour."""
|
|
return self.x + 1 # nor are comments
|
|
|
|
def over(self, game: dict) -> bool:
|
|
return game["clock"] == "0:00"
|
|
'''
|
|
|
|
OTHER = '''
|
|
class SportsLive:
|
|
def update(self):
|
|
return self.x + 2
|
|
|
|
def over(self, game):
|
|
"""Annotations and docstrings differ; the body does not."""
|
|
return game["clock"] == "0:00"
|
|
'''
|
|
|
|
|
|
@pytest.fixture
|
|
def tree(tmp_path):
|
|
for sport in ("afl", "nrl", "soccer"):
|
|
_write(tmp_path, sport, "sports.py", SAME)
|
|
_write(tmp_path, "hockey", "sports.py", OTHER)
|
|
# A second class role with its own update(): same family, counted apart.
|
|
_write(tmp_path, "hockey", "sports.py", OTHER + '''
|
|
class SportsRecent:
|
|
def update(self):
|
|
return None
|
|
''')
|
|
# manager.py classes are named after the sport; they must line up.
|
|
_write(tmp_path, "afl", "manager.py", '''
|
|
class AFLScoreboardPlugin:
|
|
def get_live_modes(self):
|
|
return ["afl_live"]
|
|
''')
|
|
_write(tmp_path, "nrl", "manager.py", '''
|
|
class NRLScoreboardPlugin:
|
|
def get_live_modes(self):
|
|
return ["nrl_live"]
|
|
''')
|
|
return tmp_path
|
|
|
|
|
|
def _row(rep, fname, family):
|
|
return next(r for r in rep["rows"] if r["file"] == fname and r["family"] == family)
|
|
|
|
|
|
def test_counts_variants_per_class_and_ignores_docstrings_and_annotations(drift, tree):
|
|
rep = drift.report(drift.build(tree / "plugins", drift.DEFAULT_FILES), 1, 2)
|
|
over = _row(rep, "sports.py", "over")
|
|
assert (over["plugins"], over["copies"], over["variants"]) == (4, 4, 1)
|
|
|
|
update = _row(rep, "sports.py", "update")
|
|
# SportsLive.update: afl/nrl/soccer agree, hockey differs -> 2; plus
|
|
# hockey's SportsRecent.update -> 3 variants in the family.
|
|
assert update["copies"] == 5
|
|
assert update["variants"] == 3
|
|
assert update["worst_class_variants"] == 2
|
|
live = next(k for k in update["classes"] if k["class"] == "SportsLive")
|
|
assert live["groups"] == [["afl", "nrl", "soccer"], ["hockey"]]
|
|
|
|
|
|
def test_sport_names_fold_classes_together_but_not_variants(drift, tree):
|
|
rep = drift.report(drift.build(tree / "plugins", drift.DEFAULT_FILES), 1, 2)
|
|
modes = _row(rep, "manager.py", "get_live_modes")
|
|
assert [k["class"] for k in modes["classes"]] == ["SScoreboardPlugin"]
|
|
# "afl_live" vs "nrl_live": two exact variants, one once sport names fold.
|
|
assert (modes["variants"], modes["folded"]) == (2, 1)
|
|
|
|
|
|
def test_drifted_identical_and_one_outlier_lists(drift, tree):
|
|
rep = drift.report(drift.build(tree / "plugins", drift.DEFAULT_FILES), 2, 3)
|
|
assert [(r["file"], r["family"]) for r in rep["drifted"]] == [("sports.py", "update")]
|
|
assert ("sports.py", "over") in [(r["file"], r["family"]) for r in rep["identical"]]
|
|
assert ("sports.py", "update") in [(r["file"], r["family"])
|
|
for r in rep["one_outlier"]]
|
|
|
|
|
|
def test_fold(drift):
|
|
assert drift.fold("nfl_live") == "S_live"
|
|
assert drift.fold("UFCScoreboardPlugin") == "SScoreboardPlugin"
|
|
assert drift.fold("HockeyLive") == "SLive"
|
|
assert drift.fold("display_mode") == "display_mode"
|
|
# Text between words survives, so different strings stay different.
|
|
assert drift.fold("NFL games: {n}") == "S games: {n}"
|
|
assert drift.fold("NFL games: {n}") != drift.fold("NFLgames:{n}")
|
|
|
|
|
|
def test_one_outlier_means_one_plugin_across_the_whole_family(drift, tmp_path):
|
|
# SportsLive: hockey is the odd one out; SportsRecent: afl is. Each class
|
|
# role has a single outlier, but the family has two.
|
|
live = "class SportsLive:\n def f(self):\n return {}\n"
|
|
recent = "class SportsRecent:\n def f(self):\n return {}\n"
|
|
for sport in ("afl", "nrl", "soccer", "hockey"):
|
|
_write(tmp_path, sport, "sports.py",
|
|
live.format("2" if sport == "hockey" else "1")
|
|
+ recent.format("2" if sport == "afl" else "1"))
|
|
rep = drift.report(drift.build(tmp_path / "plugins", ["sports.py"]), 2, 3)
|
|
assert rep["one_outlier"] == []
|
|
for sport in ("afl", "nrl", "soccer", "hockey"):
|
|
_write(tmp_path, sport, "sports.py",
|
|
live.format("2" if sport == "hockey" else "1")
|
|
+ recent.format("2" if sport == "hockey" else "1"))
|
|
rep = drift.report(drift.build(tmp_path / "plugins", ["sports.py"]), 2, 3)
|
|
assert [drift._outlier(r) for r in rep["one_outlier"]] == ["hockey"]
|
|
|
|
|
|
def test_no_checkout_is_a_report_not_a_failure(drift, tmp_path, capsys, monkeypatch):
|
|
monkeypatch.delenv("LEDMATRIX_PLUGINS", raising=False)
|
|
assert drift.main([]) == 0
|
|
assert drift.main(["--plugins", str(tmp_path), "--markdown"]) == 0
|
|
assert "Nothing to report" in capsys.readouterr().out
|
|
|
|
|
|
def test_cli_markdown_json_and_family_diff(drift, tree, tmp_path, capsys):
|
|
out = tmp_path / "report.json"
|
|
assert drift.main(["--plugins", str(tree), "--markdown", "--json", str(out),
|
|
"--min-plugins", "2", "--family", "sports.py::update",
|
|
"--diff"]) == 0
|
|
text = capsys.readouterr().out
|
|
assert "| `sports.py::update` |" in text
|
|
assert "+++ hockey-scoreboard/sports.py" in text
|
|
data = json.loads(out.read_text(encoding="utf-8"))
|
|
assert {"files", "drifted", "identical", "one_outlier", "rows"} <= set(data)
|