refactor: remove the unused src/base_classes package

No scoreboard plugin builds on src.base_classes: the nine monorepo
scoreboards ship their own sports.py and share code through src/common
(docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins
imports it. The one import anywhere, baseball-scoreboard's
rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing
instantiates.

Removed the package and the eight test files that only tested it
(test_api_extractors, test_data_sources, test_sports_base_characterization,
test_sports_capabilities, test_sports_core_promotions,
test_sports_logo_cache_bounded, test_sports_modes_promotions,
test_sports_odds_fanout). test_common_is_hardware_free no longer lists
src.base_classes as a forbidden import, and comments in sports_helpers.py
and base_odds_manager.py stop pointing at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 09:58:52 -04:00
co-authored by Claude Opus 5.5
parent 21c05c9326
commit 7f2bd6b369
23 changed files with 15 additions and 9290 deletions
-342
View File
@@ -1,342 +0,0 @@
"""
Tests for src/base_classes/api_extractors.py
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
SoccerAPIExtractor, and the shared _extract_common_details logic.
"""
import logging
import pytest
from src.base_classes.api_extractors import (
ESPNFootballExtractor,
ESPNBaseballExtractor,
ESPNHockeyExtractor,
SoccerAPIExtractor,
)
# ---------------------------------------------------------------------------
# Shared test data factories
# ---------------------------------------------------------------------------
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
home_score: str = "14", away_score: str = "7",
date_str: str = "2024-01-15T20:00:00Z",
include_situation: bool = False,
situation: dict | None = None,
status_detail: str = "2nd Qtr 8:42",
period: int = 2) -> dict:
"""Build a minimal ESPN-style game event dict."""
comp_status = {
"type": {
"state": state,
"shortDetail": status_detail,
"detail": status_detail,
"name": "STATUS_IN_PROGRESS",
},
"period": period,
"displayClock": "8:42",
}
comp = {
"status": comp_status,
"competitors": [
{
"homeAway": "home",
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
"score": home_score,
},
{
"homeAway": "away",
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
"score": away_score,
},
],
}
if include_situation:
comp["situation"] = situation or {}
return {
"id": "test-game-1",
"date": date_str,
"competitions": [comp],
}
def _make_logger() -> logging.Logger:
return logging.getLogger("test_extractor")
# ---------------------------------------------------------------------------
# ESPNFootballExtractor
# ---------------------------------------------------------------------------
class TestESPNFootballExtractor:
def setup_method(self):
self.extractor = ESPNFootballExtractor(_make_logger())
def test_extract_live_game_basic_fields(self):
event = _make_espn_event(state="in", home_score="14", away_score="7")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "KC"
assert result["away_abbr"] == "BUF"
assert result["home_score"] == "14"
assert result["away_score"] == "7"
assert result["is_live"] is True
assert result["is_final"] is False
assert result["is_upcoming"] is False
def test_extract_final_game(self):
event = _make_espn_event(state="post")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_final"] is True
assert result["is_live"] is False
def test_extract_upcoming_game(self):
event = _make_espn_event(state="pre")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_upcoming"] is True
def test_sport_specific_fields_default_when_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "down" in fields
assert "distance" in fields
assert "possession" in fields
assert "is_redzone" in fields
assert fields["is_redzone"] is False
def test_sport_specific_fields_live_with_situation(self):
situation = {
"down": 3,
"distance": 7,
"possession": "KC",
"isRedZone": True,
"homeTimeouts": 2,
"awayTimeouts": 1,
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["down"] == 3
assert fields["distance"] == 7
assert fields["is_redzone"] is True
assert fields["home_timeouts"] == 2
assert fields["away_timeouts"] == 1
def test_scoring_event_detected(self):
# situation must be non-empty (truthy) for the live block to execute
situation = {"down": 1, "distance": 10}
event = _make_espn_event(
state="in",
include_situation=True,
situation=situation,
status_detail="touchdown scored",
)
fields = self.extractor.get_sport_specific_fields(event)
assert "touchdown" in fields.get("scoring_event", "").lower()
def test_returns_none_on_empty_event(self):
assert self.extractor.extract_game_details({}) is None
def test_returns_none_when_teams_missing(self):
event = {
"id": "x",
"date": "2024-01-15T20:00:00Z",
"competitions": [
{
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
"competitors": [], # no competitors
}
],
}
assert self.extractor.extract_game_details(event) is None
def test_date_z_suffix_parsed(self):
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
result = self.extractor.extract_game_details(event)
# Should not raise and should return a result
assert result is not None
def test_id_propagated(self):
event = _make_espn_event()
result = self.extractor.extract_game_details(event)
assert result["id"] == "test-game-1"
# ---------------------------------------------------------------------------
# ESPNBaseballExtractor
# ---------------------------------------------------------------------------
class TestESPNBaseballExtractor:
def setup_method(self):
self.extractor = ESPNBaseballExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="NYY", away_abbr="BOS",
home_score="3", away_score="2"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "NYY"
assert result["is_live"] is True
def test_baseball_sport_fields_defaults(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "inning" in fields
assert "outs" in fields
assert "bases" in fields
assert "strikes" in fields
assert "balls" in fields
def test_baseball_sport_fields_live(self):
situation = {
"inning": 7,
"outs": 2,
"bases": "110",
"strikes": 2,
"balls": 3,
"pitcher": "Smith",
"batter": "Jones",
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["inning"] == 7
assert fields["outs"] == 2
assert fields["strikes"] == 2
assert fields["pitcher"] == "Smith"
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# ESPNHockeyExtractor
# ---------------------------------------------------------------------------
class TestESPNHockeyExtractor:
def setup_method(self):
self.extractor = ESPNHockeyExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="BOS", away_abbr="TOR",
home_score="2", away_score="1"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_live"] is True
def test_hockey_period_text_p1(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=1
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P1"
def test_hockey_period_text_p2(self):
situation = {"isPowerPlay": False} # non-empty so the live block executes
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=2
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P2"
def test_hockey_period_text_p3(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=3
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P3"
def test_hockey_period_text_ot(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=4
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "OT1"
def test_hockey_power_play(self):
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["power_play"] is True
assert fields["shots_on_goal"]["home"] == 12
assert fields["shots_on_goal"]["away"] == 8
def test_hockey_fields_defaults_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "period" in fields
assert "power_play" in fields
assert fields["power_play"] is False
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# SoccerAPIExtractor
# ---------------------------------------------------------------------------
class TestSoccerAPIExtractor:
def setup_method(self):
self.extractor = SoccerAPIExtractor(_make_logger())
def _make_soccer_event(self, is_live: bool = True) -> dict:
return {
"id": "soccer-1",
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
"home_score": "2",
"away_score": "1",
"status": "LIVE",
"is_live": is_live,
"is_final": not is_live,
"is_upcoming": False,
"half": "1",
"stoppage_time": "2",
"home_yellow_cards": 1,
"away_yellow_cards": 2,
"home_red_cards": 0,
"away_red_cards": 0,
"home_possession": 55,
"away_possession": 45,
}
def test_extract_live_game(self):
event = self._make_soccer_event(is_live=True)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "ARS"
assert result["away_abbr"] == "CHE"
assert result["is_live"] is True
def test_sport_specific_cards(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["cards"]["home_yellow"] == 1
assert fields["cards"]["away_yellow"] == 2
assert fields["cards"]["home_red"] == 0
def test_sport_specific_possession(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["possession"]["home"] == 55
assert fields["possession"]["away"] == 45
def test_sport_specific_half(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["half"] == "1"
def test_scores_as_strings(self):
event = self._make_soccer_event()
result = self.extractor.extract_game_details(event)
assert result["home_score"] == "2"
assert result["away_score"] == "1"
+5 -6
View File
@@ -1,9 +1,8 @@
"""src/common must stay importable without display hardware.
Plugins import src.common.sports_* in place of their bundled copies. If any of
those modules reaches src.display_manager (and through it rgbmatrix) or the
src.base_classes package (whose core.py imports DisplayManager), a scoreboard
adopting it acquires a hardware dependency it never had, and the headless
those modules reaches src.display_manager (and through it rgbmatrix), a
scoreboard adopting it acquires a hardware dependency it never had, and the headless
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
being able to load it.
@@ -26,7 +25,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
COMMON = REPO_ROOT / "src" / "common"
FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system")
FORBIDDEN = ("src.display_manager", "src.plugin_system")
#: Existing module-level violations, by file name, each with the reason it is
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
@@ -124,13 +123,13 @@ def test_the_scan_sees_a_direct_import(tmp_path):
except ImportError:
pass
if True:
import src.base_classes.sports
import src.plugin_system.plugin_manager
from src import plugin_system
def later():
from src.display_manager import DisplayManager
"""))
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
assert {"src.base_classes.sports", "src.display_manager",
assert {"src.plugin_system.plugin_manager", "src.display_manager",
"src.plugin_system"} <= targets
-307
View File
@@ -1,307 +0,0 @@
"""
Tests for src/base_classes/data_sources.py
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
All HTTP calls are mocked to avoid network access.
"""
import logging
from datetime import datetime, date
from unittest.mock import MagicMock, patch, Mock
import pytest
import requests
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
def _make_logger() -> logging.Logger:
return logging.getLogger("test_data_sources")
def _mock_response(json_data: dict, status_code: int = 200):
resp = Mock(spec=requests.Response)
resp.status_code = status_code
resp.json.return_value = json_data
resp.raise_for_status = Mock()
if status_code >= 400:
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
return resp
# ---------------------------------------------------------------------------
# ESPNDataSource
# ---------------------------------------------------------------------------
class TestESPNDataSource:
def setup_method(self):
self.source = ESPNDataSource(_make_logger())
def test_get_headers(self):
headers = self.source.get_headers()
assert headers["Accept"] == "application/json"
assert "LEDMatrix" in headers["User-Agent"]
def test_fetch_live_games_returns_live_events(self):
live_event = {
"competitions": [{"status": {"type": {"state": "in"}}}]
}
non_live_event = {
"competitions": [{"status": {"type": {"state": "pre"}}}]
}
payload = {"events": [live_event, non_live_event]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert len(result) == 1
assert result[0] is live_event
def test_fetch_live_games_empty_when_none_live(self):
payload = {"events": [
{"competitions": [{"status": {"type": {"state": "post"}}}]}
]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_schedule_returns_all_events(self):
events = [{"id": "1"}, {"id": "2"}]
payload = {"events": events}
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 7)
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("football", "nfl", (start, end))
assert len(result) == 2
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("football", "nfl")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
# A transport failure is a RequestException, not a bare Exception.
# The old stand-in passed only because the handler caught everything,
# including bugs in the method under test.
with patch.object(self.source.session, "get",
side_effect=requests.ConnectionError("error")):
result = self.source.fetch_standings("football", "nfl")
assert result == {}
# ------------------------------------------------------------------
# fetch_standings endpoint selection
#
# College leagues publish a poll at /rankings and a records table at
# /standings; professional leagues have only /standings. Probing them in
# the wrong order still returns 200 -- just without a poll in it -- so
# nothing failed and the rank badge simply never appeared. Order is the
# behaviour here, so these tests assert it directly.
# ------------------------------------------------------------------
@staticmethod
def _requested_endpoints(mock_get):
"""The endpoint names probed, in the order they were requested."""
return [call.args[0].rsplit("/", 1)[-1] for call in mock_get.call_args_list]
def test_professional_league_asks_standings_first(self):
payload = {"standings": []}
with patch.object(self.source.session, "get",
return_value=_mock_response(payload)) as mock_get:
result = self.source.fetch_standings("football", "nfl")
assert result == payload
assert self._requested_endpoints(mock_get) == ["standings"]
def test_college_league_asks_rankings_first(self):
poll = {"rankings": [{"name": "AP Top 25"}]}
with patch.object(self.source.session, "get",
return_value=_mock_response(poll)) as mock_get:
result = self.source.fetch_standings("football", "college-football")
assert result == poll
assert self._requested_endpoints(mock_get) == ["rankings"]
def test_rankings_200_without_a_poll_falls_through_to_standings(self):
"""A 200 is not the same as an answer.
This is the case the old code could not see: the endpoint responded,
so nothing raised, but the body carried no poll.
"""
empty_poll = _mock_response({"rankings": []})
table = _mock_response({"standings": [{"entries": []}]})
with patch.object(self.source.session, "get",
side_effect=[empty_poll, table]) as mock_get:
result = self.source.fetch_standings(
"basketball", "mens-college-basketball")
assert result == {"standings": [{"entries": []}]}
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
def test_404_on_the_first_endpoint_falls_through_quietly(self):
missing = _mock_response({}, status_code=404)
table = _mock_response({"standings": []})
with patch.object(self.source.session, "get",
side_effect=[missing, table]) as mock_get:
result = self.source.fetch_standings("baseball", "college-baseball")
assert result == {"standings": []}
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
def test_recovers_from_a_non_404_failure_on_the_first_endpoint(self):
table = _mock_response({"standings": [{"entries": []}]})
with patch.object(self.source.session, "get",
side_effect=[requests.ConnectionError("reset"), table]) as mock_get:
result = self.source.fetch_standings("football", "college-football")
assert result == {"standings": [{"entries": []}]}
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
def test_both_endpoints_failing_returns_empty(self):
with patch.object(self.source.session, "get",
side_effect=requests.ConnectionError("down")) as mock_get:
result = self.source.fetch_standings("football", "nfl")
assert result == {}
assert self._requested_endpoints(mock_get) == ["standings", "rankings"]
def test_a_non_object_payload_is_treated_as_a_miss(self):
odd = _mock_response(["not", "an", "object"])
table = _mock_response({"standings": []})
with patch.object(self.source.session, "get",
side_effect=[odd, table]) as mock_get:
result = self.source.fetch_standings("football", "college-football")
assert result == {"standings": []}
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
def test_a_bug_in_this_method_is_not_swallowed_as_a_failed_endpoint(self):
"""The guard for the narrowed handler.
An error raised while reading the payload used to be caught by the
endpoint handler and reported as 'no poll here', which would silently
drop rankings for a league that has them. It must surface instead.
"""
boom = Mock(spec=requests.Response)
boom.status_code = 200
boom.raise_for_status = Mock()
boom.json.side_effect = TypeError("a bug, not a network failure")
with patch.object(self.source.session, "get", return_value=boom):
with pytest.raises(TypeError):
self.source.fetch_standings("football", "nfl")
def test_base_url_set_correctly(self):
assert "espn.com" in self.source.base_url
# ---------------------------------------------------------------------------
# MLBAPIDataSource
# ---------------------------------------------------------------------------
class TestMLBAPIDataSource:
def setup_method(self):
self.source = MLBAPIDataSource(_make_logger())
def test_fetch_live_games_filters_live(self):
live_game = {"status": {"abstractGameState": "Live"}}
final_game = {"status": {"abstractGameState": "Final"}}
payload = {"dates": [{"games": [live_game, final_game]}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert len(result) == 1
assert result[0] is live_game
def test_fetch_live_games_empty_dates(self):
payload = {"dates": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_schedule_aggregates_all_dates(self):
payload = {
"dates": [
{"games": [{"id": "1"}, {"id": "2"}]},
{"games": [{"id": "3"}]},
]
}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert len(result) == 3
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"records": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("baseball", "mlb")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("baseball", "mlb")
assert result == {}
# ---------------------------------------------------------------------------
# SoccerAPIDataSource
# ---------------------------------------------------------------------------
class TestSoccerAPIDataSource:
def setup_method(self):
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
def test_headers_include_api_key(self):
headers = self.source.get_headers()
assert headers["X-Auth-Token"] == "test-key-123"
def test_headers_without_api_key(self):
source = SoccerAPIDataSource(_make_logger())
headers = source.get_headers()
assert "X-Auth-Token" not in headers
def test_fetch_live_games_success(self):
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("soccer", "eng.1")
assert len(result) == 2
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("soccer", "eng.1")
assert result == []
def test_fetch_schedule_success(self):
payload = {"matches": [{"id": "m1"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert len(result) == 1
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("soccer", "PL")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("soccer", "PL")
assert result == {}
-660
View File
@@ -1,660 +0,0 @@
"""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 reads competitor["statistics"] for shot counts;
# it now defaults to an empty list when the key is absent rather than
# dropping the whole event (see
# test_hockey_event_without_statistics_still_extracts).
"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_still_extracts(self):
# FIXED (was pinned as returning None): the hockey extractor used to
# iterate competitor["statistics"] unguarded, so a competitor without
# the key raised KeyError internally and the WHOLE event was dropped
# despite valid scores and status. It now defaults to an empty list,
# matching the behaviour already shipped in the hockey plugin, so the
# event survives with zeroed shot counts -- the same values
# test_hockey_live_power_play_and_default_shots already expects for an
# EMPTY statistics array.
event = make_event("410", "in", "2026-01-15T18:30:00Z")
for comp in event["competitions"][0]["competitors"]:
del comp["statistics"]
details = extract(Hockey, event)
assert details is not None
assert details["home_abbr"] == "TB"
assert details["away_abbr"] == "DAL"
assert details["home_score"] == "3"
assert details["home_shots"] == 0
assert details["away_shots"] == 0
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_still_extracts(self):
# FIXED (was pinned as returning None): the baseball extractor read
# game_event["status"] -- the event TOP-LEVEL status -- for the
# inning, so an otherwise-valid live event lacking that duplicate key
# was dropped entirely. Real ESPN events carry status in both places,
# but MiLB events (synthesized from the MLB Stats API into an
# ESPN-like shape) populate only the competition-level one. It now
# reads the competition-level `status` that
# _extract_game_details_common has already validated, so it can never
# be missing at that point.
event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7)
del event["status"]
details = extract(Baseball, event)
assert details is not None
assert details["inning"] == 7
def test_baseball_live_without_top_level_status_extracts_for_favorites(self):
# The favourite-team branch logs the status payload for diagnostics and
# read the same event top-level key the test above proves can be
# absent. So the identical MiLB event that extracts fine for a
# non-favourite raised KeyError and was dropped once the team WAS a
# favourite -- the worst shape for the bug, since it only hit the games
# the user cared most about, and only on the diagnostic path that was
# supposed to help debug them.
event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7)
del event["status"]
details = extract(Baseball, event, favorites=["TB"])
assert details is not None
assert details["inning"] == 7
# ---------------------------------------------------------------------------
# 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())
# Rig requests.Session BEFORE any manager is built. Construction creates
# both SportsCore.session and the ESPNDataSource.session; replacing only
# manager.session after the fact (below) leaves data_source.session real,
# so an accidental fetch during or right after construction could reach
# the network. Patching the class makes every session created here raise.
def _offline_get(*args, **kwargs):
raise requests.exceptions.ConnectionError(
"characterization tests are offline")
monkeypatch.setattr(requests.Session, "get", _offline_get)
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
File diff suppressed because it is too large Load Diff
-613
View File
@@ -1,613 +0,0 @@
"""Tests for the methods promoted onto SportsCore from the nine bundled
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
Three methods and their seams land here:
- ``cleanup()`` — byte-identical in all nine copies. The tests pin the
ordering (session close, then caches, then the completion log) and the
deliberate *omission*: the process-wide background service must never be
shut down by one unloading plugin.
- ``_get_layout_offset()`` — football's resolver-backed variant, with the
classic inline config read as the fallback used by every plugin that
doesn't hand core a ``_config_schema_path()``.
- ``_load_custom_font_from_element_config()`` — baseball's body (the only
copy that handles BDF strikes correctly) under hockey's wider signature,
resolving font files through the ``_font_root()`` seam instead of the
process cwd.
"""
import ast
import json
import logging
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image, ImageFont
# 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.sports import SportsCore
LOGGER = logging.getLogger("test_sports_core_promotions")
CORE_ROOT = Path(__file__).resolve().parents[1]
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
TTF_NAME = "PressStart2P-Regular.ttf"
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
BDF_NATIVE_SIZE = 7
class _StubSports(SportsCore):
"""Minimal concrete SportsCore — the abstract methods are never called
by anything under test here."""
def _fetch_data(self):
return None
def _extract_game_details(self, game_event):
return None
@pytest.fixture
def build(monkeypatch, tmp_path):
"""Factory for real SportsCore instances: logo dir redirected to tmp and
the process-wide background service replaced with a MagicMock so the
tests can assert nothing ever calls it."""
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(config=None, cls=_StubSports):
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))
cache_manager = MagicMock()
cache_manager.cache_dir = str(tmp_path)
return cls(config if config is not None else {"timezone": "UTC"},
display_manager, cache_manager, LOGGER, "nhl")
return _build
def probe(config=None):
"""Unbound-call stand-in for hosts we don't need a full instance for
(same pattern as make_probe in test_sports_base_characterization)."""
host = MagicMock()
host.logger = LOGGER
host.config = config if config is not None else {}
host._font_cache = {}
host._bdf_native_size_cache = {}
host._config_schema_path.return_value = None
host._font_root.side_effect = lambda: SportsCore._font_root(host)
host._resolve_font_path.side_effect = (
lambda name: SportsCore._resolve_font_path(host, name))
return host
def offset(host, element, axis, default=0):
return SportsCore._get_layout_offset(host, element, axis, default)
def load_font(host, *args, **kwargs):
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
# ---------------------------------------------------------------------------
# 1. cleanup()
# ---------------------------------------------------------------------------
class TestCleanup:
def test_closes_session_and_clears_all_caches(self, build):
manager = build()
session = MagicMock()
manager.session = session
manager._logo_cache["TB"] = object()
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
manager.cleanup()
session.close.assert_called_once_with()
assert manager._logo_cache == {}
# Promoted alongside the font loader: these hold PIL faces and are
# an unbounded leak across enable/disable cycles if never released.
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
def test_second_cleanup_is_a_noop(self, build):
manager = build()
manager.session = MagicMock()
manager._logo_cache["TB"] = object()
manager._font_cache[("x", 8)] = object()
manager.cleanup()
manager.cleanup() # must not raise on already-released state
assert manager._logo_cache == {}
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
assert manager.session.close.call_count == 2
def test_does_not_shut_down_the_shared_background_service(self, build):
# get_background_service() hands out a PROCESS-WIDE singleton shared
# by every scoreboard. One plugin unloading must not stop background
# fetching for the other eight — cleanup() touches it not at all.
manager = build()
service = manager.background_service
manager.session = MagicMock()
manager.cleanup()
assert service.shutdown.called is False
assert service.stop.called is False
assert service.method_calls == [], (
"cleanup() called into the shared background service: "
f"{service.method_calls}")
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
manager = build()
manager.session = MagicMock()
manager.session.close.side_effect = RuntimeError("socket already gone")
manager._logo_cache["TB"] = object()
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
manager.cleanup()
messages = [r.message for r in caplog.records]
assert any("Error closing session" in m for m in messages)
# Ordering is load-bearing: the caches still get cleared and the
# completion log still fires after a failed close.
assert manager._logo_cache == {}
assert any("cleanup completed" in m for m in messages)
def test_tolerates_missing_attributes(self):
# The hasattr guards exist so a partially constructed instance (an
# __init__ that raised) can still be cleaned up.
host = MagicMock(spec=["logger"])
host.logger = LOGGER
SportsCore.cleanup(host)
# ---------------------------------------------------------------------------
# 2. _get_layout_offset() + the _config_schema_path() seam
# ---------------------------------------------------------------------------
def layout_config(element, axis, value):
return {"customization": {"layout": {element: {axis: value}}}}
class TestLayoutOffsetClassicPath:
"""The default path: _config_schema_path() returns None, so offsets come
from the inline customization.layout read every plugin ships today."""
def test_config_schema_path_defaults_to_none(self, build):
manager = build()
assert manager._config_schema_path() is None
def test_reads_configured_int(self):
host = probe(layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_float_is_truncated_to_int(self):
host = probe(layout_config("score", "y_offset", 2.9))
result = offset(host, "score", "y_offset")
assert result == 2 and isinstance(result, int)
def test_numeric_string_is_coerced(self):
host = probe(layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_unconfigured_element_and_axis_use_default(self):
host = probe(layout_config("score", "x_offset", 5))
assert offset(host, "status_text", "x_offset", 7) == 7
assert offset(host, "score", "y_offset", -1) == -1
assert offset(probe(), "score", "x_offset", 4) == 4
def test_non_numeric_string_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
def test_unsupported_type_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", {"nested": 1}))
assert offset(host, "score", "x_offset", 2) == 2
host = probe(layout_config("score", "x_offset", None))
assert offset(host, "score", "x_offset", 2) == 2
def test_broken_config_object_degrades_to_default(self):
host = probe()
host.config = "not a dict"
assert offset(host, "score", "x_offset", 6) == 6
def test_boolean_counts_as_one(self):
# PINNED AS-IS: the classic read predates the resolver and treats a
# bool as its int value (True -> 1). See the resolver test below for
# the stricter, more correct handling.
host = probe(layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 1
class TestLayoutOffsetResolverPath:
"""When a plugin supplies its config_schema.json, offsets resolve through
src.element_style instead."""
@pytest.fixture
def schema_path(self, tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps({
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"layout": {"type": "object", "properties": {}},
},
},
},
}))
return str(path)
def host(self, schema_path, config):
host = probe(config)
host._config_schema_path.return_value = schema_path
del host._style_resolver_cached # MagicMock auto-attrs otherwise
host._style_resolver_cached = None
return host
def test_reads_configured_offsets(self, schema_path):
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_numeric_string_is_coerced(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_missing_value_uses_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "y_offset", 9) == 9
def test_bad_input_degrades_to_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
host = self.host(schema_path, {"customization": {"layout": "nope"}})
assert offset(host, "score", "x_offset", 3) == 3
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
# The intended behavior difference: a bool is not a pixel offset, so
# the resolver returns the default where the classic read returns 1.
host = self.host(schema_path, layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 4
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
first = host._style_resolver_cached
assert offset(host, "score", "x_offset") == 5
assert host._style_resolver_cached is first
# on_config_change swaps the dict object; the resolver must follow.
host.config = layout_config("score", "x_offset", 11)
assert offset(host, "score", "x_offset") == 11
assert host._style_resolver_cached is not first
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
# Offsets don't depend on schema defaults, so an unreadable schema
# must not cost the plugin its layout customization.
host = self.host(str(tmp_path / "absent.json"),
layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
# ---------------------------------------------------------------------------
# 3. _load_custom_font_from_element_config() + the _font_root() seam
# ---------------------------------------------------------------------------
class TestFontRootSeam:
def test_default_font_root_is_the_core_install_root(self, build):
manager = build()
assert Path(manager._font_root()) == CORE_ROOT
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
host = probe()
host._font_root.side_effect = lambda: str(tmp_path)
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
fonts / "Bundled.ttf")
def test_unknown_font_returns_the_familiar_relative_path(self):
host = probe()
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
"assets", "fonts", "Nope.ttf")
class TestFontLoaderSignature:
"""Hockey's signature is the only safe superset: basketball's positional
``default_font: str`` blows up on an explicit None."""
def test_two_arg_call(self):
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 10
def test_default_size_is_used_when_config_omits_it(self):
assert load_font(probe(), {}, 12).size == 12
def test_three_positional_args(self):
font = load_font(probe(), {}, 6, "4x6-font.ttf")
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 6
assert font.path.endswith("4x6-font.ttf")
def test_explicit_default_font_none(self):
# The regression this signature guards: os.path.join(..., None).
font = load_font(probe(), {"font_size": 9}, default_font=None)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
def test_config_font_wins_over_default_font(self):
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
assert font.path.endswith(TTF_NAME)
def test_string_font_size_is_coerced(self):
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
class TestFontLoaderBehavior:
def test_family_alias_resolves_through_the_font_manager_catalog(self):
# "press_start" is a FontManager catalog family, not a filename; the
# promoted loader must not carry its own duplicate alias table.
host = probe()
font = load_font(host, {"font": "press_start", "font_size": 8})
assert font.path.endswith(TTF_NAME)
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
def test_memo_cache_returns_the_same_face(self):
host = probe()
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
assert first is second
assert len(host._font_cache) == 1
# A different size is a different face.
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
assert len(host._font_cache) == 2
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
# size" for anything but the file's own strike. Baseball's retry is
# the only copy that gets this right.
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == BDF_NATIVE_SIZE
assert font.path.endswith(BDF_NAME)
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
# The retried face is memoized under the REQUESTED size.
assert host._font_cache[(BDF_NAME, 8)] is font
def test_bdf_at_its_native_size_needs_no_retry(self):
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
assert font.size == BDF_NATIVE_SIZE
assert host._bdf_native_size_cache == {}
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
calls = []
real = SportsCore.__module__
def counting(path):
calls.append(path)
from src.font_manager import FontManager
return FontManager._read_bdf_native_size(path)
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
host = probe()
load_font(host, {"font": BDF_NAME, "font_size": 8})
host._font_cache.clear() # force the load path again
load_font(host, {"font": BDF_NAME, "font_size": 8})
assert len(calls) == 1
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
host = probe()
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
assert any("Font file not found" in r.message for r in caplog.records)
# Cached under the requested name so a misconfiguration costs one
# disk probe, not one per frame.
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
def test_unknown_extension_falls_back(self):
host = probe()
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
assert font.path.endswith(TTF_NAME)
def test_fallback_honors_the_supplied_default_font(self):
host = probe()
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
assert font.path.endswith("4x6-font.ttf")
class TestFontLoaderCwdIndependence:
"""The bug the _font_root() seam exists to prevent: every plugin copy
joins 'assets/fonts' onto the process cwd, so a process started anywhere
else silently degrades to PIL's default bitmap face (the same defect
already fixed in FontManager — see CHANGELOG Unreleased/Fixed)."""
@pytest.mark.parametrize("font_name,expected_size",
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
expected_size):
monkeypatch.chdir("/")
host = probe()
font = load_font(host, {"font": font_name, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont), (
f"{font_name} degraded to PIL's default face when the process "
"runs outside the install root")
assert font.size == expected_size
assert Path(font.path) == FONTS_DIR / font_name
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
monkeypatch.chdir("/")
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert Path(font.path) == FONTS_DIR / TTF_NAME
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
"detail", "rank"])
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
"""`_load_fonts` had the same cwd-relative literals the seam exists to
remove; every scoreboard font silently became PIL's default bitmap face
when the process started outside the install root."""
monkeypatch.chdir("/")
fonts = SportsCore._load_fonts(probe())
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
f"fonts['{key}'] degraded to PIL's default face outside the "
"install root")
class TestShouldLogCooldown:
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
initialized in __init__ — otherwise the first warning of a run raises
AttributeError instead of logging."""
def test_cooldown_clock_is_initialized(self, build):
assert build()._last_warning_time == 0
def test_first_call_logs_then_cools_down(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
def test_cooldown_expires(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
manager._warning_cooldowns["api"] -= 61
assert manager._should_log("api", cooldown=60) is True
def test_cooldowns_are_tracked_per_warning_type(self, build):
"""The parameter was accepted and ignored: one shared timestamp meant an
API warning silenced an unrelated cache warning for the next minute."""
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("cache", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
assert manager._should_log("cache", cooldown=60) is False
def test_one_type_expiring_does_not_free_another(self, build):
manager = build()
manager._should_log("api")
manager._should_log("cache")
manager._warning_cooldowns["api"] -= 61
assert manager._should_log("api") is True
assert manager._should_log("cache") is False
def test_legacy_single_clock_field_is_kept_in_step(self, build):
"""Subclasses in the plugin copies read _last_warning_time directly."""
manager = build()
manager._should_log("api")
assert manager._last_warning_time == manager._warning_cooldowns["api"]
# ---------------------------------------------------------------------------
# 4. Seam guard rails
# ---------------------------------------------------------------------------
class TestPromotedSeamsExist:
@pytest.mark.parametrize("name", [
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
"_config_schema_path", "_font_root", "_resolve_font_path",
])
def test_method_is_callable_on_the_base_class(self, name):
assert callable(getattr(SportsCore, name, None)), (
f"SportsCore.{name} is part of the promoted plugin-facing seam "
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
"hasattr before delegating.")
def test_no_sport_names_leaked_into_core(self):
"""core.py must never branch on which sport it is (prose and skin-id
examples in docstrings are fine — executable code is not)."""
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
/ "core.py").read_text())
docstrings = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
ast.AsyncFunctionDef)):
first = node.body[0] if node.body else None
if (isinstance(first, ast.Expr)
and isinstance(first.value, ast.Constant)
and isinstance(first.value.value, str)):
docstrings.add(id(first.value))
tokens = []
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if id(node) not in docstrings:
tokens.append(node.value)
elif isinstance(node, ast.Name):
tokens.append(node.id)
elif isinstance(node, ast.Attribute):
tokens.append(node.attr)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
tokens.append(node.name)
haystack = " ".join(tokens).lower()
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
"football", "lacrosse", "soccer", "ufc"):
assert sport not in haystack, (
f"core.py code mentions '{sport}' — core must never learn "
"sport names; add an override point instead.")
class TestInstallRootResolution:
"""Guards the depth bug the sports.py -> package move introduced.
The move was byte-identical in every class body, but `__file__` gained a
directory, so `Path(__file__).resolve().parents[2]` silently changed from
the repo root to `<root>/src`. Textual identity is not semantic identity
when code measures its own location: these tests assert the resolved
values, not the index.
"""
def test_install_root_is_the_repo_root(self):
from src.base_classes.sports.core import _INSTALL_ROOT
# The repo root is the directory that actually holds src/ and assets/.
assert (_INSTALL_ROOT / "src").is_dir()
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
assert _INSTALL_ROOT.name != "src", (
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
"one, which is exactly the regression the package move caused.")
def test_resolve_project_path_roots_at_repo_not_src(self):
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
def test_absolute_paths_pass_through_unchanged(self):
from src.base_classes.sports.core import SportsCore
absolute = Path("/tmp/some/logo/dir")
assert SportsCore._resolve_project_path(None, absolute) == absolute
def test_font_root_and_project_path_share_one_anchor(self):
"""Both consumers must derive from the same constant, so a future
move needs exactly one line changed rather than two."""
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
-120
View File
@@ -1,120 +0,0 @@
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
36KB on a 256x64 panel, more for wide wordmarks -- and
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
therefore held the whole league in memory: roughly 11-18MB per manager
instance, and a league runs three of them (live/recent/upcoming) each with its
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
The bound has to be LRU rather than "clear when full": the logos on screen
right now are exactly the ones that must not be thrown away.
"""
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports import SportsCore # noqa: E402
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
class _StubSports(SportsCore):
def _fetch_data(self):
return None
def _extract_game_details(self, game_event):
return None
@pytest.fixture
def host(monkeypatch, tmp_path):
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())
# Never reach for the network: every logo these tests ask for exists.
monkeypatch.setattr(
"src.base_classes.sports.core.download_missing_logo",
lambda *a, **k: None)
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))
cache_manager = MagicMock()
cache_manager.cache_dir = str(tmp_path)
instance = _StubSports({"timezone": "UTC"}, display_manager,
cache_manager, LOGGER, "nhl")
instance._logo_dir = tmp_path
return instance, tmp_path
def _load(host, abbrev):
"""Create a real logo file for `abbrev` and load it through the cache."""
instance, tmp_path = host
path = tmp_path / f"{abbrev}.png"
if not path.exists():
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
class TestTheCacheIsBounded:
def test_it_never_exceeds_the_limit(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit + 40):
assert _load(host, f"T{i}") is not None
# The regression: this grew to limit + 40, and for NCAA to 307.
assert len(instance._logo_cache) == limit
def test_the_limit_is_smaller_than_a_real_league(self, host):
"""ncaa_logos ships 307 files; the cap has to be well under that."""
instance, _ = host
assert instance._LOGO_CACHE_MAX < 307
class TestEvictionIsLRUNotArbitrary:
def test_the_least_recently_used_goes_first(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit):
_load(host, f"T{i}")
_load(host, "NEW")
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
assert "NEW" in instance._logo_cache
def test_touching_an_entry_saves_it(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit):
_load(host, f"T{i}")
_load(host, "T0") # a cache hit -- T0 is on screen again
_load(host, "NEW") # forces one eviction
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
class TestHitsStillAvoidDiskWork:
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
instance, _ = host
first = _load(host, "TB")
def _boom(*a, **k):
raise AssertionError("cache hit re-opened the file from disk")
monkeypatch.setattr(Image, "open", _boom)
assert _load(host, "TB") is first
-585
View File
@@ -1,585 +0,0 @@
"""Tests for the methods promoted onto SportsUpcoming / SportsRecent /
SportsLive from the nine plugin copies (phase B1 of the sports unification;
see docs/SPORTS_UNIFICATION.md).
Covered promotions:
- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking`
(+ the `_zero_clock_timestamps` initializer).
- SportsLive: `_is_game_really_over` / `_detect_stale_games`
(+ `game_update_timestamps` / `stale_game_timeout`, and the
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes).
- SportsUpcoming: `_select_games_for_display`.
- SportsRecent: `_select_recent_games_for_display`.
The live pair is the risk centre: `_detect_stale_games` is the only caller
that *removes* games, so `_is_game_really_over` returning a false positive
silently drops a live game from the display. The canonical form deliberately
declines to treat a missing clock as 0:00 — the plugin variant that did so
dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward.
That regression is pinned by
`test_missing_clock_at_late_period_is_not_over`.
"""
import logging
import sys
import time
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
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.hockey import Hockey, HockeyLive
from src.base_classes.sports import (
SportsCore,
SportsLive,
SportsRecent,
SportsUpcoming,
)
# ---------------------------------------------------------------------------
# Harnesses
# ---------------------------------------------------------------------------
class _UpcomingHarness(Hockey, SportsUpcoming):
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.
`_favorite_key` is inherited from SportsCore — these harnesses
deliberately do NOT define it, so the selection tests exercise the real
seam rather than a local stand-in.
"""
def _fetch_data(self):
return None
class _RecentHarness(Hockey, SportsRecent):
def _fetch_data(self):
return None
class _LiveHarness(HockeyLive):
def _fetch_data(self):
return None
class _ThreePeriodLiveHarness(_LiveHarness):
"""Hockey-shaped: regulation ends after period 3."""
FINAL_PERIOD = 3
class _CountUpLiveHarness(_LiveHarness):
"""Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff."""
CLOCK_COUNTS_DOWN = False
class _IdFavoriteUpcomingHarness(_UpcomingHarness):
"""NRL-shaped: abbreviations are ambiguous, so favorites match on team id."""
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
class _IdFavoriteRecentHarness(_RecentHarness):
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
@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, **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))
cache_manager = MagicMock()
cache_manager.get.return_value = None
cache_manager.cache_dir = str(tmp_path)
manager = cls(config, display_manager, cache_manager,
logging.getLogger("test_sports_modes_promotions"),
"nhl")
manager.session = MagicMock()
manager.session.get.side_effect = requests.exceptions.ConnectionError(
"promotion tests are offline")
return manager
return build
def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None,
away_id=None, **extra):
g = {
"id": game_id,
"home_abbr": home,
"away_abbr": away,
"home_id": home_id,
"away_id": away_id,
"start_time_utc": start,
}
g.update(extra)
return g
def at(day, hour=12):
return datetime(2026, 1, day, hour, tzinfo=timezone.utc)
def _ids(games):
return [g["id"] for g in games]
# ---------------------------------------------------------------------------
# Tier 1 — zero-clock tracking (SportsRecent)
# ---------------------------------------------------------------------------
class TestZeroClockTracking:
def test_initializer_present_and_empty(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._zero_clock_timestamps == {}
def test_first_call_returns_zero_and_starts_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._get_zero_clock_duration("g1") == 0.0
assert "g1" in manager._zero_clock_timestamps
def test_subsequent_call_returns_elapsed_seconds(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
assert manager._get_zero_clock_duration("g1") == 0.0
frozen.tick(45)
assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0)
frozen.tick(15)
assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0)
def test_tracking_is_per_game(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
assert manager._get_zero_clock_duration("g2") == 0.0
assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0)
def test_clear_resets_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
manager._clear_zero_clock_tracking("g1")
assert "g1" not in manager._zero_clock_timestamps
# Restarts from zero after clearing.
assert manager._get_zero_clock_duration("g1") == 0.0
def test_clear_unknown_game_is_a_noop(self, build_manager):
manager = build_manager(_RecentHarness)
manager._clear_zero_clock_tracking("never-seen") # must not raise
assert manager._zero_clock_timestamps == {}
# ---------------------------------------------------------------------------
# Tier 2a — _is_game_really_over (SportsLive)
# ---------------------------------------------------------------------------
class TestIsGameReallyOver:
def test_missing_clock_at_late_period_is_not_over(self, build_manager):
"""THE baseball regression: no `clock` key at all, period 7.
The rejected variant coerced a missing clock to the literal "0:00" and
declared the game over — dropping every MLB game from the 5th inning
onward, since baseball has no game clock and `period` is the inning.
A missing clock must fail safe.
"""
manager = build_manager(_LiveHarness)
g = game(period=7, period_text="Top 7th")
assert "clock" not in g
assert manager._is_game_really_over(g) is False
def test_none_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=None, period=7, period_text="Top 7th")) is False
def test_non_string_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=0, period=9, period_text="Bot 9th")) is False
def test_blank_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=" ", period=5, period_text="5th")) is False
@pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT",
"FINAL", "Final - SO"])
def test_final_period_text_is_over(self, build_manager, period_text):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=period_text)) is True
def test_none_period_text_does_not_raise(self, build_manager):
"""All nine plugin copies called `.lower()` on `game.get("period_text", "")`,
which is None when the key is present-but-None; `_detect_stale_games`
has no try/except around the call."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=None)) is False
def test_missing_period_text_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
@pytest.mark.parametrize(
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
"""Every spelling of a zeroed clock counts, not a hand-listed few.
"00:00" is the one that motivated comparing numerically: it normalizes
to "0000", which matched none of the literals the plugin copies listed,
so a two-digit-minute expired clock kept the game on screen forever.
"""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is True
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
`_detect_stale_games` has no try/except — the same failure shape as the
`period_text` case above."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=None, period_text="Q4")) is False
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="8:12", period=None, period_text="Q2")) is False
def test_expired_clock_after_final_period_is_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=5, period_text="OT")) is True
def test_expired_clock_before_final_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="Q3")) is False
@pytest.mark.parametrize("clock", [":40", "0:40", "1:00"])
def test_running_clock_is_not_over(self, build_manager, clock):
"""Sub-minute clocks like ':40' are legitimate, not expired."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is False
def test_defaults_are_four_period_countdown(self):
assert SportsLive.FINAL_PERIOD == 4
assert SportsLive.CLOCK_COUNTS_DOWN is True
def test_final_period_override_three(self, build_manager):
"""Hockey-shaped subclass: regulation ends after period 3."""
manager = build_manager(_ThreePeriodLiveHarness)
assert manager.FINAL_PERIOD == 3
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is True
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="P2")) is False
# And the unmodified default still requires period 4.
assert build_manager(_LiveHarness)._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is False
def test_count_up_clock_never_expires(self, build_manager):
"""Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run."""
manager = build_manager(_CountUpLiveHarness)
assert manager.CLOCK_COUNTS_DOWN is False
for period in (1, 2, 4, 9):
assert manager._is_game_really_over(
game(clock="0:00", period=period, period_text="1st Half")) is False
def test_count_up_clock_still_honors_final_text(self, build_manager):
manager = build_manager(_CountUpLiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="Final")) is True
# ---------------------------------------------------------------------------
# Tier 2b — _detect_stale_games (SportsLive)
# ---------------------------------------------------------------------------
class TestDetectStaleGames:
def test_initializer_defaults(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager.game_update_timestamps == {}
assert manager.stale_game_timeout == 300
def test_stale_timeout_is_configurable(self, build_manager):
manager = build_manager(_LiveHarness, stale_game_timeout=42)
assert manager.stale_game_timeout == 42
def test_mutates_caller_list_in_place_and_returns_none(self, build_manager):
manager = build_manager(_LiveHarness)
fresh = game("1", period_text="P2", clock="10:00", period=2)
over = game("2", period_text="Final", clock="0:00", period=3)
games = [fresh, over]
original = games
result = manager._detect_stale_games(games)
assert result is None
assert games is original # same object, mutated in place
assert _ids(games) == ["1"]
def test_evicts_only_past_timeout_games(self, build_manager):
manager = build_manager(_LiveHarness)
with freeze_time("2026-01-20 12:00:00"):
now = time.time()
manager.game_update_timestamps = {
"1": {"last_seen": now - 10}, # fresh
"2": {"last_seen": now - 299}, # just inside the timeout
"3": {"last_seen": now - 301}, # past the timeout
}
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1),
game("3", period_text="P1", clock="10:00", period=1)]
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
assert "3" not in manager.game_update_timestamps
assert set(manager.game_update_timestamps) == {"1", "2"}
def test_unknown_last_seen_is_never_stale(self, build_manager):
"""last_seen == 0 (or no entry) means 'never recorded', not 'ancient'."""
manager = build_manager(_LiveHarness)
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1)]
manager.game_update_timestamps = {"1": {"last_seen": 0}}
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
def test_removes_games_that_are_really_over(self, build_manager):
manager = build_manager(_LiveHarness)
manager.game_update_timestamps = {"2": {"last_seen": 0}}
games = [game("1", period_text="Q2", clock="5:00", period=2),
game("2", period_text="Final", clock="0:00", period=4)]
manager._detect_stale_games(games)
assert _ids(games) == ["1"]
assert "2" not in manager.game_update_timestamps
def test_keeps_clockless_late_game(self, build_manager):
"""The end-to-end form of the baseball regression: a clockless game in
the 7th must survive the removal path."""
manager = build_manager(_LiveHarness)
games = [game("mlb-1", period=7, period_text="Top 7th")]
manager._detect_stale_games(games)
assert _ids(games) == ["mlb-1"]
def test_games_without_id_are_skipped(self, build_manager):
manager = build_manager(_LiveHarness)
no_id = {"home_abbr": "BOS", "away_abbr": "TOR",
"period_text": "Final", "clock": "0:00", "period": 4}
games = [no_id]
manager._detect_stale_games(games)
# `continue` fires before the "really over" check, so it stays.
assert games == [no_id]
def test_empty_list_is_tolerated(self, build_manager):
manager = build_manager(_LiveHarness)
games = []
assert manager._detect_stale_games(games) is None
assert games == []
def test_removal_is_by_value_not_identity(self, build_manager):
"""Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`,
so two structurally-equal dicts drop the FIRST occurrence."""
manager = build_manager(_LiveHarness)
first = game("1", period_text="Final", clock="0:00", period=4)
twin = dict(first)
games = [first, twin]
manager._detect_stale_games(games)
# Both entries are removed here (two iterations, two removals), but the
# first removal deletes `first`, not the dict being iterated.
assert games == []
# ---------------------------------------------------------------------------
# Tier 3a — _select_games_for_display (SportsUpcoming)
# ---------------------------------------------------------------------------
class TestSelectGamesForDisplay:
def test_no_favorites_returns_all_sorted_ascending(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("late", start=at(20)), game("early", start=at(10)),
game("mid", start=at(15))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "mid", "late"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("none", start=None), game("early", start=at(10))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "none"]
def test_filters_to_favorite_teams(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10)),
game("2", home="NYR", away="PIT", start=at(11)),
game("3", home="MTL", away="BOS", start=at(12))]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"]
def test_respects_upcoming_games_to_show_per_team(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(10)),
game("bos2", home="BOS", away="NYR", start=at(11)),
game("tor2", home="TOR", away="PIT", start=at(12))]
# "shared" fills both BOS's and TOR's single slot, so nothing else fits.
assert _ids(manager._select_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
"""The NRL case: two clubs share the abbreviation 'NEW', so favorites
must be matched on team id. Only the seam changes — the promoted
method is identical."""
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
abbr_manager = build_manager(_UpcomingHarness)
# Abbreviation matching cannot tell the two "NEW" clubs apart.
assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [
"knights", "warriors"]
id_manager = build_manager(_IdFavoriteUpcomingHarness)
assert _ids(id_manager._select_games_for_display(games, ["99"])) == [
"warriors"]
def test_favorite_key_none_never_matches(self, build_manager):
"""`_favorite_key` returning None (missing id) must not match, even
against a favorites list holding the string 'None'."""
manager = build_manager(_IdFavoriteUpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids
assert manager._select_games_for_display(games, ["None"]) == []
# ---------------------------------------------------------------------------
# Tier 3b — _select_recent_games_for_display (SportsRecent)
# ---------------------------------------------------------------------------
class TestSelectRecentGamesForDisplay:
def test_no_favorites_returns_all_sorted_descending(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("early", start=at(10)), game("late", start=at(20)),
game("mid", start=at(15))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "mid", "early"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("none", start=None), game("late", start=at(20))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "none"]
def test_respects_recent_games_to_show_per_team(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
# Most recent first.
assert _ids(manager._select_recent_games_for_display(
games, ["BOS"])) == ["4", "3"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(20)),
game("bos2", home="BOS", away="NYR", start=at(19)),
game("tor2", home="TOR", away="PIT", start=at(18))]
assert _ids(manager._select_recent_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_recent_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_recent_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
id_manager = build_manager(_IdFavoriteRecentHarness)
assert _ids(id_manager._select_recent_games_for_display(
games, ["99"])) == ["warriors"]
# ---------------------------------------------------------------------------
# Seam / inertness guards
# ---------------------------------------------------------------------------
class TestPromotionShape:
def test_promoted_methods_live_on_the_right_classes(self):
assert hasattr(SportsRecent, "_get_zero_clock_duration")
assert hasattr(SportsRecent, "_clear_zero_clock_tracking")
assert hasattr(SportsRecent, "_select_recent_games_for_display")
assert hasattr(SportsUpcoming, "_select_games_for_display")
assert hasattr(SportsLive, "_is_game_really_over")
assert hasattr(SportsLive, "_detect_stale_games")
def test_modes_does_not_define_the_favorite_key_seam(self):
"""`_favorite_key` is a SportsCore override point. modes.py must call
it, never define it — this test fails if the seam is added in the
wrong file."""
import src.base_classes.sports.modes as modes
for cls in (SportsUpcoming, SportsRecent, SportsLive):
assert "_favorite_key" not in vars(cls)
assert "_favorite_key" not in modes.__dict__
-136
View File
@@ -1,136 +0,0 @@
"""Odds must be fetched for the games shown, not every game in the window.
SportsUpcoming.update() collected every upcoming game in the schedule window
and called _fetch_odds() on each one *inside* that collection loop, narrowing
to upcoming_games_to_show only afterwards. The comment there said odds were
fetched "only for games that will be displayed", but the sole narrowing it
applied was show_favorite_teams_only, which is not the default -- so in the
usual configuration nothing narrowed it at all.
Measured on a live rig: a college league produced 946 upcoming games in one
cycle and displayed 1 of them. The same shape on the football plugin produced
a burst of 467 sequential ESPN requests that ran for 35s and blew that
plugin's 30s update budget, and it repeats every time the 1h odds TTL expires.
SportsLive is deliberately different: it walks the raw event list because it
has to find which games are live, but only fetches odds for a game that has
already passed the is_live/is_halftime test, so the fan-out is bounded by how
many games are actually in progress.
"""
import ast
from pathlib import Path
import pytest
MODES = (Path(__file__).resolve().parent.parent
/ "src" / "base_classes" / "sports" / "modes.py")
TREE = ast.parse(MODES.read_text(encoding="utf-8"))
def _fetch_sites():
"""(class name, method name, lineno) for every self._fetch_odds(...) call."""
calls = [n.lineno for n in ast.walk(TREE)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "_fetch_odds"]
sites = []
for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]:
for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]:
for lineno in calls:
if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno):
sites.append((cls.name, fn.name, lineno))
assert len(sites) == len(calls), "a _fetch_odds call sits outside any method"
return sites
def _innermost_loop_iterable(lineno):
best = None
for node in ast.walk(TREE):
if isinstance(node, ast.For) and \
node.lineno <= lineno <= (node.end_lineno or node.lineno):
if best is None or node.lineno > best.lineno:
best = node
return None if best is None else ast.unparse(best.iter)
def _spans(body, lineno):
"""True when `lineno` falls inside this list of statements."""
return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body)
def _parents(tree):
table = {}
for node in ast.walk(tree):
for child in ast.iter_child_nodes(node):
table[child] = node
return table
PARENTS = _parents(TREE)
def _mentions_positively(test, names):
"""True when `test` references every name, none of them under a `not`.
Structural, not textual. Matching the unparsed source would accept
`not (details["is_live"] or details["is_halftime"])` -- which selects
exactly the non-live games this guard exists to exclude -- because the
names still appear in the text.
"""
found = set()
for node in ast.walk(test):
if not (isinstance(node, ast.Constant) and node.value in names):
continue
negated = False
cursor = node
while cursor is not test and cursor in PARENTS:
cursor = PARENTS[cursor]
if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not):
negated = True
break
if not negated:
found.add(node.value)
return found >= set(names)
def _guarded_by_positive(lineno, names):
"""True when some enclosing `if` runs this line only if `names` hold.
Only the TRUE branch counts: an `if` whose `else` contains the call would
otherwise look like a guard while doing the opposite.
"""
for node in ast.walk(TREE):
if isinstance(node, ast.If) and _spans(node.body, lineno) \
and _mentions_positively(node.test, names):
return True
return False
def test_every_fetch_site_is_accounted_for():
"""A new call site must be classified deliberately, not inherited silently."""
found = {(cls, fn) for cls, fn, _ in _fetch_sites()}
assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, (
f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a "
"sequential ESPN request per game -- classify it here on purpose.")
def test_upcoming_fetches_only_the_selected_games():
for cls, _fn, lineno in _fetch_sites():
if cls != "SportsUpcoming":
continue
iterable = _innermost_loop_iterable(lineno)
assert iterable == "team_games", (
f"SportsUpcoming._fetch_odds at line {lineno} iterates over "
f"{iterable!r}. It must run over team_games -- already narrowed to "
"upcoming_games_to_show -- not over every event in the schedule "
"window. Each item costs one sequential ESPN request.")
def test_live_only_fetches_for_games_actually_in_progress():
for cls, _fn, lineno in _fetch_sites():
if cls != "SportsLive":
continue
assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), (
f"SportsLive._fetch_odds at line {lineno} does not sit in the true "
"branch of a test requiring the game to be in progress. Without "
"that, it fans out across the whole event list -- one sequential "
"ESPN request per game.")