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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(_):
|
||||
|
||||
Reference in New Issue
Block a user