fix(sports): harden the live game-over check and font/log init

Follow-up review findings on the promoted base-class methods.

_is_game_really_over:
- `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`,
  taking down the whole live-update pass (_detect_stale_games has no
  try/except). Same failure shape as the null `period_text` already fixed.
- An expired clock spelled "00:00" normalizes to "0000", which matched
  none of the hand-listed literals, so a finished game with a two-digit
  minute clock stayed on the scoreboard forever. Compare numerically.

SportsCore:
- _load_fonts kept the cwd-relative "assets/fonts/..." literals the
  _font_root() seam exists to remove, so every scoreboard font degraded
  to PIL's default face outside the install root.
- _should_log read self._last_warning_time unguarded while only an
  unrelated method initialized it lazily; the first warning of a run
  raised AttributeError. Initialize it in __init__.

Also documents that game_update_timestamps is written by subclasses, not
by the base class, so the staleness branch is inert until B5 adoption.

14 new tests. Gates: 463 core unit, 60 plugin safety.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
This commit is contained in:
Claude
2026-08-01 16:45:04 +00:00
parent 2486bdb249
commit 98a7c769ec
5 changed files with 102 additions and 13 deletions
+14
View File
@@ -62,6 +62,20 @@ release that ships it.
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
a bare `KeyError`. It now reads the already-validated competition-level
status.
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
— the same failure shape as the already-fixed null `period_text`.
- An expired clock spelled `"00:00"` now ends the game. The check compared the
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
not a member of, so a finished game with a two-digit-minute clock stayed on
the scoreboard indefinitely. The comparison is now numeric.
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
seam instead of the process working directory. Started outside the install
root, every scoreboard font silently degraded to PIL's default bitmap face.
- `SportsCore._should_log` no longer raises `AttributeError` on the first
warning of a run; `_last_warning_time` is initialized in `__init__` rather
than lazily by an unrelated method.
- `SportsCore._resolve_project_path` resolved relative logo directories
against `<root>/src` instead of the repo root after `sports.py` became a
package — the class bodies moved byte-identically but `__file__` gained a
+21 -8
View File
@@ -185,6 +185,10 @@ class SportsCore(ABC):
}
self.last_update = 0
self.current_game = None
# Cooldown clock for _should_log(). Initialized here rather than lazily:
# _should_log() reads it unguarded, so whichever warning fires first
# would otherwise raise AttributeError instead of logging.
self._last_warning_time = 0
self.fonts = self._load_fonts()
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
@@ -425,17 +429,26 @@ class SportsCore(ABC):
def _load_fonts(self):
"""Load fonts used by the scoreboard."""
"""Load fonts used by the scoreboard.
Paths go through :meth:`_resolve_font_path` so the bundled fonts are
found regardless of the process working directory — a bare
``"assets/fonts/..."`` silently degraded every scoreboard to the PIL
default font whenever the process started elsewhere (the plugin safety
harness on CI being the case that surfaced it).
"""
fonts = {}
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
four_by_six = self._resolve_font_path("4x6-font.ttf")
try:
fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font
fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
fonts['score'] = ImageFont.truetype(press_start, 10)
fonts['time'] = ImageFont.truetype(press_start, 8)
fonts['team'] = ImageFont.truetype(press_start, 8)
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
fonts['rank'] = ImageFont.truetype(press_start, 10)
logging.info("Successfully loaded fonts") # Changed log prefix
except IOError:
except OSError:
logging.warning("Fonts not found, using default PIL font.") # Changed log prefix
fonts['score'] = ImageFont.load_default()
fonts['time'] = ImageFont.load_default()
+14 -4
View File
@@ -842,8 +842,11 @@ class SportsLive(SportsCore):
self.count_log_interval = 5 # Only log count data every 5 seconds
# Initialize test_mode - defaults to False (live mode)
self.test_mode = self.mode_config.get("test_mode", False)
# Freshness bookkeeping for _detect_stale_games():
# {game_id: {"clock": ts, "score": ts, "last_seen": ts}}
# Freshness bookkeeping for _detect_stale_games(). The base class only
# *reads* this map; a subclass's update() stamps entries as it ingests a
# feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}.
# Until a subclass writes "last_seen", the staleness branch of
# _detect_stale_games is inert and only the game-over check applies.
self.game_update_timestamps = {}
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default
@@ -884,7 +887,11 @@ class SportsLive(SportsCore):
return False
raw_clock = game.get("clock")
period = game.get("period", 0)
# `or 0` rather than a get() default: feeds routinely send an explicit
# null period, and `None >= FINAL_PERIOD` raises TypeError — which would
# take down the whole live-update pass, since the only caller
# (_detect_stale_games) has no try/except around it.
period = game.get("period") or 0
# Only check clock-based finish if we have a valid clock string. A
# missing or non-string clock is NOT coerced to "0:00": sports without a
@@ -892,8 +899,11 @@ class SportsLive(SportsCore):
# otherwise be declared over from the FINAL_PERIOD-th period onward.
if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD:
clock = raw_clock
# Compare numerically rather than against a literal set: feeds spell
# an expired clock "0:00", ":00" and "00:00" depending on sport, and
# a membership test silently misses every spelling not listed.
clock_normalized = clock.replace(":", "").strip()
if clock_normalized in ("000", "00") or clock in ("0:00", ":00"):
if clock_normalized.isdigit() and int(clock_normalized) == 0:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
+32
View File
@@ -465,6 +465,38 @@ class TestFontLoaderCwdIndependence:
assert isinstance(font, ImageFont.FreeTypeFont)
assert Path(font.path) == FONTS_DIR / TTF_NAME
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
"detail", "rank"])
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
"""`_load_fonts` had the same cwd-relative literals the seam exists to
remove; every scoreboard font silently became PIL's default bitmap face
when the process started outside the install root."""
monkeypatch.chdir("/")
fonts = SportsCore._load_fonts(probe())
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
f"fonts['{key}'] degraded to PIL's default face outside the "
"install root")
class TestShouldLogCooldown:
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
initialized in __init__ otherwise the first warning of a run raises
AttributeError instead of logging."""
def test_cooldown_clock_is_initialized(self, build):
assert build()._last_warning_time == 0
def test_first_call_logs_then_cools_down(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
def test_cooldown_expires(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
manager._last_warning_time -= 61
assert manager._should_log("api", cooldown=60) is True
# ---------------------------------------------------------------------------
# 4. Seam guard rails
+21 -1
View File
@@ -254,12 +254,32 @@ class TestIsGameReallyOver:
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
@pytest.mark.parametrize("clock", ["0:00", ":00", "00", "000", " 0:00 "])
@pytest.mark.parametrize(
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
"""Every spelling of a zeroed clock counts, not a hand-listed few.
"00:00" is the one that motivated comparing numerically: it normalizes
to "0000", which matched none of the literals the plugin copies listed,
so a two-digit-minute expired clock kept the game on screen forever.
"""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is True
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
`_detect_stale_games` has no try/except the same failure shape as the
`period_text` case above."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=None, period_text="Q4")) is False
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="8:12", period=None, period_text="Q2")) is False
def test_expired_clock_after_final_period_is_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(