"""Deadline-queue timeout tests (full platform stack). Timeouts must be driven by the persisted deadlines and the shared queue, not by connected sockets: these tests seed sessions, queue their deadlines and let the background consumer fire them without a single websocket. """ from __future__ import annotations import asyncio import unittest from datetime import datetime, timedelta, timezone from itertools import permutations from typing import Optional from tavolo.app import game_store, platform, scheduler, tortoise_mixin from tavolo.platform import GameSession, Seat from tavolo.platform.deadlines import DeadlineScheduler, encode from tavolo.platform.models import Match from tavolo.platform.registry import GameRegistry from tavolo.platform.store import InMemoryGameStore from tavolo.scopone.state import Card, PlayerState, ScoponeState from tests.helpers import async_test PLAYERS = ("alice", "bob", "carol", "dave") def _started_session( game_id: str, code: str, turn_timeout: int = 3600, hand_ack_timeout: int = 3600, game_type: str = "scopone_scientifico", ) -> GameSession: engine = platform.registry.require(game_type) session = GameSession( id=game_id, game_type=engine.id, join_code=code, creator_sub="alice", players=[Seat(user_sub="alice", display_name="Alice")], ) engine.create(session, {}) session.state.hand_ack_timeout = hand_ack_timeout session.state.turn_timeout = turn_timeout for name in PLAYERS[1:engine.max_players]: engine.join(session, name, name.capitalize()) return session def _hand_end_session(game_id: str, deadline: str) -> GameSession: """A session paused on the hand-end summary, waiting for acks.""" engine = platform.registry.require("scopone_scientifico") session = GameSession( id=game_id, game_type=engine.id, join_code="DLhend", creator_sub="alice", players=[ Seat(user_sub=name, display_name=name.capitalize(), team=team) for name, team in zip(PLAYERS, ("A", "B", "A", "B")) ], ) session.state = ScoponeState( target_score=11, phase="hand_end", players=[ PlayerState(sub=name, name=name.capitalize(), seat=i) for i, name in enumerate(PLAYERS) ], hand_ack_timeout=3600, # Long turn timeout: the next hand's auto-play must not interfere # with later tests sharing this store. turn_timeout=3600, hand_end_deadline=deadline, ) return session async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]: """Poll the store until ``predicate`` holds for the loaded session.""" deadline = asyncio.get_running_loop().time() + timeout while asyncio.get_running_loop().time() < deadline: session = await predicate() if session is not None: return session await asyncio.sleep(0.05) return None class ConnectionIndependenceTest(unittest.TestCase): @async_test async def test_turn_timeout_fires_with_no_connections(self) -> None: session = _started_session("dl-turn-1", "DLT001", turn_timeout=1) assert session.state.turn_deadline is not None await game_store.save(session) await scheduler.sync_deadline(session) # Nobody ever connects: the consumer must still auto-play for Bob # (seat 1, first to act). result = await _wait_for( lambda: _turn_is(session.id, 2), ) self.assertIsNotNone(result, "turn deadline never fired") assert result is not None self.assertEqual( 1, result.state.last_move.seat if result.state.last_move else None ) # Defuse the follow-on turn deadlines so this game cannot keep # auto-playing while later tests run. result.state.turn_timeout = 3600 await game_store.save(result) @async_test async def test_hand_end_timeout_fires_with_no_connections(self) -> None: deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat() session = _hand_end_session("dl-handend-1", deadline) await game_store.save(session) await scheduler.sync_deadline(session) # Nobody acks (nobody is even connected): the deadline must deal # the next hand. result = await _wait_for( lambda: _phase_is("dl-handend-1", "playing"), ) self.assertIsNotNone(result, "hand-end deadline never fired") assert result is not None self.assertEqual(2, result.state.hand_number) self.assertEqual([], result.state.acked) @async_test async def test_turn_timeout_finishing_match_persists_result(self) -> None: # The match-deciding last turn auto-played by the deadline consumer # runs in a task with no request context: the result must still # reach Postgres. Previously the write raised and the deadline # retried forever, leaving the game stuck on the last turn. session = _started_session("dl-final-1", "DLF001", turn_timeout=1) state = session.state state.players[0].hand = [Card.parse("02D")] for player in state.players[1:]: player.hand = [] state.table = [Card.parse("02C")] state.turn = 0 state.target_score = 1 state.turn_deadline = ( datetime.now(timezone.utc) + timedelta(seconds=1) ).isoformat() await game_store.save(session) await scheduler.sync_deadline(session) # Nobody plays: the consumer auto-plays the last card, which ends # the hand and the match. result = await _wait_for(lambda: _phase_is(session.id, "finished")) self.assertIsNotNone(result, "turn deadline never finished the match") assert result is not None self.assertEqual(0, result.state.winner) self.assertTrue(result.stats_saved) await tortoise_mixin.ensure_context() ctx = tortoise_mixin._ctx assert ctx is not None with ctx: self.assertEqual(1, await Match.all().count()) async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]: session = await game_store.load(game_id) return session if session is not None and session.state.turn == turn else None async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]: session = await game_store.load(game_id) return session if session is not None and session.state.phase == phase else None class PartialHandEndAckTest(unittest.TestCase): @async_test async def test_every_partial_ack_ordering_advances_persisted_game(self) -> None: # Use an isolated store so the exhaustive matrix cannot affect other # integration tests through the app's ongoing-game list or queue. registry = GameRegistry(platform.registry.all()) store = InMemoryGameStore(registry) local_scheduler = DeadlineScheduler(store, registry, heartbeat_ms=60_000) loop = asyncio.get_running_loop() case_count = 0 try: for game_type in ( "scopa", "scopa_a_quattro", "scopone_scientifico", "briscola", "briscola_a_quattro", ): engine = registry.require(game_type) for ack_count in range(1, engine.max_players): for ack_order in permutations( range(engine.max_players), ack_count ): case_count += 1 with self.subTest(game_type=game_type, ack_order=ack_order): session = _started_session( f"dl-partial-{case_count}", f"DLP{case_count:03d}", game_type=game_type, ) state = session.state state.phase = "hand_end" state.turn_deadline = None state.hand_end_deadline = ( datetime.now(timezone.utc) - timedelta(seconds=1) ).isoformat() for seat in ack_order: engine.handle_action( session, state.players[seat].sub, "ack", {} ) self.assertEqual(list(ack_order), state.acked) await store.save(session) current = engine.next_deadline(session) assert current is not None member = encode({ "game_id": session.id, "kind": current.kind, "token": current.token, }) # Avoid racing the background consumer with this # direct, deterministic process_due call. await store.add_deadline( member, due_at=datetime.now(timezone.utc).timestamp() + 3600, ) await local_scheduler.process_due(member) result = await store.load(session.id) assert result is not None self.assertEqual("playing", result.state.phase) self.assertEqual(2, result.state.hand_number) self.assertEqual([], result.state.acked) self.assertIsNone(result.state.hand_end_deadline) following = engine.next_deadline(result) assert following is not None self.assertEqual("turn", following.kind) self.assertNotIn( member, await store.due_deadlines( float("inf"), limit=1000 ), ) finally: local_scheduler.stop_consumer(loop) self.assertEqual(124, case_count) 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. engine = platform.registry.require("scopone_scientifico") deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() session = _hand_end_session("dl-idem-1", deadline) await game_store.save(session) current = engine.next_deadline(session) assert current is not None member = encode({ "game_id": session.id, "kind": current.kind, "token": current.token, }) await scheduler.process_due(member) await scheduler.process_due(member) result = await game_store.load(session.id) assert result is not None # Advanced exactly once: hand 2, not hand 3. self.assertEqual("playing", result.state.phase) self.assertEqual(2, result.state.hand_number) @async_test async def test_stale_entry_is_discarded(self) -> None: # A turn entry enqueued with a forged token: the live state carries # a different deadline, so the entry must not fire. session = _started_session("dl-stale-1", "DLS001") await game_store.save(session) member = encode({ "game_id": session.id, "kind": "turn", "token": "turn:1:1:0", # not the live token }) await game_store.add_deadline(member, due_at=0.0) await scheduler.process_due(member) result = await game_store.load(session.id) assert result is not None self.assertEqual(session.state.turn, result.state.turn) # The entry was removed after processing. self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) @async_test async def test_entry_for_expired_game_is_dropped(self) -> None: member = encode({ "game_id": "dl-gone", "kind": "turn", "token": "turn:1:0:0", }) await game_store.add_deadline(member, due_at=0.0) await scheduler.process_due(member) self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) @async_test async def test_malformed_entry_is_dropped(self) -> None: await game_store.add_deadline("not json", due_at=0.0) await scheduler.process_due("not json") self.assertNotIn("not json", await game_store.due_deadlines(float("inf"))) if __name__ == "__main__": unittest.main()