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
+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})"