mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 01:38:36 +00:00
feat(display-controller): round-robin between simultaneous live-priority games (#372)
_check_live_priority() was stateless first-match-wins: it returned the first plugin in registration order with live content, and the post-dwell hold pinned the carousel to it, so when two games were live at once (e.g. a baseball game and a soccer match) the second never showed until the first ended. Add _collect_live_modes() (all currently-live modes, deduped, in registration order) and give _check_live_priority an 'advance' flag. The main rotation calls it with advance=True, which returns the live mode after the one currently shown -- using current_display_mode as the cursor -- so each dwell advances to the next live game and they take turns. The Vegas coordinator and the vegas-active check keep the default non-advancing peek (advance=False), so they only report whether any game is live without spinning the cursor. should_rotate and _apply_live_priority are unchanged; a single live game still holds as before. Adds regression tests to TestDisplayControllerLivePriority. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1505,30 +1505,68 @@ class DisplayController:
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
self._live_resume_index = None
|
||||
|
||||
def _check_live_priority(self):
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
||||
def _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
Scans all registered plugin modes; for each plugin that has live
|
||||
priority *and* live content, collects the specific live mode(s) it
|
||||
reports via get_live_modes() (only those actually registered), falling
|
||||
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||
preserving order. A plugin registered under several mode keys (the
|
||||
sports plugins register one per league) contributes each live mode once.
|
||||
"""
|
||||
live = []
|
||||
seen = set()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
||||
try:
|
||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
||||
# Get the specific live mode from the plugin if available
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
live_modes = plugin_instance.get_live_modes()
|
||||
if live_modes and len(live_modes) > 0:
|
||||
# Verify the mode actually exists before returning it
|
||||
for suggested_mode in live_modes:
|
||||
if suggested_mode in self.plugin_modes:
|
||||
return suggested_mode
|
||||
# If suggested modes don't exist, fall through to check current mode
|
||||
# Fallback: if this mode ends with _live, return it
|
||||
if mode_name.endswith('_live'):
|
||||
return mode_name
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return None
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
try:
|
||||
if not (plugin_instance.has_live_priority()
|
||||
and plugin_instance.has_live_content()):
|
||||
continue
|
||||
resolved = []
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||
if suggested_mode in self.plugin_modes:
|
||||
resolved.append(suggested_mode)
|
||||
if not resolved and mode_name.endswith('_live'):
|
||||
resolved.append(mode_name)
|
||||
for m in resolved:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
live.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
When several plugins report live content at once (e.g. a baseball game
|
||||
and a soccer match), this round-robins between them so the display
|
||||
alternates each dwell instead of pinning to whichever plugin is first in
|
||||
registration order.
|
||||
|
||||
advance=False (default): a non-advancing peek — returns the live mode
|
||||
already on screen if it is still live, otherwise the first live mode.
|
||||
Used by the Vegas coordinator and the vegas-active check, which only
|
||||
need to know whether *any* game is live (and must not spin the cursor).
|
||||
|
||||
advance=True: the rotation pick — returns the live mode *after* the one
|
||||
currently shown, so each dwell advances to the next live game. The
|
||||
currently-displayed mode is the cursor, so this stays correct as games
|
||||
start and end (no separate index to keep in sync).
|
||||
"""
|
||||
live_modes = self._collect_live_modes()
|
||||
if not live_modes:
|
||||
return None
|
||||
if self.current_display_mode in live_modes:
|
||||
if advance:
|
||||
idx = live_modes.index(self.current_display_mode)
|
||||
return live_modes[(idx + 1) % len(live_modes)]
|
||||
return self.current_display_mode
|
||||
return live_modes[0]
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
@@ -1689,9 +1727,11 @@ class DisplayController:
|
||||
# Display failed, clear the status and continue normally
|
||||
wifi_status_data = None
|
||||
|
||||
# Check for live priority content and switch to it immediately
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
|
||||
@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_mode_index == 1
|
||||
assert controller.current_display_mode == "b"
|
||||
|
||||
# --- Round-robin between multiple simultaneous live games --------------
|
||||
|
||||
@staticmethod
|
||||
def _live_plugin(live_modes):
|
||||
"""A mock plugin that is live and reports the given live mode names."""
|
||||
p = MagicMock()
|
||||
p.has_live_priority = MagicMock(return_value=True)
|
||||
p.has_live_content = MagicMock(return_value=True)
|
||||
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||
return p
|
||||
|
||||
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||
"""A sports plugin registered under several mode keys (one per league)
|
||||
contributes each live mode once, in registration order; plugins with no
|
||||
live content are skipped."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"baseball_recent": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
"soccer_usa.1_live": soccer,
|
||||
"soccer_recent": soccer,
|
||||
"clock": idle,
|
||||
}
|
||||
assert controller._collect_live_modes() == [
|
||||
"baseball_live", "soccer_fifa.world_live"
|
||||
]
|
||||
|
||||
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||
"""Regression: with two games live at once, the live-priority pick
|
||||
round-robins each dwell instead of pinning to the first plugin in
|
||||
registration order (the bug where a baseball game hid a live World Cup
|
||||
match)."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
}
|
||||
# First entry into live priority from an ambient mode -> first live game.
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
# The controller switches to it; the next dwell advances to the other.
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||
# And wraps back again.
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||
not spin the cursor: it returns the live mode already on screen."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||
}
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
# From an ambient mode the peek reports the first live game (truthy).
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority() == "baseball_live"
|
||||
|
||||
def test_no_live_content_returns_none(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {"clock": idle}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) is None
|
||||
|
||||
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||
controller = test_display_controller
|
||||
legacy = MagicMock()
|
||||
legacy.has_live_priority = MagicMock(return_value=True)
|
||||
legacy.has_live_content = MagicMock(return_value=True)
|
||||
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||
controller.plugin_modes = {"hockey_live": legacy}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
|
||||
Reference in New Issue
Block a user