feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped

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:
2026-04-19 12:47:31 -04:00
parent e0db8f0859
commit ebe770fecd
21 changed files with 3163 additions and 31 deletions
+49
View File
@@ -19,6 +19,8 @@ def make_game(
game_type=2,
situation=None,
series_status=None,
home_abbrev="TOR",
away_abbrev="BOS",
):
clock = {
"timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}",
@@ -33,6 +35,7 @@ def make_game(
"clock": clock,
"homeTeam": {
"name": {"default": home_name},
"abbrev": home_abbrev,
"score": home_score,
"sog": 15,
"logo": "https://example.com/home.png",
@@ -40,6 +43,7 @@ def make_game(
},
"awayTeam": {
"name": {"default": away_name},
"abbrev": away_abbrev,
"score": away_score,
"sog": 12,
"logo": "https://example.com/away.png",
@@ -52,6 +56,49 @@ def make_game(
}
def make_playoff_game(
top_wins=0,
bottom_wins=0,
round_num=1,
series_letter="A",
top_abbrev="TOR",
bottom_abbrev="BOS",
top_is_home=True,
game_state="LIVE",
**kwargs,
):
"""Convenience wrapper around make_game for playoff fixtures.
`top_is_home` controls which side of the matchup hosts this game, so tests
for the blurb copy (leader vs. trailer names) don't have to juggle raw dicts.
"""
series_status = {
"round": round_num,
"topSeedWins": top_wins,
"bottomSeedWins": bottom_wins,
"seriesLetter": series_letter,
"topSeedTeamAbbrev": top_abbrev,
"bottomSeedTeamAbbrev": bottom_abbrev,
}
if top_is_home:
home_abbrev, away_abbrev = top_abbrev, bottom_abbrev
home_name, away_name = "Top Seeds", "Bottom Seeds"
else:
home_abbrev, away_abbrev = bottom_abbrev, top_abbrev
home_name, away_name = "Bottom Seeds", "Top Seeds"
return make_game(
game_state=game_state,
game_type=3,
series_status=series_status,
home_abbrev=kwargs.pop("home_abbrev", home_abbrev),
away_abbrev=kwargs.pop("away_abbrev", away_abbrev),
home_name=kwargs.pop("home_name", home_name),
away_name=kwargs.pop("away_name", away_name),
**kwargs,
)
LIVE_GAME = make_game()
PRE_GAME = make_game(
game_state="FUT", home_score=0, away_score=0, period=0, seconds_remaining=1200
@@ -89,9 +136,11 @@ def flask_client(tmp_path, monkeypatch):
# Patch module-level path constants so no reloads are needed
import app.routes as routes
import app.games as games
import app.playoff_cache as playoff_cache
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(scoreboard_file))
monkeypatch.setattr(games, "DB_PATH", str(db_path))
monkeypatch.setattr(playoff_cache, "DB_PATH", str(db_path))
from app import app as flask_app
+124
View File
@@ -0,0 +1,124 @@
from app.bracket_view import build_bracket_view
def _series(letter, top_abbrev, top_id, top_wins, bot_abbrev, bot_id, bot_wins,
rnd=1, winning_id=None, top_seed="D1", bot_seed="WC1"):
return {
"seriesLetter": letter,
"playoffRound": rnd,
"topSeedWins": top_wins,
"bottomSeedWins": bot_wins,
"topSeedRankAbbrev": top_seed,
"bottomSeedRankAbbrev": bot_seed,
"winningTeamId": winning_id,
"topSeedTeam": {
"id": top_id,
"abbrev": top_abbrev,
"name": {"default": f"{top_abbrev} Team"},
"commonName": {"default": top_abbrev},
"darkLogo": f"http://example.com/{top_abbrev}_dark.svg",
},
"bottomSeedTeam": {
"id": bot_id,
"abbrev": bot_abbrev,
"name": {"default": f"{bot_abbrev} Team"},
"commonName": {"default": bot_abbrev},
"darkLogo": f"http://example.com/{bot_abbrev}_dark.svg",
},
}
class TestEmptyBracket:
def test_empty_payload_returns_all_placeholders(self):
view = build_bracket_view(2026, {"series": []})
assert len(view["east_r1"]) == 4
assert len(view["west_r1"]) == 4
assert len(view["east_r2"]) == 2
assert len(view["west_r2"]) == 2
assert len(view["east_cf"]) == 1
assert len(view["west_cf"]) == 1
assert len(view["cup"]) == 1
for slot in view["east_r1"]:
assert slot["empty"] is True
assert slot["series_id"].startswith("2026-")
def test_none_payload_is_safe(self):
view = build_bracket_view(2026, None)
assert all(s["empty"] for s in view["east_r1"])
class TestMatchupStates:
def test_complete_series_marks_winner(self):
s = _series("A", "TOR", 10, 4, "OTT", 9, 2, winning_id=10)
view = build_bracket_view(2026, {"series": [s]})
a = view["east_r1"][0]
assert a["empty"] is False
assert a["state"] == "complete"
assert a["winner_abbrev"] == "TOR"
assert a["top_wins"] == 4
assert a["bottom_wins"] == 2
def test_active_series_has_wins_but_no_winner(self):
s = _series("B", "FLA", 13, 2, "TBL", 14, 1)
view = build_bracket_view(2026, {"series": [s]})
b = view["east_r1"][1]
assert b["state"] == "active"
assert b["winner_abbrev"] is None
def test_upcoming_series_zero_zero(self):
s = _series("C", "WSH", 15, 0, "MTL", 8, 0)
view = build_bracket_view(2026, {"series": [s]})
c = view["east_r1"][2]
assert c["state"] == "upcoming"
class TestRoutingToRounds:
def test_round_1_east_vs_west_by_letter(self):
series = [
_series("A", "T1", 1, 1, "T2", 2, 0),
_series("E", "T3", 3, 1, "T4", 4, 0),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_r1"][0]["top"]["abbrev"] == "T1"
assert view["west_r1"][0]["top"]["abbrev"] == "T3"
def test_round_2_routing(self):
series = [
_series("I", "T1", 1, 0, "T2", 2, 0, rnd=2),
_series("K", "T3", 3, 0, "T4", 4, 0, rnd=2),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_r2"][0]["top"]["abbrev"] == "T1"
assert view["west_r2"][0]["top"]["abbrev"] == "T3"
def test_conf_finals_routing(self):
series = [
_series("M", "T1", 1, 0, "T2", 2, 0, rnd=3),
_series("N", "T3", 3, 0, "T4", 4, 0, rnd=3),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_cf"][0]["top"]["abbrev"] == "T1"
assert view["west_cf"][0]["top"]["abbrev"] == "T3"
def test_cup_final_routing(self):
series = [_series("O", "T1", 1, 2, "T2", 2, 4, rnd=4, winning_id=2)]
view = build_bracket_view(2026, {"series": series})
assert view["cup"][0]["bottom"]["abbrev"] == "T2"
assert view["cup"][0]["winner_abbrev"] == "T2"
class TestSeriesIdLink:
def test_series_id_format(self):
s = _series("A", "TOR", 10, 0, "OTT", 9, 0)
view = build_bracket_view(2026, {"series": [s]})
assert view["east_r1"][0]["series_id"] == "2026-A"
class TestRoundsAccordionBundle:
def test_rounds_has_four_entries(self):
view = build_bracket_view(2026, {"series": []})
assert len(view["rounds"]) == 4
assert view["rounds"][0]["label"] == "First Round"
assert view["rounds"][3]["label"] == "Stanley Cup Final"
assert "east" in view["rounds"][0]
assert "cup" in view["rounds"][3]
+102 -1
View File
@@ -1,5 +1,5 @@
import app.games
from tests.conftest import make_game
from tests.conftest import make_game, make_playoff_game
from app.games import (
_get_man_advantage,
calculate_game_importance,
@@ -745,6 +745,107 @@ class TestGetComebackBonus:
assert result == 70 # 50*1.0 + 20
class TestPlayoffEnrichment:
_FULL_STANDINGS = {
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
}
def test_regular_season_game_has_empty_playoff_fields(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
result = parse_games({"games": [make_game()]})
g = result[0]
assert g["Is Playoff"] is False
assert g["Pinned"] is False
assert g["Playoff OT"] is False
assert g["Series Blurb"] == ""
assert g["Series Badges"] == []
def test_playoff_game_gets_series_fields(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(top_wins=2, bottom_wins=1, round_num=1)
result = parse_games({"games": [game]})
g = result[0]
assert g["Is Playoff"] is True
assert g["Pinned"] is False
assert "Game 4" in g["Series Blurb"]
assert "R1" in g["Series Badges"]
def test_game7_is_pinned(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(top_wins=3, bottom_wins=3)
result = parse_games({"games": [game]})
assert result[0]["Pinned"] is True
assert "GAME 7" in result[0]["Series Badges"]
def test_pinned_game_sorts_first(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
# Low-priority Game 7 PRE should sort above a high-priority non-playoff LIVE
g7_pre = make_playoff_game(
top_wins=3,
bottom_wins=3,
game_state="FUT",
period=0,
seconds_remaining=1200,
start_time_utc="2026-04-20T23:00:00Z",
home_name="Kings",
away_name="Oilers",
)
hype_live = make_game(
game_state="LIVE",
home_name="Rangers",
away_name="Devils",
home_score=2,
away_score=2,
period=3,
seconds_remaining=60,
)
result = parse_games({"games": [hype_live, g7_pre]})
assert result[0]["Home Team"] == "Kings"
assert result[0]["Pinned"] is True
def test_playoff_ot_flagged(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(
top_wins=1,
bottom_wins=1,
period=4,
seconds_remaining=600,
game_state="LIVE",
)
result = parse_games({"games": [game]})
assert result[0]["Playoff OT"] is True
assert result[0]["OT Label"] == "OT"
def test_regular_season_ot_not_flagged_as_playoff_ot(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
game = make_game(
game_state="LIVE", period=4, seconds_remaining=180, game_type=2
)
result = parse_games({"games": [game]})
assert result[0]["Playoff OT"] is False
assert result[0]["OT Label"] == ""
class TestCalculateGameImportance:
def _standings(
self,
+205
View File
@@ -0,0 +1,205 @@
import json
import time
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from app import playoff_cache
EASTERN = ZoneInfo("America/New_York")
@pytest.fixture
def tmp_db(tmp_path, monkeypatch):
db_path = tmp_path / "playoff_cache.db"
monkeypatch.setattr(playoff_cache, "DB_PATH", str(db_path))
return str(db_path)
class _Resp:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
import requests
raise requests.HTTPError(f"HTTP {self.status_code}")
class TestParseSeriesId:
def test_valid(self):
assert playoff_cache.parse_series_id("2026-A") == ("20252026", "A")
def test_lowercase_rejected(self):
assert playoff_cache.parse_series_id("2026-a") is None
def test_invalid_letter(self):
assert playoff_cache.parse_series_id("2026-Q") is None
def test_malformed(self):
assert playoff_cache.parse_series_id("abc") is None
def test_none(self):
assert playoff_cache.parse_series_id(None) is None
class TestBracket:
def test_refresh_success_stores_payload(self, tmp_db, monkeypatch):
payload = {"series": [{"seriesLetter": "A"}], "year": 2026}
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: _Resp(payload),
)
result = playoff_cache.refresh_bracket(2026)
assert result == payload
cached, fetched = playoff_cache.get_bracket(2026)
assert cached == payload
assert fetched is not None
def test_refresh_failure_returns_none(self, tmp_db, monkeypatch):
import requests
def raiser(*a, **kw):
raise requests.ConnectionError("boom")
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
assert playoff_cache.refresh_bracket(2026) is None
def test_get_bracket_empty(self, tmp_db):
payload, fetched = playoff_cache.get_bracket(2026)
assert payload is None and fetched is None
class TestFetchSeries:
def test_success_stores_and_returns(self, tmp_db, monkeypatch):
payload = {"seriesLetter": "A", "games": []}
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: _Resp(payload),
)
result = playoff_cache.fetch_series("2026-A")
assert result == payload
def test_invalid_id_returns_none(self, tmp_db):
assert playoff_cache.fetch_series("garbage") is None
def test_cache_hit_skips_network(self, tmp_db, monkeypatch):
payload_cached = {"from": "cache"}
playoff_cache._put(playoff_cache.series_key("20252026", "A"), payload_cached)
def should_not_be_called(*a, **kw):
raise AssertionError("network should not be called within TTL")
monkeypatch.setattr("app.playoff_cache.requests.get", should_not_be_called)
assert playoff_cache.fetch_series("2026-A") == payload_cached
def test_stale_cache_falls_back_on_failure(self, tmp_db, monkeypatch):
import requests
key = playoff_cache.series_key("20252026", "A")
playoff_cache._put(key, {"from": "stale"})
# Force the cached row to look older than the TTL but within MAX_STALE
with playoff_cache._connect() as conn:
old_ts = int(time.time()) - (playoff_cache.SERIES_TTL + 60)
conn.execute(
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
(old_ts, key),
)
conn.commit()
def raiser(*a, **kw):
raise requests.ConnectionError("network gone")
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
assert playoff_cache.fetch_series("2026-A") == {"from": "stale"}
def test_ancient_stale_cache_returns_none(self, tmp_db, monkeypatch):
import requests
key = playoff_cache.series_key("20252026", "A")
playoff_cache._put(key, {"from": "ancient"})
with playoff_cache._connect() as conn:
ancient_ts = int(time.time()) - (playoff_cache.MAX_STALE_SECONDS + 60)
conn.execute(
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
(ancient_ts, key),
)
conn.commit()
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("x")),
)
assert playoff_cache.fetch_series("2026-A") is None
class TestRecordStartDate:
def test_no_playoff_games_no_write(self, tmp_db):
result = playoff_cache.record_start_date_if_missing([{"gameType": 2}])
assert result is None
assert playoff_cache.get_playoff_start_date() is None
def test_records_on_first_playoff_sighting(self, tmp_db):
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
result = playoff_cache.record_start_date_if_missing(
[{"gameType": 3}], now=now
)
assert result == "2026-04-18"
assert playoff_cache.get_playoff_start_date().isoformat() == "2026-04-18"
def test_idempotent_after_first_write(self, tmp_db):
first_now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
second_now = datetime(2026, 4, 25, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=first_now)
# Second call should not overwrite
result = playoff_cache.record_start_date_if_missing(
[{"gameType": 3}], now=second_now
)
assert result == "2026-04-18"
class TestDayN:
def test_no_start_date(self, tmp_db):
assert playoff_cache.day_n() == (None, None)
def test_day_one(self, tmp_db):
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=now)
n, total = playoff_cache.day_n(now=now)
assert n == 1
assert total == 60
def test_day_five(self, tmp_db):
start = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
later = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=start)
n, _ = playoff_cache.day_n(now=later)
assert n == 5
class TestSchema:
def test_table_created_on_first_use(self, tmp_db):
# Accessing _get triggers create_cache_table
payload, fetched = playoff_cache._get("missing")
assert payload is None
conn = playoff_cache._connect()
try:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='playoff_cache'"
)
assert cur.fetchone() is not None
finally:
conn.close()
def test_put_upserts(self, tmp_db):
playoff_cache._put("k", {"v": 1})
playoff_cache._put("k", {"v": 2})
cached, _ = playoff_cache._get("k")
assert cached == {"v": 2}
+271
View File
@@ -0,0 +1,271 @@
import pytest
from app.playoff import (
is_pinned,
is_playoff_game,
is_playoff_ot,
ot_label,
series_badges,
series_blurb,
series_state,
series_summary,
today_meta,
)
from tests.conftest import make_game, make_playoff_game
class TestSeriesState:
def test_empty_returns_defaults(self):
state = series_state({})
assert state["is_opener"] is True
assert state["game_number"] == 1
assert state["round"] == 1
assert state["leader"] is None
@pytest.mark.parametrize(
"top,bot,expected_game",
[(0, 0, 1), (1, 0, 2), (1, 1, 3), (2, 1, 4), (2, 2, 5), (3, 2, 6), (3, 3, 7)],
)
def test_game_number_computation(self, top, bot, expected_game):
state = series_state({"round": 1, "topSeedWins": top, "bottomSeedWins": bot})
assert state["game_number"] == expected_game
def test_game7_predicate(self):
state = series_state({"round": 1, "topSeedWins": 3, "bottomSeedWins": 3})
assert state["is_game7"] is True
assert state["is_clincher"] is False
assert state["is_pivotal"] is False
def test_clincher_predicate(self):
state = series_state({"round": 1, "topSeedWins": 3, "bottomSeedWins": 1})
assert state["is_clincher"] is True
assert state["is_elimination"] is True
assert state["is_game7"] is False
def test_pivotal_predicate(self):
state = series_state({"round": 2, "topSeedWins": 2, "bottomSeedWins": 2})
assert state["is_pivotal"] is True
assert state["is_game7"] is False
assert state["is_clincher"] is False
def test_opener_predicate(self):
state = series_state({"round": 1, "topSeedWins": 0, "bottomSeedWins": 0})
assert state["is_opener"] is True
def test_leader_top(self):
state = series_state({"round": 1, "topSeedWins": 2, "bottomSeedWins": 1})
assert state["leader"] == "top"
assert state["hi"] == 2 and state["lo"] == 1
def test_leader_bottom(self):
state = series_state({"round": 1, "topSeedWins": 1, "bottomSeedWins": 2})
assert state["leader"] == "bottom"
def test_no_leader_when_tied(self):
state = series_state({"round": 1, "topSeedWins": 1, "bottomSeedWins": 1})
assert state["leader"] is None
class TestSeriesBlurb:
def test_opener_blurb(self):
game = make_playoff_game(top_wins=0, bottom_wins=0)
assert series_blurb(game) == "Series opener."
def test_game7_blurb(self):
game = make_playoff_game(top_wins=3, bottom_wins=3)
assert series_blurb(game) == "Win-or-go-home \u2014 Game 7."
def test_clincher_blurb_names_leader(self):
game = make_playoff_game(
top_wins=3,
bottom_wins=1,
top_abbrev="TOR",
bottom_abbrev="BOS",
top_is_home=True,
)
blurb = series_blurb(game)
assert "Top Seeds" in blurb
assert "close it out" in blurb
assert "Game 5" in blurb
def test_pivotal_blurb(self):
game = make_playoff_game(top_wins=2, bottom_wins=2)
assert "pivotal" in series_blurb(game).lower()
assert "Game 5" in series_blurb(game)
def test_leader_trailer_blurb(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
blurb = series_blurb(game)
assert "leads" in blurb
assert "Game 4" in blurb
def test_tied_mid_series_blurb(self):
game = make_playoff_game(top_wins=1, bottom_wins=1)
blurb = series_blurb(game)
assert "1" in blurb
assert "Game 3" in blurb
class TestSeriesBadges:
def test_round_1_always_first(self):
game = make_playoff_game(round_num=1, top_wins=1, bottom_wins=0)
assert series_badges(game)[0] == "R1"
def test_cup_final_label(self):
game = make_playoff_game(round_num=4, top_wins=0, bottom_wins=0)
assert series_badges(game)[0] == "CUP FINAL"
def test_conf_final_label(self):
game = make_playoff_game(round_num=3, top_wins=0, bottom_wins=0)
assert series_badges(game)[0] == "CONF FINAL"
def test_game7_badge(self):
game = make_playoff_game(top_wins=3, bottom_wins=3)
assert "GAME 7" in series_badges(game)
def test_clincher_badge(self):
game = make_playoff_game(top_wins=3, bottom_wins=1)
assert "CLINCHER" in series_badges(game)
def test_pivotal_badge(self):
game = make_playoff_game(top_wins=2, bottom_wins=2)
assert "PIVOTAL" in series_badges(game)
def test_opener_has_no_stake_badge(self):
badges = series_badges(make_playoff_game(top_wins=0, bottom_wins=0))
assert badges == ["R1"]
class TestSeriesSummary:
def test_opener_summary(self):
game = make_playoff_game(top_wins=0, bottom_wins=0, round_num=1)
assert "Round 1" in series_summary(game)
def test_leader_summary(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
assert "leads" in series_summary(game)
def test_tied_mid_series_summary(self):
game = make_playoff_game(top_wins=1, bottom_wins=1)
assert "tied" in series_summary(game).lower()
class TestIsPinned:
def test_game7_live_is_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="LIVE")
assert is_pinned(game) is True
def test_game7_pre_is_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="FUT")
assert is_pinned(game) is True
def test_game7_final_not_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="OFF")
assert is_pinned(game) is False
def test_non_game7_not_pinned(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
assert is_pinned(game) is False
def test_regular_season_not_pinned(self):
game = make_game() # game_type=2, no series
assert is_pinned(game) is False
class TestIsPlayoffOt:
def test_playoff_period_4_live(self):
game = make_playoff_game(period=4, game_state="LIVE")
assert is_playoff_ot(game) is True
def test_playoff_period_5_live(self):
game = make_playoff_game(period=5, game_state="LIVE")
assert is_playoff_ot(game) is True
def test_playoff_period_3_not_ot(self):
game = make_playoff_game(period=3, game_state="LIVE")
assert is_playoff_ot(game) is False
def test_regular_season_ot_not_playoff_ot(self):
game = make_game(period=4, game_state="LIVE", game_type=2)
assert is_playoff_ot(game) is False
def test_crit_state_counts_as_live(self):
game = make_playoff_game(period=4, game_state="CRIT")
assert is_playoff_ot(game) is True
def test_final_state_not_playoff_ot(self):
game = make_playoff_game(period=4, game_state="OFF")
assert is_playoff_ot(game) is False
class TestOtLabel:
def test_period_4_is_ot(self):
assert ot_label(4) == "OT"
def test_period_5_is_2ot(self):
assert ot_label(5) == "2OT"
def test_period_6_is_3ot(self):
assert ot_label(6) == "3OT"
def test_pre_ot_returns_empty(self):
assert ot_label(3) == ""
assert ot_label(0) == ""
class TestIsPlayoffGame:
def test_playoff_raw_shape(self):
assert is_playoff_game(make_playoff_game()) is True
def test_regular_raw_shape(self):
assert is_playoff_game(make_game(game_type=2)) is False
def test_parsed_shape(self):
# parse_games produces {"Game Type": 3} — is_playoff_game should handle both
assert is_playoff_game({"Game Type": 3}) is True
assert is_playoff_game({"Game Type": 2}) is False
class TestTodayMeta:
def test_no_playoff_games_off_mode(self):
meta = today_meta([make_game(game_type=2)])
assert meta["playoff_mode"] is False
assert meta["round_label"] is None
def test_playoff_games_on_mode(self):
games = [make_playoff_game(series_letter="A"), make_playoff_game(series_letter="B")]
meta = today_meta(games)
assert meta["playoff_mode"] is True
assert meta["series_active"] == 2
assert meta["round_label"] == "First Round"
def test_counts_game7(self):
games = [
make_playoff_game(top_wins=3, bottom_wins=3, series_letter="A"),
make_playoff_game(top_wins=1, bottom_wins=0, series_letter="B"),
]
meta = today_meta(games)
assert meta["game7_count"] == 1
assert meta["elimination_count"] == 0
def test_counts_elimination_games(self):
games = [
make_playoff_game(top_wins=3, bottom_wins=1, series_letter="A"),
make_playoff_game(top_wins=3, bottom_wins=2, series_letter="B"),
]
meta = today_meta(games)
assert meta["elimination_count"] == 2
assert meta["game7_count"] == 0
def test_round_label_reflects_highest_active_round(self):
games = [
make_playoff_game(round_num=1, series_letter="A"),
make_playoff_game(round_num=2, series_letter="I"),
]
meta = today_meta(games)
assert meta["round_label"] == "Second Round"
def test_cup_final_label(self):
games = [make_playoff_game(round_num=4, series_letter="P")]
meta = today_meta(games)
assert meta["round_label"] == "Stanley Cup Final"
+217 -1
View File
@@ -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
+25
View File
@@ -6,6 +6,7 @@ from app.scheduler import start_scheduler
class TestStartScheduler:
def test_registers_standings_refresh_every_600_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
@@ -16,6 +17,7 @@ class TestStartScheduler:
def test_registers_score_refresh_every_10_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
@@ -24,8 +26,30 @@ class TestStartScheduler:
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 10 in intervals
def test_registers_bracket_refresh_every_3600_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 3600 in intervals
def test_invokes_bracket_refresh_eagerly_at_startup(self, mocker):
mocker.patch("app.scheduler.schedule")
eager = mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
assert eager.called
def test_runs_pending_on_each_tick(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
call_count = {"n": 0}
def sleep_twice(_):
@@ -42,6 +66,7 @@ class TestStartScheduler:
def test_continues_after_exception_in_run_pending(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
call_count = {"n": 0}
def raise_then_stop(_):