fix: anchor Day N to each round's first game instead of lazy first sighting
The banner read "Day 1 of ~60" on day 2 of the playoffs because the old anchor recorded whatever date we first polled a playoff game as Day 1. Now round start dates come from /v1/schedule/playoff-series, so Day N is authoritative and resets at each round boundary. Drops the noisy "of ~60" denominator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+97
-31
@@ -144,46 +144,112 @@ class TestFetchSeries:
|
||||
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
|
||||
class TestRefreshRoundStartDates:
|
||||
def test_no_bracket_returns_none(self, tmp_db):
|
||||
assert playoff_cache.refresh_round_start_dates(2026) 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
|
||||
def test_anchors_round_to_earliest_game(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{"series": [{"seriesLetter": "A", "playoffRound": 1}]},
|
||||
)
|
||||
assert result == "2026-04-18"
|
||||
series_payload = {
|
||||
"games": [
|
||||
{"startTimeUTC": "2026-04-19T23:00:00Z"},
|
||||
{"startTimeUTC": "2026-04-18T23:00:00Z"},
|
||||
{"startTimeUTC": "2026-04-21T23:00:00Z"},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp(series_payload),
|
||||
)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged == {"1": "2026-04-18"}
|
||||
assert playoff_cache.get_round_start_date(1).isoformat() == "2026-04-18"
|
||||
|
||||
def test_merges_multiple_rounds_min_per_round(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{
|
||||
"series": [
|
||||
{"seriesLetter": "A", "playoffRound": 1},
|
||||
{"seriesLetter": "B", "playoffRound": 1},
|
||||
{"seriesLetter": "I", "playoffRound": 2},
|
||||
]
|
||||
},
|
||||
)
|
||||
payloads = {
|
||||
"A": {"games": [{"startTimeUTC": "2026-04-19T23:00:00Z"}]},
|
||||
"B": {"games": [{"startTimeUTC": "2026-04-18T23:00:00Z"}]},
|
||||
"I": {"games": [{"startTimeUTC": "2026-04-29T23:00:00Z"}]},
|
||||
}
|
||||
|
||||
def fake_get(url, *a, **kw):
|
||||
letter = url.rstrip("/").rsplit("/", 1)[-1].upper()
|
||||
return _Resp(payloads[letter])
|
||||
|
||||
monkeypatch.setattr("app.playoff_cache.requests.get", fake_get)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged == {"1": "2026-04-18", "2": "2026-04-29"}
|
||||
|
||||
def test_preserves_existing_rounds_on_merge(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18", "2": "2026-04-29"}
|
||||
)
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{"series": [{"seriesLetter": "M", "playoffRound": 3}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp(
|
||||
{"games": [{"startTimeUTC": "2026-05-15T23:00:00Z"}]}
|
||||
),
|
||||
)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged["1"] == "2026-04-18"
|
||||
assert merged["2"] == "2026-04-29"
|
||||
assert merged["3"] == "2026-05-15"
|
||||
|
||||
|
||||
class TestDayN:
|
||||
def test_no_start_date(self, tmp_db):
|
||||
assert playoff_cache.day_n() == (None, None)
|
||||
class TestDayNForRound:
|
||||
def test_no_round_num(self, tmp_db):
|
||||
assert playoff_cache.day_n_for_round(None) is None
|
||||
|
||||
def test_round_not_anchored(self, tmp_db):
|
||||
assert playoff_cache.day_n_for_round(1) is None
|
||||
|
||||
def test_day_one(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
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
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 1
|
||||
|
||||
def test_day_two(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
now = datetime(2026, 4, 19, 10, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 2
|
||||
|
||||
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
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
now = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 5
|
||||
|
||||
def test_round_two_resets_to_day_one(self, tmp_db):
|
||||
playoff_cache._put(
|
||||
playoff_cache.ROUND_DATES_KEY,
|
||||
{"1": "2026-04-18", "2": "2026-04-29"},
|
||||
)
|
||||
now = datetime(2026, 4, 29, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(2, now=now) == 1
|
||||
|
||||
def test_before_start_returns_none(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"2": "2026-04-29"})
|
||||
now = datetime(2026, 4, 20, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(2, now=now) is None
|
||||
|
||||
|
||||
class TestSchema:
|
||||
|
||||
+33
-5
@@ -3,10 +3,15 @@ import pytest
|
||||
from app.scheduler import start_scheduler
|
||||
|
||||
|
||||
def _patch_eager(mocker):
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
mocker.patch("app.scheduler.refresh_round_start_dates")
|
||||
|
||||
|
||||
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")
|
||||
_patch_eager(mocker)
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -17,7 +22,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")
|
||||
_patch_eager(mocker)
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -28,7 +33,7 @@ class TestStartScheduler:
|
||||
|
||||
def test_registers_bracket_refresh_every_3600_seconds(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
_patch_eager(mocker)
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -37,9 +42,32 @@ class TestStartScheduler:
|
||||
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
|
||||
assert 3600 in intervals
|
||||
|
||||
def test_registers_round_start_dates_refresh_every_21600_seconds(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
_patch_eager(mocker)
|
||||
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 21600 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.refresh_round_start_dates")
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
start_scheduler()
|
||||
|
||||
assert eager.called
|
||||
|
||||
def test_invokes_round_start_dates_refresh_eagerly_at_startup(self, mocker):
|
||||
mocker.patch("app.scheduler.schedule")
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
eager = mocker.patch("app.scheduler.refresh_round_start_dates")
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -49,7 +77,7 @@ class TestStartScheduler:
|
||||
|
||||
def test_runs_pending_on_each_tick(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
_patch_eager(mocker)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def sleep_twice(_):
|
||||
@@ -66,7 +94,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")
|
||||
_patch_eager(mocker)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def raise_then_stop(_):
|
||||
|
||||
Reference in New Issue
Block a user