mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-15 19:39:14 +00:00
* feat(common): sports_view_model -- the reconciled game view model (sports family 10)
New hardware-free module src/common/sports_view_model.py, copied from
ledmatrix-plugins claude/family10-reconcile once the nine scoreboards made
_extract_game_details_common (nine bodies) one. It lives on the plugins'
SportsCore, so one mixin, SportsViewModelMixin, carries exactly that method,
the helpers it calls (_status_is_final, _score_text, _team_logo_url,
_extract_team_record), the _logo_filename seam and the two status-name
sets; no manager gains a method it lacks today. The module docstring lists
every key of the game dict with its type and default; keys are additive
only.
Owner decisions (2026-10-13):
- One final rule: state "post", not completed: false (a payload without
the flag is judged on state and name), and not a not-played status (the
union of the nine lists). A forfeit is final. A called-off game is never
live, even while ESPN still reports it "in".
- Scores are integer text everywhere; a missing, empty or non-numeric
score is "0".
- A time without an offset is UTC.
- Halftime reads "HALF" with no clock (each sport's own extractor; not in
this module).
- Superset with safe defaults: period (0) and clock (""). period_text has
none: the renderers read its absence (baseball) as "draw ESPN's status
text", and an empty default blanked baseball's Recent "Final" in the
harness.
- test/test_sports_view_model.py: the plugins' pinned cases (states, the
final rule, scores, records, logos, ids, broadcast, dates, offsets, DST,
malformed events), the seam, and the host contract (every self read
documented, every key in the docstring's table). Mutation-checked (11
mutants, all caught).
- test/test_sports_view_model_parity.py: with LEDMATRIX_PLUGINS, compares
each method and both status sets with every plugin copy. Passes against
claude/family10-reconcile; fails against the pre-reconcile tree.
- test_sports_twins.py, sports_card and sports_game_renderer tests pass
with the reconciled plugins as the corpus.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules),
docs/SPORTS_UNIFICATION.md family 10 status and decisions, and the
_logo_filename seam in the override-points table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #654 for the family 10 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(sports): view-model parity requires SportsCore to inherit the mixin
A plugin without a copy counted as adopted if its source merely mentioned
the module. It now also has to list SportsViewModelMixin among SportsCore's
bases (CodeRabbit on #796).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""sports_view_model still matches every plugin copy.
|
|
|
|
``src.common.sports_view_model`` was copied from the scoreboards once family
|
|
10 had made ``_extract_game_details_common`` one body in all nine
|
|
``SportsCore`` classes, with the helpers it calls (``_status_is_final``,
|
|
``_score_text``, ``_team_logo_url``, ``_extract_team_record``, the
|
|
``_logo_filename`` seam) and the two status-name sets. The plugins delete
|
|
their copies once they floor on the release that ships this module. nrl
|
|
overrides the seam on ``BaseNrlManager``, not on ``SportsCore``. 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 each 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.
|
|
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_view_model
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
|
"nrl", "soccer", "ufc")
|
|
CARRIER, MIXIN = "SportsCore", "SportsViewModelMixin"
|
|
METHODS = ("_extract_game_details_common", "_status_is_final", "_score_text",
|
|
"_team_logo_url", "_extract_team_record", "_logo_filename")
|
|
CONSTANTS = ("_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES")
|
|
|
|
|
|
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, name):
|
|
return next((n for n in cls.body
|
|
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
|
|
|
|
|
def _constant(cls, name):
|
|
"""The value assigned to ``name`` in the class body, as an AST dump."""
|
|
return next((ast.dump(n.value) for n in cls.body
|
|
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", None) == name),
|
|
None)
|
|
|
|
|
|
|
|
def _adopted(source, tree):
|
|
"""The plugin imports core's module and its carrier inherits the mixin."""
|
|
bases = _class(tree, CARRIER).bases
|
|
return (sports_view_model.__name__ in source
|
|
and any(getattr(b, "id", getattr(b, "attr", None)) == MIXIN for b in bases))
|
|
|
|
|
|
def _fingerprint(node):
|
|
return (DRIFT._digest(node, DRIFT._Canonical()),
|
|
tuple(ast.unparse(d) for d in node.decorator_list))
|
|
|
|
|
|
def _plugin_tree(root, sport):
|
|
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
|
return source, ast.parse(source)
|
|
|
|
|
|
OURS = _class(ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8")), MIXIN)
|
|
|
|
|
|
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
|
def test_every_remaining_plugin_copy_matches(sport, name):
|
|
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
copy = _method(_class(tree, CARRIER), name)
|
|
if copy is None:
|
|
assert _adopted(source, tree), (
|
|
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
|
else:
|
|
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
|
f"{CARRIER}.{name} in {sport} differs from sports_view_model. "
|
|
f"Port the change to both, or stop treating it as shared.")
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in CONSTANTS])
|
|
def test_every_remaining_status_set_matches(sport, name):
|
|
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
copy = _constant(_class(tree, CARRIER), name)
|
|
if copy is None:
|
|
assert _adopted(source, tree), (
|
|
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
|
else:
|
|
assert copy == _constant(OURS, name), f"{CARRIER}.{name} in {sport} differs"
|
|
|
|
|
|
@pytest.mark.parametrize("sport", SPORTS)
|
|
def test_no_other_plugin_class_carries_a_copy(sport):
|
|
"""A copy on another class would shadow the shared one for that class."""
|
|
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
|
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
|
assert strays == []
|
|
|
|
|
|
def test_the_drift_report_calls_them_identical():
|
|
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
|
rows = {(r["file"], r["family"]): r
|
|
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
|
for name in METHODS:
|
|
row = rows.get(("sports.py", name))
|
|
assert row is None or row["worst_class_variants"] == 1, name
|