Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions plugins/soccer-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion plugins/soccer-scoreboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<slug>.display_modes.<mode>_display_mode`.
`leagues.<slug>.display_modes.<mode>_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.
Expand Down
16 changes: 10 additions & 6 deletions plugins/soccer-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion plugins/soccer-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 17 additions & 6 deletions plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
190 changes: 190 additions & 0 deletions plugins/soccer-scoreboard/test_per_league_mode_scope.py
Original file line number Diff line number Diff line change
@@ -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())
Loading