diff --git a/plugins.json b/plugins.json index 4657b6c9..1a64f3d9 100644 --- a/plugins.json +++ b/plugins.json @@ -844,12 +844,11 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-03", + "last_updated": "2026-10-04", "verified": true, "screenshot": "", - "latest_version": "2.39.3", - "ledmatrix_min_version": "3.8.0", - "commit": "47bc85c726534ae87b4e66a686b1220b347a79bb" + "latest_version": "2.39.4", + "ledmatrix_min_version": "3.8.0" }, { "id": "static-image", diff --git a/plugins/soccer-scoreboard/CHANGELOG.md b/plugins/soccer-scoreboard/CHANGELOG.md index 30dae37c..35e24138 100644 --- a/plugins/soccer-scoreboard/CHANGELOG.md +++ b/plugins/soccer-scoreboard/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2.39.4] - 2026-10-04 + +### Fixed +- A per-league mode in switch mode (`soccer_esp.1_recent`, + `soccer_usa.1_upcoming`, ...) showed another league's game: `display()` + parsed the league out of the mode name and then tried every enabled + league's manager for that mode type, drawing the first with games -- so + La Liga's slot showed the Premier League card. Only the named league is + consulted now; when it has nothing to show the mode returns `False` and the + core's empty-mode handling moves on. + ## [2.39.3] - 2026-10-03 ### Fixed diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index a92e38bd..73866b7a 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -112,7 +112,9 @@ cannot be selected; use the per-league names above. Each mode renders as **switch** (one match at a time, timed) or **scroll** (all matches scroll horizontally at high FPS), set per league and per mode with -`leagues..display_modes._display_mode`. +`leagues..display_modes._display_mode`. In switch mode a league's +mode shows only that league's matches: when it has none, the mode is skipped +rather than filled with another league's game. The mode toggles are named `live`, `recent`, `upcoming` — no `show_` prefix, unlike the football and basketball scoreboards. diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 4711ac26..8375a4a6 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -2080,17 +2080,21 @@ def display(self, display_mode: str = None, force_clear: bool = False) -> bool: ) return False - # Get managers for this mode type across all enabled leagues (switch mode) - # Use _get_enabled_leagues_for_mode to respect per-mode enablement + # Switch mode: only the league named in the mode. A per-league + # mode such as soccer_esp.1_recent must never fall through to + # another league's manager -- that showed the eng.1 card in + # the esp.1 slot. When this league has nothing to show we + # return False and let the core's empty-mode handling move on. + # _get_enabled_leagues_for_mode still gates it, so a league (or + # this mode for it) that is switched off shows nothing. # Refresh before reading the managers -- a stale manager can # look like it has nothing to show and be skipped entirely. self._refresh_switch_mode_managers(mode_type) managers_to_try = [] - enabled_league_keys = self._get_enabled_leagues_for_mode(mode_type) - for key in enabled_league_keys: - manager = self._get_league_manager_for_mode(key, mode_type) + if league_key in self._get_enabled_leagues_for_mode(mode_type): + manager = self._get_league_manager_for_mode(league_key, mode_type) if manager and self._manager_has_displayable_games(manager, mode_type): - managers_to_try.append((key, manager)) + managers_to_try.append((league_key, manager)) # Try each manager until one returns True (has content) first_manager = True diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 07a525ea..92893e13 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.39.3", + "version": "2.39.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -53,6 +53,12 @@ "soccer_usa.1_upcoming" ], "versions": [ + { + "version": "2.39.4", + "released": "2026-10-04", + "ledmatrix_min_version": "3.8.0", + "notes": "A per-league mode in switch mode (soccer_esp.1_recent, soccer_usa.1_upcoming, ...) now shows only that league's games. It used to try every enabled league's manager and draw the first with games, so La Liga's slot could show the Premier League card. When the named league has nothing to show, the mode returns False and the display moves on." + }, { "version": "2.39.3", "released": "2026-10-03", diff --git a/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py b/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py index a3be399e..248f9362 100644 --- a/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py +++ b/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py @@ -8,9 +8,11 @@ Background ---------- -In switch mode the plugin pools every enabled league's manager for a given mode -(e.g. ``soccer_usa.1_recent`` tries the recent managers of *all* enabled -leagues). A recent/upcoming manager with no games clears the shared canvas in +In switch mode the plugin used to pool every enabled league's manager for a +given mode (e.g. ``soccer_usa.1_recent`` tried the recent managers of *all* +enabled leagues); since 2.39.4 a per-league mode only reaches its own league +(see ``test_per_league_mode_scope.py``), but the gate below still matters. A +recent/upcoming manager with no games clears the shared canvas in its ``display()`` (see ``SportsRecent.display`` / ``SportsUpcoming.display`` in ``sports.py``) and returns False. If that empty manager runs *before* the league that actually has a game, it wipes the panel; the league with content then hits @@ -115,13 +117,22 @@ def test_empty_manager_is_skipped() -> None: result = plugin.display("soccer_usa.1_recent") - assert result is True, f"expected content to display, got {result!r}" + assert result is False, ( + f"usa.1 has nothing, so its mode must return False, got {result!r}" + ) assert empty.display_calls == 0, ( "empty manager's display() was called — it clears the canvas and blanks " - "the league that has content" + "the panel" ) + assert populated.display_calls == 0, ( + "another league's manager was drawn in the usa.1 slot" + ) + + result = plugin.display("soccer_fifa.world_recent") + assert result is True, f"expected fifa.world's game to display, got {result!r}" assert populated.display_calls == 1, "manager with the game should have displayed once" - print(" [ok] empty manager skipped; populated manager drawn") + assert empty.display_calls == 0 + print(" [ok] empty manager skipped; populated manager drawn in its own slot") def test_all_empty_mode_returns_false() -> None: diff --git a/plugins/soccer-scoreboard/test_per_league_mode_scope.py b/plugins/soccer-scoreboard/test_per_league_mode_scope.py new file mode 100644 index 00000000..5cbb588d --- /dev/null +++ b/plugins/soccer-scoreboard/test_per_league_mode_scope.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Regression test: a per-league mode shows only that league's games. + +Soccer registers one set of modes per league (``soccer_eng.1_recent``, +``soccer_esp.1_recent``, ...). In switch mode, ``display()`` parsed the league +out of the mode name and then ignored it: it tried every enabled league's +manager for the mode type and drew the first one with games. So +``soccer_esp.1_recent`` showed the eng.1 card whenever La Liga had nothing (or +ranked below the Premier League), and every league's slot repeated one game. + +Expected: only the named league is consulted. If it has nothing to show, +``display()`` returns False so the core's empty-mode handling moves on. + +Run standalone from the plugin directory: + + cd plugins/soccer-scoreboard + python test_per_league_mode_scope.py +""" + +from __future__ import annotations + +import logging +import os +import sys +import types +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +if str(PLUGIN_DIR) not in sys.path: + sys.path.insert(0, str(PLUGIN_DIR)) + + +def _install_host_stubs() -> None: + for name in ( + "src", + "src.plugin_system", + "src.plugin_system.base_plugin", + "src.background_data_service", + "src.common", + "src.common.scroll_helper", + "src.logo_downloader", + ): + sys.modules.setdefault(name, types.ModuleType(name)) + sys.modules["src.plugin_system.base_plugin"].BasePlugin = object + sys.modules["src.plugin_system.base_plugin"].VegasDisplayMode = None + sys.modules["src.background_data_service"].get_background_service = lambda *a, **k: None + sys.modules["src.common.scroll_helper"].ScrollHelper = None + sys.modules["src.logo_downloader"].LogoDownloader = object + sys.modules["src.logo_downloader"].download_missing_logo = lambda *a, **k: None + + # Plain ModuleType stubs are not packages; give them a __path__ so genuine + # core submodules (sports_shared, sports_card, ...) resolve from a core + # checkout while the stubbed ones stay stubbed. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + + +_install_host_stubs() +logging.basicConfig(level=logging.CRITICAL) + +import manager # noqa: E402 + + +class FakeManager: + """A league's recent/upcoming/live manager: True when it has a game.""" + + def __init__(self, name, games): + self.name = name + self.games_list = list(games) + self.live_games = list(games) + self.display_calls = 0 + + def display(self, force_clear=False): + self.display_calls += 1 + return bool(self.games_list) + + +def _make_plugin(managers_by_league, enabled=None): + """A plugin wired to fake managers, in switch mode, with no celebration. + + ``enabled`` is what _get_enabled_leagues_for_mode returns, in priority + order; it defaults to every league given. + """ + enabled = list(managers_by_league) if enabled is None else list(enabled) + plugin = manager.SoccerScoreboardPlugin.__new__(manager.SoccerScoreboardPlugin) + plugin.is_enabled = True + plugin.logger = logging.getLogger("test") + plugin._should_use_scroll_mode = lambda mode_type: False + plugin._get_active_celebration_manager = lambda: None + plugin._refresh_switch_mode_managers = lambda mode_type: None + plugin._get_enabled_leagues_for_mode = lambda mode_type: list(enabled) + plugin._get_league_manager_for_mode = ( + lambda key, mode_type: managers_by_league.get(key)) + plugin._record_dynamic_progress = lambda m: None + plugin._evaluate_dynamic_cycle_completion = lambda: None + plugin._current_display_league = None + plugin._current_display_mode_type = None + return plugin + + +def test_league_mode_does_not_show_another_league() -> None: + """soccer_esp.1_* with an empty La Liga must not draw the eng.1 card.""" + eng = FakeManager("eng.1", [{"id": "ars-che"}]) + esp = FakeManager("esp.1", []) + plugin = _make_plugin({"eng.1": eng, "esp.1": esp}) + + for mode_type in ("recent", "upcoming", "live"): + result = plugin.display(f"soccer_esp.1_{mode_type}") + assert result is False, ( + f"soccer_esp.1_{mode_type}: La Liga has nothing, expected False so " + f"the core moves on, got {result!r}" + ) + assert eng.display_calls == 0, ( + f"the eng.1 manager was drawn {eng.display_calls} time(s) in the esp.1 " + "slot -- a per-league mode fell through to another league" + ) + assert esp.display_calls == 0, "an empty manager must not be asked to draw" + print(" [ok] empty esp.1 slot returns False without drawing eng.1") + + +def test_league_mode_draws_its_own_league_not_a_higher_priority_one() -> None: + """Both leagues have games: each slot draws its own, not the top-priority one.""" + eng = FakeManager("eng.1", [{"id": "ars-che"}]) + esp = FakeManager("esp.1", [{"id": "rma-bar"}]) + plugin = _make_plugin({"eng.1": eng, "esp.1": esp}) + + assert plugin.display("soccer_esp.1_recent") is True + assert (esp.display_calls, eng.display_calls) == (1, 0), ( + f"esp.1 slot drew esp={esp.display_calls} eng={eng.display_calls}" + ) + assert plugin._current_display_league == "esp.1" + + assert plugin.display("soccer_eng.1_recent") is True + assert (esp.display_calls, eng.display_calls) == (1, 1) + assert plugin._current_display_league == "eng.1" + print(" [ok] each league's slot draws its own league") + + +def test_custom_league_key_with_underscores() -> None: + """A custom league code containing underscores is still matched exactly.""" + eng = FakeManager("eng.1", [{"id": "ars-che"}]) + custom = FakeManager("my_cup", [{"id": "x"}]) + plugin = _make_plugin({"eng.1": eng, "my_cup": custom}) + + assert plugin.display("soccer_my_cup_recent") is True + assert (custom.display_calls, eng.display_calls) == (1, 0) + print(" [ok] custom league with underscores routes to itself") + + +def test_disabled_league_mode_returns_false() -> None: + """A league (or its mode) switched off shows nothing, even with games.""" + eng = FakeManager("eng.1", [{"id": "ars-che"}]) + esp = FakeManager("esp.1", [{"id": "rma-bar"}]) + plugin = _make_plugin({"eng.1": eng, "esp.1": esp}, enabled=["eng.1"]) + + assert plugin.display("soccer_esp.1_recent") is False + assert (esp.display_calls, eng.display_calls) == (0, 0) + print(" [ok] disabled league's mode returns False") + + +def main() -> int: + tests = [ + test_league_mode_does_not_show_another_league, + test_league_mode_draws_its_own_league_not_a_higher_priority_one, + test_custom_league_key_with_underscores, + test_disabled_league_mode_returns_false, + ] + failed = 0 + for t in tests: + try: + t() + except AssertionError as e: + print(f" [FAIL] {t.__name__}: {e}") + failed += 1 + if failed: + return 1 + print("All tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())