Files
LEDMatrix/test/test_sports_live_fetch_parity.py
T
ChuckandClaude Opus 5.5 f9b0b237a3 feat(common): sports_live_fetch -- the reconciled live fetch and odds (sports family 9) (#793)
New hardware-free module src/common/sports_live_fetch.py, copied from
ledmatrix-plugins claude/family9-reconcile once the nine scoreboards made
_fetch_todays_games (seven bodies when measured) and _fetch_odds (three) one
body each. Both live on the plugins' SportsCore, so one mixin,
SportsLiveFetchMixin, carries exactly those two plus the odds worker
_drain_odds_queue; no manager gains a method it lacks today.

Owner decisions (2026-10-12):
- Live scores are always fresh: every poll asks ESPN through
  get_espn_scoreboard and leaves the answer under the shared
  espn_scoreboard_cache_key for its dates (max_age=1: only a copy another
  reader fetched within the last second is reused). The plugins' 30 s
  <sport_key>_scoreboard_current cache is gone.
- College basketball is asked by date like every other league (ESPN
  answered single dates for both college leagues on 2026-10-12).
- Odds never block update(): one daemon worker per manager answers a queue
  and attaches each line in place under _games_lock; one batch in flight per
  manager, games queued meanwhile join it, every dict queued for an id gets
  the one answer. show_odds, the two odds intervals and a missing odds
  manager (debug, not ERROR) are respected.
- An answer whose events is not a list is no games.

Fix: get_espn_scoreboard() no longer caches an answer whose events is not a
list ("events": null), which every reader of the shared key inherited; it is
still returned to the caller.

- test/test_sports_live_fetch.py: the plugins' pinned cases (requests,
  dates and keys at 10:00 and 02:00 Eastern, freshness, malformed and failed
  answers, odds that never block and land later, the intervals, one batch per
  manager, a worker that cannot start, update and display on two threads),
  plus the host contract. Mutation-checked (11 mutants, all caught).
- test/test_sports_live_fetch_parity.py: with LEDMATRIX_PLUGINS, compares
  each body with every plugin copy. Passes against claude/family9-reconcile;
  fails against the pre-reconcile tree.
- test/test_espn_scoreboard_cache.py: the null-events regression.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules,
  Fixes), docs/SPORTS_UNIFICATION.md family 9 status and decisions.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:21:50 -04:00

105 lines
3.9 KiB
Python

"""sports_live_fetch still matches every plugin copy.
``src.common.sports_live_fetch`` was copied from the scoreboards once family 9
had made each method one body in all nine ``SportsCore`` classes:
``_fetch_todays_games``, ``_fetch_odds`` and ``_drain_odds_queue``. 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 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_live_fetch
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsLiveFetchMixin"
METHODS = ("_fetch_todays_games", "_fetch_odds", "_drain_odds_queue")
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 _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_live_fetch.__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 sports_live_fetch.__name__ in source, (
f"{sport}: no {name} on {CARRIER} and no {sports_live_fetch.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
f"{CARRIER}.{name} in {sport} differs from sports_live_fetch. "
f"Port the change to both, or stop treating it as shared.")
@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