Files
LEDMatrix/test/test_sports_scroll.py
T
Claude 274ea65b39 fix(sports): harden the B2/B3 capabilities against bad config and subclasses
Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.

celebrations:
- celebration_duration is coerced and floored at init. It is compared
  numerically in display() *outside* any try block, so a string from a
  hand-edited config propagated a TypeError straight out; zero or
  negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
  retried the same broken render on every frame for the rest of the
  window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
  entries, so a game that left the live list any other way leaked its
  baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
  window comparison, and log lines carry a [Celebrations] prefix.

rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
  every game, so an unbounded weight from a misread config spins the
  display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
  registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
  next_game is previewed with its own ordering -- which is what the
  method promises.

sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
  when the key is absent; present-but-null reached the multiplication
  inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
  They ran outside it, so a raise there reached the plugin's frame loop
  despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
  payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
  manager said None while the display said "".

Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.

Gates: 747 core unit, 66 plugin safety.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 17:46:06 +00:00

586 lines
25 KiB
Python

"""Tests for src/common/sports_scroll.py (phase B3).
The module is drawn along the split the survey found: the orchestration layer
is shared, the content layer is not. Two things are worth asserting beyond
"it works":
* ``prepare_scroll_content`` must stay an override point — a base class that
quietly rendered *something* would let a plugin ship a blank scroll.
* ``target_fps`` must actually reach the helper. That is the whole reason this
module exists upstream; the bundled plugin copies hardcode ~100 FPS.
"""
import logging
import sys
from unittest.mock import MagicMock
import pytest
from PIL import Image
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.common.sports_scroll import ( # noqa: E402
DEFAULT_SCROLL_SETTINGS,
MAX_PIXELS_PER_FRAME,
MIN_PIXELS_PER_FRAME,
SportsScrollDisplay,
SportsScrollDisplayManager,
)
LOGGER = logging.getLogger("test.sports_scroll")
@pytest.fixture
def display_manager():
manager = MagicMock()
manager.matrix.width = 128
manager.matrix.height = 32
return manager
@pytest.fixture(autouse=True)
def fake_scroll_helper(monkeypatch):
"""Replace ScrollHelper with a recording double.
The real helper is covered by test_scroll_helper.py; here what matters is
*which* calls this module makes and with what values.
"""
created = []
def _factory(width, height, logger):
helper = MagicMock()
helper.width, helper.height = width, height
helper.cached_image = None
helper.is_scroll_complete.return_value = False
helper.get_dynamic_duration.return_value = 42
helper.get_scroll_info.return_value = {"position": 0}
created.append(helper)
return helper
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", _factory)
return created
class _Display(SportsScrollDisplay):
"""A minimal concrete subclass, as a plugin would write it."""
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens")
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
self._current_games = list(games)
self._current_game_type = game_type
self._current_leagues = list(leagues)
self.scroll_helper.cached_image = Image.new("RGB", (400, 32))
return bool(games)
class _Manager(SportsScrollDisplayManager):
display_class = _Display
@pytest.fixture
def build(display_manager):
def _build(config=None, global_config=None, cls=_Display):
return cls(display_manager, config or {}, LOGGER, global_config=global_config)
return _build
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
class TestConstruction:
def test_dimensions_come_from_the_matrix(self, build):
display = build()
assert (display.display_width, display.display_height) == (128, 32)
def test_dimensions_fall_back_when_there_is_no_matrix(self, display_manager):
display_manager.matrix = None
display_manager.width, display_manager.height = 256, 64
display = _Display(display_manager, {}, LOGGER)
assert (display.display_width, display.display_height) == (256, 64)
def test_final_fallback_dimensions(self):
"""A display manager exposing neither must not crash construction."""
bare = MagicMock(spec=[])
display = _Display(bare, {}, LOGGER)
assert (display.display_width, display.display_height) == (128, 32)
def test_global_config_is_optional(self, build):
"""An older caller that doesn't pass it keeps working."""
assert build().global_config == {}
def test_state_starts_empty(self, build):
display = build()
assert display._current_games == []
assert display._vegas_content_items == []
assert display.get_current_game_count() == 0
# ---------------------------------------------------------------------------
# Settings resolution — the league ladder
# ---------------------------------------------------------------------------
class TestScrollSettings:
def test_defaults_when_nothing_is_configured(self, build):
settings = build()._get_scroll_settings()
for key, value in DEFAULT_SCROLL_SETTINGS.items():
assert settings[key] == value
def test_card_width_defaults_to_the_panel_width(self, build):
assert build()._get_scroll_settings()["game_card_width"] == 128
def test_named_league_wins(self, build):
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}},
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings("ncaa_mens")["scroll_speed"] == 20
def test_ladder_is_walked_in_order(self, build):
"""The only reason the eight plugin copies differed: which league keys
to try, and in what order."""
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}},
"nhl": {"scroll_settings": {"scroll_speed": 10}}})
assert display._get_scroll_settings()["scroll_speed"] == 10
def test_ladder_falls_through_to_the_next_key(self, build):
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings()["scroll_speed"] == 20
def test_overrides_merge_onto_defaults(self, build):
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}}})
settings = display._get_scroll_settings()
assert settings["scroll_speed"] == 10
assert settings["gap_between_games"] == DEFAULT_SCROLL_SETTINGS[
"gap_between_games"]
def test_empty_override_does_not_shadow_the_next_candidate(self, build):
display = build({"nhl": {"scroll_settings": {}},
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings()["scroll_speed"] == 20
def test_single_block_config_shape(self, build):
"""The afl/nrl/soccer shape: one scroll_mode block, no league concept."""
class _Single(_Display):
SCROLL_LEAGUE_KEYS = ()
SCROLL_CONFIG_KEY = "scroll_mode"
display = build({"scroll_mode": {"scroll_speed": 33}}, cls=_Single)
assert display._get_scroll_settings()["scroll_speed"] == 33
def test_defaults_are_overridable_by_a_subclass(self, build):
class _Wide(_Display):
def scroll_settings_defaults(self):
return {**super().scroll_settings_defaults(),
"gap_between_games": 24}
assert build(cls=_Wide)._get_scroll_settings()["gap_between_games"] == 24
def test_a_null_league_block_is_tolerated(self, build):
"""`config['nhl'] = None` appears in hand-edited configs."""
display = build({"nhl": None})
assert (display._get_scroll_settings()["scroll_speed"]
== DEFAULT_SCROLL_SETTINGS["scroll_speed"])
# ---------------------------------------------------------------------------
# Helper configuration — including the reason this module exists upstream
# ---------------------------------------------------------------------------
class TestConfigureScrollHelper:
def test_speed_is_converted_to_pixels_per_frame(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
# 100 px/s * 0.02 s/frame = 2 px/frame
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
def test_conversion_is_clamped_low(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
def test_conversion_is_clamped_high(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0}}})
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
def test_frame_based_scrolling_is_enabled(self, build):
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
def test_dynamic_duration_settings_are_applied(self, build):
display = build({"nhl": {"scroll_settings": {
"dynamic_duration": False, "min_duration": 5, "max_duration": 50}}})
_, kwargs = display.scroll_helper.set_dynamic_duration_settings.call_args
assert kwargs["enabled"] is False
assert kwargs["min_duration"] == 5
assert kwargs["max_duration"] == 50
def test_target_fps_reaches_the_helper(self, build):
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
via scroll_delay and never consult the global target."""
display = build(global_config={"target_fps": 120})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_legacy_key_is_honored(self, build):
display = build(global_config={"scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
def test_modern_key_wins_over_legacy(self, build):
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
build().scroll_helper.set_target_fps.assert_not_called()
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
"""A malformed global config must cost the FPS target, not the display."""
display = build(global_config={"target_fps": bad})
display.scroll_helper.set_target_fps.assert_not_called()
def test_string_digits_are_accepted(self, build):
display = build(global_config={"target_fps": "120"})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
"""`.get(key, default)` only helps when the key is *absent*. A key
present with null reaches the arithmetic and raises inside __init__,
taking the whole display down before it renders anything."""
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
@pytest.mark.parametrize("bad", [None, "slow", {}])
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
def test_numeric_strings_are_accepted(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": "100", "scroll_delay": "0.02"}}})
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
def test_fps_clamping_is_left_to_the_helper(self, build):
"""Deliberately not clamped here — a second copy of the range would
drift from ScrollHelper.set_target_fps."""
display = build(global_config={"target_fps": 5000})
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
# ---------------------------------------------------------------------------
# The content seam
# ---------------------------------------------------------------------------
class TestContentIsAnOverridePoint:
def test_base_refuses_to_render(self, display_manager):
"""Eight plugins have eight different bodies for this; a base class that
rendered *something* would let a plugin ship a silently blank scroll."""
display = SportsScrollDisplay(display_manager, {}, LOGGER)
with pytest.raises(NotImplementedError, match="prepare_scroll_content"):
display.prepare_scroll_content([], "live", [])
def test_the_error_names_the_offending_class(self, display_manager):
class Incomplete(SportsScrollDisplay):
pass
with pytest.raises(NotImplementedError, match="Incomplete"):
Incomplete(display_manager, {}, LOGGER).prepare_scroll_content(
[], "live", [])
def test_separator_icons_default_to_a_no_op(self, build):
assert build()._separator_icons == {}
# ---------------------------------------------------------------------------
# Frame pumping
# ---------------------------------------------------------------------------
class TestFramePumping:
def test_no_content_means_no_frame(self, build):
assert build().display_scroll_frame() is False
def test_a_frame_is_drawn_and_pushed(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
assert display.display_scroll_frame() is True
display.scroll_helper.update_scroll_position.assert_called_once()
display.display_manager.update_display.assert_called_once()
def test_no_visible_portion_means_no_frame(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = None
assert display.display_scroll_frame() is False
def test_a_display_failure_is_contained(self, build):
"""A display error must not propagate into the plugin's render loop."""
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
display.display_manager.update_display.side_effect = RuntimeError("boom")
assert display.display_scroll_frame() is False
@pytest.mark.parametrize("failing", ["update_scroll_position",
"get_visible_portion"])
def test_a_scroll_helper_failure_is_contained_too(self, build, failing):
"""These ran outside the try, so a raise there reached the caller's
frame loop despite the stated promise that none can."""
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
getattr(display.scroll_helper, failing).side_effect = RuntimeError("boom")
assert display.display_scroll_frame() is False
def test_frames_are_counted(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
for _ in range(3):
display.display_scroll_frame()
assert display._frame_count == 3
def test_progress_logging_is_throttled(self, build):
display = build()
display._log_interval = 10_000
display._last_log_time = 0
display._log_scroll_progress()
first = display._last_log_time
display._log_scroll_progress()
assert display._last_log_time == first
class TestLifecycle:
def test_reset_keeps_content_but_rewinds(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display._frame_count = 9
display.reset_scroll()
display.scroll_helper.reset_scroll.assert_called_once()
assert display._frame_count == 0
assert display._current_games, "reset must not drop content"
def test_clear_drops_everything(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display._vegas_content_items = [Image.new("RGB", (8, 8))]
display.clear()
display.scroll_helper.clear_cache.assert_called_once()
assert display._current_games == []
assert display._current_game_type == ""
assert display._vegas_content_items == []
def test_completion_delegates_to_the_helper(self, build):
display = build()
display.scroll_helper.is_scroll_complete.return_value = True
assert display.is_scroll_complete() is True
def test_dynamic_duration_delegates(self, build):
assert build().get_dynamic_duration() == 42
def test_has_cached_content(self, build):
display = build()
assert display.has_cached_content() is False
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
assert display.has_cached_content() is True
def test_scroll_info_merges_helper_and_local_state(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
info = display.get_scroll_info()
assert info["position"] == 0 # from the helper
assert info["game_count"] == 1 # from this display
assert info["leagues"] == ["nhl"]
def test_leagues_are_returned_as_a_copy(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.get_current_leagues().append("mutated")
assert display.get_current_leagues() == ["nhl"]
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class TestManager:
@pytest.fixture
def manager(self, display_manager):
return _Manager(display_manager, {}, LOGGER, global_config={"target_fps": 120})
def test_displays_are_created_lazily_and_reused(self, manager):
first = manager.get_scroll_display("live")
assert manager.get_scroll_display("live") is first
assert isinstance(first, _Display)
def test_each_game_type_gets_its_own(self, manager):
assert manager.get_scroll_display("live") is not manager.get_scroll_display(
"recent")
def test_global_config_is_threaded_to_children(self, manager):
"""A missed hand-off here is exactly how the plugin copies ended up
never honoring target_fps."""
child = manager.get_scroll_display("live")
assert child.global_config == {"target_fps": 120}
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_prepare_sets_the_active_type(self, manager):
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
assert manager._current_game_type == "live"
def test_failed_prepare_does_not_become_active(self, manager):
assert manager.prepare_and_display([], "live", ["nhl"]) is False
assert not manager._current_game_type
def test_a_raising_subclass_does_not_escape_the_orchestration(self, manager):
"""prepare_scroll_content is subclass code building cards from feed
data. One sport's bad payload must not take down the others."""
display = manager.get_scroll_display("live")
display.prepare_scroll_content = MagicMock(side_effect=KeyError("status"))
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is False
assert not manager._current_game_type
def test_empty_game_type_sentinel_matches_the_display(self, manager):
"""Both classes must spell 'nothing active' the same way; two spellings
across two classes is a trap for anyone comparing their state."""
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.clear_all()
assert (manager._current_game_type
== manager.get_scroll_display("live")._current_game_type == "")
def test_display_frame_uses_the_active_type(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
display = manager.get_scroll_display("live")
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
assert manager.display_frame() is True
def test_display_frame_with_no_active_type(self, manager):
assert manager.display_frame() is False
def test_display_frame_for_an_unknown_type(self, manager):
assert manager.display_frame("never-prepared") is False
def test_completion_is_true_when_there_is_nothing_to_scroll(self, manager):
"""A caller waiting on completion must never be wedged by absence."""
assert manager.is_complete() is True
assert manager.is_complete("never-prepared") is True
def test_completion_delegates_to_the_active_display(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.get_scroll_display("live").scroll_helper \
.is_scroll_complete.return_value = True
assert manager.is_complete() is True
def test_clear_all_clears_every_display(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.prepare_and_display([{"id": "g2"}], "recent", ["nhl"])
manager.clear_all()
assert not manager._current_game_type
for game_type in ("live", "recent"):
assert manager.get_scroll_display(game_type)._current_games == []
def test_vegas_items_are_collected_across_displays(self, manager):
for game_type in ("live", "recent"):
manager.get_scroll_display(game_type)._vegas_content_items = [
Image.new("RGB", (8, 8))]
assert len(manager.get_all_vegas_content_items()) == 2
def test_vegas_collection_tolerates_empty_displays(self, manager):
manager.get_scroll_display("live")
assert manager.get_all_vegas_content_items() == []
def test_display_class_is_the_subclass_seam(self, display_manager):
class Other(_Display):
pass
class OtherManager(SportsScrollDisplayManager):
display_class = Other
manager = OtherManager(display_manager, {}, LOGGER)
assert isinstance(manager.get_scroll_display("live"), Other)
# ---------------------------------------------------------------------------
# Integration — against the real ScrollHelper
# ---------------------------------------------------------------------------
class _RealDisplay(SportsScrollDisplay):
"""Builds a strip the way a plugin would, via the real helper API."""
SCROLL_LEAGUE_KEYS = ("nhl",)
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
settings = self._get_scroll_settings()
self.scroll_helper.create_scrolling_image(
[Image.new("RGB", (100, 32), (20, 20, 20)) for _ in games],
item_gap=settings["gap_between_games"],
)
self._current_games = list(games)
self._current_game_type = game_type
self._current_leagues = list(leagues)
return bool(games)
class _RealManager(SportsScrollDisplayManager):
display_class = _RealDisplay
class TestAgainstTheRealScrollHelper:
"""The mocked tests above pin *which* calls this module makes; these pin
that those calls exist and mean what we think. Without this, a rename in
ScrollHelper would sail past a suite built entirely on MagicMock."""
@pytest.fixture
def real(self, display_manager, monkeypatch):
from src.common.scroll_helper import ScrollHelper
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", ScrollHelper)
return _RealManager(
display_manager,
{"nhl": {"scroll_settings": {"scroll_speed": 500.0,
"scroll_delay": 0.001}}},
LOGGER,
global_config={"target_fps": 120},
)
def test_configuration_lands_on_the_real_helper(self, real):
helper = real.get_scroll_display("live").scroll_helper
assert helper.target_fps == 120.0
assert helper.frame_based_scrolling is True
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
import time
assert real.prepare_and_display(
[{"id": "a"}, {"id": "b"}, {"id": "c"}], "live", ["nhl"]) is True
display = real.get_scroll_display("live")
# 3 cards of 100px + gaps, so the strip is wider than the 128px panel.
assert display.scroll_helper.cached_image.width > 128
# Frame-based scrolling is wall-clock gated on scroll_delay, so the
# loop has to actually pass time rather than spin.
deadline = time.time() + 20
while not real.is_complete() and time.time() < deadline:
real.display_frame()
time.sleep(0.0012)
# Asserted separately so a host too slow to sustain the frame rate
# reports a timeout rather than looking like a scrolling defect.
assert time.time() < deadline, (
"scroll did not finish within 20s — the host may be too slow to "
"sustain the configured frame rate")
assert real.is_complete() is True
assert display.scroll_helper.scroll_position > 128
def test_dynamic_duration_is_a_real_number(self, real):
real.prepare_and_display([{"id": "a"}], "live", ["nhl"])
assert real.get_scroll_display("live").get_dynamic_duration() > 0