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:
@@ -0,0 +1,348 @@
|
||||
"""Shared fixtures for the tavolo-platform test suite.
|
||||
|
||||
The centerpiece is :class:`DummyEngine`: a tiny two-player game
|
||||
implementing the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract, so every platform
|
||||
behaviour (lobby, store, websockets, deadlines, stats) is exercised
|
||||
without importing any real game. Its rules: the first player to reach
|
||||
``target`` plays wins the match.
|
||||
|
||||
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
|
||||
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
|
||||
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
|
||||
tests construct their own app instead of importing a global one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import unittest.mock as _mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin
|
||||
|
||||
from tavolo.platform import (
|
||||
AlreadyJoined,
|
||||
Deadline,
|
||||
GameEngine,
|
||||
GameError,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
GameSession,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
MatchResult,
|
||||
NotYourTurn,
|
||||
Platform,
|
||||
PlatformMixin,
|
||||
PlayerResult,
|
||||
Seat,
|
||||
)
|
||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||
from tavolo.platform.deadlines import DeadlineScheduler
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
|
||||
|
||||
class DummyEngine(GameEngine):
|
||||
"""A two-player toy game: first to ``target`` plays wins.
|
||||
|
||||
Team labels are "A"/"B" (one player per team) so Elo paths are
|
||||
exercised too. A ``deadline_in_seconds`` creation option arms a
|
||||
``tick`` deadline that plays for the first player when it fires.
|
||||
"""
|
||||
|
||||
id = "dummy"
|
||||
name = "Dummy game"
|
||||
description = "A two-player toy game for testing the platform."
|
||||
min_players = 2
|
||||
max_players = 2
|
||||
options_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 3,
|
||||
"description": "Plays needed to win the match.",
|
||||
},
|
||||
"deadline_in_seconds": {
|
||||
"type": "number",
|
||||
"description": "Arm a tick deadline this far in the future.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
target = options.get("target", 3)
|
||||
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
|
||||
raise IllegalMove("target must be a positive integer")
|
||||
# The creator takes team A.
|
||||
creator = session.players[0]
|
||||
session.players[0] = Seat(
|
||||
user_sub=creator.user_sub,
|
||||
display_name=creator.display_name,
|
||||
team="A",
|
||||
)
|
||||
session.state = {
|
||||
"target": target,
|
||||
"plays": [],
|
||||
"started": False,
|
||||
"finished": False,
|
||||
"winner": None,
|
||||
"deadline_in_seconds": options.get("deadline_in_seconds"),
|
||||
}
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
state = session.state
|
||||
if state["started"]:
|
||||
raise GameNotStarted("game has already started")
|
||||
if session.seated(user_sub):
|
||||
raise AlreadyJoined("already joined this game")
|
||||
if len(session.players) >= 2:
|
||||
raise LobbyFull("game is full")
|
||||
session.players.append(
|
||||
Seat(
|
||||
user_sub=user_sub,
|
||||
display_name=display_name,
|
||||
team="A" if not session.players else "B",
|
||||
)
|
||||
)
|
||||
if len(session.players) == 2:
|
||||
state["started"] = True
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state = session.state
|
||||
if not state["started"]:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
if state["finished"]:
|
||||
raise GameFinished("the match is over")
|
||||
if not session.seated(user_sub):
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
if action != "play":
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
self._play(session, user_sub)
|
||||
|
||||
def _play(self, session: GameSession, user_sub: str) -> None:
|
||||
state = session.state
|
||||
state["plays"].append(user_sub)
|
||||
if len(state["plays"]) >= state["target"]:
|
||||
state["finished"] = True
|
||||
state["winner"] = user_sub
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
state = session.state
|
||||
return {
|
||||
"started": state["started"],
|
||||
"finished": state["finished"],
|
||||
"winner": state["winner"],
|
||||
"plays": len(state["plays"]),
|
||||
"target": state["target"],
|
||||
"viewer_seated": session.seated(user_sub),
|
||||
}
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": "playing" if session.state["started"] else "lobby",
|
||||
"target": session.state["target"],
|
||||
}
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return not session.state["started"]
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return bool(session.state["finished"])
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state = session.state
|
||||
winner = state["winner"]
|
||||
if winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
subs = [seat.user_sub for seat in session.players]
|
||||
plays: List[str] = state["plays"]
|
||||
return MatchResult(
|
||||
teams=[[subs[0]], [subs[1]]],
|
||||
winner_team=subs.index(winner),
|
||||
players=[
|
||||
PlayerResult(
|
||||
user_sub=seat.user_sub,
|
||||
seat=index,
|
||||
won=seat.user_sub == winner,
|
||||
team=seat.team,
|
||||
score=float(plays.count(seat.user_sub)),
|
||||
details={"plays": plays.count(seat.user_sub)},
|
||||
)
|
||||
for index, seat in enumerate(session.players)
|
||||
],
|
||||
summary={
|
||||
"target": state["target"],
|
||||
"plays": len(plays),
|
||||
"winner": winner,
|
||||
},
|
||||
)
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
return dict(state)
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return dict(data)
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
seconds = session.state.get("deadline_in_seconds")
|
||||
if (
|
||||
seconds is None
|
||||
or not session.state["started"]
|
||||
or session.state["finished"]
|
||||
):
|
||||
return None
|
||||
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
|
||||
return Deadline(
|
||||
kind="tick",
|
||||
due_at=due_at,
|
||||
token=f"tick:{len(session.state['plays'])}",
|
||||
)
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
raise GameError("stale deadline")
|
||||
# The house plays for the first player.
|
||||
self._play(session, session.players[0].user_sub)
|
||||
|
||||
|
||||
def make_platform(
|
||||
engines: Sequence[GameEngine] = (DummyEngine(),),
|
||||
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
|
||||
"""Build a throwaway app + platform wired with in-memory stores."""
|
||||
registry = GameRegistry(engines)
|
||||
game_store = InMemoryGameStore(registry)
|
||||
session_mixin = SessionMixin(InMemorySessionStore())
|
||||
oidc_mixin = OIDCMixin(
|
||||
OIDCConfig(
|
||||
issuer="http://localhost:8180/tavolo",
|
||||
client_id="tavolo",
|
||||
client_secret=None,
|
||||
redirect_uri="http://localhost:8080/auth/callback",
|
||||
post_login_redirect="/",
|
||||
post_logout_redirect="/",
|
||||
),
|
||||
session=session_mixin,
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url="sqlite://:memory:",
|
||||
models_modules=["tavolo.platform.models"],
|
||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||
)
|
||||
openapi_mixin = OpenAPIMixin(
|
||||
title="tavolo-platform-tests",
|
||||
version="0.1.0",
|
||||
description="test app",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
)
|
||||
scheduler = DeadlineScheduler(game_store, registry, heartbeat_ms=50)
|
||||
platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
)
|
||||
app = KayaApp(
|
||||
mixins=[
|
||||
session_mixin,
|
||||
oidc_mixin,
|
||||
tortoise_mixin,
|
||||
openapi_mixin,
|
||||
PlatformMixin(platform),
|
||||
]
|
||||
)
|
||||
_tortoise_mixins.append(tortoise_mixin)
|
||||
_schedulers.append(scheduler)
|
||||
return app, platform, tortoise_mixin
|
||||
|
||||
|
||||
_tortoise_mixins: List[TortoiseMixin] = []
|
||||
_schedulers: List[DeadlineScheduler] = []
|
||||
|
||||
|
||||
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
|
||||
platform pieces afterwards: close Tortoise contexts (otherwise
|
||||
orphaned aiosqlite threads block interpreter shutdown) and stop
|
||||
deadline consumers."""
|
||||
|
||||
@wraps(coro)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
async def run() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await coro(*args, **kwargs)
|
||||
finally:
|
||||
for scheduler in _schedulers:
|
||||
scheduler.stop_consumer(loop)
|
||||
_schedulers.clear()
|
||||
for mixin in _tortoise_mixins:
|
||||
await mixin.aclose()
|
||||
_tortoise_mixins.clear()
|
||||
|
||||
with asyncio.Runner() as runner:
|
||||
runner.run(run())
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def use_db(tortoise_mixin: TortoiseMixin):
|
||||
"""Bind the app's Tortoise context for this loop, for seeding rows."""
|
||||
from tortoise.context import TortoiseContext
|
||||
|
||||
await tortoise_mixin._bind()
|
||||
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
return ctx
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: patch ``oidc.get_user`` to return this user."""
|
||||
user = make_user(sub, name)
|
||||
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||
one per websocket connection, in order. Once exhausted it keeps
|
||||
returning the last user."""
|
||||
from tavolo.platform import auth
|
||||
|
||||
remaining = list(users)
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
if remaining:
|
||||
return remaining.pop(0)
|
||||
return last
|
||||
|
||||
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
|
||||
patcher.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
patcher.stop()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Deadline-scheduler tests, driven by the DummyEngine.
|
||||
|
||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||
not by connected sockets: these tests seed sessions, enqueue their
|
||||
deadlines and let the background consumer fire them without a single
|
||||
websocket. The engine owns the meaning of each deadline; the scheduler
|
||||
owns enqueueing, delivery and removal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.deadlines import encode
|
||||
from helpers import DummyEngine, async_test, make_platform, use_db
|
||||
|
||||
|
||||
def _started_session(
|
||||
game_id: str = "dl-1",
|
||||
code: str = "DL0001",
|
||||
target: int = 3,
|
||||
deadline_in_seconds: Optional[float] = None,
|
||||
) -> GameSession:
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
options: Dict[str, Any] = {"target": target}
|
||||
if deadline_in_seconds is not None:
|
||||
options["deadline_in_seconds"] = deadline_in_seconds
|
||||
engine.create(session, options)
|
||||
engine.join(session, "bob", "bob")
|
||||
return session
|
||||
|
||||
|
||||
async def _wait_for(predicate, timeout: float = 5.0):
|
||||
"""Poll the store until ``predicate`` returns a truthy value."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
result = await predicate()
|
||||
if result:
|
||||
return result
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_tick_fires_with_no_connections(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=0.05)
|
||||
await store.save(session)
|
||||
await scheduler.sync_deadline(session)
|
||||
|
||||
# Nobody ever connects: the consumer must still fire the tick,
|
||||
# which plays for the first player.
|
||||
result = await _wait_for(
|
||||
lambda: _plays_is(store, session.id, 1),
|
||||
)
|
||||
self.assertIsNotNone(result, "deadline never fired")
|
||||
|
||||
@async_test
|
||||
async def test_no_deadline_nothing_enqueued(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
session = _started_session() # no deadline_in_seconds option
|
||||
await platform.game_store.save(session)
|
||||
await platform.scheduler.sync_deadline(session)
|
||||
self.assertIsNone(await platform.game_store.next_deadline())
|
||||
|
||||
|
||||
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
|
||||
session = await store.load(game_id)
|
||||
if session is not None and len(session.state["plays"]) == count:
|
||||
return session
|
||||
return None
|
||||
|
||||
|
||||
class ProcessDueTest(unittest.TestCase):
|
||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||
|
||||
@async_test
|
||||
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||
# Simulates a worker dying after firing but before removing the
|
||||
# entry: another worker re-delivers the same entry. The engine's
|
||||
# token has moved on, so the second delivery is stale.
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
|
||||
await scheduler.process_due(member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
# Fired exactly once: one play, not two.
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
|
||||
@async_test
|
||||
async def test_stale_entry_is_discarded(self) -> None:
|
||||
# A tick enqueued before a play landed in time: the token has
|
||||
# moved, so the entry must not fire.
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
session.state["plays"].append("alice") # a play landed in time
|
||||
await store.save(session)
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": "tick",
|
||||
"token": "tick:0", # not the live token ("tick:1")
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
# The entry was removed after processing.
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
member = encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": "tick",
|
||||
"token": "tick:0",
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
await scheduler.process_due(member)
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_malformed_entry_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
await store.add_deadline("not json", due_at=0.0)
|
||||
await scheduler.process_due("not json")
|
||||
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_on_tick(self) -> None:
|
||||
# A tick that completes the match writes the result to Postgres.
|
||||
from tavolo.platform.models import Match
|
||||
|
||||
_, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(target=1, deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
with ctx:
|
||||
await scheduler.process_due(member)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tavolo.platform.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()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from helpers import async_test, make_platform, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.post("/api/games", json={})
|
||||
self.assertEqual(401, response.status_code)
|
||||
self.assertEqual({"error": "unauthenticated"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_create_and_read_lobby(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"options": {"target": 5}}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual(5, body["target"])
|
||||
self.assertEqual("dummy", body["game_type"])
|
||||
self.assertEqual("A", body["players"][0]["team"])
|
||||
self.assertEqual(6, len(body["join_code"]))
|
||||
game_id = body["id"]
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
snapshot = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
snap = snapshot.json()
|
||||
self.assertEqual(game_id, snap["id"])
|
||||
self.assertEqual("dummy", snap["game_type"])
|
||||
self.assertTrue(snap["viewer_seated"])
|
||||
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
forbidden = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(403, forbidden.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_starts_game(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, started.status_code)
|
||||
state = started.json()
|
||||
# The second join started the match, so the response is the
|
||||
# personalized view rather than the lobby payload.
|
||||
self.assertTrue(state["started"])
|
||||
self.assertFalse(state["finished"])
|
||||
self.assertEqual(0, state["plays"])
|
||||
self.assertTrue(state["viewer_seated"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_options(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
zero = await client.post(
|
||||
"/api/games", json={"options": {"target": 0}}
|
||||
)
|
||||
text = await client.post(
|
||||
"/api/games", json={"options": {"target": "three"}}
|
||||
)
|
||||
non_object = await client.post(
|
||||
"/api/games", json={"options": [1, 2]}
|
||||
)
|
||||
self.assertEqual(400, zero.status_code)
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, non_object.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
duplicate = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, duplicate.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
missing = await client.post("/api/games/join", json={})
|
||||
self.assertEqual(400, missing.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user(platform.oidc, "erin"):
|
||||
late = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, late.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_get_unknown_game(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/games/does-not-exist")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_lists_available_game_types(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/game-types")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(["dummy"], [g["id"] for g in results])
|
||||
self.assertEqual("Dummy game", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
self.assertEqual(2, results[0]["min_players"])
|
||||
self.assertEqual(2, results[0]["max_players"])
|
||||
self.assertIn("target", results[0]["options_schema"]["properties"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, created.status_code)
|
||||
self.assertEqual("dummy", created.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_with_explicit_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "dummy"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("dummy", body["game_type"])
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
snapshot = await client.get(f"/api/games/{body['id']}")
|
||||
self.assertEqual("dummy", snapshot.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_unknown_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
self.assertEqual(400, non_string.status_code)
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_me_authenticated(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
|
||||
|
||||
|
||||
def _finished_session(target: int = 2):
|
||||
"""A started dummy session one play short of completion."""
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id="stats-game",
|
||||
game_type=engine.id,
|
||||
join_code="STATS1",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": target})
|
||||
engine.join(session, "bob", "bob")
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
return engine, session
|
||||
|
||||
|
||||
class SaveMatchResultTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_once(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
self.assertTrue(engine.is_finished(session))
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
await save_match_result(session, engine) # idempotent
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(2, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
# The game type travels from the session onto the row; the
|
||||
# engine's summary is stored verbatim as the result.
|
||||
self.assertEqual("dummy", match.game_type)
|
||||
self.assertEqual("alice", match.result["winner"])
|
||||
self.assertEqual(2, match.result["plays"])
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice"}, {p.user_sub for p in winners})
|
||||
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
||||
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row for row in await PlayerRating.all()
|
||||
}
|
||||
self.assertEqual(2, len(ratings))
|
||||
# Two players at 1500: winner gains K/2, loser loses it.
|
||||
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
|
||||
self.assertEqual(1, ratings["alice"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||
self.assertEqual(1, ratings["bob"].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, "bob": -16}, deltas)
|
||||
|
||||
@async_test
|
||||
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
engine2, session2 = DummyEngine(), GameSession(
|
||||
id="stats-game-2",
|
||||
game_type="dummy",
|
||||
join_code="STATS2",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
engine2.create(session2, {"target": 2})
|
||||
engine2.join(session2, "bob", "bob")
|
||||
# Bob wins the second match.
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
await save_match_result(session2, engine2)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row.rating for row in await PlayerRating.all()
|
||||
}
|
||||
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
||||
# 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_test
|
||||
async def test_unfinished_match_is_not_persisted(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(0, await Match.all().count())
|
||||
|
||||
|
||||
async def _seed_two_matches(
|
||||
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
|
||||
) -> None:
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
for index, (winner, finished) in enumerate(
|
||||
[
|
||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
||||
]
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=game_types[index],
|
||||
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
|
||||
finished_at=finished,
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
won=(sub == winner),
|
||||
score=2.0 + index if sub == winner else 1.0,
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_my_matches_newest_first(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "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("bob", results[0]["result"]["winner"]) # newest first
|
||||
self.assertFalse(results[0]["you_won"])
|
||||
self.assertTrue(results[1]["you_won"])
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
self.assertIn("next_cursor", response.json())
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_pagination(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "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:
|
||||
app, _, _ = make_platform()
|
||||
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:
|
||||
app, _, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
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"]) # alice won match 1
|
||||
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
|
||||
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
|
||||
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
|
||||
# Bob leads on points after tying Alice on wins.
|
||||
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||
app, _, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# Alice outranks everyone despite Bob leading on points.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
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["alice"]["elo"])
|
||||
# Players without a rating row report the initial rating.
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
# Elo outranks wins/points.
|
||||
self.assertEqual("alice", results[0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform(
|
||||
engines=(DummyEngine(), SecondEngine())
|
||||
)
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="second",
|
||||
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=dummy")
|
||||
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["bob"]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
self.assertEqual("second", platform.registry.all()[1].id)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_include_elo_delta(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
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:
|
||||
with oidc_user(platform.oidc, "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:
|
||||
app, _, _ = make_platform()
|
||||
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:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
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(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me/ratings")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
|
||||
response.json()["results"],
|
||||
)
|
||||
|
||||
|
||||
class SecondEngine(DummyEngine):
|
||||
id = "second"
|
||||
name = "Second game"
|
||||
|
||||
|
||||
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.
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
all_matches = await client.get("/api/me/matches")
|
||||
scoped = await client.get("/api/me/matches?game_type=dummy")
|
||||
unknown = await client.get("/api/me/matches?game_type=briscola")
|
||||
self.assertEqual(2, len(all_matches.json()["results"]))
|
||||
self.assertEqual(
|
||||
{"dummy", "other_game"},
|
||||
{m["game_type"] for m in all_matches.json()["results"]},
|
||||
)
|
||||
scoped_results = scoped.json()["results"]
|
||||
self.assertEqual(1, len(scoped_results))
|
||||
self.assertEqual("dummy", scoped_results[0]["game_type"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_filter_by_game_type(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||
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=dummy")
|
||||
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, alice 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from helpers import DummyEngine, async_test
|
||||
|
||||
|
||||
def _registry() -> GameRegistry:
|
||||
return GameRegistry([DummyEngine()])
|
||||
|
||||
|
||||
def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": 5})
|
||||
return session
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_load_roundtrip(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
session = _session()
|
||||
await store.save(session)
|
||||
|
||||
loaded = await store.load("g1")
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual("CODE01", loaded.join_code)
|
||||
self.assertEqual("dummy", loaded.game_type)
|
||||
self.assertEqual(5, loaded.state["target"])
|
||||
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
|
||||
# The loaded state is a deserialized copy, not the same object.
|
||||
self.assertIsNot(loaded.state, session.state)
|
||||
|
||||
@async_test
|
||||
async def test_unknown_game_type_rejected(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
session = _session()
|
||||
session.game_type = "nope"
|
||||
with self.assertRaises(Exception):
|
||||
await store.save(session)
|
||||
|
||||
@async_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
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(_registry())
|
||||
await store.save(_session())
|
||||
found = await store.find_by_code("code01") # case-insensitive
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g1", found.id)
|
||||
|
||||
@async_test
|
||||
async def test_load_returns_a_copy(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
first = await store.load("g1")
|
||||
assert first is not None
|
||||
first.state["target"] = 999
|
||||
second = await store.load("g1")
|
||||
assert second is not None
|
||||
self.assertEqual(5, second.state["target"])
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g1") as events:
|
||||
await store.publish("g1")
|
||||
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(_registry())
|
||||
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(_registry())
|
||||
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()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
|
||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class WebSocketTest(unittest.TestCase):
|
||||
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
|
||||
"""Create a game and seat both players; return the started state."""
|
||||
with oidc_user(oidc, "alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"options": {"target": target}}
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user(oidc, "bob"):
|
||||
response = await client.post("/api/games/join", json={"code": code})
|
||||
return response.json()
|
||||
|
||||
@async_test
|
||||
async def test_move_updates_all_connections(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client, platform.oidc)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob"), make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
first = await bob_ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertEqual("dummy", first["game"]["game_type"])
|
||||
self.assertEqual(0, first["game"]["plays"])
|
||||
self.assertEqual(game_id, first["game"]["id"])
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
alice_first = await alice_ws.receive_json()
|
||||
self.assertEqual("state", alice_first["type"])
|
||||
|
||||
await bob_ws.send_json({"action": "play"})
|
||||
bob_update = await bob_ws.receive_json()
|
||||
alice_update = await alice_ws.receive_json()
|
||||
|
||||
for update in (bob_update, alice_update):
|
||||
self.assertEqual("state", update["type"])
|
||||
self.assertEqual(1, update["game"]["plays"])
|
||||
|
||||
@async_test
|
||||
async def test_unknown_action_returns_error(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client, platform.oidc)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
await bob_ws.receive_json()
|
||||
await bob_ws.send_json({"action": "dance"})
|
||||
error = await bob_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("illegal_move", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_state_action_resyncs(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client, platform.oidc)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
await bob_ws.receive_json()
|
||||
await bob_ws.send_json({"action": "state"})
|
||||
resent = await bob_ws.receive_json()
|
||||
self.assertEqual("state", resent["type"])
|
||||
|
||||
@async_test
|
||||
async def test_game_over_broadcast(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
# target 1: the first play ends the match.
|
||||
state = await self._started_game(client, platform.oidc, target=1)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice"), make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
await alice_ws.receive_json()
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
await bob_ws.receive_json()
|
||||
await bob_ws.send_json({"action": "play"})
|
||||
# Both connections see the final state...
|
||||
alice_final = await alice_ws.receive_json()
|
||||
bob_final = await bob_ws.receive_json()
|
||||
self.assertTrue(alice_final["game"]["finished"])
|
||||
self.assertTrue(bob_final["game"]["finished"])
|
||||
# ...followed by the game_over announcement.
|
||||
alice_over = await alice_ws.receive_json()
|
||||
bob_over = await bob_ws.receive_json()
|
||||
self.assertEqual("game_over", alice_over["type"])
|
||||
self.assertEqual("game_over", bob_over["type"])
|
||||
|
||||
@async_test
|
||||
async def test_unknown_game_is_closed(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||
pass
|
||||
self.assertEqual(4404, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_non_player_is_closed(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client, platform.oidc)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||
pass
|
||||
self.assertEqual(4403, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_unauthenticated_is_closed(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||
pass
|
||||
self.assertEqual(4401, caught.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user