diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d46783..69560434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/src` instead of the repo root after `sports.py` became a package — the class bodies moved byte-identically but `__file__` gained a diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py index b4fc92da..7e8f4893 100644 --- a/src/base_classes/sports/core.py +++ b/src/base_classes/sports/core.py @@ -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() diff --git a/src/base_classes/sports/modes.py b/src/base_classes/sports/modes.py index 0c9dd695..615ad1a7 100644 --- a/src/base_classes/sports/modes.py +++ b/src/base_classes/sports/modes.py @@ -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})" diff --git a/test/test_sports_core_promotions.py b/test/test_sports_core_promotions.py index 6826cd80..67038ade 100644 --- a/test/test_sports_core_promotions.py +++ b/test/test_sports_core_promotions.py @@ -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 diff --git a/test/test_sports_modes_promotions.py b/test/test_sports_modes_promotions.py index 18f2a8cf..72f6a211 100644 --- a/test/test_sports_modes_promotions.py +++ b/test/test_sports_modes_promotions.py @@ -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(