diff --git a/app/playoff.py b/app/playoff.py index 461a63d..166346e 100644 --- a/app/playoff.py +++ b/app/playoff.py @@ -158,8 +158,6 @@ def series_badges(game): def series_summary(game): """Short line rendered above the card, e.g. 'Game 2 of 7'.""" - if game.get("gameState") == "FUT": - return "" state = series_state(game.get("seriesStatus", {})) return f"Game {_game_number(game, state)} of 7" diff --git a/app/playoff_cache.py b/app/playoff_cache.py index 4eea1df..bf85c2e 100644 --- a/app/playoff_cache.py +++ b/app/playoff_cache.py @@ -158,6 +158,55 @@ def fetch_series(series_id): return None +# ── Game-number enrichment ──────────────────────────────────────── + + +def enrich_game_numbers(raw_games): + """Inject gameNumber from cached series data into raw score-endpoint games. + + The /v1/score/{date} endpoint omits gameNumber. For future dates the + fallback computation (top_wins + bot_wins + 1) gives every game in a + series the same number. The series-detail endpoint includes gameNumber, + so we cross-reference by game id. + """ + need = {} + for game in raw_games or []: + if game.get("gameType") != 3: + continue + if isinstance(game.get("gameNumber"), int) and game["gameNumber"] > 0: + continue + ss = game.get("seriesStatus") or {} + letter = ss.get("seriesLetter") or ss.get("seriesAbbrev") + if not letter: + continue + start = game.get("startTimeUTC") or "" + try: + year = ( + datetime.fromisoformat(start.replace("Z", "+00:00")) + .astimezone(EASTERN) + .year + ) + except (ValueError, AttributeError): + year = datetime.now(EASTERN).year + sid = f"{year}-{letter.upper()}" + need.setdefault(sid, []).append(game) + + for sid, games in need.items(): + payload = fetch_series(sid) + if not payload: + continue + lookup = {} + for sg in payload.get("games") or []: + gid = sg.get("id") + gn = sg.get("gameNumber") + if gid is not None and isinstance(gn, int) and gn > 0: + lookup[gid] = gn + for game in games: + gid = game.get("id") + if gid is not None and gid in lookup: + game["gameNumber"] = lookup[gid] + + # ── Per-round start dates (drive the "Day N" banner) ────────────── ROUND_DATES_KEY = "meta:round_start_dates" diff --git a/app/routes.py b/app/routes.py index ee0b431..62f7dee 100644 --- a/app/routes.py +++ b/app/routes.py @@ -18,6 +18,7 @@ from app.playoff import today_meta from app.bracket_view import build_bracket_view from app.playoff_cache import ( day_n_for_round, + enrich_game_numbers, fetch_series, get_bracket, parse_series_id, @@ -118,6 +119,7 @@ def get_scoreboard(): if scoreboard_data: raw_games = scoreboard_data.get("games", []) + enrich_game_numbers(raw_games) games = parse_games(scoreboard_data) max_round = _max_playoff_round(raw_games) n = day_n_for_round(max_round) if max_round else None diff --git a/tests/test_playoff_cache.py b/tests/test_playoff_cache.py index f6fe6c5..4b4dc72 100644 --- a/tests/test_playoff_cache.py +++ b/tests/test_playoff_cache.py @@ -273,3 +273,98 @@ class TestSchema: playoff_cache._put("k", {"v": 2}) cached, _ = playoff_cache._get("k") assert cached == {"v": 2} + + +def _raw_playoff_game(game_id, series_letter="A", game_number=None): + """Minimal raw score-endpoint playoff game for enrichment tests.""" + game = { + "id": game_id, + "gameType": 3, + "gameState": "FUT", + "startTimeUTC": "2026-04-25T23:00:00Z", + "seriesStatus": {"seriesLetter": series_letter, "round": 1}, + } + if game_number is not None: + game["gameNumber"] = game_number + return game + + +class TestEnrichGameNumbers: + def test_basic_enrichment(self, tmp_db, monkeypatch): + games = [_raw_playoff_game(101), _raw_playoff_game(102)] + series_payload = { + "games": [ + {"id": 101, "gameNumber": 3}, + {"id": 102, "gameNumber": 4}, + ] + } + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: _Resp(series_payload), + ) + playoff_cache.enrich_game_numbers(games) + assert games[0]["gameNumber"] == 3 + assert games[1]["gameNumber"] == 4 + + def test_skips_games_with_existing_game_number(self, tmp_db, monkeypatch): + games = [_raw_playoff_game(101, game_number=2)] + called = [] + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: called.append(1) or _Resp({"games": []}), + ) + playoff_cache.enrich_game_numbers(games) + assert games[0]["gameNumber"] == 2 + assert len(called) == 0 + + def test_skips_non_playoff_games(self, tmp_db, monkeypatch): + games = [{"id": 101, "gameType": 2, "gameState": "FUT"}] + called = [] + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: called.append(1) or _Resp({"games": []}), + ) + playoff_cache.enrich_game_numbers(games) + assert "gameNumber" not in games[0] + assert len(called) == 0 + + def test_graceful_on_cache_miss(self, tmp_db, monkeypatch): + import requests as req + + games = [_raw_playoff_game(101)] + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: (_ for _ in ()).throw(req.ConnectionError("x")), + ) + playoff_cache.enrich_game_numbers(games) + assert "gameNumber" not in games[0] + + def test_handles_missing_id(self, tmp_db, monkeypatch): + game = { + "gameType": 3, + "gameState": "FUT", + "startTimeUTC": "2026-04-25T23:00:00Z", + "seriesStatus": {"seriesLetter": "A", "round": 1}, + } + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: _Resp({"games": [{"id": 101, "gameNumber": 3}]}), + ) + playoff_cache.enrich_game_numbers([game]) + assert "gameNumber" not in game + + def test_multiple_series(self, tmp_db, monkeypatch): + games = [_raw_playoff_game(101, "A"), _raw_playoff_game(201, "B")] + payloads = { + "a": {"games": [{"id": 101, "gameNumber": 2}]}, + "b": {"games": [{"id": 201, "gameNumber": 5}]}, + } + + def fake_get(url, *a, **kw): + letter = url.rstrip("/").rsplit("/", 1)[-1] + return _Resp(payloads[letter]) + + monkeypatch.setattr("app.playoff_cache.requests.get", fake_get) + playoff_cache.enrich_game_numbers(games) + assert games[0]["gameNumber"] == 2 + assert games[1]["gameNumber"] == 5 diff --git a/tests/test_playoff_series.py b/tests/test_playoff_series.py index e36a2c5..f2034c8 100644 --- a/tests/test_playoff_series.py +++ b/tests/test_playoff_series.py @@ -181,9 +181,15 @@ class TestSeriesSummary: ) assert series_summary(game) == "Game 2 of 7" - def test_fut_game_suppresses_summary(self): + def test_fut_game_uses_explicit_game_number(self): + game = make_playoff_game( + top_wins=1, bottom_wins=1, game_state="FUT", game_number=4 + ) + assert series_summary(game) == "Game 4 of 7" + + def test_fut_game_without_game_number_uses_fallback(self): game = make_playoff_game(top_wins=1, bottom_wins=1, game_state="FUT") - assert series_summary(game) == "" + assert series_summary(game) == "Game 3 of 7" class TestIsPinned: