Auto merge dev → main (#1361)
* fix: don't freeze tournament brackets that overrun their scheduled end date_end (TSS dateEndTournament) is only the *scheduled* end; tournaments overrun it. compute_status now trusts in_active (GetActiveTournaments presence) over date_end, and needs_scan no longer disables re-scans past date_end + buffer. Fixes brackets stuck while their final was still pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: split _store_scan_sync into reusable upsert helpers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: store_schedule writes meta/matches/standings without wiping battles Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: extract gather_structure_sync; add fetch_schedule_sync (no battles) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: reconcile_battles gap-fills replay links for played matches only Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: debounced schedule refresh coalesces game bursts into one TSS call Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: maybe_link_battle links replays from game match_id with zero TSS calls Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: route received games through maybe_link_battle (fast-path bracket link) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * tss_tournaments: three small final-review cleanups - link_battle_sync: drop unused team_a_name/team_b_name from SELECT and fix status index from r[3] to r[1] - _replace_battles: add clarifying comment about fast-path caveat and that schedule refresh deliberately skips this function - store_scan / store_schedule: dispatch sqlite writes off the event loop via run_in_thread instead of calling sync writers directly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -222,14 +222,50 @@ def test_compute_status():
|
||||
assert tt.compute_status([], None, 1000) == "pending"
|
||||
assert tt.compute_status(played, None, 1000) == "finished"
|
||||
assert tt.compute_status(mixed, None, 1000) == "active"
|
||||
# past end + buffer → finished even with pending matches
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "finished"
|
||||
# A tournament still listed as active is NOT finished just because it overran
|
||||
# its *scheduled* dateEndTournament — finals routinely get played late.
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "active"
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||
in_active=True) == "active"
|
||||
# absent from the active list → finished (old tournament, no date_end)
|
||||
assert tt.compute_status(mixed, None, 1000, in_active=False) == "finished"
|
||||
# unknown active-list state should not finish a tournament by absence alone
|
||||
# active-list state unknown → fall back to the scheduled end + buffer
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||
in_active=None) == "finished"
|
||||
# unknown active-list state without a past end should not finish by absence alone
|
||||
assert tt.compute_status(mixed, None, 1000, in_active=None) == "active"
|
||||
|
||||
|
||||
def test_needs_scan_overrun_keeps_scanning():
|
||||
# A live tournament past its *scheduled* end must stay eligible for re-scan so
|
||||
# a late final still lands; only status=='finished' permanently stops re-scans.
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
now = 1_000_000
|
||||
match = {
|
||||
"match_id": "m1", "type_bracket": "Winner", "side": "winner",
|
||||
"round": 0, "position": 0, "team_a_uuid": None, "team_a_name": "A",
|
||||
"team_b_uuid": None, "team_b_name": "B", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 77777, "name": "Overrun Cup", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1,
|
||||
"date_end": now - 10 * tt.RESCAN_END_BUFFER_SECONDS, # long past scheduled end
|
||||
"team_count": 2, "match_count": 1, "status": "active",
|
||||
"scanned_unix": now - tt.RESCAN_INTERVAL_SECONDS - 1, # interval elapsed
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
assert await tt.needs_scan(77777, now=now) is True
|
||||
# Once truly finished, never re-scan again.
|
||||
scan["status"] = "finished"
|
||||
await tt.store_scan(scan)
|
||||
assert await tt.needs_scan(77777, now=now) is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_store_scan_roundtrip():
|
||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||
@@ -277,3 +313,180 @@ async def _restore_and_count(scan):
|
||||
"SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999"
|
||||
).fetchone()
|
||||
assert mcount == 2
|
||||
|
||||
|
||||
def test_fetch_schedule_sync_has_no_battles_and_makes_no_battle_calls(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, action, **params):
|
||||
calls.append(action)
|
||||
if action == "get_shedule_matches":
|
||||
return {"data": SCHEDULE}
|
||||
return {"data": {}}
|
||||
|
||||
monkeypatch.setattr(tt, "_request", fake_request)
|
||||
monkeypatch.setattr(tt, "_active_meta", lambda tid, now: ({}, True))
|
||||
|
||||
scan = tt.fetch_schedule_sync(12345, now=1000)
|
||||
assert scan["battles"] == []
|
||||
assert scan["match_count"] == len(SCHEDULE["all_matches"])
|
||||
assert "getListAllBattles" not in calls # the whole point: no per-match battle loop
|
||||
|
||||
|
||||
def test_store_schedule_preserves_battles():
|
||||
# A schedule refresh updates matches/standings/status but must NOT wipe the
|
||||
# battle links the fast path inserted.
|
||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||
battles, _ = tt.parse_battles(BATTLES_PLAYED, 88888, "686556", "Looser")
|
||||
full = {
|
||||
"tournament_id": 88888, "name": "Cup", "format": "double-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 2, "match_count": len(matches), "status": "active",
|
||||
"scanned_unix": 1000, "matches": matches, "battles": battles, "standings": [],
|
||||
}
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
await tt.store_scan(full) # seeds matches + battles
|
||||
# Now a schedule-only refresh with NO battles in the payload.
|
||||
sched = dict(full, status="finished", scanned_unix=2000, battles=[])
|
||||
await tt.store_schedule(sched)
|
||||
import sqlite3
|
||||
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||
n_battles = conn.execute(
|
||||
"SELECT COUNT(*) FROM tournament_battles WHERE tournament_id = 88888"
|
||||
).fetchone()[0]
|
||||
status = conn.execute(
|
||||
"SELECT status FROM tournaments WHERE tournament_id = 88888"
|
||||
).fetchone()[0]
|
||||
assert n_battles == len(battles) # battles preserved
|
||||
assert status == "finished" # meta updated
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_request_schedule_refresh_coalesces_burst(monkeypatch):
|
||||
fetched = []
|
||||
|
||||
def fake_fetch(tid, **kwargs):
|
||||
fetched.append(tid)
|
||||
return {
|
||||
"tournament_id": tid, "name": "T", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 0, "match_count": 0, "status": "active",
|
||||
"scanned_unix": 1000, "matches": [], "standings": [], "battles": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tt, "fetch_schedule_sync", fake_fetch)
|
||||
monkeypatch.setattr(tt, "_REFRESH_DEBOUNCE_SECONDS", 0.05)
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
for _ in range(8): # burst of 8 games for the same tournament
|
||||
tt.request_schedule_refresh(60002)
|
||||
await asyncio.sleep(0.2) # let the single debounced refresh fire
|
||||
|
||||
asyncio.run(run())
|
||||
assert fetched == [60002] # 8 requests collapsed into one fetch
|
||||
|
||||
|
||||
def test_link_battle_sync_links_known_match():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
match = {
|
||||
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
linked = tt.link_battle_sync(24836, "330669", "1abcd", winner_name="NEXOOOS")
|
||||
assert linked is True
|
||||
import sqlite3
|
||||
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT type_bracket FROM tournament_battles WHERE session_hex = '1abcd'"
|
||||
).fetchone()
|
||||
assert row == ("Swiss",) # type_bracket pulled from the stored match
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_link_battle_sync_returns_false_for_unknown_match():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
assert tt.link_battle_sync(999999, "nope", "deadbeef") is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_maybe_link_battle_known_match_makes_no_network_call(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("fast path must not call the TSS API")
|
||||
|
||||
monkeypatch.setattr(tt, "_request", boom)
|
||||
refreshed = []
|
||||
monkeypatch.setattr(tt, "request_schedule_refresh",
|
||||
lambda tid, **k: refreshed.append(tid))
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
match = {
|
||||
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
game = {
|
||||
"_id": "1abcd", "winner": "1",
|
||||
"tss": {"tournament_id": 24836, "match_id": "330669", "bracket": "swiss",
|
||||
"1": {"name": "NEXOOOS"}, "2": {"name": "GFRIS2"}},
|
||||
}
|
||||
await tt.maybe_link_battle(game)
|
||||
|
||||
asyncio.run(run())
|
||||
assert refreshed == [24836] # linked + scheduled a refresh, no network
|
||||
|
||||
|
||||
def test_reconcile_targets_only_played_without_battles():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
m_played = {
|
||||
"match_id": "p1", "type_bracket": "Winner", "side": "winner", "round": 0,
|
||||
"position": 0, "team_a_uuid": None, "team_a_name": "A", "team_b_uuid": None,
|
||||
"team_b_name": "B", "winner_name": "A", "score_a": 1, "score_b": 0,
|
||||
"status": "played", "time_start": None,
|
||||
}
|
||||
m_pending = dict(m_played, match_id="p2", status="pending", winner_name=None)
|
||||
m_linked = dict(m_played, match_id="p3")
|
||||
battle = {
|
||||
"session_hex": "abc", "session_decimal": "2748", "tournament_id": 70001,
|
||||
"match_id": "p3", "type_bracket": "Winner", "position": 0,
|
||||
"winner_name": "A", "status_replay": "view replay",
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 70001, "name": "T", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 2, "match_count": 3, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [m_played, m_pending, m_linked], "battles": [battle], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
targets = tt.reconcile_targets_sync(70001)
|
||||
ids = {t["match_id"] for t in targets}
|
||||
assert ids == {"p1"} # played + no battle; p2 pending excluded, p3 already linked
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
Reference in New Issue
Block a user