Files
tavolo/server/tests/test_stats.py
T
woggioni 5a73601ddf 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).
2026-09-19 07:28:58 +00:00

141 lines
5.6 KiB
Python

"""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
from httpx import ASGITransport, AsyncClient
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
PLAYERS = ("alice", "bob", "carol", "dave")
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",
players=[Seat(user_sub="alice", display_name="Alice")],
)
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
class ScoponeStatsTest(unittest.TestCase):
@async_test
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(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("scopone_scientifico", match.game_type)
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)
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,
)
# 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_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:
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(
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
)
@async_test
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:
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__":
unittest.main()