From 2486bdb249cfac97ed85e6af5ebcffb76b0b17e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:25:32 +0000 Subject: [PATCH] fix(sports): stop dropping hockey and baseball events on optional feed keys Both bugs were pinned AS-IS by the B0 characterization suite so this phase could change them knowingly. Both fixes are adoptions of code the corresponding plugins already ship, not new inventions. Hockey: the extractor read competitor["statistics"] unguarded, so a competitor arriving without that array raised KeyError inside the generator and the WHOLE event was discarded -- valid scores and status included. Shot/save counts now default to 0, which is already what the suite expects for an empty statistics array. Baseball: for live games the extractor read game_event["status"], the event TOP-LEVEL status, to get the inning. Real ESPN events duplicate status there, but MiLB events (synthesized from the MLB Stats API into an ESPN-like shape) populate only the competition-level one, so the lookup raised a bare KeyError and dropped the event. It now reads the competition-level status that _extract_game_details_common has already validated, so it cannot be missing at that point. The two characterization tests that pinned the old behaviour are rewritten to assert the fix rather than deleted, so the suite still documents the edge case -- and still totals 94. CHANGELOG records these plus the live-clock change from aaabc61 under Changed/Fixed, since all three are user-visible. The two new promotion suites join the CI unit job (449 tests). Verified: unit job 449 passed, plugin-safety job 60 passed, and the hockey (16) and baseball (24) plugin harnesses render clean at every panel size. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 --- .github/workflows/test.yml | 4 ++- CHANGELOG.md | 38 ++++++++++++++++++++ src/base_classes/baseball.py | 10 ++++-- src/base_classes/hockey.py | 15 +++++--- test/test_sports_base_characterization.py | 43 ++++++++++++++++------- 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd982e2f..7bc4ffeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,4 +72,6 @@ jobs: test/test_adaptive_layout.py \ test/test_loader_compat_warning.py \ test/test_sports_base_characterization.py \ - test/test_element_style.py + test/test_element_style.py \ + test/test_sports_core_promotions.py \ + test/test_sports_modes_promotions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfe6c59..f4d46783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,48 @@ release that ships it. compatibility warning) plus new characterization tests for `src/base_classes/sports.py` ahead of the shared sports-code unification. +- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` + + `modes.py`). The import path is unchanged: `from src.base_classes.sports + import SportsCore` still works. +- Nine methods promoted onto the sports base classes from the plugins' + bundled copies, plus the override points `_favorite_key`, + `_config_schema_path` and `_font_root` and the class attributes + `FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`. + A plugin may start calling these once its manifest floors + `ledmatrix_min_version` at the release that ships them. + +### Changed +- **Live games are no longer dropped when the feed omits a game clock.** + `SportsLive._is_game_really_over` previously (in the baseball and UFC + plugin lineages) coerced a missing or non-string clock to the literal + `"0:00"` and then treated the game as finished once `period >= 4`. Baseball + has no game clock and `period` is the inning, so live MLB games disappeared + from the scoreboard from the 5th inning onward; UFC was affected the same + way. The clock check is now skipped when the clock is unusable, and the + period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3). + Sports whose clocks count up — soccer, AFL, NRL — set + `CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00` + there means kickoff rather than expiry. + ### Fixed - `FontManager` resolves `assets/fonts` against the core install root instead of the process working directory, so font loading works when the process starts elsewhere (e.g. the plugin safety harness on CI). +- Hockey events whose competitors carry no `statistics` array are no longer + discarded. The extractor read `competitor["statistics"]` unguarded, so a + `KeyError` inside the generator dropped the entire event despite valid + scores and status; shot counts now fall back to `0`. +- Live baseball events that populate status only at the competition level are + no longer discarded. The extractor read the event top-level + `game_event["status"]` for the inning; real ESPN events duplicate it, but + 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. +- `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 + directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT` + constant. ## 3.1.0 diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py index 9a46dd5a..83b90a24 100644 --- a/src/base_classes/baseball.py +++ b/src/base_classes/baseball.py @@ -164,7 +164,13 @@ class Baseball(SportsCore): # Get game state information if status_state == "in": # For live games, get detailed state - inning = game_event["status"].get( + # Use the competition-level `status` already validated by + # _extract_game_details_common. Real ESPN events duplicate + # status at the event top level, but MiLB events (synthesized + # from the MLB Stats API into an ESPN-like shape) populate + # only the competition-level one, so the top-level lookup + # raised a bare KeyError and dropped the event. + inning = status.get( "period", 1 ) # Get inning from status period @@ -187,7 +193,7 @@ class Baseball(SportsCore): if "end" in status_detail or "end" in status_short: inning_half = "top" inning = ( - game_event["status"].get("period", 1) + 1 + status.get("period", 1) + 1 ) # Use period and increment for next inning if is_favorite_game: self.logger.debug( diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py index 9c25da35..419f09ec 100644 --- a/src/base_classes/hockey.py +++ b/src/base_classes/hockey.py @@ -38,10 +38,17 @@ class Hockey(SportsCore): status = competition["status"] powerplay = False penalties = "" + # A competitor may legitimately arrive without a "statistics" + # array (pre-game feeds, and some in-progress ones). Reading it + # unguarded raised KeyError inside the generator and dropped the + # WHOLE event, discarding valid scores and status. Default to an + # empty list so the saves/shots figures fall back to 0 instead. + home_stats = home_team.get("statistics", []) + away_stats = away_team.get("statistics", []) home_team_saves = next( ( int(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "saves" ), 0, @@ -49,7 +56,7 @@ class Hockey(SportsCore): home_team_saves_per = next( ( float(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "savePct" ), 0.0, @@ -57,7 +64,7 @@ class Hockey(SportsCore): away_team_saves = next( ( int(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "saves" ), 0, @@ -65,7 +72,7 @@ class Hockey(SportsCore): away_team_saves_per = next( ( float(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "savePct" ), 0.0, diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py index 057a4875..980446f7 100644 --- a/test/test_sports_base_characterization.py +++ b/test/test_sports_base_characterization.py @@ -305,15 +305,25 @@ class TestExtractGameDetailsContract: assert details["home_shots"] == 0 assert details["away_shots"] == 0 - def test_hockey_event_without_statistics_returns_none(self): - # PINNED AS-IS: the hockey extractor unconditionally iterates - # competitor["statistics"]; a competitor without the key raises - # KeyError internally and the WHOLE event is dropped (returns - # None), even though scores/status are present. + def test_hockey_event_without_statistics_still_extracts(self): + # FIXED (was pinned as returning None): the hockey extractor used to + # iterate competitor["statistics"] unguarded, so a competitor without + # the key raised KeyError internally and the WHOLE event was dropped + # despite valid scores and status. It now defaults to an empty list, + # matching the behaviour already shipped in the hockey plugin, so the + # event survives with zeroed shot counts -- the same values + # test_hockey_live_power_play_and_default_shots already expects for an + # EMPTY statistics array. event = make_event("410", "in", "2026-01-15T18:30:00Z") for comp in event["competitions"][0]["competitors"]: del comp["statistics"] - assert extract(Hockey, event) is None + details = extract(Hockey, event) + assert details is not None + assert details["home_abbr"] == "TB" + assert details["away_abbr"] == "DAL" + assert details["home_score"] == "3" + assert details["home_shots"] == 0 + assert details["away_shots"] == 0 def test_baseball_live_inning_and_count(self): event = make_event( @@ -337,14 +347,21 @@ class TestExtractGameDetailsContract: assert details["status"] == "status_in_progress" assert details["series_summary"] == "" - def test_baseball_live_without_top_level_status_returns_none(self): - # PINNED AS-IS: for live games the baseball extractor reads - # game_event["status"] (the event TOP-LEVEL status, not the - # competition status) for the inning; an otherwise-valid live - # event lacking that duplicate key is dropped entirely. - event = make_event("412", "in", "2026-07-16T23:05:00Z") + def test_baseball_live_without_top_level_status_still_extracts(self): + # FIXED (was pinned as returning None): the baseball extractor read + # game_event["status"] -- the event TOP-LEVEL status -- for the + # inning, so an otherwise-valid live event lacking that duplicate key + # was dropped entirely. Real ESPN events carry status in both places, + # but MiLB events (synthesized from the MLB Stats API into an + # ESPN-like shape) populate only the competition-level one. It now + # reads the competition-level `status` that + # _extract_game_details_common has already validated, so it can never + # be missing at that point. + event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7) del event["status"] - assert extract(Baseball, event) is None + details = extract(Baseball, event) + assert details is not None + assert details["inning"] == 7 # ---------------------------------------------------------------------------