mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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>
194 lines
7.3 KiB
Python
194 lines
7.3 KiB
Python
"""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)
|