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).
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""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()
|