"""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 pwo import async_test 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 tests.helpers import 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 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", creator_sub="alice", target_score=2, phase=engine.PHASE_PLAYING, turn=0, table=[engine.parse_card("02C")], ) 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 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): @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) with ctx: await save_match_result(state) await save_match_result(state) # idempotent 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}) @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), ) 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()) @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() 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"])) self.assertEqual( {"scopone_scientifico", "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("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")) 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) if __name__ == "__main__": unittest.main()