mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
docs(sports): reconcile-then-promote roadmap, drift report and report-only CI job (#680)
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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user