mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
feat(sports): promote the nine universal methods into the base classes
Phase B1b of docs/SPORTS_UNIFICATION.md. Every method here is present in
all nine bundled plugin sports.py copies and absent from core, so this is
reuse of code the fleet already agreed on — not new behavior. The
promotions are inert until B5: the plugins' own overrides still run.
SportsCore: cleanup, _get_layout_offset, _load_custom_font_from_element_config
SportsUpcoming: _select_games_for_display
SportsRecent: _get_zero_clock_duration, _clear_zero_clock_tracking,
_select_recent_games_for_display
SportsLive: _is_game_really_over, _detect_stale_games
Where the copies disagreed, the canonical form was chosen on evidence and
the genuine per-sport differences became seams rather than branches:
- _favorite_key(game, side) -- NRL matches favorites on team id because its
abbreviations are ambiguous (NEW is both Newcastle Knights and New
Zealand Warriors). Default is the abbreviation; NRL overrides. Core never
learns the string nrl.
- FINAL_PERIOD / CLOCK_COUNTS_DOWN -- hockey ends in P3, and soccer/afl/nrl
clocks count UP, so 0:00 means kickoff, not expiry.
- _config_schema_path() / _font_root() -- plugin-supplied locations, never
derived from this module's __file__.
BEHAVIOR CHANGE (baseball, ufc): the rejected variant coerced a missing or
non-str clock to the literal 0:00 and then declared the game over at
period >= 4. MLB has no game clock and period is the inning, so live games
were being evicted from the 5th inning onward; UFC likewise. The promoted
variant skips the clock check when the clock is unusable -- it fails safe
(keeps showing the game) instead of failing destructive.
Also fixes a regression from the package move in e591cec: the bodies were
byte-identical but __file__ gained a directory, so _resolve_project_path's
parents[2] silently began resolving to <root>/src instead of the repo root.
Both it and _font_root now derive from a single _INSTALL_ROOT constant, so
a future move needs one line changed rather than two hand-counted depths.
Tests assert the resolved values, not the index.
The font loader takes baseball's body (BDF memo cache + native-strike
retry) under hockey's Optional signature -- the older lineage is the
correct one here, and basketball's positional str default breaks on an
explicit None. It resolves through _font_root rather than the cwd, so it
does not reintroduce the bug just fixed for FontManager, and delegates to
FontManager for the alias table and BDF header parse instead of shipping
second copies. cleanup gained the two new font caches and still leaves
background_service alone -- it is a process-wide singleton.
Verified: 111 new tests (48 core + 59 modes + 4 install-root regression);
characterization + skin suites still exactly 94, unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
"""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 "])
|
||||
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=clock, period=4, period_text="Q4")) is True
|
||||
|
||||
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__
|
||||
Reference in New Issue
Block a user