feat(common): sports_celebration, sports_fetch and sports_card_wrappers, promoted from the scoreboards (sports consolidation stage 3) (#672)

Three new hardware-free modules holding code the scoreboard plugins carry
as identical copies (executable AST, docstrings stripped, checked across
every carrying plugin at ledmatrix-plugins 30455671). The bodies are the
plugins'; the changes are type annotations for the mypy ratchet, the
colour helpers losing their leading underscore as public free functions,
and two comments that described the plugins' files.

- src/common/sports_celebration.py: SportsCelebrationMixin, the score/win
  takeover drawn by afl, football, hockey, nrl and soccer
  (_draw_celebration_layout and the palette, backdrop, scenery, confetti,
  crest and _fit_font steps, with their class constants), plus the colour
  helpers (logo_palette, lift_color, cap_luminance, mix_color, ...). Only
  the drawing: _start_celebration, _check_for_goal/_check_for_score,
  _check_for_win and display() differ between the plugins and stay there.
- src/common/sports_fetch.py: SportsFetchMixin, the four SportsCore methods
  identical in all nine scoreboards: _fetch_season_directly,
  _background_fetches_espn_ranges, _needs_previous_day and
  _wants_live_odds, with _LOOKBACK_CUTOFF_HOUR and _LIVE_ODDS_LOOKAHEAD.
  _get_timezone, _extract_game_details and _fetch_data are as identical
  and stay behind, for the reasons sports_shared gives (a per-plugin
  import; the abstract contract); so does SportsUpcoming.__init__, since
  no src/common mixin has a constructor.
- src/common/sports_card_wrappers.py: SportsCardWrappersMixin, the
  seventeen sports_card delegations the eight game renderers carry (15 in
  all eight, 2 in all but football, whose own versions override them).
  _schema_font_size/_resolve_font_size look identical but read each
  plugin's own _SCHEMA_PATH, so they stay.

Each mixin has no __init__ and creates no attributes (the host contract is
declared as annotations only), defines no name the mixins beside it
define, and documents the attributes it reads; a host-contract test
parses each and fails on an undocumented read. A method kept on a
plugin's class wins over the mixin's.

Tests: behaviour ported from the plugins' celebration, odds, lookback and
date-range tests against stub hosts carrying exactly the contract, with
crests drawn by the test (test_sports_celebration.py, test_sports_fetch.py,
test_sports_card_wrappers.py), and test_sports_stage3_parity.py, which with
LEDMATRIX_PLUGINS set compares every body with every plugin copy that is
left (58 pass against the plugins today; a copy that is gone counts as
adopted). All three modules are on the mypy ratchet, in
src/common/README.md, the CHANGELOG's Unreleased section and
SPORTS_UNIFICATION's module table. Nothing in core uses them yet.

Full suite: the same 67 failing test ids as main (Windows-only), 77 more
passing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-29 12:38:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7f96075076
commit 1e4c890d59
11 changed files with 2013 additions and 1 deletions
+193
View File
@@ -0,0 +1,193 @@
"""src.common.sports_fetch: behaviour and host contract.
Ported from the scoreboards' tests of the same methods (football's
test_live_odds_follow_the_rotation.py, test_lookback_only_when_it_can_matter.py
and test_espn_date_ranges.py) against a stub host carrying exactly the
documented contract.
"""
import ast
import logging
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from src.common import espn_dates, sports_fetch
from src.common.sports_fetch import SportsFetchMixin
ET = timezone(timedelta(hours=-5))
class _Response:
status_code = 200
content = None
def __init__(self, data):
self._data = data
def json(self):
return self._data
def raise_for_status(self):
pass
class _Session:
def __init__(self, data=None, error=None):
self.data, self.error, self.calls = data, error, []
def get(self, url, params=None, headers=None, timeout=None):
self.calls.append((url, dict(params or {}), headers, timeout))
if self.error:
raise self.error
return _Response(self.data)
class _Cache:
def __init__(self):
self.sets = []
def set(self, key, data, **kwargs):
self.sets.append((key, data, kwargs))
class Host(SportsFetchMixin):
"""The documented contract, and not one attribute more."""
def __init__(self, session=None):
self.session = session or _Session(data={"events": []})
self.headers = {"User-Agent": "test"}
self.cache_manager = _Cache()
self.logger = logging.getLogger("test.sports_fetch")
self._games_lock = threading.RLock()
class TestWantsLiveOdds:
def test_cold_start_asks_for_every_game(self):
assert Host()._wants_live_odds({"id": "a"}) is True
def test_only_the_game_on_screen_and_the_next(self):
host = Host()
host.live_games = [{"id": i} for i in "abcd"]
host.current_game_index = 1
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [False, True, True, False]
def test_the_rotation_schedule_is_followed_and_wraps(self):
host = Host()
host.live_games = [{"id": i} for i in "abcd"]
host._rotation_schedule = ["d", "c", "b", "a"]
host.current_game_index = 3
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, False, False, True]
def test_an_index_past_the_end_starts_at_the_front(self):
host = Host()
host.live_games = [{"id": i} for i in "abc"]
host.current_game_index = 9
assert [host._wants_live_odds({"id": i}) for i in "abc"] == [True, True, False]
def test_the_lookahead_is_a_class_setting(self):
class Wider(Host):
_LIVE_ODDS_LOOKAHEAD = 2
host = Wider()
host.live_games = [{"id": i} for i in "abcd"]
host.current_game_index = 0
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, True, True, False]
class TestNeedsPreviousDay:
def test_before_the_cutoff_yesterday_is_kept(self):
assert Host()._needs_previous_day(datetime(2026, 1, 15, 5, 59, tzinfo=ET)) is True
def test_after_it_with_nothing_live_it_is_dropped(self):
assert Host()._needs_previous_day(datetime(2026, 1, 15, 6, 0, tzinfo=ET)) is False
def test_a_live_game_from_yesterday_keeps_it(self):
host = Host()
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 3, 0, tzinfo=timezone.utc)}]
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is True
def test_todays_live_game_does_not(self):
host = Host()
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 18, 0, tzinfo=timezone.utc)}]
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
@pytest.mark.parametrize("game", [{}, {"start_time_utc": "2026-01-14"}, "not a game"])
def test_unusable_start_times_are_skipped(self, game):
host = Host()
host.live_games = [game]
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
class TestBackgroundFetchesEspnRanges:
def test_no_service(self):
assert Host()._background_fetches_espn_ranges() is False
@pytest.mark.parametrize("flag,expected", [(True, True), (False, False), (None, False)])
def test_follows_the_service(self, flag, expected):
host = Host()
host.background_service = type("S", (), {"handles_espn_date_ranges": flag})()
assert host._background_fetches_espn_ranges() is expected
def test_an_old_service_without_the_flag(self):
host = Host()
host.background_service = object()
assert host._background_fetches_espn_ranges() is False
class TestFetchSeasonDirectly:
def test_fetches_caches_and_returns(self):
host = Host(_Session(data={"events": [1, 2]}))
data = host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season")
assert data == {"events": [1, 2]}
assert host.cache_manager.sets == [("k", data, {})]
assert host.session.calls == [
("http://espn/sb", {"dates": "20260115", "limit": espn_dates.ESPN_MAX_LIMIT},
{"User-Agent": "test"}, 30)]
def test_a_ttl_reaches_the_cache(self):
host = Host()
host._fetch_season_directly("http://espn/sb", "20260115", "k", "x", ttl=60)
assert host.cache_manager.sets[0][2] == {"ttl": 60}
def test_a_failure_returns_none_and_caches_nothing(self, caplog):
host = Host(_Session(error=OSError("down")))
with caplog.at_level(logging.ERROR):
assert host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season") is None
assert host.cache_manager.sets == []
assert "Failed to fetch 2026 season schedule" in caplog.text
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
tree = ast.parse(Path(sports_fetch.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsFetchMixin")
names = set()
for node in ast.walk(cls):
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"):
names.add(node.attr)
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "getattr" and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)):
names.add(node.args[1].value)
return names
class TestHostContract:
def test_every_host_read_is_documented(self):
needed = _self_reads() - set(dir(SportsFetchMixin))
undocumented = sorted(n for n in needed if f"``{n}``" not in sports_fetch.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_attributes_of_its_own(self):
for name in ("session", "headers", "cache_manager", "logger", "_games_lock"):
assert not hasattr(SportsFetchMixin, name)