Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages
Move the game-independent machinery (lobby, live-game store, websocket, deadline scheduler, match history, leaderboards) into a new tavolo-platform distribution behind a GameEngine contract, the scopone scientifico rules plus a platform adapter into tavolo-scopone, and keep only the composition root in tavolo-app. The three distributions share the tavolo namespace (PEP 420, kaya-style monorepo). Match history becomes fully generic: Match carries the engine's result JSON and MatchPlayer points/details instead of scopone-shaped team columns (migration 3 backfills existing rows). Lobby creation takes an opaque per-game options object and websocket actions dispatch to the session's engine. Tests: platform suite runs against a DummyEngine toy game, scopone keeps the rules tests plus new adapter tests, server/tests covers the wired stack end to end (194 tests, was 143).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Test helpers package."""
|
||||
"""Test helpers package for the tavolo-app integration suite."""
|
||||
from .asynctest import async_test
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""An ``async_test`` that also closes the app's Tortoise context.
|
||||
|
||||
``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``).
|
||||
The app's :class:`~tavolo.tortoise_mixin.TortoiseMixin` builds one
|
||||
The app's :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` builds one
|
||||
``TortoiseContext`` per loop, so without an explicit close each test orphans
|
||||
an aiosqlite connection whose non-daemon worker thread keeps the interpreter
|
||||
alive after the suite reports "OK".
|
||||
@@ -12,18 +12,21 @@ import asyncio
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from tavolo.app import tortoise_mixin
|
||||
from tavolo.app import scheduler, tortoise_mixin
|
||||
|
||||
|
||||
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||
"""Like ``pwo.async_test``, but close the Tortoise context afterwards."""
|
||||
"""Like ``pwo.async_test``, but close the Tortoise context and stop the
|
||||
deadline consumer afterwards."""
|
||||
|
||||
@wraps(coro)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
async def run() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await coro(*args, **kwargs)
|
||||
finally:
|
||||
scheduler.stop_consumer(loop)
|
||||
await tortoise_mixin.aclose()
|
||||
|
||||
with asyncio.Runner() as runner:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Helpers for faking the OIDC authenticated user during tests.
|
||||
|
||||
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
||||
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
|
||||
lets route and websocket tests run entirely in-process with no IdP.
|
||||
through :func:`tavolo.platform.auth.get_ws_user`. Patching those two entry
|
||||
points lets route and websocket tests run entirely in-process with no IdP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,10 +13,9 @@ from typing import Iterator, Optional, Sequence
|
||||
from kaya.oidc import OIDCUser
|
||||
|
||||
# Import the app first: it pulls in the route modules, which import
|
||||
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||
# partially initialized module (same constraint as reimpasto).
|
||||
# ``tavolo.platform.auth`` themselves.
|
||||
from tavolo.app import oidc_mixin
|
||||
from tavolo import auth
|
||||
from tavolo.platform import auth
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
|
||||
+104
-88
@@ -1,8 +1,9 @@
|
||||
"""Deadline-queue timeout tests.
|
||||
"""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 games, queue their deadlines
|
||||
and let the background consumer fire them without a single websocket.
|
||||
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
|
||||
|
||||
@@ -11,47 +12,73 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from tavolo import deadlines
|
||||
from tavolo.app import game_store
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import GameState, PlayerState
|
||||
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 _ms(iso: str) -> int:
|
||||
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form)."""
|
||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||
|
||||
|
||||
def _hand_end_state(game_id: str, deadline: str) -> GameState:
|
||||
"""A game paused on the hand-end summary, waiting for acks."""
|
||||
state = GameState(
|
||||
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,
|
||||
)
|
||||
state.players = [
|
||||
PlayerState(sub=name, name=name.capitalize(), seat=i)
|
||||
for i, name in enumerate(PLAYERS)
|
||||
]
|
||||
state.hand_end_deadline = deadline
|
||||
return state
|
||||
return session
|
||||
|
||||
|
||||
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
|
||||
"""Poll the store until ``predicate`` holds for the loaded state."""
|
||||
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:
|
||||
state = await predicate()
|
||||
if state is not None:
|
||||
return state
|
||||
session = await predicate()
|
||||
if session is not None:
|
||||
return session
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
@@ -59,36 +86,33 @@ async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
||||
state = engine.create_game(
|
||||
"dl-turn-1", "DLT001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=1,
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
assert state.turn_deadline is not None
|
||||
await game_store.save(state)
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
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(state.id, 2),
|
||||
lambda: _turn_is(session.id, 2),
|
||||
)
|
||||
self.assertIsNotNone(result, "turn deadline never fired")
|
||||
assert result is not None
|
||||
self.assertEqual(1, result.last_move.seat if result.last_move else 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.turn_timeout = 3600
|
||||
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()
|
||||
state = _hand_end_state("dl-handend-1", deadline)
|
||||
await game_store.save(state)
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
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.
|
||||
@@ -97,92 +121,84 @@ class ConnectionIndependenceTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNotNone(result, "hand-end deadline never fired")
|
||||
assert result is not None
|
||||
self.assertEqual(2, result.hand_number)
|
||||
self.assertEqual([], result.acked)
|
||||
self.assertEqual(2, result.state.hand_number)
|
||||
self.assertEqual([], result.state.acked)
|
||||
|
||||
|
||||
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]:
|
||||
state = await game_store.load(game_id)
|
||||
return state if state is not None and state.turn == turn else None
|
||||
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[GameState]:
|
||||
state = await game_store.load(game_id)
|
||||
return state if state is not None and state.phase == phase 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: revalidation and idempotency."""
|
||||
"""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()
|
||||
state = _hand_end_state("dl-idem-1", deadline)
|
||||
await game_store.save(state)
|
||||
member = deadlines.encode({
|
||||
"game_id": state.id,
|
||||
"kind": deadlines.KIND_HAND_END,
|
||||
"hand": state.hand_number,
|
||||
"deadline": _ms(deadline),
|
||||
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 deadlines.process_due(game_store, member)
|
||||
await deadlines.process_due(game_store, member)
|
||||
await scheduler.process_due(member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await game_store.load(state.id)
|
||||
result = await game_store.load(session.id)
|
||||
assert result is not None
|
||||
# Advanced exactly once: hand 2, not hand 3.
|
||||
self.assertEqual("playing", result.phase)
|
||||
self.assertEqual(2, result.hand_number)
|
||||
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 before a play landed in time: the state's
|
||||
# deadline has moved, so the entry must not fire.
|
||||
state = engine.create_game(
|
||||
"dl-stale-1", "DLS001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=3600,
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
await game_store.save(state)
|
||||
member = deadlines.encode({
|
||||
"game_id": state.id,
|
||||
"kind": deadlines.KIND_TURN,
|
||||
"hand": state.hand_number,
|
||||
"turn": state.turn,
|
||||
# Not the live deadline (epoch milliseconds).
|
||||
"deadline": 946684800000,
|
||||
# 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 deadlines.process_due(game_store, member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await game_store.load(state.id)
|
||||
result = await game_store.load(session.id)
|
||||
assert result is not None
|
||||
self.assertEqual(state.turn, result.turn)
|
||||
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 = deadlines.encode({
|
||||
member = encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": deadlines.KIND_TURN,
|
||||
"hand": 1,
|
||||
"turn": 0,
|
||||
"deadline": 946684800000,
|
||||
"kind": "turn",
|
||||
"token": "turn:1:0:0",
|
||||
})
|
||||
await game_store.add_deadline(member, due_at=0.0)
|
||||
await deadlines.process_due(game_store, member)
|
||||
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 deadlines.process_due(game_store, "not json")
|
||||
await scheduler.process_due("not json")
|
||||
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
||||
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Unit tests for the chess-style Elo math in :mod:`tavolo.elo`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tavolo.elo import (
|
||||
INITIAL_RATING,
|
||||
K_FACTOR,
|
||||
expected_score,
|
||||
match_delta,
|
||||
team_rating,
|
||||
)
|
||||
|
||||
|
||||
class ExpectedScoreTest(unittest.TestCase):
|
||||
def test_equal_ratings_give_even_odds(self) -> None:
|
||||
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
|
||||
|
||||
def test_higher_rating_is_favoured(self) -> None:
|
||||
self.assertGreater(expected_score(1700, 1500), 0.5)
|
||||
self.assertLess(expected_score(1500, 1700), 0.5)
|
||||
|
||||
def test_scores_sum_to_one(self) -> None:
|
||||
self.assertAlmostEqual(
|
||||
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
|
||||
)
|
||||
|
||||
def test_four_hundred_points_is_ten_to_one(self) -> None:
|
||||
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
|
||||
|
||||
|
||||
class TeamRatingTest(unittest.TestCase):
|
||||
def test_mean_of_members(self) -> None:
|
||||
self.assertEqual(1600, team_rating([1500, 1700]))
|
||||
|
||||
def test_empty_team_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
team_rating([])
|
||||
|
||||
|
||||
class MatchDeltaTest(unittest.TestCase):
|
||||
def test_equal_teams_exchange_half_k(self) -> None:
|
||||
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||
self.assertEqual(K_FACTOR // 2, delta)
|
||||
|
||||
def test_favourite_gains_less_than_underdog(self) -> None:
|
||||
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
|
||||
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
|
||||
self.assertGreater(underdog, favourite)
|
||||
self.assertGreater(favourite, 0)
|
||||
|
||||
def test_losing_side_loses_the_winners_gain(self) -> None:
|
||||
# Zero-sum: the losers' delta is the negation of the winners'.
|
||||
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
|
||||
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
|
||||
self.assertEqual(-win, -abs(win)) # winner gains
|
||||
# Losing the same pairing costs K * E, winning gains K * (1 - E);
|
||||
# both are computed from the same expectation, so loss = win - K.
|
||||
self.assertEqual(win - K_FACTOR, loss)
|
||||
|
||||
def test_team_average_decides_not_individual_ratings(self) -> None:
|
||||
# [1700, 1300] averages 1500, same as [1500, 1500].
|
||||
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
|
||||
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||
self.assertEqual(even, mixed)
|
||||
|
||||
def test_initial_rating_constant(self) -> None:
|
||||
self.assertEqual(1500, INITIAL_RATING)
|
||||
self.assertEqual(32, K_FACTOR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,539 +0,0 @@
|
||||
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.game.state import (
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
GameState,
|
||||
PlayerState,
|
||||
)
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
table,
|
||||
turn: int = 0,
|
||||
*,
|
||||
captured=None,
|
||||
scope=None,
|
||||
target: int = 11,
|
||||
last_taker=None,
|
||||
) -> GameState:
|
||||
"""Build a controlled game state directly (bypassing the deal)."""
|
||||
state = GameState(
|
||||
id="game-1",
|
||||
join_code="ABC123",
|
||||
creator_sub="p0",
|
||||
target_score=target,
|
||||
phase=PHASE_PLAYING,
|
||||
turn=turn,
|
||||
last_taker=last_taker,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand])
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
if scope is not None:
|
||||
for player, value in zip(state.players, scope):
|
||||
player.scope = value
|
||||
state.table = [card(c) for c in table]
|
||||
return state
|
||||
|
||||
|
||||
class DeckTest(unittest.TestCase):
|
||||
def test_full_deck_has_40_unique_cards(self) -> None:
|
||||
deck = engine.full_deck()
|
||||
self.assertEqual(40, len(deck))
|
||||
self.assertEqual(40, len({c.code for c in deck}))
|
||||
self.assertEqual(4, len({c.suit for c in deck}))
|
||||
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
|
||||
|
||||
def test_shuffled_deck_is_permutation(self) -> None:
|
||||
deck = engine.shuffled_deck()
|
||||
self.assertEqual(
|
||||
sorted(c.code for c in engine.full_deck()),
|
||||
sorted(c.code for c in deck),
|
||||
)
|
||||
|
||||
|
||||
class CaptureTest(unittest.TestCase):
|
||||
def test_equal_card_is_mandatory(self) -> None:
|
||||
table = [card("05C"), card("02D"), card("03S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_sum_combination(self) -> None:
|
||||
table = [card("01C"), card("03C"), card("02S")]
|
||||
options = engine.legal_captures(table, card("04D"))
|
||||
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
|
||||
table = [card("05C"), card("05S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual(
|
||||
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
|
||||
)
|
||||
|
||||
def test_no_capture(self) -> None:
|
||||
table = [card("09C"), card("08S")]
|
||||
self.assertEqual([], engine.legal_captures(table, card("02D")))
|
||||
|
||||
def test_play_without_capture_places_card_on_table(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertIn("02D", [c.code for c in state.table])
|
||||
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
|
||||
self.assertEqual(1, state.turn)
|
||||
|
||||
def test_play_capture_and_scopa(self) -> None:
|
||||
state = make_state([["02D", "09C"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["02C"])
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(1, state.players[0].scope)
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(
|
||||
["02C", "02D"], [c.code for c in state.players[0].captured]
|
||||
)
|
||||
# The move is recorded for the "who played what" announcement.
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(0, state.last_move.seat)
|
||||
self.assertEqual("p0", state.last_move.name)
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual(["02C"], state.last_move.captured)
|
||||
self.assertTrue(state.last_move.scopa)
|
||||
|
||||
def test_play_without_capture_records_move(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
assert state.last_move is not None
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual([], state.last_move.captured)
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
|
||||
def test_illegal_combination_when_equal_card_present(self) -> None:
|
||||
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["05C", "02D", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D", ["02D", "03S"])
|
||||
|
||||
def test_illegal_capture_rejected(self) -> None:
|
||||
state = make_state([["04D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["02C", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "04D", ["02C"])
|
||||
|
||||
def test_no_capture_requested_when_capture_possible(self) -> None:
|
||||
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["05C"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D")
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]],
|
||||
table=[], turn=1)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
def test_card_not_in_hand(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
with self.assertRaises(CardNotInHand):
|
||||
engine.play(state, "p0", "07D")
|
||||
|
||||
def test_finished_game_rejects_moves(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
|
||||
class LastPlayTest(unittest.TestCase):
|
||||
def test_no_scopa_on_last_play_of_hand(self) -> None:
|
||||
# p0 plays the last card of the hand (everyone else is already
|
||||
# empty): the capture empties the table but must NOT count as a
|
||||
# scopa. Team A still reaches the target of 2 with carte + denara.
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=2)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
|
||||
|
||||
def test_table_swept_to_last_taker(self) -> None:
|
||||
# target 2 so the game ends on this hand and the capture piles are
|
||||
# not reset by the next deal.
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["05C", "04D"], last_taker=1, target=2)
|
||||
engine.play(state, "p0", "02D")
|
||||
captured = {c.code for c in state.players[1].captured}
|
||||
self.assertEqual({"05C", "04D", "02D"}, captured)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(1, state.winner)
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def test_primiera_values_and_all_suits_requirement(self) -> None:
|
||||
self.assertEqual(70, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
|
||||
self.assertEqual(0, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S"]]))
|
||||
self.assertEqual(40, engine.primiera_score(
|
||||
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
|
||||
|
||||
def test_hand_points_carte_denara_settebello_primiera_scope(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["07D", "06C", "01S", "05B"], # seat 0, team A
|
||||
["03D", "04C", "07S", "02B"], # seat 1, team B
|
||||
["02D"], # seat 2, team A
|
||||
["10D", "10C", "10S", "10B"], # seat 3, team B
|
||||
],
|
||||
scope=[1, 0, 0, 2],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertEqual([3, 3], points)
|
||||
self.assertEqual({"A": 5, "B": 8}, details["cards"])
|
||||
self.assertEqual({"A": 2, "B": 2}, details["denara"])
|
||||
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||
self.assertEqual({"A": 1, "B": 2}, details["scope"])
|
||||
|
||||
def test_ties_award_nothing(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["06C", "01S", "05B", "02D"],
|
||||
["06S", "01B", "05D", "02C"],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
scope=[0, 0, 0, 0],
|
||||
)
|
||||
points, _ = engine.hand_points(state)
|
||||
# Equal cards, equal denara, equal primiera and no settebello:
|
||||
# everything ties, so no points at all.
|
||||
self.assertEqual([0, 0], points)
|
||||
|
||||
|
||||
class NapolaTest(unittest.TestCase):
|
||||
def test_napola_score_runs(self) -> None:
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["02D", "03D", "04D"]])) # no ace
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D"]])) # too short
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["03D", "01D", "02D"]])) # order-independent
|
||||
self.assertEqual(4, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "04D", "07C"]]))
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run
|
||||
self.assertEqual(10, engine.napola_score(
|
||||
[card(f"{rank:02d}D") for rank in range(1, 11)]))
|
||||
|
||||
def test_hand_points_napola(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"], # seat 0, team A
|
||||
["05D", "06D", "07D", "08D"], # seat 1, team B
|
||||
["09D", "10D", "01C", "02C"], # seat 2, team A
|
||||
["03C", "05C", "06C", "07C"], # seat 3, team B
|
||||
],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
# Team A has the ace-led run 01D-03D (3 points); team B's denari
|
||||
# start at the 5, so no napola. Carte tie (8 each), denara to A
|
||||
# (5 vs 4), settebello to B, primiere tied at 0 (missing suits).
|
||||
self.assertEqual({"A": 3, "B": 0}, details["napola"])
|
||||
self.assertEqual("A", details["award"]["napola"])
|
||||
self.assertEqual([4, 1], points)
|
||||
|
||||
def test_napola_disabled(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"],
|
||||
["05D", "06D", "07D", "08D"],
|
||||
["09D", "10D", "01C", "02C"],
|
||||
["03C", "05C", "06C", "07C"],
|
||||
],
|
||||
)
|
||||
state.napola = False
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertNotIn("napola", details)
|
||||
self.assertEqual([1, 1], points)
|
||||
|
||||
def test_full_denari_sweep_wins_match_instantly(self) -> None:
|
||||
# Team A already captured the whole denari suit; the last play of
|
||||
# the hand cannot capture. Team B leads 50-0, yet the napola ends
|
||||
# the match in team A's favour, well below the target of 100.
|
||||
state = make_state(
|
||||
[["02C"], [], [], []],
|
||||
table=[],
|
||||
target=100,
|
||||
captured=[
|
||||
[f"{rank:02d}D" for rank in range(1, 11)],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
)
|
||||
state.scores = [0, 50]
|
||||
engine.play(state, "p0", "02C")
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertLess(state.scores[0], 100)
|
||||
self.assertEqual(10, state.hand_scores[-1]["napola"]["A"])
|
||||
|
||||
def test_napola_serialization_roundtrip(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=[])
|
||||
self.assertTrue(state.napola)
|
||||
state.napola = False
|
||||
self.assertFalse(GameState.from_json(state.to_json()).napola)
|
||||
# States serialized before the option existed default to enabled.
|
||||
data = state.to_json()
|
||||
del data["napola"]
|
||||
self.assertTrue(GameState.from_json(data).napola)
|
||||
|
||||
def test_create_game_napola_default_and_override(self) -> None:
|
||||
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
|
||||
self.assertFalse(
|
||||
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
|
||||
)
|
||||
|
||||
|
||||
class MatchFlowTest(unittest.TestCase):
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
|
||||
self.assertEqual(1, len(state.players))
|
||||
engine.join_game(state, "p1", "p1")
|
||||
engine.join_game(state, "p2", "p2")
|
||||
self.assertEqual("lobby", state.phase)
|
||||
engine.join_game(state, "p3", "p3")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual(4, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(10, len(player.hand))
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(1, state.turn) # dealer is seat 0
|
||||
|
||||
def test_match_ends_when_target_reached(self) -> None:
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=1)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertGreaterEqual(state.scores[0], 1)
|
||||
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertEqual(1, players[1]["cards_left"])
|
||||
self.assertEqual(["07C"], view["table"])
|
||||
self.assertTrue(view.get("your_turn"))
|
||||
|
||||
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
legal = view["legal_moves"]
|
||||
# 02D can capture nothing; 03C has no combination either (only 07C
|
||||
# on the table).
|
||||
self.assertEqual({}, legal)
|
||||
|
||||
state = make_state([["09D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C", "02S"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"])
|
||||
|
||||
# A player who is not on turn gets no legal_moves key.
|
||||
other = engine.state_for_player(state, "p1")
|
||||
self.assertNotIn("legal_moves", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_full_random_match_reaches_completion(self) -> None:
|
||||
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
|
||||
for i in range(1, 4):
|
||||
engine.join_game(state, f"p{i}", f"p{i}")
|
||||
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||
if state.phase == "hand_end":
|
||||
for p in state.players:
|
||||
engine.acknowledge_hand(state, p.sub)
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = engine.legal_captures(state.table, played)
|
||||
capture = [c.code for c in options[0]] if options else None
|
||||
engine.play(state, player.sub, played.code, capture)
|
||||
moves += 1
|
||||
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertIn(state.winner, (0, 1))
|
||||
# At the end all 40 cards are captured and no hand is left.
|
||||
self.assertEqual([], state.table)
|
||||
self.assertTrue(all(not p.hand for p in state.players))
|
||||
self.assertEqual(40, sum(len(p.captured) for p in state.players))
|
||||
self.assertTrue(state.finished_at)
|
||||
|
||||
|
||||
class HandEndAckTest(unittest.TestCase):
|
||||
def _hand_end_state(self) -> GameState:
|
||||
"""Drive a game into the hand_end phase with a one-card hand."""
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=11)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
return state
|
||||
|
||||
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
self.assertEqual("hand_end", state.phase)
|
||||
# Nobody has acknowledged yet, and no new hand was dealt.
|
||||
self.assertEqual([], state.acked)
|
||||
self.assertEqual(1, state.hand_number)
|
||||
self.assertTrue(state.hand_end_deadline)
|
||||
# Capture piles stay visible during the summary.
|
||||
self.assertEqual(["02C", "02D"],
|
||||
[c.code for c in state.players[0].captured])
|
||||
# The summary carries the award map.
|
||||
summary = state.hand_scores[-1]
|
||||
self.assertEqual(1, summary["hand"])
|
||||
self.assertIn("award", summary)
|
||||
|
||||
def test_play_during_hand_end_is_rejected(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
def test_ack_all_four_deals_next_hand(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
dealer_before = state.dealer
|
||||
for i, sub in enumerate(("p0", "p1", "p2")):
|
||||
engine.acknowledge_hand(state, sub)
|
||||
self.assertEqual(list(range(i + 1)), state.acked)
|
||||
self.assertEqual("hand_end", state.phase)
|
||||
engine.acknowledge_hand(state, "p3")
|
||||
self.assertEqual("playing", state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual((dealer_before + 1) % 4, state.dealer)
|
||||
self.assertEqual([], state.acked)
|
||||
self.assertIsNone(state.hand_end_deadline)
|
||||
self.assertIsNone(state.last_move)
|
||||
for player in state.players:
|
||||
self.assertEqual(10, len(player.hand))
|
||||
self.assertEqual([], player.captured)
|
||||
self.assertEqual((dealer_before + 2) % 4, state.turn)
|
||||
|
||||
def test_double_ack_is_idempotent(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
engine.acknowledge_hand(state, "p0")
|
||||
engine.acknowledge_hand(state, "p0")
|
||||
self.assertEqual([0], state.acked)
|
||||
|
||||
def test_ack_outside_hand_end_is_rejected(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.acknowledge_hand(state, "p0")
|
||||
|
||||
def test_ack_by_non_player_is_rejected(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
engine.acknowledge_hand(state, "mallory")
|
||||
|
||||
def test_state_exposes_ack_progress(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
engine.acknowledge_hand(state, "p1")
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertEqual([1], view["acknowledged"])
|
||||
self.assertTrue(view["hand_end_deadline"])
|
||||
self.assertIsNotNone(view["last_hand"])
|
||||
self.assertIn("award", view["last_hand"])
|
||||
|
||||
|
||||
class AutoPlayTest(unittest.TestCase):
|
||||
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["09B"])
|
||||
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||
engine.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
# The played card could not capture the nine, so the table grew.
|
||||
self.assertEqual(2, len(state.table))
|
||||
self.assertIsNotNone(state.last_move)
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(0, state.last_move.seat)
|
||||
self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline)
|
||||
|
||||
def test_auto_play_takes_a_mandatory_capture(self) -> None:
|
||||
# p0 holds only the five of denari, which must capture the equal
|
||||
# five of coppe instead of the unrelated nine on the table.
|
||||
state = make_state([["05D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "09B"])
|
||||
engine.auto_play(state)
|
||||
self.assertIsNotNone(state.last_move)
|
||||
assert state.last_move is not None
|
||||
self.assertEqual("05D", state.last_move.card)
|
||||
self.assertEqual(["05C"], state.last_move.captured)
|
||||
self.assertEqual(["09B"], [c.code for c in state.table])
|
||||
self.assertEqual(["05C", "05D"],
|
||||
[c.code for c in state.players[0].captured])
|
||||
|
||||
def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=["02C"])
|
||||
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||
engine.auto_play(state)
|
||||
self.assertEqual("hand_end", state.phase)
|
||||
self.assertIsNone(state.turn_deadline)
|
||||
self.assertTrue(state.hand_end_deadline)
|
||||
|
||||
def test_auto_play_requires_playing_phase(self) -> None:
|
||||
state = make_state([["02D"], ["04D"], ["05D"], ["06D"]], table=[])
|
||||
state.phase = "hand_end"
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.auto_play(state)
|
||||
|
||||
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
|
||||
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
|
||||
self.assertEqual(7, state.turn_timeout)
|
||||
for i in range(1, 4):
|
||||
engine.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertTrue(state.turn_deadline)
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertTrue(view["turn_deadline"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport."""
|
||||
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
@@ -23,7 +23,9 @@ class GamesRouteTest(unittest.TestCase):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={"target_score": 16})
|
||||
created = await client.post(
|
||||
"/api/games", json={"options": {"target_score": 16}}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
@@ -80,12 +82,16 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertTrue(default.json()["napola"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
disabled = await client.post("/api/games", json={"napola": False})
|
||||
disabled = await client.post(
|
||||
"/api/games", json={"options": {"napola": False}}
|
||||
)
|
||||
self.assertEqual(201, disabled.status_code)
|
||||
self.assertFalse(disabled.json()["napola"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
invalid = await client.post("/api/games", json={"napola": "yes"})
|
||||
invalid = await client.post(
|
||||
"/api/games", json={"options": {"napola": "yes"}}
|
||||
)
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
@@ -120,9 +126,15 @@ class GamesRouteTest(unittest.TestCase):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
zero = await client.post("/api/games", json={"target_score": 0})
|
||||
text = await client.post("/api/games", json={"target_score": "eleven"})
|
||||
huge = await client.post("/api/games", json={"target_score": 1000})
|
||||
zero = await client.post(
|
||||
"/api/games", json={"options": {"target_score": 0}}
|
||||
)
|
||||
text = await client.post(
|
||||
"/api/games", json={"options": {"target_score": "eleven"}}
|
||||
)
|
||||
huge = await client.post(
|
||||
"/api/games", json={"options": {"target_score": 1000}}
|
||||
)
|
||||
self.assertEqual(400, zero.status_code)
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, huge.status_code)
|
||||
@@ -147,6 +159,10 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
|
||||
self.assertEqual("Scopone scientifico", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
self.assertEqual(4, results[0]["min_players"])
|
||||
self.assertEqual(4, results[0]["max_players"])
|
||||
self.assertIn("target_score", results[0]["options_schema"]["properties"])
|
||||
self.assertIn("napola", results[0]["options_schema"]["properties"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
|
||||
@@ -42,7 +42,7 @@ class StaticRouteTest(unittest.TestCase):
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.routes.static.settings", patched):
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
index = await client.get("/")
|
||||
@@ -58,7 +58,7 @@ class StaticRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
with mock.patch("tavolo.routes.static.settings", patched):
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
+103
-359
@@ -1,395 +1,139 @@
|
||||
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||
"""Scopone result-persistence tests: engine result to Postgres to API.
|
||||
|
||||
The platform suite covers the generic machinery against a toy game;
|
||||
these tests pin the scopone-specific shape: the match ``result`` summary,
|
||||
per-player scores/teams, Elo deltas and what the history and leaderboard
|
||||
endpoints expose for a finished scopone match.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, tortoise_mixin
|
||||
from tavolo.elo import INITIAL_RATING
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import GameState
|
||||
from tavolo.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.stats import save_match_result
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopone import engine as rules
|
||||
from tests.helpers import async_test, oidc_user
|
||||
|
||||
|
||||
async def _use_app_db():
|
||||
"""Bind the same Tortoise context the app uses for this event loop and
|
||||
return it, so tests can seed rows the route handlers will see."""
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
return ctx
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
|
||||
def _finished_state() -> GameState:
|
||||
# Team A sweeps the (single-card) table with carte + denara and reaches
|
||||
# a target of 2, ending the match.
|
||||
state = GameState(
|
||||
id="stats-game",
|
||||
join_code="STATS1",
|
||||
async def _finished_session(target_score: int = 1) -> GameSession:
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = GameSession(
|
||||
id="stats-scope-1",
|
||||
game_type=engine.id,
|
||||
join_code="SS0001",
|
||||
creator_sub="alice",
|
||||
target_score=2,
|
||||
phase=engine.PHASE_PLAYING,
|
||||
turn=0,
|
||||
table=[engine.parse_card("02C")],
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
)
|
||||
from tavolo.game.state import PlayerState, Card
|
||||
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="bob", seat=1),
|
||||
PlayerState(sub="carol", name="carol", seat=2),
|
||||
PlayerState(sub="dave", name="dave", seat=3),
|
||||
]
|
||||
return state
|
||||
engine.create(session, {"target_score": target_score})
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for player in state.players:
|
||||
engine.handle_action(session, player.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = rules.legal_captures(state.table, played)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
def _finished_state_reversed() -> GameState:
|
||||
"""Same one-capture ending as ``_finished_state``, but team B scores it."""
|
||||
state = GameState(
|
||||
id="stats-game-2",
|
||||
join_code="STATS2",
|
||||
creator_sub="alice",
|
||||
target_score=2,
|
||||
phase=engine.PHASE_PLAYING,
|
||||
turn=1,
|
||||
table=[engine.parse_card("02C")],
|
||||
)
|
||||
from tavolo.game.state import PlayerState, Card
|
||||
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="alice", seat=0),
|
||||
PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="carol", name="carol", seat=2),
|
||||
PlayerState(sub="dave", name="dave", seat=3),
|
||||
]
|
||||
return state
|
||||
|
||||
|
||||
class SaveMatchResultTest(unittest.TestCase):
|
||||
class ScoponeStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_once(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
state = _finished_state()
|
||||
engine.play(state, "alice", "02D", ["02C"])
|
||||
self.assertEqual(engine.PHASE_FINISHED, state.phase)
|
||||
|
||||
async def test_finished_match_persisted_with_scopone_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = await _finished_session()
|
||||
with ctx:
|
||||
await save_match_result(state)
|
||||
await save_match_result(state) # idempotent
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(4, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual(state.scores[0], match.team_a_score)
|
||||
self.assertEqual("A", match.winner_team)
|
||||
# The game type travels from the live state onto the row.
|
||||
self.assertEqual("scopone_scientifico", match.game_type)
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
|
||||
summary = match.result
|
||||
self.assertEqual(1, summary["target_score"])
|
||||
self.assertIn("team_a_score", summary)
|
||||
self.assertIn("team_b_score", summary)
|
||||
self.assertIn("winner_team", summary)
|
||||
self.assertIn("hands_played", summary)
|
||||
self.assertIn("hand_scores", summary)
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
state = _finished_state()
|
||||
engine.play(state, "alice", "02D", ["02C"])
|
||||
|
||||
with ctx:
|
||||
await save_match_result(state)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row for row in await PlayerRating.all()
|
||||
}
|
||||
self.assertEqual(4, len(ratings))
|
||||
# Four players at 1500: winners gain K/2, losers lose it.
|
||||
for winner in ("alice", "carol"):
|
||||
self.assertEqual(INITIAL_RATING + 16, ratings[winner].rating)
|
||||
self.assertEqual(1, ratings[winner].matches_played)
|
||||
for loser in ("bob", "dave"):
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings[loser].rating)
|
||||
self.assertEqual(1, ratings[loser].matches_played)
|
||||
|
||||
# The per-match delta is recorded on each participation row.
|
||||
deltas = {
|
||||
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
||||
}
|
||||
self.assertEqual(
|
||||
{"alice": 16, "carol": 16, "bob": -16, "dave": -16}, deltas
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
state = _finished_state()
|
||||
engine.play(state, "alice", "02D", ["02C"])
|
||||
reversed_state = _finished_state_reversed()
|
||||
engine.play(reversed_state, "bob", "02D", ["02C"])
|
||||
|
||||
with ctx:
|
||||
await save_match_result(state)
|
||||
# A second match between the same players, won by team B.
|
||||
await save_match_result(reversed_state)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row.rating for row in await PlayerRating.all()
|
||||
}
|
||||
# Match 1: even teams, team A wins (+16/-16). Match 2: team A
|
||||
# is now the favourite (1516 vs 1484), so losing costs 17.
|
||||
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
|
||||
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
|
||||
bob = await PlayerRating.get(user_sub="bob")
|
||||
self.assertEqual(2, bob.matches_played)
|
||||
|
||||
|
||||
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
|
||||
ctx = await _use_app_db()
|
||||
with ctx:
|
||||
for index, (a_score, b_score, winner, finished) in enumerate(
|
||||
[
|
||||
(11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
(8, 11, "B", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
||||
]
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=game_types[index],
|
||||
team_a_score=a_score,
|
||||
team_b_score=b_score,
|
||||
winner_team=winner,
|
||||
target_score=11,
|
||||
hands_played=2 + index,
|
||||
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
|
||||
finished_at=finished,
|
||||
)
|
||||
seats = [
|
||||
("alice", 0, "A"),
|
||||
("bob", 1, "B"),
|
||||
("carol", 2, "A"),
|
||||
("dave", 3, "B"),
|
||||
]
|
||||
for sub, seat, team in seats:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team=team,
|
||||
won=(team == winner),
|
||||
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
||||
winner_team = summary["winner_team"]
|
||||
for sub, row in players.items():
|
||||
if row.won:
|
||||
self.assertEqual(winner_team, row.team)
|
||||
else:
|
||||
self.assertNotEqual(winner_team, row.team)
|
||||
self.assertEqual(
|
||||
summary["team_a_score"] if row.team == "A"
|
||||
else summary["team_b_score"],
|
||||
row.score,
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_my_matches_newest_first(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
self.assertEqual("B", results[0]["winner_team"]) # newest first
|
||||
self.assertFalse(results[0]["you_won"])
|
||||
self.assertTrue(results[1]["you_won"])
|
||||
self.assertEqual(4, len(results[0]["players"]))
|
||||
self.assertIn("next_cursor", response.json())
|
||||
# Winners share a team, losers the other.
|
||||
winners = {sub for sub, row in players.items() if row.won}
|
||||
self.assertEqual(2, len(winners))
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_pagination(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
first = await client.get("/api/me/matches?limit=1")
|
||||
cursor = first.json()["next_cursor"]
|
||||
self.assertIsNotNone(cursor)
|
||||
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
|
||||
self.assertEqual(1, len(first.json()["results"]))
|
||||
self.assertEqual(1, len(second.json()["results"]))
|
||||
self.assertNotEqual(
|
||||
first.json()["results"][0]["id"],
|
||||
second.json()["results"][0]["id"],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_requires_auth(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_aggregates(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||
self.assertEqual(2, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"]) # team A won match 1
|
||||
self.assertEqual(19, by_sub["alice"]["points"])
|
||||
self.assertEqual(1, by_sub["bob"]["wins"]) # team B won match 2
|
||||
self.assertEqual(16, by_sub["bob"]["points"])
|
||||
# Alice leads on points after tying Bob on wins.
|
||||
self.assertEqual("alice", response.json()["results"][0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||
await _seed_two_matches()
|
||||
ctx = await _use_app_db()
|
||||
async def test_finished_match_updates_elo(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = await _finished_session()
|
||||
with ctx:
|
||||
# Bob outranks everyone despite Alice leading on points.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="scopone_scientifico",
|
||||
rating=1600,
|
||||
matches_played=2,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1600, by_sub["bob"]["elo"])
|
||||
# Players without a rating row report the initial rating.
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
# Elo outranks wins/points.
|
||||
self.assertEqual("bob", results[0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||
await _seed_two_matches()
|
||||
ctx = await _use_app_db()
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="scopone_scientifico",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
# Alice's rating in another game must not leak into the
|
||||
# scopone leaderboard.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="other_game",
|
||||
rating=1800,
|
||||
matches_played=1,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard?game_type=scopone_scientifico")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1516, by_sub["alice"]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_include_elo_delta(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
state = _finished_state()
|
||||
engine.play(state, "alice", "02D", ["02C"])
|
||||
with ctx:
|
||||
await save_match_result(state)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, response.status_code)
|
||||
players = {
|
||||
p["user_sub"]: p
|
||||
for p in response.json()["results"][0]["players"]
|
||||
}
|
||||
self.assertEqual(16, players["alice"]["elo_delta"])
|
||||
self.assertEqual(-16, players["bob"]["elo_delta"])
|
||||
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_requires_auth(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me/ratings")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_returns_only_own_rows(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="scopone_scientifico",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="scopone_scientifico",
|
||||
rating=1484,
|
||||
matches_played=1,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/me/ratings")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
[{"game_type": "scopone_scientifico", "rating": 1516, "matches_played": 1}],
|
||||
response.json()["results"],
|
||||
)
|
||||
|
||||
|
||||
class GameTypeFilterTest(unittest.TestCase):
|
||||
"""Stats endpoints scope results by the match's game type."""
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_filter_by_game_type(self) -> None:
|
||||
# The second seed names a game the registry does not know; rows are
|
||||
# written directly, so this only exercises the SQL filter.
|
||||
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
all_matches = await client.get("/api/me/matches")
|
||||
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
|
||||
unknown = await client.get("/api/me/matches?game_type=briscola")
|
||||
self.assertEqual(2, len(all_matches.json()["results"]))
|
||||
await save_match_result(session, engine)
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(4, len(ratings))
|
||||
self.assertEqual(
|
||||
{"scopone_scientifico", "other_game"},
|
||||
{m["game_type"] for m in all_matches.json()["results"]},
|
||||
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
||||
)
|
||||
scoped_results = scoped.json()["results"]
|
||||
self.assertEqual(1, len(scoped_results))
|
||||
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_filter_by_game_type(self) -> None:
|
||||
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
|
||||
async def test_history_and_leaderboard_expose_scopone_result(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = await _finished_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
|
||||
unknown = await client.get("/api/leaderboard?game_type=briscola")
|
||||
self.assertEqual(200, scoped.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||
# Only the first match counts: one match per player, team A won.
|
||||
self.assertEqual(1, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"])
|
||||
self.assertEqual(0, by_sub["bob"]["wins"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
with oidc_user("alice"):
|
||||
history = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, history.status_code)
|
||||
results = history.json()["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("scopone_scientifico", results[0]["game_type"])
|
||||
self.assertIn("team_a_score", results[0]["result"])
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
self.assertEqual(4, len(results[0]["players"]))
|
||||
|
||||
board = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, board.status_code)
|
||||
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
|
||||
self.assertEqual(4, len(by_sub))
|
||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from tavolo.game import engine
|
||||
from tavolo.store import InMemoryGameStore
|
||||
from tests.helpers import async_test
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_load_roundtrip(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16)
|
||||
engine.join_game(state, "bob", "bob")
|
||||
await store.save(state)
|
||||
|
||||
loaded = await store.load("g1")
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual("CODE01", loaded.join_code)
|
||||
self.assertEqual(16, loaded.target_score)
|
||||
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
|
||||
|
||||
@async_test
|
||||
async def test_game_type_roundtrip_and_default(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game(
|
||||
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
|
||||
)
|
||||
await store.save(state)
|
||||
loaded = await store.load("g1b")
|
||||
assert loaded is not None
|
||||
self.assertEqual("scopone_scientifico", loaded.game_type)
|
||||
|
||||
# States serialized before game types existed load with the default.
|
||||
legacy = state.to_json()
|
||||
del legacy["game_type"]
|
||||
from tavolo.game.state import GameState
|
||||
|
||||
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
|
||||
|
||||
@async_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
self.assertIsNone(await store.load("nope"))
|
||||
self.assertIsNone(await store.find_by_code("NOPE01"))
|
||||
|
||||
@async_test
|
||||
async def test_find_by_code(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g2", "CODE02", "alice", "alice")
|
||||
await store.save(state)
|
||||
found = await store.find_by_code("code02") # case-insensitive
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g2", found.id)
|
||||
|
||||
@async_test
|
||||
async def test_load_returns_a_copy(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g3", "CODE03", "alice", "alice")
|
||||
await store.save(state)
|
||||
first = await store.load("g3")
|
||||
assert first is not None
|
||||
first.phase = "tampered"
|
||||
second = await store.load("g3")
|
||||
assert second is not None
|
||||
self.assertEqual("lobby", second.phase)
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g4", "CODE04", "alice", "alice")
|
||||
await store.save(state)
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g4") as events:
|
||||
await store.publish("g4")
|
||||
async for _ in events:
|
||||
received.append(True)
|
||||
break
|
||||
|
||||
self.assertEqual([True], received)
|
||||
|
||||
@async_test
|
||||
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
order = []
|
||||
|
||||
async def holder() -> None:
|
||||
async with store.lock("g5"):
|
||||
order.append("holder-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-exit")
|
||||
|
||||
async def contender() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
async with store.lock("g5"):
|
||||
order.append("contender")
|
||||
|
||||
await asyncio.gather(holder(), contender())
|
||||
self.assertEqual(
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
self.assertIsNone(await store.next_deadline())
|
||||
self.assertEqual([], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.add_deadline("b", due_at=50.0)
|
||||
await store.add_deadline("a", due_at=10.0)
|
||||
await store.add_deadline("c", due_at=200.0)
|
||||
# Re-adding an existing member only updates its due time.
|
||||
await store.add_deadline("b", due_at=60.0)
|
||||
|
||||
self.assertEqual(10.0, await store.next_deadline())
|
||||
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
# Due entries come out in due-time order and stay queued until removed.
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.remove_deadline("a")
|
||||
await store.remove_deadline("a") # removing twice is a no-op
|
||||
self.assertEqual(60.0, await store.next_deadline())
|
||||
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,14 +8,44 @@ from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
|
||||
from tavolo.app import app, game_store
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import Card, GameState, PlayerState
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
|
||||
def _started_session(
|
||||
engine,
|
||||
game_id: str,
|
||||
code: str,
|
||||
hand_ack_timeout: int = 30,
|
||||
turn_timeout: int = 30,
|
||||
) -> GameSession:
|
||||
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,
|
||||
{
|
||||
"target_score": 11,
|
||||
"napola": True,
|
||||
},
|
||||
)
|
||||
# Apply per-test timeouts (the plugin normally copies them from its
|
||||
# own constructor arguments).
|
||||
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
|
||||
|
||||
|
||||
class WebSocketTest(unittest.TestCase):
|
||||
async def _started_game(self, client: AsyncClient) -> dict:
|
||||
"""Create a game and seat four players; return the playing state."""
|
||||
@@ -132,24 +162,34 @@ class WebSocketTest(unittest.TestCase):
|
||||
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
"""Seed a game where a single play ends the hand: p0 holds the only
|
||||
card left and can capture the only table card."""
|
||||
state = GameState(
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = GameSession(
|
||||
id="hand-end-1",
|
||||
game_type=engine.id,
|
||||
join_code="HEND01",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub="carol", display_name="Carol", team="A"),
|
||||
Seat(user_sub="dave", display_name="Dave", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = ScoponeState(
|
||||
target_score=11,
|
||||
phase="playing",
|
||||
turn=0,
|
||||
table=[Card.parse("02C")],
|
||||
players=[
|
||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub="carol", name="Carol", seat=2),
|
||||
PlayerState(sub="dave", name="Dave", seat=3),
|
||||
],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
)
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub="carol", name="Carol", seat=2),
|
||||
PlayerState(sub="dave", name="Dave", seat=3),
|
||||
]
|
||||
state.hand_ack_timeout = hand_ack_timeout
|
||||
await game_store.save(state)
|
||||
return state.id
|
||||
await game_store.save(session)
|
||||
return session.id
|
||||
|
||||
|
||||
class HandEndWebSocketTest(unittest.TestCase):
|
||||
@@ -240,18 +280,16 @@ class HandEndWebSocketTest(unittest.TestCase):
|
||||
class TurnTimeoutWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||
state = engine.create_game(
|
||||
"turn-timeout-1", "TT0001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=1,
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
session = _started_session(
|
||||
engine, "turn-timeout-1", "TT0001", turn_timeout=1
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
await game_store.save(state)
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{state.id}", ws_client) as ws:
|
||||
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
# Bob (seat 1) is first to act and never connects.
|
||||
self.assertEqual(1, first["game"]["turn"])
|
||||
|
||||
Reference in New Issue
Block a user