From 21099b9494a5377ec7fd7bff52121caf2eb62beb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:27:51 -0400 Subject: [PATCH 1/3] feat(football): make the live screen renderable offline The season starts in weeks and live is the screen that carries it, yet it was the one screen the safety harness never rendered. The fixture said why: "_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." Both halves of that turned out to be 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 -- which made the fully seeded simulated game already sitting in NFLLiveManager.__init__ unreachable, along with everything behind `if self.test_mode`. SportsLive.update() then 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 exactly this, so the short-circuit is ported from there rather than invented. With both fixed the harness renders nfl_live at every panel size, from 16 renders to 24, none of them blank -- the simulated card draws the score, the quarter and clock, down and distance, possession and timeouts, which are the parts only this mode has. No behaviour change for anyone running it: test_mode defaults to False on both paths, so live fetches exactly as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 4 +- plugins/football-scoreboard/manager.py | 8 ++ plugins/football-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/sports.py | 10 ++ plugins/football-scoreboard/test/harness.json | 7 +- .../football-scoreboard/test_live_screen.py | 120 ++++++++++++++++++ 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 plugins/football-scoreboard/test_live_screen.py diff --git a/plugins.json b/plugins.json index efca7e92..5f38d795 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-09", + "last_updated": "2026-08-11", "plugins": [ { "id": "cricket-scoreboard", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.13.0" + "latest_version": "2.14.0" }, { "id": "geochron", diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 5f12f587..528caa98 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -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), @@ -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), diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 4d5f9a0a..22649e6a 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -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!", @@ -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", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index fe9890aa..67ad014c 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -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() diff --git a/plugins/football-scoreboard/test/harness.json b/plugins/football-scoreboard/test/harness.json index 34db36c0..bec727f9 100644 --- a/plugins/football-scoreboard/test/harness.json +++ b/plugins/football-scoreboard/test/harness.json @@ -1,5 +1,5 @@ { - "_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. It previously could not: the fetch was unconditional and test_mode was never passed through from config, so live was disabled here and was the only screen the harness never rendered.", "config": { "enabled": true, "timezone": "UTC", @@ -7,7 +7,7 @@ "enabled": true, "favorite_teams": [], "display_modes": { - "show_live": false, + "show_live": true, "show_recent": true, "show_upcoming": true, "live_display_mode": "switch", @@ -23,7 +23,8 @@ "filtering": { "show_favorite_teams_only": false, "show_all_live": true - } + }, + "test_mode": true }, "ncaa_fb": { "enabled": false diff --git a/plugins/football-scoreboard/test_live_screen.py b/plugins/football-scoreboard/test_live_screen.py new file mode 100644 index 00000000..0e2ffee6 --- /dev/null +++ b/plugins/football-scoreboard/test_live_screen.py @@ -0,0 +1,120 @@ +#!/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: /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)) + +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.""" + import manager as mgr_mod + + 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.""" + import sports + + 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_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) + + +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. + """ + import nfl_managers + + 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("\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()) From b4207dd4171fed8ca4569f85de59212d7a2002cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:36:45 -0400 Subject: [PATCH 2/3] test(football): drop three unused imports from the live-screen test Vestigial: the checks read the source as text rather than importing the modules, and importing them here would need the core on the path anyway. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/football-scoreboard/test_live_screen.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/football-scoreboard/test_live_screen.py b/plugins/football-scoreboard/test_live_screen.py index 0e2ffee6..89f0ad3d 100644 --- a/plugins/football-scoreboard/test_live_screen.py +++ b/plugins/football-scoreboard/test_live_screen.py @@ -45,8 +45,6 @@ def check(name, cond, detail=""): def test_config_passthrough(): """test_mode must survive the trip from league config to manager config.""" - import manager as mgr_mod - 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) @@ -56,8 +54,6 @@ def test_config_passthrough(): def test_update_short_circuits_in_test_mode(): """update() must simulate, not fetch, or the seeded game is lost.""" - import sports - 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) @@ -84,8 +80,6 @@ def test_seeded_live_game_is_complete(): distance are the parts that only appear in this mode, so a fixture missing them would render something that passes without covering what matters. """ - import nfl_managers - 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) From 1c3003ad8d0b14501a4b56419f3cee92d92fde51 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 12:44:29 -0400 Subject: [PATCH 3/3] test(football): make the fixture actually execute the no-fetch path The fixture kept live_update_interval at 9999999999, inherited from when live mode was disabled and the always-instantiated live manager had to be stopped from attempting a doomed network fetch. With last_update at 0 and time frozen at 2026-01-15, the first update was therefore never overdue: the harness rendered the seeded game straight from NFLLiveManager.__init__ and never ran the short-circuit this change adds. test_mode returns before any fetch, so the guard is obsolete. The interval is a normal 30s and the fixture now exercises the path it exists to cover. Adds the behavioural test that was missing alongside the source checks: drive the real SportsLive.update() with test_mode on and assert _test_mode_update() ran, no fetch happened, and the seeded game survived. Deleting the short-circuit makes it fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/football-scoreboard/test/harness.json | 4 +- .../football-scoreboard/test_live_screen.py | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/plugins/football-scoreboard/test/harness.json b/plugins/football-scoreboard/test/harness.json index bec727f9..9ae6fbc7 100644 --- a/plugins/football-scoreboard/test/harness.json +++ b/plugins/football-scoreboard/test/harness.json @@ -1,5 +1,5 @@ { - "_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. It previously could not: the fetch was unconditional and test_mode was never passed through from config, so live was disabled here and was the only screen the harness never rendered.", + "_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", @@ -14,7 +14,7 @@ "recent_display_mode": "switch", "upcoming_display_mode": "switch" }, - "live_update_interval": 9999999999, + "live_update_interval": 30, "display_options": { "show_records": false, "show_ranking": false, diff --git a/plugins/football-scoreboard/test_live_screen.py b/plugins/football-scoreboard/test_live_screen.py index 89f0ad3d..40a23c93 100644 --- a/plugins/football-scoreboard/test_live_screen.py +++ b/plugins/football-scoreboard/test_live_screen.py @@ -32,6 +32,13 @@ PLUGIN_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(PLUGIN_DIR)) +class _Silent: + """Swallow log records; this test is about control flow, not output.""" + + def __getattr__(self, _name): + return lambda *a, **k: None + + failures = [] @@ -64,6 +71,57 @@ def test_update_short_circuits_in_test_mode(): 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")) @@ -71,6 +129,12 @@ def test_harness_covers_live(): 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(): @@ -99,6 +163,9 @@ def main(): 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()