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).
159 lines
8.0 KiB
Python
159 lines
8.0 KiB
Python
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
|
from httpx_ws.transport import ASGIWebSocketTransport
|
|
|
|
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
|
|
|
|
|
class WebSocketTest(unittest.TestCase):
|
|
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
|
|
"""Create a game and seat both players; return the started state."""
|
|
with oidc_user(oidc, "alice"):
|
|
created = await client.post(
|
|
"/api/games", json={"options": {"target": target}}
|
|
)
|
|
code = created.json()["join_code"]
|
|
with oidc_user(oidc, "bob"):
|
|
response = await client.post("/api/games/join", json={"code": code})
|
|
return response.json()
|
|
|
|
@async_test
|
|
async def test_move_updates_all_connections(self) -> None:
|
|
app, platform, _ = make_platform()
|
|
api_transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
|
state = await self._started_game(client, platform.oidc)
|
|
game_id = state["id"]
|
|
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("bob"), make_user("alice")]):
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
|
first = await bob_ws.receive_json()
|
|
self.assertEqual("state", first["type"])
|
|
self.assertEqual("dummy", first["game"]["game_type"])
|
|
self.assertEqual(0, first["game"]["plays"])
|
|
self.assertEqual(game_id, first["game"]["id"])
|
|
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
|
alice_first = await alice_ws.receive_json()
|
|
self.assertEqual("state", alice_first["type"])
|
|
|
|
await bob_ws.send_json({"action": "play"})
|
|
bob_update = await bob_ws.receive_json()
|
|
alice_update = await alice_ws.receive_json()
|
|
|
|
for update in (bob_update, alice_update):
|
|
self.assertEqual("state", update["type"])
|
|
self.assertEqual(1, update["game"]["plays"])
|
|
|
|
@async_test
|
|
async def test_unknown_action_returns_error(self) -> None:
|
|
app, platform, _ = make_platform()
|
|
api_transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
|
state = await self._started_game(client, platform.oidc)
|
|
game_id = state["id"]
|
|
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("bob")]):
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
|
await bob_ws.receive_json()
|
|
await bob_ws.send_json({"action": "dance"})
|
|
error = await bob_ws.receive_json()
|
|
self.assertEqual("error", error["type"])
|
|
self.assertEqual("illegal_move", error["code"])
|
|
|
|
@async_test
|
|
async def test_state_action_resyncs(self) -> None:
|
|
app, platform, _ = make_platform()
|
|
api_transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
|
state = await self._started_game(client, platform.oidc)
|
|
game_id = state["id"]
|
|
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("bob")]):
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
|
await bob_ws.receive_json()
|
|
await bob_ws.send_json({"action": "state"})
|
|
resent = await bob_ws.receive_json()
|
|
self.assertEqual("state", resent["type"])
|
|
|
|
@async_test
|
|
async def test_game_over_broadcast(self) -> None:
|
|
app, platform, _ = make_platform()
|
|
api_transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
|
# target 1: the first play ends the match.
|
|
state = await self._started_game(client, platform.oidc, target=1)
|
|
game_id = state["id"]
|
|
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("alice"), make_user("bob")]):
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
|
await alice_ws.receive_json()
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
|
await bob_ws.receive_json()
|
|
await bob_ws.send_json({"action": "play"})
|
|
# Both connections see the final state...
|
|
alice_final = await alice_ws.receive_json()
|
|
bob_final = await bob_ws.receive_json()
|
|
self.assertTrue(alice_final["game"]["finished"])
|
|
self.assertTrue(bob_final["game"]["finished"])
|
|
# ...followed by the game_over announcement.
|
|
alice_over = await alice_ws.receive_json()
|
|
bob_over = await bob_ws.receive_json()
|
|
self.assertEqual("game_over", alice_over["type"])
|
|
self.assertEqual("game_over", bob_over["type"])
|
|
|
|
@async_test
|
|
async def test_unknown_game_is_closed(self) -> None:
|
|
app, _, _ = make_platform()
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("alice")]):
|
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
|
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
|
pass
|
|
self.assertEqual(4404, caught.exception.code)
|
|
|
|
@async_test
|
|
async def test_non_player_is_closed(self) -> None:
|
|
app, platform, _ = make_platform()
|
|
api_transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
|
state = await self._started_game(client, platform.oidc)
|
|
game_id = state["id"]
|
|
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([make_user("mallory")]):
|
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
|
pass
|
|
self.assertEqual(4403, caught.exception.code)
|
|
|
|
@async_test
|
|
async def test_unauthenticated_is_closed(self) -> None:
|
|
app, _, _ = make_platform()
|
|
ws_transport = ASGIWebSocketTransport(app=app)
|
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
|
with ws_users([]):
|
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
|
async with aconnect_ws("/ws/games/whatever", ws_client):
|
|
pass
|
|
self.assertEqual(4401, caught.exception.code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|