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:
@@ -86,6 +86,17 @@ class TestRegistry:
|
||||
with pytest.raises(ValueError):
|
||||
register_rotation_strategy("", SimpleRotation)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
SimpleRotation(), # an instance, not the class
|
||||
str, # unrelated class
|
||||
lambda **kw: None, # a factory function
|
||||
])
|
||||
def test_a_non_strategy_factory_is_rejected(self, bad):
|
||||
"""Fail at registration, not several frames later inside schedule(),
|
||||
where the cause is no longer on the stack."""
|
||||
with pytest.raises(TypeError):
|
||||
register_rotation_strategy("bad-factory", bad)
|
||||
|
||||
def test_weight_for_is_optional(self):
|
||||
"""Default weights are equal, so every strategy degenerates to a plain
|
||||
round robin — the pre-boost behavior."""
|
||||
@@ -111,6 +122,17 @@ class TestWeights:
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
|
||||
assert strategy.weights([game("a")]) == {"a": 1}
|
||||
|
||||
def test_huge_weights_are_clamped(self):
|
||||
"""A cycle is sum(weights) long and each step scans every game, so an
|
||||
unbounded weight from a misread config spins the display thread."""
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT}
|
||||
|
||||
def test_a_clamped_cycle_stays_bounded(self):
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
order = strategy.schedule([game("a"), game("b")])
|
||||
assert len(order) == 2 * RotationStrategy.MAX_WEIGHT
|
||||
|
||||
|
||||
class TestSimpleRotation:
|
||||
def test_one_pass_in_feed_order(self):
|
||||
@@ -218,6 +240,20 @@ class TestSmoothWeightedRotation:
|
||||
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
|
||||
assert preview == actual
|
||||
|
||||
def test_preview_uses_the_subclass_ordering(self):
|
||||
"""schedule() promises the order repeated next_game calls produce. Built
|
||||
from the base class, a subclass that overrides next_game gets a preview
|
||||
of the wrong algorithm."""
|
||||
class Reversed(SmoothWeightedRotation):
|
||||
def next_game(self, games):
|
||||
return super().next_game(list(reversed(games)))
|
||||
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
strategy = Reversed()
|
||||
preview = strategy.schedule(games)
|
||||
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
|
||||
assert preview == actual
|
||||
|
||||
def test_empty(self):
|
||||
assert SmoothWeightedRotation().next_game([]) is None
|
||||
assert SmoothWeightedRotation().schedule([]) == []
|
||||
@@ -462,6 +498,24 @@ class TestCelebrationConfig:
|
||||
"celebrate_opponent_goals": True})
|
||||
assert manager.celebrate_opponent_scores is False
|
||||
|
||||
@pytest.mark.parametrize("bad", ["eight", None, {}, []])
|
||||
def test_unusable_duration_falls_back(self, celebrating, bad):
|
||||
"""The duration is compared numerically on the display path, outside
|
||||
any try block — a string from a hand-edited config would propagate a
|
||||
TypeError straight out of display()."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 8.0
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5])
|
||||
def test_non_positive_duration_is_floored(self, celebrating, bad):
|
||||
"""Zero or negative would arm a celebration that can never render."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 1.0
|
||||
|
||||
def test_numeric_string_duration_is_accepted(self, celebrating):
|
||||
assert celebrating(
|
||||
mode_config={"celebration_duration": "12"}).celebration_duration == 12.0
|
||||
|
||||
|
||||
class TestScoreDetection:
|
||||
def test_first_sighting_never_celebrates(self, celebrating):
|
||||
@@ -725,6 +779,52 @@ class TestDisplayTakeover:
|
||||
assert manager.display() is True
|
||||
assert manager.display_calls == [False]
|
||||
|
||||
def test_a_render_failure_disarms_rather_than_retrying(self, celebrating):
|
||||
"""Left armed, the same render fails on every frame for the rest of the
|
||||
window — a traceback per frame, and no scorebug."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom"))
|
||||
manager.display()
|
||||
assert manager.active_celebration is None
|
||||
manager.display()
|
||||
assert manager._draw_celebration_layout.call_count == 1
|
||||
|
||||
|
||||
class TestBaselinePruning:
|
||||
"""`_score_baselines` gains an entry per game and only _check_for_win ever
|
||||
removed one, so a board running all season grows the dict without bound."""
|
||||
|
||||
def test_prunes_games_no_longer_live(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2", "g3"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g2")])
|
||||
assert set(manager._score_baselines) == {"g2"}
|
||||
|
||||
def test_keeps_every_still_live_game(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g1"), game("g2")])
|
||||
assert set(manager._score_baselines) == {"g1", "g2"}
|
||||
|
||||
def test_empty_live_set_clears_everything(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.prune_score_baselines([])
|
||||
assert manager._score_baselines == {}
|
||||
|
||||
def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
manager.prune_score_baselines([game("g1")])
|
||||
manager._check_for_score(game("g1", home_score=3))
|
||||
assert manager.active_celebration is not None, (
|
||||
"pruning must not drop a live game's baseline and re-trigger the "
|
||||
"first-sighting suppression")
|
||||
|
||||
def test_disabled_manager_renders_nothing(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager.is_enabled = False
|
||||
|
||||
Reference in New Issue
Block a user