mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 01:08:05 +00:00
Follow-up review findings on the promoted base-class methods. _is_game_really_over: - `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`, taking down the whole live-update pass (_detect_stale_games has no try/except). Same failure shape as the null `period_text` already fixed. - An expired clock spelled "00:00" normalizes to "0000", which matched none of the hand-listed literals, so a finished game with a two-digit minute clock stayed on the scoreboard forever. Compare numerically. SportsCore: - _load_fonts kept the cwd-relative "assets/fonts/..." literals the _font_root() seam exists to remove, so every scoreboard font degraded to PIL's default face outside the install root. - _should_log read self._last_warning_time unguarded while only an unrelated method initialized it lazily; the first warning of a run raised AttributeError. Initialize it in __init__. Also documents that game_update_timestamps is written by subclasses, not by the base class, so the staleness branch is inert until B5 adoption. 14 new tests. Gates: 463 core unit, 60 plugin safety. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
586 lines
25 KiB
Python
586 lines
25 KiB
Python
"""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__
|