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 # ---------------------------------------------------------------------------