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..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 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", @@ -7,14 +7,14 @@ "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, @@ -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..40a23c93 --- /dev/null +++ b/plugins/football-scoreboard/test_live_screen.py @@ -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: /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)) + +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())