mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
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
This commit is contained in:
@@ -178,7 +178,8 @@ class TestScrollSettings:
|
||||
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"] == 50.0
|
||||
assert (display._get_scroll_settings()["scroll_speed"]
|
||||
== DEFAULT_SCROLL_SETTINGS["scroll_speed"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -245,6 +246,25 @@ class TestConfigureScrollHelper:
|
||||
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."""
|
||||
@@ -308,6 +328,16 @@ class TestFramePumping:
|
||||
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"])
|
||||
@@ -407,7 +437,23 @@ class TestManager:
|
||||
|
||||
def test_failed_prepare_does_not_become_active(self, manager):
|
||||
assert manager.prepare_and_display([], "live", ["nhl"]) is False
|
||||
assert manager._current_game_type is None
|
||||
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"])
|
||||
@@ -437,7 +483,7 @@ class TestManager:
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.prepare_and_display([{"id": "g2"}], "recent", ["nhl"])
|
||||
manager.clear_all()
|
||||
assert manager._current_game_type is None
|
||||
assert not manager._current_game_type
|
||||
for game_type in ("live", "recent"):
|
||||
assert manager.get_scroll_display(game_type)._current_games == []
|
||||
|
||||
@@ -526,6 +572,11 @@ class TestAgainstTheRealScrollHelper:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user