mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py becomes a package so the upcoming capability modules have a home and diffs show their blast radius: sports/__init__.py re-exports the public API sports/core.py SportsCore sports/modes.py SportsUpcoming / SportsRecent / SportsLive No logic change: the 1515 class-body lines are byte-identical to the original (verified by concatenating the two modules and diffing against HEAD). Only module docstrings and the redistributed import blocks are new. MRO and __abstractmethods__ are unchanged, and every existing import site — including 'from src.base_classes.sports import SportsCore' in the sport subclasses, the skin tests, and the characterization suite — resolves through the package __init__. One test edit was required: the characterization suite monkeypatched 'src.base_classes.sports.get_background_service', which is no longer a module attribute on a package. Retargeted to 'src.base_classes.sports.core.get_background_service' — the module whose globals SportsCore.__init__ actually resolves, so the patch is effective exactly as before. No test logic or assertion changed. Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole B1-B5 sequence — how upgradability (guarded imports, capability probing, frozen view-model keys, the sunset rule), reusability (promote only what all nine copies share), and modularity (capabilities as opt-in mixins rather than config branches, variants as named strategies, sport-unique code as declared override points) are kept as three separate mechanisms. Verified: characterization + skin 94 passed; the 10-file unit suite 338 passed; test/plugins 60 passed — all identical to pre-change counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
618 lines
26 KiB
Python
618 lines
26 KiB
Python
"""Characterization tests for src/base_classes/sports.py.
|
|
|
|
These tests PIN the current behavior of SportsCore / SportsUpcoming /
|
|
SportsRecent / SportsLive ahead of the sports-unification merge (features
|
|
from nine drifted plugin copies are about to be folded in). They assert
|
|
what the code DOES today, not what it should do — a few pinned behaviors
|
|
look like bugs and are flagged inline with "PINNED AS-IS".
|
|
|
|
Coverage:
|
|
- `_extract_game_details_common` + the four sport extractors
|
|
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
|
|
events (adapted from the ledmatrix-plugins monorepo test fixtures).
|
|
The output must remain a superset of the frozen skin view-model
|
|
contract (GUARANTEED_KEYS, imported from test_skin_system).
|
|
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
|
|
subclasses: population, favorite-team filtering, empty/failed-fetch
|
|
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
|
|
and every instance's requests session raises ConnectionError.
|
|
- Rendering smoke: one `display()` per mode class at 128x32 draws
|
|
non-zero ink onto a real PIL image.
|
|
- Guard rails: the skin-system seam methods on SportsCore must survive
|
|
the merge.
|
|
"""
|
|
|
|
import logging
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import pytz
|
|
import requests
|
|
from freezegun import freeze_time
|
|
from PIL import Image
|
|
|
|
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
# stub it so these tests can import the sports base classes off-device.
|
|
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
|
|
from src.base_classes.baseball import Baseball
|
|
from src.base_classes.basketball import Basketball
|
|
from src.base_classes.football import Football
|
|
from src.base_classes.hockey import Hockey, HockeyLive
|
|
from src.base_classes.sports import (
|
|
SportsCore,
|
|
SportsLive,
|
|
SportsRecent,
|
|
SportsUpcoming,
|
|
)
|
|
|
|
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
|
|
from test.test_skin_system import GUARANTEED_KEYS
|
|
|
|
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
|
|
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
|
|
|
|
# All update()-flow tests run at this frozen instant so the 21-day
|
|
# SportsRecent window and time.time() interval gates are deterministic.
|
|
FROZEN_NOW = "2026-01-20 12:00:00"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
|
|
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
|
|
# team-shaped competitors with status/score/records).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
|
|
return {
|
|
"homeAway": home_away,
|
|
"id": team_id,
|
|
"score": score,
|
|
"team": {
|
|
"id": team_id,
|
|
"abbreviation": abbr,
|
|
"name": abbr.title(),
|
|
"displayName": abbr.title(),
|
|
"logo": None,
|
|
},
|
|
"records": [{"summary": record}],
|
|
# The hockey extractor iterates competitor["statistics"] and
|
|
# returns None for the whole event when the key is absent (see
|
|
# test_hockey_event_without_statistics_returns_none).
|
|
"statistics": [],
|
|
}
|
|
|
|
|
|
def make_event(event_id, state, date, home=("TB", "20", "3"),
|
|
away=("DAL", "9", "2"), period=2, clock="12:45",
|
|
name=None, short_detail=None, situation=None,
|
|
home_record="30-10-5", away_record="25-14-6"):
|
|
"""Build a realistic ESPN scoreboard event in the given state
|
|
('in' / 'post' / 'pre')."""
|
|
defaults = {
|
|
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
|
|
"post": ("STATUS_FINAL", "Final"),
|
|
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
|
|
}
|
|
default_name, default_detail = defaults[state]
|
|
status = {
|
|
"clock": 0.0,
|
|
"displayClock": clock,
|
|
"period": period,
|
|
"type": {
|
|
"id": "2",
|
|
"name": name or default_name,
|
|
"state": state,
|
|
"completed": state == "post",
|
|
"description": short_detail or default_detail,
|
|
"detail": short_detail or default_detail,
|
|
"shortDetail": short_detail or default_detail,
|
|
},
|
|
}
|
|
competition = {
|
|
"id": event_id,
|
|
"date": date,
|
|
"status": status,
|
|
"competitors": [
|
|
_competitor(home[0], home[1], home[2], "home", home_record),
|
|
_competitor(away[0], away[1], away[2], "away", away_record),
|
|
],
|
|
}
|
|
if situation is not None:
|
|
competition["situation"] = situation
|
|
return {
|
|
"id": event_id,
|
|
"date": date,
|
|
"name": f"{away[0]} at {home[0]}",
|
|
"shortName": f"{away[0]} @ {home[0]}",
|
|
"competitions": [competition],
|
|
# Real ESPN payloads duplicate status at the event top level; the
|
|
# baseball extractor reads it there for live innings.
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def make_probe(favorites=None):
|
|
"""Bare-bones SportsCore stand-in for exercising the real extractors
|
|
unbound (same pattern as TestViewModelContract in test_skin_system)."""
|
|
probe = MagicMock()
|
|
probe.logger = logging.getLogger("test_sports_base_characterization")
|
|
probe.favorite_teams = list(favorites or [])
|
|
probe.config = {}
|
|
probe.logo_dir = Path("assets/logos")
|
|
probe._get_timezone.return_value = pytz.utc
|
|
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
|
|
# The sport extractors call self._extract_game_details_common — route
|
|
# it to the real implementation instead of a MagicMock.
|
|
probe._extract_game_details_common = (
|
|
lambda event: SportsCore._extract_game_details_common(probe, event))
|
|
return probe
|
|
|
|
|
|
def extract(sport_cls, event, favorites=None):
|
|
return sport_cls._extract_game_details(make_probe(favorites), event)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. _extract_game_details_common contract, per wired sport
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExtractGameDetailsContract:
|
|
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
|
|
event = make_event("401", "in", "2026-01-15T18:30:00Z")
|
|
details = extract(sport_cls, event)
|
|
assert details is not None
|
|
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
|
assert not missing, (
|
|
f"{sport_cls.__name__} extractor no longer emits {missing} — "
|
|
"these keys are the frozen skin view-model contract.")
|
|
assert details["id"] == "401"
|
|
assert details["home_abbr"] == "TB"
|
|
assert details["away_abbr"] == "DAL"
|
|
assert details["home_id"] == "20"
|
|
assert details["away_id"] == "9"
|
|
assert details["home_score"] == "3"
|
|
assert details["away_score"] == "2"
|
|
assert details["home_record"] == "30-10-5"
|
|
assert details["away_record"] == "25-14-6"
|
|
assert details["is_live"] is True
|
|
assert details["is_final"] is False
|
|
assert details["is_upcoming"] is False
|
|
assert details["status_text"] == "P2 12:45"
|
|
assert details["start_time_utc"] == datetime(
|
|
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
|
|
# Sport-specific formatting of the same event:
|
|
if sport_cls in (Football, Basketball):
|
|
assert details["period_text"] == "Q2"
|
|
assert details["clock"] == "12:45"
|
|
elif sport_cls is Hockey:
|
|
assert details["period_text"] == "P2"
|
|
assert details["clock"] == "12:45"
|
|
else: # Baseball keys inning/status instead of period_text
|
|
assert details["inning"] == 2
|
|
assert details["status_state"] == "in"
|
|
|
|
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
def test_final_event_classification(self, sport_cls):
|
|
event = make_event("402", "post", "2026-01-14T00:00:00Z",
|
|
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
period=3, clock="0:00")
|
|
details = extract(sport_cls, event)
|
|
assert details is not None
|
|
assert details["is_final"] is True
|
|
assert details["is_live"] is False
|
|
assert details["is_upcoming"] is False
|
|
assert details["home_score"] == "4"
|
|
assert details["away_score"] == "2"
|
|
if sport_cls in (Football, Hockey, Basketball):
|
|
assert details["period_text"] == "Final"
|
|
|
|
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
def test_upcoming_event_classification(self, sport_cls):
|
|
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
|
|
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
period=0, clock="0:00")
|
|
details = extract(sport_cls, event)
|
|
assert details is not None
|
|
assert details["is_upcoming"] is True
|
|
assert details["is_live"] is False
|
|
assert details["is_final"] is False
|
|
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
|
|
# date rendered through display_manager.format_date_with_ordinal.
|
|
assert details["game_time"] == "6:30PM"
|
|
assert details["game_date"] == "Jan 15th"
|
|
|
|
def test_halftime_state_flags(self):
|
|
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
|
|
# while state "in" still counts as live.
|
|
event = make_event("404", "in", "2026-01-15T18:30:00Z",
|
|
name="STATUS_HALFTIME", short_detail="Halftime")
|
|
details, *_ = SportsCore._extract_game_details_common(
|
|
make_probe(), event)
|
|
assert details["is_live"] is True
|
|
assert details["is_halftime"] is True
|
|
|
|
def test_state_name_conflict_is_both_final_and_upcoming(self):
|
|
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
|
|
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
|
|
# an event with state="post" but name="Scheduled" reports BOTH
|
|
# is_final and is_upcoming True.
|
|
event = make_event("405", "post", "2026-01-14T00:00:00Z",
|
|
name="Scheduled")
|
|
details, *_ = SportsCore._extract_game_details_common(
|
|
make_probe(), event)
|
|
assert details["is_final"] is True
|
|
assert details["is_upcoming"] is True
|
|
|
|
def test_zero_zero_record_blanked(self):
|
|
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
|
|
home_record="0-0", away_record="0-0-0")
|
|
details, *_ = SportsCore._extract_game_details_common(
|
|
make_probe(), event)
|
|
assert details["home_record"] == ""
|
|
assert details["away_record"] == ""
|
|
|
|
def test_missing_abbreviation_uses_name_prefix(self):
|
|
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
|
|
for comp in event["competitions"][0]["competitors"]:
|
|
del comp["team"]["abbreviation"]
|
|
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
|
|
else "Penguins"
|
|
details, *_ = SportsCore._extract_game_details_common(
|
|
make_probe(), event)
|
|
assert details["home_abbr"] == "Sha"
|
|
assert details["away_abbr"] == "Pen"
|
|
|
|
def test_empty_or_malformed_event_returns_none_tuple(self):
|
|
probe = make_probe()
|
|
assert SportsCore._extract_game_details_common(probe, {}) == \
|
|
(None, None, None, None, None)
|
|
assert SportsCore._extract_game_details_common(probe, None) == \
|
|
(None, None, None, None, None)
|
|
# Malformed event (no competitions) is swallowed, not raised.
|
|
assert SportsCore._extract_game_details_common(
|
|
probe, {"id": "999"}) == (None, None, None, None, None)
|
|
|
|
def test_football_live_situation_fields(self):
|
|
event = make_event(
|
|
"408", "in", "2026-01-15T18:30:00Z",
|
|
situation={
|
|
"shortDownDistanceText": "3rd & 4",
|
|
"downDistanceText": "3rd & 4 at TB 30",
|
|
"isRedZone": False,
|
|
"possession": "20",
|
|
"homeTimeouts": 2,
|
|
"awayTimeouts": 3,
|
|
})
|
|
details = extract(Football, event)
|
|
assert details["down_distance_text"] == "3rd & 4"
|
|
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
|
|
assert details["possession"] == "20"
|
|
assert details["possession_indicator"] == "home" # matches home id
|
|
assert details["home_timeouts"] == 2
|
|
assert details["away_timeouts"] == 3
|
|
|
|
def test_hockey_live_power_play_and_default_shots(self):
|
|
event = make_event("409", "in", "2026-01-15T18:30:00Z",
|
|
situation={"isPowerPlay": True, "penalties": ""})
|
|
details = extract(Hockey, event)
|
|
assert details["power_play"] is True
|
|
# Empty statistics arrays -> save-percentage math yields 0 shots.
|
|
assert details["home_shots"] == 0
|
|
assert details["away_shots"] == 0
|
|
|
|
def test_hockey_event_without_statistics_returns_none(self):
|
|
# PINNED AS-IS: the hockey extractor unconditionally iterates
|
|
# competitor["statistics"]; a competitor without the key raises
|
|
# KeyError internally and the WHOLE event is dropped (returns
|
|
# None), even though scores/status are present.
|
|
event = make_event("410", "in", "2026-01-15T18:30:00Z")
|
|
for comp in event["competitions"][0]["competitors"]:
|
|
del comp["statistics"]
|
|
assert extract(Hockey, event) is None
|
|
|
|
def test_baseball_live_inning_and_count(self):
|
|
event = make_event(
|
|
"411", "in", "2026-07-16T23:05:00Z",
|
|
home=("LAD", "19", "5"), away=("SF", "26", "3"),
|
|
period=7, short_detail="Bot 7th",
|
|
situation={
|
|
"count": {"balls": 2, "strikes": 1},
|
|
"outs": 2,
|
|
"onFirst": True,
|
|
"onSecond": False,
|
|
"onThird": True,
|
|
})
|
|
details = extract(Baseball, event)
|
|
assert details["inning"] == 7 # from top-level status period
|
|
assert details["inning_half"] == "bottom"
|
|
assert details["balls"] == 2
|
|
assert details["strikes"] == 1
|
|
assert details["outs"] == 2
|
|
assert details["bases_occupied"] == [True, False, True]
|
|
assert details["status"] == "status_in_progress"
|
|
assert details["series_summary"] == ""
|
|
|
|
def test_baseball_live_without_top_level_status_returns_none(self):
|
|
# PINNED AS-IS: for live games the baseball extractor reads
|
|
# game_event["status"] (the event TOP-LEVEL status, not the
|
|
# competition status) for the inning; an otherwise-valid live
|
|
# event lacking that duplicate key is dropped entirely.
|
|
event = make_event("412", "in", "2026-07-16T23:05:00Z")
|
|
del event["status"]
|
|
assert extract(Baseball, event) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. update() flow on concrete subclasses (offline, cache-fed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _UpcomingHarness(Hockey, SportsUpcoming):
|
|
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
|
|
|
|
def _fetch_data(self):
|
|
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
|
|
|
|
class _RecentHarness(Hockey, SportsRecent):
|
|
def _fetch_data(self):
|
|
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
|
|
|
|
class _LiveHarness(HockeyLive):
|
|
def _fetch_data(self):
|
|
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
|
|
|
|
def make_schedule():
|
|
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
|
|
return {"events": [
|
|
# Final 6 days ago — inside the recent 21-day window.
|
|
make_event("9001", "post", "2026-01-14T00:00:00Z",
|
|
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
period=3, clock="0:00"),
|
|
# Live game.
|
|
make_event("9002", "in", "2026-01-15T00:30:00Z",
|
|
home=("TB", "20", "3"), away=("DAL", "9", "2")),
|
|
# Two scheduled games.
|
|
make_event("9003", "pre", "2026-01-16T00:00:00Z",
|
|
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
period=0),
|
|
make_event("9004", "pre", "2026-01-17T00:00:00Z",
|
|
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
|
|
period=0),
|
|
# Final from November — outside the recent 21-day window.
|
|
make_event("9005", "post", "2025-11-01T00:00:00Z",
|
|
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
|
|
period=3, clock="0:00"),
|
|
]}
|
|
|
|
|
|
@pytest.fixture
|
|
def build_manager(monkeypatch, tmp_path):
|
|
"""Factory for concrete sports managers: mocked display/cache managers,
|
|
logo dir redirected to tmp, background service stubbed, and the
|
|
requests session rigged to prove nothing hits the network."""
|
|
monkeypatch.setattr(
|
|
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
monkeypatch.setattr(
|
|
"src.base_classes.sports.core.get_background_service",
|
|
lambda *args, **kwargs: MagicMock())
|
|
|
|
def build(cls, schedule, **mode_cfg):
|
|
config = {
|
|
"timezone": "UTC",
|
|
"display": {},
|
|
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
|
}
|
|
display_manager = MagicMock()
|
|
display_manager.matrix.width = 128
|
|
display_manager.matrix.height = 32
|
|
display_manager.width = 128
|
|
display_manager.height = 32
|
|
display_manager.image = Image.new("RGB", (128, 32))
|
|
display_manager.format_date_with_ordinal.side_effect = (
|
|
lambda dt: dt.strftime("%b %d"))
|
|
cache_manager = MagicMock()
|
|
cache_manager.get.return_value = schedule
|
|
cache_manager.cache_dir = str(tmp_path)
|
|
manager = cls(config, display_manager, cache_manager,
|
|
logging.getLogger("test_sports_base_characterization"),
|
|
"nhl")
|
|
# Safety net: any accidental network fetch must fail loudly.
|
|
manager.session = MagicMock()
|
|
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
|
"characterization tests are offline")
|
|
return manager
|
|
|
|
return build
|
|
|
|
|
|
def _ids(games):
|
|
return [g["id"] for g in games]
|
|
|
|
|
|
@freeze_time(FROZEN_NOW)
|
|
class TestUpcomingUpdateFlow:
|
|
def test_populates_games_list_sorted_by_start_time(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
manager.update()
|
|
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
|
|
# (state 'pre') — there is NO date filter, so 'pre' games whose
|
|
# start time is already in the past (9003/9004 vs frozen 1/20)
|
|
# are still shown.
|
|
assert _ids(manager.games_list) == ["9003", "9004"]
|
|
assert manager.current_game["id"] == "9003"
|
|
|
|
def test_filters_by_favorite_teams(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
show_favorite_teams_only=True,
|
|
favorite_teams=["BOS"])
|
|
manager.update()
|
|
assert _ids(manager.games_list) == ["9004"]
|
|
assert manager.current_game["id"] == "9004"
|
|
|
|
def test_favorites_only_with_no_favorites_shows_nothing(
|
|
self, build_manager):
|
|
# PINNED AS-IS: show_favorite_teams_only=True with an empty
|
|
# favorite_teams list drops every game rather than falling back
|
|
# to showing all games.
|
|
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
show_favorite_teams_only=True,
|
|
favorite_teams=[])
|
|
manager.update()
|
|
assert manager.games_list == []
|
|
assert manager.current_game is None
|
|
|
|
def test_caps_at_upcoming_games_to_show(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
upcoming_games_to_show=1)
|
|
manager.update()
|
|
assert _ids(manager.games_list) == ["9003"]
|
|
|
|
def test_tolerates_empty_events_list(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, {"events": []})
|
|
manager.update() # must not raise
|
|
assert manager.games_list == []
|
|
assert manager.current_game is None
|
|
|
|
def test_tolerates_fetch_returning_none(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, None)
|
|
manager.update() # must not raise
|
|
assert manager.games_list == []
|
|
assert manager.current_game is None
|
|
|
|
def test_disabled_manager_update_is_noop(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
enabled=False)
|
|
manager.update()
|
|
assert manager.games_list == []
|
|
manager.cache_manager.get.assert_not_called()
|
|
|
|
|
|
@freeze_time(FROZEN_NOW)
|
|
class TestRecentUpdateFlow:
|
|
def test_populates_only_finals_within_21_day_window(self, build_manager):
|
|
manager = build_manager(_RecentHarness, make_schedule())
|
|
manager.update()
|
|
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
|
|
# excluded by the 21-day cutoff; live/pre games excluded.
|
|
assert _ids(manager.games_list) == ["9001"]
|
|
assert manager.current_game["id"] == "9001"
|
|
assert manager.current_game["is_final"] is True
|
|
|
|
def test_filters_by_favorite_teams(self, build_manager):
|
|
manager = build_manager(_RecentHarness, make_schedule(),
|
|
show_favorite_teams_only=True,
|
|
favorite_teams=["TOR"])
|
|
manager.update()
|
|
assert _ids(manager.games_list) == ["9001"]
|
|
|
|
stranger = build_manager(_RecentHarness, make_schedule(),
|
|
show_favorite_teams_only=True,
|
|
favorite_teams=["XXX"])
|
|
stranger.update()
|
|
assert stranger.games_list == []
|
|
assert stranger.current_game is None
|
|
|
|
def test_tolerates_empty_events_list(self, build_manager):
|
|
manager = build_manager(_RecentHarness, {"events": []})
|
|
manager.update() # must not raise
|
|
assert manager.games_list == []
|
|
assert manager.current_game is None
|
|
|
|
|
|
@freeze_time(FROZEN_NOW)
|
|
class TestLiveUpdateFlow:
|
|
def test_selects_only_live_games(self, build_manager):
|
|
manager = build_manager(_LiveHarness, make_schedule())
|
|
manager.update()
|
|
assert _ids(manager.live_games) == ["9002"]
|
|
assert manager.current_game["id"] == "9002"
|
|
assert manager.current_game["is_live"] is True
|
|
|
|
def test_no_live_games_clears_current_game(self, build_manager):
|
|
schedule = {"events": [
|
|
make_event("9001", "post", "2026-01-14T00:00:00Z"),
|
|
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
|
|
]}
|
|
manager = build_manager(_LiveHarness, schedule)
|
|
manager.update()
|
|
assert manager.live_games == []
|
|
assert manager.current_game is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Rendering smoke — one display() per mode class at 128x32
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fake_logo(*args, **kwargs):
|
|
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
|
|
|
|
|
|
@freeze_time(FROZEN_NOW)
|
|
class TestRenderingSmoke:
|
|
def _assert_rendered(self, manager):
|
|
manager.display_manager.update_display.assert_called()
|
|
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
|
|
def test_upcoming_display_draws_ink(self, build_manager):
|
|
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
manager.update()
|
|
manager._load_and_resize_logo = _fake_logo
|
|
assert manager.display(force_clear=True) is True
|
|
self._assert_rendered(manager)
|
|
|
|
def test_recent_display_draws_ink(self, build_manager):
|
|
manager = build_manager(_RecentHarness, make_schedule())
|
|
manager.update()
|
|
manager._load_and_resize_logo = _fake_logo
|
|
assert manager.display(force_clear=True) is True
|
|
self._assert_rendered(manager)
|
|
|
|
def test_live_display_draws_ink(self, build_manager):
|
|
manager = build_manager(_LiveHarness, make_schedule())
|
|
manager.update()
|
|
manager._load_and_resize_logo = _fake_logo
|
|
assert manager.display(force_clear=True) is True
|
|
self._assert_rendered(manager)
|
|
|
|
def test_draw_scorebug_layout_direct_call_does_not_raise(
|
|
self, build_manager):
|
|
# The base-class placeholder renderer must also stay callable.
|
|
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
game = manager._extract_game_details(make_schedule()["events"][2])
|
|
manager._load_and_resize_logo = _fake_logo
|
|
SportsCore._draw_scorebug_layout(manager, game)
|
|
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Guard rails — seams the merge must not silently drop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGuardRails:
|
|
def test_skin_seam_methods_survive(self):
|
|
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
|
|
"render_skin_card"):
|
|
assert callable(getattr(SportsCore, name, None)), (
|
|
f"SportsCore.{name} is part of the skin-system seam "
|
|
"(src/skin_system) — the sports-unification merge must "
|
|
"keep it.")
|
|
|
|
def test_skin_mode_per_class(self):
|
|
assert SportsCore.SKIN_MODE == "live"
|
|
assert SportsUpcoming.SKIN_MODE == "upcoming"
|
|
assert SportsRecent.SKIN_MODE == "recent"
|
|
assert SportsLive.SKIN_MODE == "live" # inherits the default
|
|
|
|
def test_core_display_and_extractor_seams_survive(self):
|
|
for name in ("display", "_draw_scorebug_layout",
|
|
"_extract_game_details_common", "update"):
|
|
owner = SportsCore if name != "update" else SportsUpcoming
|
|
assert callable(getattr(owner, name, None)), name
|