ebe770fecd
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>
206 lines
7.0 KiB
Python
206 lines
7.0 KiB
Python
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}
|