diff --git a/plugins.json b/plugins.json index 2255cd4a..cc0ce909 100644 --- a/plugins.json +++ b/plugins.json @@ -80,9 +80,8 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.57.3", - "ledmatrix_min_version": "3.8.0", - "commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c" + "latest_version": "1.57.4", + "ledmatrix_min_version": "3.8.0" }, { "id": "basketball-scoreboard", @@ -385,10 +384,9 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.42.4", + "latest_version": "1.42.5", "icon": "fas fa-hockey-puck", - "ledmatrix_min_version": "3.8.0", - "commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c" + "ledmatrix_min_version": "3.8.0" }, { "id": "lacrosse-scoreboard", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 7a188e88..bf4d2b79 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.57.4] - 2026-10-05 + +### Fixed +- A win celebration can take the live-priority slot. A win fires as the + game goes final, so the game has already left the live list: + `has_live_content()` reported the celebration but `get_live_modes()` + returned no mode for it. `get_live_modes()` now returns a celebrating + league's live mode (MLB, MiLB and NCAA baseball), once, and only for a + league that is enabled with `live_priority` on; `has_live_content()` + applies the same `live_priority` gate. + ## [1.57.3] - 2026-10-05 ### Changed diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 94abeb3d..8ae95086 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -2296,14 +2296,21 @@ def display(self, display_mode: str = None, force_clear: bool = False) -> bool: self.logger.error(f"Error in display method: {e}") return False - def _get_active_celebration_manager(self): + def _get_active_celebration_manager(self, live_priority_only=False): """Return the (league_key, live_manager) of an enabled league whose live - manager currently has a celebration running, else None.""" + manager currently has a celebration running, else None. + + ``live_priority_only`` also skips leagues with live priority off -- the + same gate get_live_modes() applies, so has_live_content() never reports + a celebration that no live mode can show. + """ if not self.is_enabled: return None for league_key, league_data in self._league_registry.items(): if not league_data.get("enabled", False): continue + if live_priority_only and not league_data.get("live_priority", True): + continue live_manager = self._get_league_manager_for_mode(league_key, "live") if live_manager is not None and getattr( live_manager, "has_active_celebration", lambda: False)(): @@ -2386,7 +2393,7 @@ def has_live_content(self) -> bool: # A running celebration (notably a win, whose game has already left # the live list) keeps the live mode on screen. - if self._get_active_celebration_manager() is not None: + if self._get_active_celebration_manager(live_priority_only=True) is not None: return True # Live game counts per league, folded into the single throttled summary @@ -2688,6 +2695,14 @@ def get_live_modes(self) -> list: and self.mlb_live_priority and hasattr(self, "mlb_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.mlb_live, "has_active_celebration") + and self.mlb_live.has_active_celebration() + ): + live_modes.append("mlb_live") + live_games = getattr(self.mlb_live, "live_games", []) if live_games: # Filter out any games that are final or appear over @@ -2716,6 +2731,14 @@ def get_live_modes(self) -> list: and self.milb_live_priority and hasattr(self, "milb_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.milb_live, "has_active_celebration") + and self.milb_live.has_active_celebration() + ): + live_modes.append("milb_live") + live_games = getattr(self.milb_live, "live_games", []) if live_games: live_games = [g for g in live_games if not g.get("is_final", False)] @@ -2740,6 +2763,14 @@ def get_live_modes(self) -> list: and self.ncaa_baseball_live_priority and hasattr(self, "ncaa_baseball_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.ncaa_baseball_live, "has_active_celebration") + and self.ncaa_baseball_live.has_active_celebration() + ): + live_modes.append("ncaa_baseball_live") + live_games = getattr(self.ncaa_baseball_live, "live_games", []) if live_games: live_games = [g for g in live_games if not g.get("is_final", False)] @@ -2758,7 +2789,8 @@ def get_live_modes(self) -> list: else: live_modes.append("ncaa_baseball_live") - return live_modes + # A celebration and live games for the same league can both append it. + return list(dict.fromkeys(live_modes)) def _get_game_duration(self, league: str, mode_type: str, manager=None) -> float: """Get game duration for a league and mode type combination. diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index e1e93aac..2c4647c6 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.57.3", + "version": "1.57.4", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.57.4", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A win celebration can take the live-priority slot. A win fires as the game goes final, so the game has already left the live list; has_live_content() reported the celebration but get_live_modes() returned no mode for it. get_live_modes() now returns a celebrating league's live mode (MLB, MiLB and NCAA baseball), once, and only for a league that is enabled with live_priority on; has_live_content() applies the same live_priority gate." + }, { "version": "1.57.3", "released": "2026-10-05", diff --git a/plugins/baseball-scoreboard/test_celebration_is_a_live_mode.py b/plugins/baseball-scoreboard/test_celebration_is_a_live_mode.py new file mode 100644 index 00000000..dadd726d --- /dev/null +++ b/plugins/baseball-scoreboard/test_celebration_is_a_live_mode.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +A win celebration must be selectable as a live mode. + +A win fires as the game goes final, so by the time it is on screen the game has +already left the league's live list. has_live_content() said True (a celebration +is running) while get_live_modes() -- which only looked at live games -- said [], +so the display controller saw live content with no live mode of this league to +show for it. Football carried the fix; baseball did not. + +Covers, for every league: + 1. A celebration with an empty live list -> get_live_modes() returns that + league's live mode, and has_live_content() agrees. + 2. A celebration and a live game in the same league -> the mode appears once. + 3. A disabled league, or one with live priority off -> neither method reports + the celebration. + +Run: /bin/python plugins/baseball-scoreboard/test_celebration_is_a_live_mode.py +""" + +import os +import sys + +import pytest + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from manager import BaseballScoreboardPlugin # noqa: E402 + +LEAGUES = ("mlb", "milb", "ncaa_baseball") + + +class _QuietLogger: + def _drop(self, *a, **k): + pass + + debug = info = warning = error = _drop + + +class _Live: + """Stands in for a league's live manager. Has no _is_game_really_over, so + both methods skip that filter.""" + + def __init__(self, games=(), celebrating=False): + self.live_games = list(games) + self.favorite_teams = [] + self.celebrating = celebrating + + def has_active_celebration(self): + return self.celebrating + + +class _Stub: + """Carries just the attributes the two methods read, with the real methods + bound to it -- the full constructor needs a display manager.""" + + get_live_modes = BaseballScoreboardPlugin.get_live_modes + has_live_content = BaseballScoreboardPlugin.has_live_content + _get_active_celebration_manager = BaseballScoreboardPlugin._get_active_celebration_manager + + def __init__(self, enabled=True, live_priority=True): + self.logger = _QuietLogger() + self.is_enabled = True + self._league_registry = {} + for league in LEAGUES: + setattr(self, f"{league}_enabled", enabled) + setattr(self, f"{league}_live_priority", live_priority) + setattr(self, f"{league}_live", _Live()) + self._league_registry[league] = { + "enabled": enabled, "live_priority": live_priority} + self._last_live_content_log = 0.0 + self._last_live_content_state = None + self._live_content_log_interval = 60.0 + + def _get_league_manager_for_mode(self, league, mode): + return getattr(self, f"{league}_live") if mode == "live" else None + + +def _game(): + return {"home_abbr": "ATL", "away_abbr": "PHI", "is_final": False, "is_live": True} + + +@pytest.mark.parametrize("league", LEAGUES) +def test_celebration_with_empty_live_list_is_a_live_mode(league): + stub = _Stub() + getattr(stub, f"{league}_live").celebrating = True + + assert stub.get_live_modes() == [f"{league}_live"] + assert stub.has_live_content() is True + + +@pytest.mark.parametrize("league", LEAGUES) +def test_celebration_and_live_game_list_the_mode_once(league): + stub = _Stub() + live = getattr(stub, f"{league}_live") + live.celebrating = True + live.live_games.append(_game()) + + assert stub.get_live_modes() == [f"{league}_live"] + assert stub.has_live_content() is True + + +@pytest.mark.parametrize("league", LEAGUES) +@pytest.mark.parametrize("gate", ("enabled", "live_priority")) +def test_gated_off_league_does_not_report_its_celebration(league, gate): + stub = _Stub(**{gate: False}) + getattr(stub, f"{league}_live").celebrating = True + + assert stub.get_live_modes() == [] + assert stub.has_live_content() is False + + +def test_only_the_celebrating_league_is_returned(): + stub = _Stub() + stub.mlb_live.live_games.append(_game()) + stub.ncaa_baseball_live.celebrating = True + + assert stub.get_live_modes() == ["mlb_live", "ncaa_baseball_live"] + + +def test_no_celebration_and_no_games_is_not_live(): + stub = _Stub() + + assert stub.get_live_modes() == [] + assert stub.has_live_content() is False diff --git a/plugins/baseball-scoreboard/test_live_content_log_throttle.py b/plugins/baseball-scoreboard/test_live_content_log_throttle.py index cde65208..b2529e4e 100644 --- a/plugins/baseball-scoreboard/test_live_content_log_throttle.py +++ b/plugins/baseball-scoreboard/test_live_content_log_throttle.py @@ -60,7 +60,7 @@ class _Stub: has_live_content = BaseballScoreboardPlugin.has_live_content - def _get_active_celebration_manager(self): + def _get_active_celebration_manager(self, live_priority_only=False): return None # no celebration is running in this stub def __init__(self, mlb_games=(), milb_games=(), ncaa_games=()): diff --git a/plugins/hockey-scoreboard/CHANGELOG.md b/plugins/hockey-scoreboard/CHANGELOG.md index 398cc831..23ae7c24 100644 --- a/plugins/hockey-scoreboard/CHANGELOG.md +++ b/plugins/hockey-scoreboard/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.42.5] - 2026-10-05 + +### Fixed +- A win celebration can take the live-priority slot. A win fires as the + game goes final, so the game has already left the live list: + `has_live_content()` reported the celebration but `get_live_modes()` + returned no mode for it. `get_live_modes()` now returns a celebrating + league's live mode (NHL and NCAA men's and women's hockey), once, and only for a + league that is enabled with `live_priority` on; `has_live_content()` + applies the same `live_priority` gate. + ## [1.42.4] - 2026-10-05 ### Fixed diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index dc7028c4..2d2508b3 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -2147,14 +2147,21 @@ def get_dynamic_duration_cap(self) -> Optional[float]: # No global fallback - return None return None - def _get_active_celebration_manager(self): + def _get_active_celebration_manager(self, live_priority_only=False): """Return the (league_key, live_manager) of an enabled league whose live - manager currently has an active goal/win celebration, else None.""" + manager currently has an active goal/win celebration, else None. + + ``live_priority_only`` also skips leagues with live priority off -- the + same gate get_live_modes() applies, so has_live_content() never reports + a celebration that no live mode can show. + """ if not self.is_enabled: return None for league_key, league_data in self._league_registry.items(): if not league_data.get("enabled", False): continue + if live_priority_only and not league_data.get("live_priority", True): + continue live_manager = self._get_league_manager_for_mode(league_key, "live") if ( live_manager @@ -2240,7 +2247,7 @@ def has_live_content(self) -> bool: # An active celebration (notably a win, whose game has already left the # live list) must keep the live mode on screen. - if self._get_active_celebration_manager() is not None: + if self._get_active_celebration_manager(live_priority_only=True) is not None: return True # Check NHL live content @@ -2376,6 +2383,14 @@ def get_live_modes(self) -> list: and self.nhl_live_priority and hasattr(self, "nhl_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.nhl_live, "has_active_celebration") + and self.nhl_live.has_active_celebration() + ): + live_modes.append("nhl_live") + live_games = getattr(self.nhl_live, "live_games", []) if live_games: # Filter out any games that are final or appear over @@ -2405,6 +2420,14 @@ def get_live_modes(self) -> list: and self.ncaa_mens_live_priority and hasattr(self, "ncaa_mens_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.ncaa_mens_live, "has_active_celebration") + and self.ncaa_mens_live.has_active_celebration() + ): + live_modes.append("ncaa_mens_live") + live_games = getattr(self.ncaa_mens_live, "live_games", []) if live_games: # Filter out any games that are final or appear over @@ -2434,6 +2457,14 @@ def get_live_modes(self) -> list: and self.ncaa_womens_live_priority and hasattr(self, "ncaa_womens_live") ): + # A celebrating league must be selectable even if its live list is + # already empty (a win fires as the game goes final). + if ( + hasattr(self.ncaa_womens_live, "has_active_celebration") + and self.ncaa_womens_live.has_active_celebration() + ): + live_modes.append("ncaa_womens_live") + live_games = getattr(self.ncaa_womens_live, "live_games", []) if live_games: # Filter out any games that are final or appear over @@ -2457,7 +2488,8 @@ def get_live_modes(self) -> list: # No favorite teams configured, include if any live games exist live_modes.append("ncaa_womens_live") - return live_modes + # A celebration and live games for the same league can both append it. + return list(dict.fromkeys(live_modes)) def _has_any_scroll_mode(self) -> bool: """Return True if any enabled league uses scroll display for any mode. diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 11f79a0d..f5f36f04 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.42.4", + "version": "1.42.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.42.5", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A win celebration can take the live-priority slot. A win fires as the game goes final, so the game has already left the live list; has_live_content() reported the celebration but get_live_modes() returned no mode for it. get_live_modes() now returns a celebrating league's live mode (NHL and NCAA men's and women's hockey), once, and only for a league that is enabled with live_priority on; has_live_content() applies the same live_priority gate." + }, { "version": "1.42.4", "released": "2026-10-05", diff --git a/plugins/hockey-scoreboard/test_celebration_is_a_live_mode.py b/plugins/hockey-scoreboard/test_celebration_is_a_live_mode.py new file mode 100644 index 00000000..c9eb2811 --- /dev/null +++ b/plugins/hockey-scoreboard/test_celebration_is_a_live_mode.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +A win celebration must be selectable as a live mode. + +A win fires as the game goes final, so by the time it is on screen the game has +already left the league's live list. has_live_content() said True (a celebration +is running) while get_live_modes() -- which only looked at live games -- said [], +so the display controller saw live content with no live mode of this league to +show for it. Football carried the fix; hockey did not. + +Covers, for every league: + 1. A celebration with an empty live list -> get_live_modes() returns that + league's live mode, and has_live_content() agrees. + 2. A celebration and a live game in the same league -> the mode appears once. + 3. A disabled league, or one with live priority off -> neither method reports + the celebration. + +Run: /bin/python plugins/hockey-scoreboard/test_celebration_is_a_live_mode.py +""" + +import os +import sys + +import pytest + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from manager import HockeyScoreboardPlugin # noqa: E402 + +LEAGUES = ("nhl", "ncaa_mens", "ncaa_womens") + + +class _QuietLogger: + def _drop(self, *a, **k): + pass + + debug = info = warning = error = _drop + + +class _Live: + """Stands in for a league's live manager. Has no _is_game_really_over, so + both methods skip that filter.""" + + def __init__(self, games=(), celebrating=False): + self.live_games = list(games) + self.favorite_teams = [] + self.celebrating = celebrating + + def has_active_celebration(self): + return self.celebrating + + +class _Stub: + """Carries just the attributes the two methods read, with the real methods + bound to it -- the full constructor needs a display manager.""" + + get_live_modes = HockeyScoreboardPlugin.get_live_modes + has_live_content = HockeyScoreboardPlugin.has_live_content + _get_active_celebration_manager = HockeyScoreboardPlugin._get_active_celebration_manager + + def __init__(self, enabled=True, live_priority=True): + self.logger = _QuietLogger() + self.is_enabled = True + self._league_registry = {} + for league in LEAGUES: + setattr(self, f"{league}_enabled", enabled) + setattr(self, f"{league}_live_priority", live_priority) + setattr(self, f"{league}_live", _Live()) + self._league_registry[league] = { + "enabled": enabled, "live_priority": live_priority} + self._last_live_content_log = 0.0 + self._last_live_content_state = None + self._live_content_log_interval = 60.0 + + def _get_league_manager_for_mode(self, league, mode): + return getattr(self, f"{league}_live") if mode == "live" else None + + +def _game(): + return {"home_abbr": "BOS", "away_abbr": "TOR", "is_final": False, "is_live": True} + + +@pytest.mark.parametrize("league", LEAGUES) +def test_celebration_with_empty_live_list_is_a_live_mode(league): + stub = _Stub() + getattr(stub, f"{league}_live").celebrating = True + + assert stub.get_live_modes() == [f"{league}_live"] + assert stub.has_live_content() is True + + +@pytest.mark.parametrize("league", LEAGUES) +def test_celebration_and_live_game_list_the_mode_once(league): + stub = _Stub() + live = getattr(stub, f"{league}_live") + live.celebrating = True + live.live_games.append(_game()) + + assert stub.get_live_modes() == [f"{league}_live"] + assert stub.has_live_content() is True + + +@pytest.mark.parametrize("league", LEAGUES) +@pytest.mark.parametrize("gate", ("enabled", "live_priority")) +def test_gated_off_league_does_not_report_its_celebration(league, gate): + stub = _Stub(**{gate: False}) + getattr(stub, f"{league}_live").celebrating = True + + assert stub.get_live_modes() == [] + assert stub.has_live_content() is False + + +def test_only_the_celebrating_league_is_returned(): + stub = _Stub() + stub.nhl_live.live_games.append(_game()) + stub.ncaa_womens_live.celebrating = True + + assert stub.get_live_modes() == ["nhl_live", "ncaa_womens_live"] + + +def test_no_celebration_and_no_games_is_not_live(): + stub = _Stub() + + assert stub.get_live_modes() == [] + assert stub.has_live_content() is False