mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 23:56:36 +00:00
feat(common): sports_game_over -- the reconciled game-over check (sports family 5) (#770)
* feat(common): sports_game_over -- the reconciled game-over check (sports family 5) New hardware-free module src/common/sports_game_over.py with SportsGameOverMixin._is_game_really_over, the scoreboards' SportsLive check that drops a game ESPN still lists as live, copied from ledmatrix-plugins claude/family5-reconcile once the nine copies (five bodies) became one. Over on a final period text; from period FINAL_PERIOD on, also on a 0:00 clock string unless the score is level (a tie at the end of regulation goes to overtime; a game that ends tied ends on its final status). FINAL_PERIOD is the one per-sport seam, a class attribute defaulting to None (the clock never ends a game); the scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or None (afl, nrl, soccer, baseball, ufc). - test/test_sports_game_over.py: the plugins' pinned matrix folded to the three FINAL_PERIOD values, edge shapes, the tie guard, ufc's recorded ESPN MMA states, an override deferring through super() (baseball), the base order with SportsLiveSharedMixin._detect_stale_games, host contract. - test/test_sports_game_over_parity.py: with LEDMATRIX_PLUGINS, compares the body with every plugin copy (drift-report normalisation plus decorators) and each plugin's FINAL_PERIOD with the owner's decision. - mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules). - docs/SPORTS_UNIFICATION.md: family 5 status and decisions; the seam tables now match the code (FINAL_PERIOD defaults to None; the CLOCK_COUNTS_DOWN seam never existed and is gone from the doc). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: cite ledmatrix-plugins #625 for the family 5 reconcile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
|
||||
|
||||
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
|
||||
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
|
||||
body. The plugins delete their copies once they floor on the release that
|
||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
||||
side has and the other lacks.
|
||||
|
||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
|
||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||
decorators. A copy that is gone counts as adopted when the plugin's
|
||||
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
|
||||
compared with the value the owner decided for its sport, which stays in the
|
||||
plugin after adoption. Without the variable this skips: core CI has no plugins
|
||||
checkout.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_game_over
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
|
||||
#: from which a 0:00 clock ends a game, None where the clock never does.
|
||||
FINAL_PERIOD = {
|
||||
"afl": None, "baseball": None, "basketball": 4, "football": 4,
|
||||
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
|
||||
}
|
||||
NAME = "_is_game_really_over"
|
||||
|
||||
|
||||
def _drift_report():
|
||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DRIFT = _drift_report()
|
||||
|
||||
|
||||
def _plugins_root():
|
||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||
if root is None:
|
||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||
"compare this module against the plugin copies")
|
||||
return root
|
||||
|
||||
|
||||
def _class(tree, name):
|
||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||
|
||||
|
||||
def _method(cls):
|
||||
return next((n for n in cls.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
|
||||
|
||||
|
||||
def _fingerprint(node):
|
||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||
|
||||
|
||||
def _final_period(cls):
|
||||
for node in cls.body:
|
||||
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
|
||||
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
||||
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("SportsLive declares no FINAL_PERIOD")
|
||||
|
||||
|
||||
def _ours():
|
||||
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
|
||||
return _class(tree, "SportsGameOverMixin")
|
||||
|
||||
|
||||
def test_the_mixin_holds_one_method_and_the_default():
|
||||
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
|
||||
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
|
||||
and (isinstance(n, ast.FunctionDef) or n.value is not None))
|
||||
assert names == ["FINAL_PERIOD", NAME]
|
||||
assert _final_period(_ours()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_remaining_plugin_copy_matches(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
live = _class(ast.parse(source), "SportsLive")
|
||||
copy = _method(live)
|
||||
if copy is None:
|
||||
assert sports_game_over.__name__ in source, (
|
||||
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
|
||||
else:
|
||||
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
|
||||
f"{NAME} in {sport} differs from sports_game_over. "
|
||||
f"Port the change to both, or stop treating it as shared.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_plugin_declares_its_final_period(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
|
||||
|
||||
|
||||
def test_the_drift_report_still_calls_it_identical():
|
||||
root = _plugins_root()
|
||||
families = DRIFT.build(root, ("sports.py",))
|
||||
rows = {(r["file"], r["family"]): r
|
||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||
row = rows.get(("sports.py", NAME))
|
||||
assert row is None or row["worst_class_variants"] == 1
|
||||
Reference in New Issue
Block a user