326 lines
13 KiB
Python
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()
|