diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e449a6..9a709790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Fixes + +- `SportsRankingsMixin` (#789's deferred review points, now that it is the + only copy): a poll entry whose `current` cannot be converted to an + integer (`None`, a non-numeric string) is skipped instead of throwing + away the whole poll -- which left + every team unranked and re-fetched the standings on every update -- and + `_ranked_positions()` decides id-or-abbreviation per side, so one side + without an id no longer drops the other side's id and lets a shared + abbreviation rank the wrong school. Well-formed ESPN data ranks as before. + ## 3.8.5 Fewer ESPN requests on the hourly schedule refresh (#754), and sports diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 4ad2ba0e..d89d911b 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -383,7 +383,7 @@ ufc's MMA managers override `update()` and never build the pools, so the rotation is dormant there. `_best_rank`, `_is_ranked_game` and `_passes_other_filters` were family 8. -### Family 8: rankings (reconciled; promoted, core 3.8.5) +### Family 8: rankings (done: core 3.8.5, adopted) ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the nine plugins first, every ESPN request answered offline from recorded-shape @@ -502,7 +502,7 @@ release. | 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) | | 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) | | 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) | -| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings` (core 3.8.5). See [Family 8](#family-8-rankings-reconciled-promoted-core-385) | +| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) | | 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins | | 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` | | 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release | diff --git a/src/common/sports_rankings.py b/src/common/sports_rankings.py index 5a3d449a..d0bf3c75 100644 --- a/src/common/sports_rankings.py +++ b/src/common/sports_rankings.py @@ -216,7 +216,12 @@ class SportsRankingsMixin: for team_data in poll.get("ranks", []): team_info = team_data.get("team", {}) team_abbr = team_info.get("abbreviation", "") - current_rank = team_data.get("current", 0) + # One malformed entry (a None or non-numeric "current") is + # skipped, not allowed to throw away the whole poll. + try: + current_rank = int(team_data.get("current")) + except (TypeError, ValueError): + continue if current_rank <= 0: continue if team_abbr: @@ -337,28 +342,30 @@ class SportsRankingsMixin: return self._division_team_ids def _ranked_positions(self, game: Dict) -> List[int]: - """Poll positions held by either side: ids first, abbreviations second. + """Poll positions held by either side: by id, else by abbreviation. - The id table is authoritative whenever the game carries ids and a poll + The id table is authoritative whenever a side carries an id and a poll has been parsed, because it is built from a top-division poll alone -- so an FCS side cannot appear in it however it is abbreviated, and an abbreviation shared across divisions cannot promote the wrong school. - The abbreviation table stays as the fallback for a game dict with no - ids on it. + Decided per side: a side without a usable id falls back to its + abbreviation, without costing the other side its id. """ by_id = getattr(self, "_ranked_team_ids", None) or {} - ids = [] - for side in ("home_id", "away_id"): - try: - ids.append(int(game.get(side))) # type: ignore[arg-type] # None: caught below - except (TypeError, ValueError): - ids = [] - break - if by_id and ids: - return [rank for rank in (by_id.get(i, 0) for i in ids) if rank] by_abbr = getattr(self, "_team_rankings_cache", None) or {} - return [rank for rank in (by_abbr.get(game.get("home_abbr"), 0), - by_abbr.get(game.get("away_abbr"), 0)) if rank] + found = [] + for side in ("home", "away"): + rank = None + if by_id: + try: + rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below + except (TypeError, ValueError): + pass + if rank is None: + rank = by_abbr.get(game.get(f"{side}_abbr"), 0) + if rank: + found.append(rank) + return found def _is_ranked_game(self, game: Dict) -> bool: return bool(self._ranked_positions(game)) diff --git a/test/test_sports_rankings.py b/test/test_sports_rankings.py index 99790797..23f7f5d3 100644 --- a/test/test_sports_rankings.py +++ b/test/test_sports_rankings.py @@ -173,6 +173,11 @@ def test_only_a_league_with_a_poll_is_asked(clock, league, asked): {"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}}, {"current": 4, "team": {"abbreviation": "TEX"}}]}]}, {"OSU": 3, "TEX": 4}, {87: 2}), + ({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry + {"current": None, "team": {"id": "61", "abbreviation": "UGA"}}, + {"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}}, + {"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]}, + {"OSU": 3}, {194: 3}), ]) def test_payload_shapes(clock, payload, abbr, ids): host = Host(payload=payload) @@ -320,6 +325,17 @@ def test_ranked_and_best_rank(clock, label, best): assert host._is_ranked_game(game(label)) is (best != 99) +@pytest.mark.parametrize("home, away, best", [ + (NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it + (("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side +]) +def test_each_side_is_ranked_by_its_own_id(clock, home, away, best): + """#789: one side's missing id no longer sends both sides to abbreviations.""" + g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away)) + for key, value in zip(("id", "abbr"), team) if value is not None} + assert loaded()._best_rank(g) == best + + #: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry) FILTERS = { "any": ("any", (), True, "YYYYYYYY"),