"""Deadline-scheduler tests, driven by the DummyEngine. Timeouts must be driven by the persisted deadlines and the shared queue, not by connected sockets: these tests seed sessions, enqueue their deadlines and let the background consumer fire them without a single websocket. The engine owns the meaning of each deadline; the scheduler owns enqueueing, delivery and removal. """ from __future__ import annotations import asyncio import unittest from typing import Any, Dict, Optional from tavolo.platform import GameSession, Seat from tavolo.platform.deadlines import encode from helpers import DummyEngine, async_test, make_platform, use_db def _started_session( game_id: str = "dl-1", code: str = "DL0001", target: int = 3, deadline_in_seconds: Optional[float] = None, ) -> GameSession: engine = DummyEngine() session = GameSession( id=game_id, game_type=engine.id, join_code=code, creator_sub="alice", players=[Seat(user_sub="alice", display_name="alice", team="A")], ) options: Dict[str, Any] = {"target": target} if deadline_in_seconds is not None: options["deadline_in_seconds"] = deadline_in_seconds engine.create(session, options) engine.join(session, "bob", "bob") return session async def _wait_for(predicate, timeout: float = 5.0): """Poll the store until ``predicate`` returns a truthy value.""" deadline = asyncio.get_running_loop().time() + timeout while asyncio.get_running_loop().time() < deadline: result = await predicate() if result: return result await asyncio.sleep(0.05) return None class ConnectionIndependenceTest(unittest.TestCase): @async_test async def test_tick_fires_with_no_connections(self) -> None: _, platform, _ = make_platform() store = platform.game_store scheduler = platform.scheduler session = _started_session(deadline_in_seconds=0.05) await store.save(session) await scheduler.sync_deadline(session) # Nobody ever connects: the consumer must still fire the tick, # which plays for the first player. result = await _wait_for( lambda: _plays_is(store, session.id, 1), ) self.assertIsNotNone(result, "deadline never fired") @async_test async def test_no_deadline_nothing_enqueued(self) -> None: _, platform, _ = make_platform() session = _started_session() # no deadline_in_seconds option await platform.game_store.save(session) await platform.scheduler.sync_deadline(session) self.assertIsNone(await platform.game_store.next_deadline()) async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]: session = await store.load(game_id) if session is not None and len(session.state["plays"]) == count: return session return None class ProcessDueTest(unittest.TestCase): """Direct ``process_due`` behaviour: engine revalidation and idempotency.""" @async_test async def test_processing_twice_is_a_no_op(self) -> None: # Simulates a worker dying after firing but before removing the # entry: another worker re-delivers the same entry. The engine's # token has moved on, so the second delivery is stale. _, platform, _ = make_platform() store = platform.game_store scheduler = platform.scheduler session = _started_session(deadline_in_seconds=3600) await store.save(session) deadline = DummyEngine().next_deadline(session) assert deadline is not None member = encode({ "game_id": session.id, "kind": deadline.kind, "token": deadline.token, }) await scheduler.process_due(member) await scheduler.process_due(member) result = await store.load(session.id) assert result is not None # Fired exactly once: one play, not two. self.assertEqual(["alice"], result.state["plays"]) @async_test async def test_stale_entry_is_discarded(self) -> None: # A tick enqueued before a play landed in time: the token has # moved, so the entry must not fire. _, platform, _ = make_platform() scheduler = platform.scheduler store = platform.game_store session = _started_session(deadline_in_seconds=3600) session.state["plays"].append("alice") # a play landed in time await store.save(session) member = encode({ "game_id": session.id, "kind": "tick", "token": "tick:0", # not the live token ("tick:1") }) await store.add_deadline(member, due_at=0.0) await scheduler.process_due(member) result = await store.load(session.id) assert result is not None self.assertEqual(["alice"], result.state["plays"]) # The entry was removed after processing. self.assertNotIn(member, await store.due_deadlines(float("inf"))) @async_test async def test_entry_for_expired_game_is_dropped(self) -> None: _, platform, _ = make_platform() scheduler = platform.scheduler store = platform.game_store member = encode({ "game_id": "dl-gone", "kind": "tick", "token": "tick:0", }) await store.add_deadline(member, due_at=0.0) await scheduler.process_due(member) self.assertNotIn(member, await store.due_deadlines(float("inf"))) @async_test async def test_malformed_entry_is_dropped(self) -> None: _, platform, _ = make_platform() scheduler = platform.scheduler store = platform.game_store await store.add_deadline("not json", due_at=0.0) await scheduler.process_due("not json") self.assertNotIn("not json", await store.due_deadlines(float("inf"))) @async_test async def test_finished_match_is_persisted_on_tick(self) -> None: # A tick that completes the match writes the result to Postgres. from tavolo.platform.models import Match _, platform, tortoise_mixin = make_platform() ctx = await use_db(tortoise_mixin) scheduler = platform.scheduler store = platform.game_store session = _started_session(target=1, deadline_in_seconds=3600) await store.save(session) deadline = DummyEngine().next_deadline(session) assert deadline is not None member = encode({ "game_id": session.id, "kind": deadline.kind, "token": deadline.token, }) with ctx: await scheduler.process_due(member) self.assertEqual(1, await Match.all().count()) if __name__ == "__main__": unittest.main()