Files
tavolo/server/tests/test_routes_games.py
T
woggioni 68200bbe62 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 14:45:20 +08:00

400 lines
18 KiB
Python

"""Game lobby route tests via kaya's ASGI transport (all registered games)."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tests.helpers import async_test, oidc_user
class GamesRouteTest(unittest.TestCase):
@async_test
async def test_create_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.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:
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={"options": {"target_score": 16}}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("lobby", body["phase"])
self.assertEqual(3, body["seats_open"])
self.assertEqual(16, body["target_score"])
self.assertEqual(6, len(body["join_code"]))
game_id = body["id"]
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
# A logged-in non-player is a read-only spectator: no join
# code, and the view is marked as a spectator view.
with oidc_user("mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
self.assertTrue(watched.json()["spectator"])
self.assertNotIn("join_code", watched.json())
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
@async_test
async def test_join_fills_seats_and_starts_game(self) -> None:
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={})
code = created.json()["join_code"]
for player in ("bob", "carol"):
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, joined.status_code)
self.assertEqual("lobby", joined.json()["phase"])
with oidc_user("dave"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
game_id = state["id"]
self.assertEqual("playing", state["phase"])
self.assertEqual(4, len(state["players"]))
self.assertEqual([], state["table"])
for participant in state["players"]:
self.assertEqual(10, participant["cards_left"])
# The view is personalized to Dave: he sees his own hand in
# seat 3 but not Alice's in seat 0.
self.assertIn("hand", state["players"][3])
self.assertNotIn("hand", state["players"][0])
self.assertEqual(1, state["turn"])
# A spectator sees the table, the score and each player's
# captured count and scope — but no hand at all.
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
self.assertEqual([], spect["table"])
self.assertEqual({"A": 0, "B": 0}, spect["scores"])
for participant in spect["players"]:
self.assertNotIn("hand", participant)
self.assertEqual(10, participant["cards_left"])
self.assertEqual(0, participant["captured_count"])
self.assertEqual(0, participant["scope"])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertTrue(default.json()["napola"])
with oidc_user("alice"):
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={"options": {"napola": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test
async def test_create_asso_pigliatutto_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertFalse(default.json()["asso_pigliatutto"])
with oidc_user("alice"):
enabled = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": True}}
)
self.assertEqual(201, enabled.status_code)
self.assertTrue(enabled.json()["asso_pigliatutto"])
with oidc_user("alice"):
invalid = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test
async def test_preview_join_code(self) -> None:
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={
"options": {
"target_score": 16,
"napola": False,
"asso_pigliatutto": True,
}
},
)
code = created.json()["join_code"]
response = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(401, response.status_code)
with oidc_user("bob"):
missing = await client.get("/api/games/preview")
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
self.assertEqual(400, missing.status_code)
self.assertEqual(404, unknown.status_code)
with oidc_user("bob"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("scopone_scientifico", body["game_type"])
self.assertEqual("Scopone scientifico", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(16, body["target_score"])
self.assertFalse(body["napola"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(3, body["seats_open"])
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
for player in ("bob", "carol", "dave"):
with oidc_user(player):
await client.post("/api/games/join", json={"code": code})
with oidc_user("bob"):
started = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, started.status_code)
self.assertEqual("playing", started.json()["phase"])
self.assertEqual(0, started.json()["seats_open"])
@async_test
async def test_join_errors(self) -> None:
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={})
code = created.json()["join_code"]
with oidc_user("bob"):
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
self.assertEqual(404, unknown.status_code)
with oidc_user("alice"):
duplicate = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, duplicate.status_code)
with oidc_user("bob"):
missing = await client.post("/api/games/join", json={})
self.assertEqual(400, missing.status_code)
for player in ("bob", "carol", "dave"):
with oidc_user(player):
await client.post("/api/games/join", json={"code": code})
with oidc_user("erin"):
late = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, late.status_code)
@async_test
async def test_create_rejects_bad_target_score(self) -> None:
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={"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)
@async_test
async def test_my_games_lists_my_ongoing_games(self) -> None:
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={"game_type": "briscola"}
)
game_id = created.json()["id"]
with oidc_user("alice"):
listed = await client.get("/api/me/games")
self.assertEqual(200, listed.status_code)
# The app suite shares one in-memory store, so other tests'
# games may be listed too: pick ours by id.
by_id = {g["id"]: g for g in listed.json()["results"]}
self.assertIn(game_id, by_id)
entry = by_id[game_id]
self.assertEqual("briscola", entry["game_type"])
self.assertEqual("Briscola", entry["game_name"])
self.assertTrue(entry["game_description"])
self.assertEqual("lobby", entry["phase"])
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
self.assertNotIn("join_code", entry)
# A logged-in non-player does not see the game.
with oidc_user("mallory"):
others = await client.get("/api/me/games")
self.assertNotIn(
game_id, [g["id"] for g in others.json()["results"]]
)
# Once the match starts it stays listed, for both players.
code = created.json()["join_code"]
with oidc_user("bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user("bob"):
started = await client.get("/api/me/games")
bob_entry = next(
g for g in started.json()["results"] if g["id"] == game_id
)
self.assertEqual("playing", bob_entry["phase"])
self.assertEqual(1, bob_entry["your_seat"])
self.assertEqual(0, bob_entry["seats_open"])
@async_test
async def test_get_unknown_game(self) -> None:
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/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:
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(
[
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
[g["id"] for g in results],
)
scopone = results[0]
self.assertEqual("Scopone scientifico", scopone["name"])
self.assertTrue(scopone["description"])
self.assertEqual(4, scopone["min_players"])
self.assertEqual(4, scopone["max_players"])
self.assertIn("target_score", scopone["options_schema"]["properties"])
self.assertIn("napola", scopone["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", scopone["options_schema"]["properties"]
)
scopa = results[1]
self.assertEqual("Scopa", scopa["name"])
self.assertTrue(scopa["description"])
self.assertEqual(2, scopa["min_players"])
self.assertEqual(2, scopa["max_players"])
self.assertIn("target_score", scopa["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", scopa["options_schema"]["properties"]
)
scopa_quattro = results[2]
self.assertEqual("Scopa a quattro", scopa_quattro["name"])
self.assertTrue(scopa_quattro["description"])
self.assertEqual(4, scopa_quattro["min_players"])
self.assertEqual(4, scopa_quattro["max_players"])
self.assertIn(
"target_score", scopa_quattro["options_schema"]["properties"]
)
self.assertIn(
"asso_pigliatutto", scopa_quattro["options_schema"]["properties"]
)
briscola = results[3]
self.assertEqual("Briscola", briscola["name"])
self.assertTrue(briscola["description"])
self.assertEqual(2, briscola["min_players"])
self.assertEqual(2, briscola["max_players"])
self.assertIn("target_score", briscola["options_schema"]["properties"])
quattro = results[4]
self.assertEqual("Briscola a quattro", quattro["name"])
self.assertTrue(quattro["description"])
self.assertEqual(4, quattro["min_players"])
self.assertEqual(4, quattro["max_players"])
self.assertIn("target_score", quattro["options_schema"]["properties"])
chiamata = results[5]
self.assertEqual("Briscola chiamata", chiamata["name"])
self.assertTrue(chiamata["description"])
self.assertEqual(5, chiamata["min_players"])
self.assertEqual(5, chiamata["max_players"])
self.assertEqual({}, chiamata["options_schema"])
@async_test
async def test_create_defaults_game_type(self) -> None:
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={})
self.assertEqual(201, created.status_code)
self.assertEqual("scopone_scientifico", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
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={"game_type": "scopone_scientifico"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopone_scientifico", body["game_type"])
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
unknown = await client.post("/api/games", json={"game_type": "tresette"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
if __name__ == "__main__":
unittest.main()