fix: show correct "Game X of 7" for future playoff dates
Enrich raw score-endpoint games with gameNumber from the series cache before parsing. The score API omits gameNumber and its seriesStatus reflects current wins, so all future games in a series computed the same number. Now we cross-reference by game id against the cached series-detail endpoint which includes the correct gameNumber per game. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -158,8 +158,6 @@ def series_badges(game):
|
|||||||
|
|
||||||
def series_summary(game):
|
def series_summary(game):
|
||||||
"""Short line rendered above the card, e.g. 'Game 2 of 7'."""
|
"""Short line rendered above the card, e.g. 'Game 2 of 7'."""
|
||||||
if game.get("gameState") == "FUT":
|
|
||||||
return ""
|
|
||||||
state = series_state(game.get("seriesStatus", {}))
|
state = series_state(game.get("seriesStatus", {}))
|
||||||
return f"Game {_game_number(game, state)} of 7"
|
return f"Game {_game_number(game, state)} of 7"
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,55 @@ def fetch_series(series_id):
|
|||||||
return None
|
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) ──────────────
|
# ── Per-round start dates (drive the "Day N" banner) ──────────────
|
||||||
|
|
||||||
ROUND_DATES_KEY = "meta:round_start_dates"
|
ROUND_DATES_KEY = "meta:round_start_dates"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.playoff import today_meta
|
|||||||
from app.bracket_view import build_bracket_view
|
from app.bracket_view import build_bracket_view
|
||||||
from app.playoff_cache import (
|
from app.playoff_cache import (
|
||||||
day_n_for_round,
|
day_n_for_round,
|
||||||
|
enrich_game_numbers,
|
||||||
fetch_series,
|
fetch_series,
|
||||||
get_bracket,
|
get_bracket,
|
||||||
parse_series_id,
|
parse_series_id,
|
||||||
@@ -118,6 +119,7 @@ def get_scoreboard():
|
|||||||
|
|
||||||
if scoreboard_data:
|
if scoreboard_data:
|
||||||
raw_games = scoreboard_data.get("games", [])
|
raw_games = scoreboard_data.get("games", [])
|
||||||
|
enrich_game_numbers(raw_games)
|
||||||
games = parse_games(scoreboard_data)
|
games = parse_games(scoreboard_data)
|
||||||
max_round = _max_playoff_round(raw_games)
|
max_round = _max_playoff_round(raw_games)
|
||||||
n = day_n_for_round(max_round) if max_round else None
|
n = day_n_for_round(max_round) if max_round else None
|
||||||
|
|||||||
@@ -273,3 +273,98 @@ class TestSchema:
|
|||||||
playoff_cache._put("k", {"v": 2})
|
playoff_cache._put("k", {"v": 2})
|
||||||
cached, _ = playoff_cache._get("k")
|
cached, _ = playoff_cache._get("k")
|
||||||
assert cached == {"v": 2}
|
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
|
||||||
|
|||||||
@@ -181,9 +181,15 @@ class TestSeriesSummary:
|
|||||||
)
|
)
|
||||||
assert series_summary(game) == "Game 2 of 7"
|
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")
|
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:
|
class TestIsPinned:
|
||||||
|
|||||||
Reference in New Issue
Block a user