feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
Turn a regular-season-looking Tuesday into a full playoff experience: - Playoff banner with round + day + series + elimination counts, gold/silver Cup theme toggled by body.playoff-mode - Series context on each playoff card: round chip, series score, stake badges (GAME 7, CLINCHER, PIVOTAL), and one-line blurb - Game 7s pin to a new Spotlight section above Live - Playoff OT renders with SUDDEN DEATH badge and pulsing gold border - Client-side OT notifications via bell button in the banner - New /series/<id> drill-down with headline, next-game, and game-by-game history - New /bracket page: 7-column desktop grid, accordion on mobile - Day N banner count auto-anchors on first playoff scoreboard hit - SQLite cache for bracket + per-series schedules, stale-on-failure up to 24h Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+217
-1
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from tests.conftest import make_game
|
||||
from tests.conftest import make_game, make_playoff_game
|
||||
|
||||
|
||||
class TestIndexRoute:
|
||||
@@ -86,3 +86,219 @@ class TestScoreboardRoute:
|
||||
response = flask_client.get("/scoreboard")
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_meta_and_pinned_keys_present(self, flask_client):
|
||||
response = flask_client.get("/scoreboard")
|
||||
data = json.loads(response.data)
|
||||
assert "meta" in data
|
||||
assert "pinned_games" in data
|
||||
assert "playoff_mode" in data["meta"]
|
||||
|
||||
def test_meta_playoff_mode_off_for_regular_season(self, flask_client):
|
||||
response = flask_client.get("/scoreboard")
|
||||
data = json.loads(response.data)
|
||||
assert data["meta"]["playoff_mode"] is False
|
||||
|
||||
def test_playoff_day_populates_meta(self, flask_client, monkeypatch, tmp_path):
|
||||
import app.routes as routes
|
||||
|
||||
playoff_game = make_playoff_game(
|
||||
top_wins=3,
|
||||
bottom_wins=3,
|
||||
round_num=1,
|
||||
series_letter="A",
|
||||
game_state="LIVE",
|
||||
)
|
||||
scoreboard = {"games": [playoff_game]}
|
||||
f = tmp_path / "scoreboard_data.json"
|
||||
f.write_text(json.dumps(scoreboard))
|
||||
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(f))
|
||||
|
||||
data = json.loads(flask_client.get("/scoreboard").data)
|
||||
assert data["meta"]["playoff_mode"] is True
|
||||
assert data["meta"]["round_label"] == "First Round"
|
||||
assert data["meta"]["game7_count"] == 1
|
||||
assert data["meta"]["series_active"] == 1
|
||||
|
||||
def test_game7_goes_to_pinned_bucket_not_live(
|
||||
self, flask_client, monkeypatch, tmp_path
|
||||
):
|
||||
import app.routes as routes
|
||||
|
||||
g7 = make_playoff_game(
|
||||
top_wins=3,
|
||||
bottom_wins=3,
|
||||
game_state="LIVE",
|
||||
home_name="Kings",
|
||||
away_name="Oilers",
|
||||
)
|
||||
regular_live = make_game(home_name="Rangers", away_name="Devils")
|
||||
scoreboard = {"games": [g7, regular_live]}
|
||||
f = tmp_path / "scoreboard_data.json"
|
||||
f.write_text(json.dumps(scoreboard))
|
||||
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(f))
|
||||
|
||||
data = json.loads(flask_client.get("/scoreboard").data)
|
||||
pinned_names = [g["Home Team"] for g in data["pinned_games"]]
|
||||
live_names = [g["Home Team"] for g in data["live_games"]]
|
||||
assert "Kings" in pinned_names
|
||||
assert "Kings" not in live_names
|
||||
assert "Rangers" in live_names
|
||||
|
||||
|
||||
class TestSeriesDetailRoute:
|
||||
_SAMPLE_PAYLOAD = {
|
||||
"round": 1,
|
||||
"roundLabel": "1st-round",
|
||||
"seriesLetter": "A",
|
||||
"neededToWin": 4,
|
||||
"length": 7,
|
||||
"topSeedTeam": {
|
||||
"id": 10,
|
||||
"name": {"default": "Maple Leafs"},
|
||||
"abbrev": "TOR",
|
||||
"placeName": {"default": "Toronto"},
|
||||
"record": "2-1",
|
||||
"seriesWins": 2,
|
||||
"divisionAbbrev": "A",
|
||||
"seed": 1,
|
||||
"logo": "https://example.com/tor.svg",
|
||||
"darkLogo": "https://example.com/tor_dark.svg",
|
||||
"conference": {"abbrev": "E"},
|
||||
},
|
||||
"bottomSeedTeam": {
|
||||
"id": 9,
|
||||
"name": {"default": "Senators"},
|
||||
"abbrev": "OTT",
|
||||
"placeName": {"default": "Ottawa"},
|
||||
"record": "1-2",
|
||||
"seriesWins": 1,
|
||||
"divisionAbbrev": "A",
|
||||
"seed": 4,
|
||||
"logo": "https://example.com/ott.svg",
|
||||
"darkLogo": "https://example.com/ott_dark.svg",
|
||||
"conference": {"abbrev": "E"},
|
||||
},
|
||||
"games": [
|
||||
{
|
||||
"id": 1,
|
||||
"gameNumber": 1,
|
||||
"ifNecessary": False,
|
||||
"venue": {"default": "Arena"},
|
||||
"startTimeUTC": "2026-04-18T23:00:00Z",
|
||||
"gameState": "OFF",
|
||||
"periodDescriptor": {"number": 3, "periodType": "REG"},
|
||||
"awayTeam": {"abbrev": "OTT", "score": 2, "commonName": {"default": "Senators"}},
|
||||
"homeTeam": {"abbrev": "TOR", "score": 6, "commonName": {"default": "Maple Leafs"}},
|
||||
"gameOutcome": {"lastPeriodType": "REG"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"gameNumber": 4,
|
||||
"ifNecessary": False,
|
||||
"venue": {"default": "Arena"},
|
||||
"startTimeUTC": "2026-04-22T23:00:00Z",
|
||||
"gameState": "FUT",
|
||||
"periodDescriptor": {"number": 1, "periodType": "REG"},
|
||||
"awayTeam": {"abbrev": "TOR", "commonName": {"default": "Maple Leafs"}},
|
||||
"homeTeam": {"abbrev": "OTT", "commonName": {"default": "Senators"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def test_invalid_series_id_404(self, flask_client):
|
||||
response = flask_client.get("/series/garbage")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_valid_format_but_missing_cache_404(self, flask_client, monkeypatch):
|
||||
import app.routes as routes
|
||||
monkeypatch.setattr(routes, "fetch_series", lambda sid: None)
|
||||
response = flask_client.get("/series/2026-A")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_renders_with_cached_payload(self, flask_client, monkeypatch):
|
||||
import app.routes as routes
|
||||
monkeypatch.setattr(routes, "fetch_series", lambda sid: self._SAMPLE_PAYLOAD)
|
||||
|
||||
response = flask_client.get("/series/2026-A")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode("utf-8")
|
||||
assert "Maple Leafs" in body
|
||||
assert "Senators" in body
|
||||
assert "Game 1" in body
|
||||
assert "Game 4" in body
|
||||
|
||||
def test_letter_out_of_range_404(self, flask_client):
|
||||
response = flask_client.get("/series/2026-Z")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestBracketRoute:
|
||||
_BRACKET = {
|
||||
"bracketLogo": "http://example.com/bracket.png",
|
||||
"series": [
|
||||
{
|
||||
"seriesLetter": "A",
|
||||
"playoffRound": 1,
|
||||
"topSeedWins": 2,
|
||||
"bottomSeedWins": 1,
|
||||
"topSeedRankAbbrev": "D1",
|
||||
"bottomSeedRankAbbrev": "WC1",
|
||||
"winningTeamId": None,
|
||||
"topSeedTeam": {
|
||||
"id": 10,
|
||||
"abbrev": "TOR",
|
||||
"name": {"default": "Toronto Maple Leafs"},
|
||||
"commonName": {"default": "Maple Leafs"},
|
||||
"darkLogo": "http://example.com/TOR.svg",
|
||||
},
|
||||
"bottomSeedTeam": {
|
||||
"id": 9,
|
||||
"abbrev": "OTT",
|
||||
"name": {"default": "Ottawa Senators"},
|
||||
"commonName": {"default": "Senators"},
|
||||
"darkLogo": "http://example.com/OTT.svg",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_bracket_renders_with_cache(self, flask_client, monkeypatch):
|
||||
import app.routes as routes
|
||||
monkeypatch.setattr(routes, "get_bracket", lambda y: (self._BRACKET, 123))
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"refresh_bracket",
|
||||
lambda y=None: (_ for _ in ()).throw(AssertionError("should not refetch")),
|
||||
)
|
||||
|
||||
response = flask_client.get("/bracket")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode("utf-8")
|
||||
assert "Stanley Cup Playoffs" in body
|
||||
assert "TOR" in body
|
||||
assert "OTT" in body
|
||||
|
||||
def test_bracket_falls_back_to_fresh_fetch_when_cache_empty(
|
||||
self, flask_client, monkeypatch
|
||||
):
|
||||
import app.routes as routes
|
||||
monkeypatch.setattr(routes, "get_bracket", lambda y: (None, None))
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_refresh(year=None):
|
||||
called["n"] += 1
|
||||
return self._BRACKET
|
||||
|
||||
monkeypatch.setattr(routes, "refresh_bracket", fake_refresh)
|
||||
|
||||
response = flask_client.get("/bracket")
|
||||
assert response.status_code == 200
|
||||
assert called["n"] == 1
|
||||
|
||||
def test_bracket_returns_404_when_no_data(self, flask_client, monkeypatch):
|
||||
import app.routes as routes
|
||||
monkeypatch.setattr(routes, "get_bracket", lambda y: (None, None))
|
||||
monkeypatch.setattr(routes, "refresh_bracket", lambda year=None: None)
|
||||
response = flask_client.get("/bracket")
|
||||
assert response.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user