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
151 changes: 151 additions & 0 deletions plugins/football-scoreboard/test_empty_mode_signals_no_content.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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()
30 changes: 18 additions & 12 deletions plugins/hockey-scoreboard/data_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 7 additions & 1 deletion plugins/hockey-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions plugins/hockey-scoreboard/test_season_date_range.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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()
Loading