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.
400 lines
18 KiB
Python
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()
|