diff --git a/plugins/football-scoreboard/test_empty_mode_signals_no_content.py b/plugins/football-scoreboard/test_empty_mode_signals_no_content.py new file mode 100644 index 00000000..e4e96ca6 --- /dev/null +++ b/plugins/football-scoreboard/test_empty_mode_signals_no_content.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Tests that a mode with no games reports "no content" instead of "displayed". + +Companion to the same test in hockey-scoreboard, which was added after the bug +was found there. football-scoreboard ships its own fork of sports.py and had no +equivalent guard, so the same regression could land here unnoticed -- the forks +diverge and a fix in one lineage does not reach the other. The manager's dispatcher treats a +non-boolean as success ("Result is None or other - assume success"), so every +mode reported content whether or not it had any. + +The display controller skips a mode whose display() returns False. Reporting +success for an empty mode meant it was never skipped, so an out-of-season +league sat on a blank panel -- the no-games branch calls display_manager.clear() +-- for its entire display duration. The NFL equivalent is the stretch before Week 1: recent and +live are empty while upcoming already carries the preseason schedule. + +The methods are exercised against a stand-in ``self`` rather than a constructed +manager, so the test needs no display hardware, no network and no cache. + +Run: /bin/python plugins/football-scoreboard/test_empty_mode_signals_no_content.py +""" + +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import sports # noqa: E402 + + +class _Logger: + def warning(self, *a, **k): pass + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def error(self, *a, **k): pass + + +class _DisplayManager: + def __init__(self): + self.clears = 0 + self.updates = 0 + + def clear(self): + self.clears += 1 + + def update_display(self): + self.updates += 1 + + +class _Manager: + """A stand-in ``self`` carrying only what display() touches.""" + + def __init__(self, games, enabled=True): + self.is_enabled = enabled + self.games_list = list(games) + self.current_game = games[0] if games else None + self.display_manager = _DisplayManager() + self.logger = _Logger() + self.sport_key = 'nfl' + self._games_lock = threading.Lock() + self.current_game_index = 0 + self.last_game_switch = 0.0 + self.game_display_duration = 1e9 # never switch mid-test + self.last_warning_time = 0.0 + self.warning_cooldown = 1e9 + self._last_warning_time = 0.0 + self.draws = 0 + # SportsLive.display() consults the celebration mixin before drawing. + self.active_celebration = None + self.celebration_end_time = 0.0 + + def _draw_scorebug_layout(self, game, force_clear=False): + self.draws += 1 + self.display_manager.update_display() + + # SportsLive.display() drives rotation through these before drawing; the + # test pins one game, so they are no-ops. + def _advance_live_game_if_due(self): + pass + + def _get_live_game_duration(self, *a, **k): + return 1e9 + + +GAME = {'id': 'g1', 'away_abbr': 'KC', 'home_abbr': 'BUF'} + +# Unlike hockey's fork, this one overrides display() on SportsLive. Its only +# paths that do not delegate are `return False` when disabled and `return True` +# for an active celebration; the no-games case falls through to +# `super().display(force_clear)`, so SportsCore below covers it. SportsLive is +# checked separately for the disabled path, because exercising super() needs a +# real subclass instance rather than this stand-in. +CASES = [ + ('SportsCore', sports.SportsCore.display), + ('SportsUpcoming', sports.SportsUpcoming.display), + ('SportsRecent', sports.SportsRecent.display), +] + +failures = [] + + +def check(name, actual, expected): + if actual == expected: + print(" PASS %s" % name) + else: + print(" FAIL %s: expected %r, got %r" % (name, expected, actual)) + failures.append(name) + + +def main(): + print("an empty mode reports no content, so the controller can skip it") + for label, display in CASES: + mgr = _Manager([]) + result = display(mgr, force_clear=False) + check("%s with no games returns False" % label, result, False) + check("%s with no games draws nothing" % label, mgr.draws, 0) + + print("\nthe same is true when the manager is disabled") + for label, display in CASES: + mgr = _Manager([GAME], enabled=False) + check("%s disabled returns False" % label, display(mgr), False) + + print("\na mode that does have a game still reports success") + for label, display in CASES: + mgr = _Manager([GAME]) + result = display(mgr, force_clear=False) + check("%s with a game returns True" % label, result, True) + check("%s with a game draws it" % label, mgr.draws, 1) + + print("\nthe result is a real bool, not something merely truthy") + # The dispatcher branches on `result is True` / `result is False`, so a truthy + # non-bool would fall through to the "assume success" path and reintroduce this. + for label, display in CASES: + check("%s empty -> bool" % label, type(display(_Manager([]))), bool) + check("%s populated -> bool" % label, + type(display(_Manager([GAME]))), bool) + + print("\nSportsLive's own early return") + live_disabled = sports.SportsLive.display(_Manager([GAME], enabled=False)) + check("SportsLive disabled returns False", live_disabled, False) + check("SportsLive disabled -> bool", type(live_disabled), bool) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/plugins/hockey-scoreboard/data_fetcher.py b/plugins/hockey-scoreboard/data_fetcher.py index 4318d2e2..a71e0ab0 100644 --- a/plugins/hockey-scoreboard/data_fetcher.py +++ b/plugins/hockey-scoreboard/data_fetcher.py @@ -54,23 +54,29 @@ def __init__(self, cache_manager, logger: logging.Logger): def _get_season_date_range(self, league_key: str) -> str: """Get the date range for the current season.""" - from datetime import datetime - + # datetime is imported at module scope; a function-local re-import here + # shadowed it and made the derivation untestable (a patched module + # attribute was ignored). now = datetime.now() - # For 2025, we want the 2025-26 season (October 2025 to end of season 2026) + # Derive the season from the current date rather than pinning it. + # A hockey season spans two calendar years, so anything from August + # onwards belongs to the season labelled with this year; before that + # we are still inside the season that started last year. Same rule + # nhl_managers.py uses, so both agree on which season is current. + season_year = now.year if now.month >= 8 else now.year - 1 + if league_key == 'nhl': - # NHL 2025-26 season: October 2025 to June 2026 - season_start = datetime(2025, 10, 1) - season_end = datetime(2026, 6, 30) + # NHL: October through June (playoffs run into June) + season_start = datetime(season_year, 10, 1) + season_end = datetime(season_year + 1, 6, 30) elif league_key in ['ncaa_mens', 'ncaa_womens']: - # NCAA 2025-26 season: October 2025 to March 2026 - season_start = datetime(2025, 10, 1) - season_end = datetime(2026, 3, 31) + # NCAA: October through March (tournament ends in March) + season_start = datetime(season_year, 10, 1) + season_end = datetime(season_year + 1, 3, 31) else: - # Default to 2025-26 season - season_start = datetime(2025, 10, 1) - season_end = datetime(2026, 6, 30) + season_start = datetime(season_year, 10, 1) + season_end = datetime(season_year + 1, 6, 30) # Format as YYYYMMDD-YYYYMMDD start_str = season_start.strftime('%Y%m%d') diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 8c54edee..ede0734d 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.13.4", + "version": "1.13.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.13.5", + "released": "2026-08-22", + "ledmatrix_min_version": "2.0.0", + "notes": "Derive the ESPN season window from the current date instead of pinning it to 2025-26. _get_season_date_range built its range from literal datetimes and discarded the `now` it had just computed, so once the 2026-27 season opened it would have kept asking for last season. It now uses the same August rollover rule as nhl_managers.py, so the two agree on which season is current. Also drops a function-local datetime re-import that shadowed the module one and made the derivation untestable." + }, { "version": "1.13.1", "released": "2026-08-19", diff --git a/plugins/hockey-scoreboard/test_season_date_range.py b/plugins/hockey-scoreboard/test_season_date_range.py new file mode 100644 index 00000000..fa3b61bc --- /dev/null +++ b/plugins/hockey-scoreboard/test_season_date_range.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""The ESPN season window must follow the calendar, not a pinned year. + +_get_season_date_range built its range from literal dates: + + season_start = datetime(2025, 10, 1) + season_end = datetime(2026, 6, 30) + +`now` was computed on the line above and never used, so the request stayed +pinned to 2025-26 forever. Once the 2026-27 season opened, the fetch would have +asked ESPN for last season's window. + +The range now derives from the current date using the same rule +nhl_managers.py applies -- from August onwards you are in the season labelled +with this year -- so the two agree on which season is current instead of +drifting apart. + +Run: /bin/python plugins/hockey-scoreboard/test_season_date_range.py +""" + +import sys +from datetime import datetime +from pathlib import Path +from unittest import mock + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import data_fetcher # noqa: E402 + +failures = [] + + +def check(name, actual, expected): + if actual == expected: + print(" PASS %s" % name) + else: + print(" FAIL %s: expected %r, got %r" % (name, expected, actual)) + failures.append(name) + + +def range_on(date_str, league): + """The window the fetcher would request on a given date.""" + fetcher = data_fetcher.HockeyDataFetcher.__new__(data_fetcher.HockeyDataFetcher) + frozen = datetime.fromisoformat(date_str) + + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return frozen + + with mock.patch.object(data_fetcher, "datetime", _DT): + return fetcher._get_season_date_range(league) + + +def main(): + print("the window follows the calendar year") + # July is still last season; August flips to the one about to start. + check("nhl in July 2026 asks for 2025-26", + range_on("2026-07-31", "nhl"), "20251001-20260630") + check("nhl in August 2026 asks for 2026-27", + range_on("2026-08-22", "nhl"), "20261001-20270630") + check("nhl at the October opener asks for 2026-27", + range_on("2026-10-07", "nhl"), "20261001-20270630") + check("nhl in June 2027 still asks for 2026-27", + range_on("2027-06-20", "nhl"), "20261001-20270630") + check("nhl rolls again in August 2027", + range_on("2027-08-01", "nhl"), "20271001-20280630") + + print("\nNCAA ends in March, not June") + check("ncaa_mens 2026-27", range_on("2026-10-07", "ncaa_mens"), "20261001-20270331") + check("ncaa_womens 2026-27", range_on("2026-10-07", "ncaa_womens"), "20261001-20270331") + + print("\nan unknown league still gets a current window, not a pinned one") + check("unknown league follows the year", + range_on("2026-10-07", "somethingelse"), "20261001-20270630") + + print("\nno literal season year survives in the source") + src = (plugin_dir / "data_fetcher.py").read_text() + body = src[src.index("def _get_season_date_range"):] + body = body[:body.index("def ", 10)] + for pinned in ("2025", "2026"): + check("%s is not hardcoded in the range builder" % pinned, + pinned in body, False) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main()