"""Deadline-queue timeout tests (scopone game, 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 typing import Optional from tavolo.app import game_store, platform, scheduler from tavolo.platform import GameSession, Seat from tavolo.platform.deadlines import encode from tavolo.scopone.state import 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, ) -> GameSession: engine = platform.registry.require("scopone_scientifico") 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.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 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 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()