Files
tavolo/server/tests/test_deadlines.py
T

326 lines
13 KiB
Python

"""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()