mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-11 01:26:37 +00:00
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:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user