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
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-08-09",
"last_updated": "2026-08-11",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -240,7 +240,7 @@
"last_updated": "2026-08-05",
"verified": true,
"screenshot": "",
"latest_version": "2.13.0"
"latest_version": "2.14.0"
},
{
"id": "geochron",
Expand Down
8 changes: 8 additions & 0 deletions plugins/football-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,13 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]:
filtering = league_config.get("filtering", {})
display_modes_config = league_config.get("display_modes", {})

# test_mode drives the built-in simulated live game. Without passing it
# through, SportsLive.test_mode could never be set from config, so the
# seeded game in NFLLiveManager.__init__ and the whole
# _test_mode_update() path were unreachable -- which is why the safety
# harness has to disable live mode and never renders that screen.
manager_test_mode = league_config.get("test_mode", False)

manager_display_modes = {
f"{league}_live": display_modes_config.get("show_live", True),
f"{league}_recent": display_modes_config.get("show_recent", True),
Expand Down Expand Up @@ -645,6 +652,7 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]:
"favorite_teams": league_config.get("favorite_teams", []),
"exclude_teams": league_config.get("exclude_teams", []),
"display_modes": manager_display_modes,
"test_mode": manager_test_mode,
"recent_games_to_show": game_limits.get("recent_games_to_show", 5),
"upcoming_games_to_show": game_limits.get("upcoming_games_to_show", 10),
"show_records": display_options.get("show_records", False),
Expand Down
8 changes: 7 additions & 1 deletion plugins/football-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "football-scoreboard",
"name": "Football Scoreboard",
"version": "2.13.0",
"version": "2.14.0",
"author": "ChuckBuilds",
"class_name": "FootballScoreboardPlugin",
"description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!",
Expand All @@ -24,6 +24,12 @@
"ncaa_fb_live"
],
"versions": [
{
"version": "2.14.0",
"released": "2026-08-11",
"notes": "Make the live screen renderable offline, so it is covered before the season rather than during it. Live was the one screen the safety harness never rendered, and two latent bugs are why: _adapt_config_for_manager() never passed test_mode into the per-manager config, so SportsLive.test_mode could not be set at all and the fully seeded simulated game in NFLLiveManager.__init__ was unreachable; and SportsLive.update() fetched unconditionally, so even with the flag set the seeded game was overwritten on the first tick -- _test_mode_update() was defined and never called. Both are fixed, the second by porting the short-circuit baseball-scoreboard already uses. The harness now renders nfl_live at every panel size (16 renders to 24, none blank). No behaviour change for real users: test_mode defaults to False on both paths, so live fetches exactly as before.",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.13.0",
"released": "2026-08-06",
Expand Down
10 changes: 10 additions & 0 deletions plugins/football-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2774,6 +2774,16 @@ def update(self):
if current_time - self.last_update >= interval:
self.last_update = current_time

# Test mode: advance the simulated game instead of fetching real API
# data, which would overwrite the seeded live game with an empty
# list. Without this the seeded game survives only until the first
# update tick, so live mode could never be rendered from a fixture
# -- _test_mode_update() was defined and never called. Ported from
# baseball-scoreboard, which fixed the same gap.
if _test_mode_attr:
self._test_mode_update()
return

# Fetch rankings if enabled
if self.show_ranking:
self._fetch_team_rankings()
Expand Down
9 changes: 5 additions & 4 deletions plugins/football-scoreboard/test/harness.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"_comment": "Deterministic fixture for the plugin safety harness. Seeds the NFL season-schedule cache (key nfl_schedule_2025) with one final, one in-progress, and one scheduled game so recent/upcoming render real game cards without network access. Live mode is disabled: NFLLiveManager._fetch_data() -> _fetch_todays_games() is a direct network call with no cache read and no test_mode passthrough in _adapt_config_for_manager, so the live screen can never be fed from mock data. The huge live_update_interval keeps the always-instantiated live manager from attempting that doomed network fetch on every update() (under freeze_time, now - last_update stays below it), keeping CI runs fast.",
"_comment": "Deterministic fixture for the plugin safety harness. Seeds the NFL season-schedule cache (key nfl_schedule_2025) with one final, one in-progress and one scheduled game so recent/upcoming render real game cards without network access. Live mode uses the plugin's built-in simulated game (nfl.test_mode): NFLLiveManager seeds a full Q4 scorebug -- score, clock, down and distance, possession, timeouts -- and SportsLive.update() short-circuits to _test_mode_update() rather than fetching, so the live screen renders offline like every other mode. live_update_interval is a normal 30s: it was 9999999999 to stop the always-instantiated live manager attempting a doomed network fetch, which also meant the first update was never overdue and the no-fetch path never ran. test_mode returns before any fetch, so the guard is no longer needed and the fixture now exercises the path it is meant to cover.",
"config": {
"enabled": true,
"timezone": "UTC",
"nfl": {
"enabled": true,
"favorite_teams": [],
"display_modes": {
"show_live": false,
"show_live": true,
"show_recent": true,
"show_upcoming": true,
"live_display_mode": "switch",
"recent_display_mode": "switch",
"upcoming_display_mode": "switch"
},
"live_update_interval": 9999999999,
"live_update_interval": 30,
"display_options": {
"show_records": false,
"show_ranking": false,
Expand All @@ -23,7 +23,8 @@
"filtering": {
"show_favorite_teams_only": false,
"show_all_live": true
}
},
"test_mode": true
},
"ncaa_fb": {
"enabled": false
Expand Down
181 changes: 181 additions & 0 deletions plugins/football-scoreboard/test_live_screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Tests that the live screen can be driven offline, and draws a real scorebug.

Regression under test: live was the one screen the safety harness never
rendered. Its own fixture said why -- "NFLLiveManager._fetch_data() ->
_fetch_todays_games() is a direct network call with no cache read and no
test_mode passthrough in _adapt_config_for_manager, so the live screen can
never be fed from mock data" -- so live mode was switched off there.

Two things made it untestable, and both were latent bugs rather than gaps:

* `_adapt_config_for_manager()` never passed `test_mode` into the per-manager
config, so `SportsLive.test_mode` could not be set from config at all. The
fully-seeded simulated game in `NFLLiveManager.__init__` was unreachable.
* `SportsLive.update()` fetched unconditionally, so even with the flag set
the seeded game was overwritten on the first tick. `_test_mode_update()`
was defined and never called -- dead code. baseball-scoreboard had already
fixed the same thing; this ports it.

Which matters now because the season starts in weeks and live is the screen
that carries it. Bugs in this path stay dormant all off-season, exactly as the
has_live_content log flood did (see test_live_content_log_throttle.py:
"football was dormant only because it was the off-season").

Run: <core-venv>/bin/python plugins/football-scoreboard/test_live_screen.py
"""

import json
import sys
from pathlib import Path

PLUGIN_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(PLUGIN_DIR))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class _Silent:
"""Swallow log records; this test is about control flow, not output."""

def __getattr__(self, _name):
return lambda *a, **k: None


failures = []


def check(name, cond, detail=""):
if cond:
print(" PASS %s" % name)
else:
print(" FAIL %s%s" % (name, (": " + detail) if detail else ""))
failures.append(name)


def test_config_passthrough():
"""test_mode must survive the trip from league config to manager config."""
src = (PLUGIN_DIR / "manager.py").read_text(encoding="utf-8")
check("_adapt_config_for_manager reads test_mode",
'league_config.get("test_mode"' in src)
check("and puts it in the manager config",
'"test_mode": manager_test_mode' in src)


def test_update_short_circuits_in_test_mode():
"""update() must simulate, not fetch, or the seeded game is lost."""
src = (PLUGIN_DIR / "sports.py").read_text(encoding="utf-8")
idx_guard = src.find("if _test_mode_attr:")
idx_fetch = src.find("data = self._fetch_data()", idx_guard if idx_guard >= 0 else 0)
check("update() branches on test_mode", idx_guard != -1)
check("_test_mode_update() is actually called",
"self._test_mode_update()" in src)
check("and it short-circuits before the network fetch",
idx_guard != -1 and idx_fetch != -1 and idx_guard < idx_fetch)


def test_update_really_simulates_and_keeps_the_game():
"""Drive the real update() and prove it neither fetches nor loses the game.

The source checks above say the branch exists; this says it executes. The
fixture used live_update_interval=9999999999 to stop the live manager
attempting a doomed network fetch, which also meant the first update was
never overdue -- so the harness rendered the seeded game without the
no-fetch path ever running.
"""
import sports

# SportsLive is abstract; a concrete stub is the smallest way to reach the
# real update() without constructing a whole plugin.
class _Concrete(sports.SportsLive):
def _fetch_data(self, *a, **k):
calls["fetch"] += 1
return None

def _extract_game_details(self, *a, **k):
return None

calls = {"test_update": 0, "fetch": 0}
live = _Concrete.__new__(_Concrete)

live.is_enabled = True
live.test_mode = True
live.live_games = [{"id": "test001", "is_live": True}]
live.last_update = 0
live.update_interval = 30
live.no_data_interval = 300
live.show_ranking = False
live.logger = _Silent()
live._games_lock = __import__("threading").Lock()
live.current_game = live.live_games[0]
live._test_mode_update = lambda: calls.__setitem__("test_update",
calls["test_update"] + 1)

try:
sports.SportsLive.update(live)
except Exception as exc: # noqa: BLE001 - reported, not hidden
check("update() ran without raising", False, repr(exc))
return

check("the simulated update ran", calls["test_update"] == 1,
"called %d times" % calls["test_update"])
check("and no network fetch happened", calls["fetch"] == 0,
"fetched %d times" % calls["fetch"])
check("the seeded game survived", live.live_games and
live.live_games[0]["id"] == "test001")


def test_harness_covers_live():
"""The fixture must actually exercise the screen."""
spec = json.loads((PLUGIN_DIR / "test" / "harness.json").read_text(encoding="utf-8"))
nfl = spec.get("config", {}).get("nfl", {})
check("harness enables live",
nfl.get("display_modes", {}).get("show_live") is True)
check("harness turns on the simulated game", nfl.get("test_mode") is True)
interval = nfl.get("live_update_interval")
# Must be small enough that the very first update is overdue, or update()
# returns before reaching the simulated path.
check("the first update is overdue",
isinstance(interval, (int, float)) and interval < 10 ** 6,
"live_update_interval=%r" % (interval,))


def test_seeded_live_game_is_complete():
"""The simulated game must carry what a live scorebug draws.

A live card is not just a score: the clock, the quarter and the down and
distance are the parts that only appear in this mode, so a fixture missing
them would render something that passes without covering what matters.
"""
src = (PLUGIN_DIR / "nfl_managers.py").read_text(encoding="utf-8")
start = src.find("if self.test_mode:")
end = src.find("self.live_games = [self.current_game]", start)
block = src[start:end] if start != -1 and end != -1 else ""

for field in ("home_score", "away_score", "period_text", "clock",
"down_distance_text", "possession", "home_timeouts",
"is_live"):
check("seeded game carries %s" % field, '"%s"' % field in block)
check("and is marked live", '"is_live": True' in block)


def main():
print("the plugin can be told to simulate a live game")
test_config_passthrough()

print("\nand simulating means simulating, not fetching")
test_update_short_circuits_in_test_mode()

print("\nupdate() simulates rather than fetching")
test_update_really_simulates_and_keeps_the_game()

print("\nthe harness renders the live screen")
test_harness_covers_live()

print("\nthe simulated game is a real scorebug")
test_seeded_live_game_is_complete()

print("\n%s" % ("FAILED: %d" % len(failures) if failures
else "All checks passed"))
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())
Loading