Files
tukaunuandClaude Opus 5.5 c52f30dc59 Moonchess: vanishing tic-tac-toe, online and practice modes
Online play over WebSockets with any-string game codes, seats and spectators,
and a 1-second heartbeat. Practice mode against Columbina, backed by a full
solver, with an accuracy slider, spoilers, undo and piece freshness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 03:36:46 +02:00

181 lines
6.7 KiB
Python

import asyncio
import unittest
from unittest import mock
from aiohttp.test_utils import AioHTTPTestCase
import server
class ServerTest(AioHTTPTestCase):
async def get_application(self):
server.rooms.clear()
return server.make_app()
async def new_game(self):
return "TESTGAME"
async def join(self, token, client_id=""):
return await self.client.ws_connect("/ws", params={"game": token, "client": client_id})
async def next_state(self, ws):
while True:
msg = await asyncio.wait_for(ws.receive_json(), 2)
if msg["type"] == "state":
return msg
async def settle(self, ws):
"""Return the most recent state after pending broadcasts arrive."""
state = await self.next_state(ws)
while True:
try:
msg = await asyncio.wait_for(ws.receive_json(), 0.2)
except asyncio.TimeoutError:
return state
if msg["type"] == "state":
state = msg
async def test_any_code_creates_a_game(self):
a = await self.join(" friday night ")
s = await self.settle(a)
self.assertEqual(s["token"], "FRIDAY NIGHT")
self.assertEqual(s["seat"], 0)
b = await self.join("Friday Night")
self.assertEqual((await self.settle(b))["seat"], 1)
self.assertEqual(list(server.rooms), ["FRIDAY NIGHT"])
async def test_invalid_codes_are_rejected(self):
for code in ["", " ", "x" * (server.MAX_CODE_LENGTH + 1)]:
ws = await self.join(code)
await ws.receive()
self.assertEqual(ws.close_code, server.CLOSE_INVALID_CODE)
self.assertEqual(server.rooms, {})
async def test_oversized_and_odd_messages(self):
a = await self.join("junk")
await self.settle(a)
await a.send_str("[]") # valid JSON, but not an object: ignored
await a.send_json({"type": "ping"})
self.assertEqual((await asyncio.wait_for(a.receive_json(), 2))["type"], "pong")
await a.send_str("x" * (server.MAX_MESSAGE_SIZE + 1))
while not a.closed:
await asyncio.wait_for(a.receive(), 2)
self.assertEqual(a.close_code, 1009) # message too big
async def test_empty_game_is_deleted_immediately(self):
a = await self.join("gone")
b = await self.join("gone")
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
await self.settle(b)
await a.close()
await b.close()
await asyncio.sleep(0.1)
self.assertNotIn("GONE", server.rooms)
c = await self.join("gone")
self.assertEqual((await self.settle(c))["board"][4], None)
async def test_game_survives_while_anyone_stays(self):
a = await self.join("keep")
b = await self.join("keep")
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
await self.settle(b)
await a.close()
a = await self.join("keep")
self.assertEqual((await self.settle(a))["board"][4], 0)
async def test_index_served(self):
res = await self.client.get("/")
self.assertEqual(res.status, 200)
self.assertIn("Moonchess", await res.text())
async def test_seats_and_spectators(self):
token = await self.new_game()
a = await self.join(token)
self.assertEqual((await self.settle(a))["seat"], 0)
b = await self.join(token)
self.assertEqual((await self.settle(b))["seat"], 1)
c = await self.join(token)
s = await self.settle(c)
self.assertIsNone(s["seat"])
self.assertEqual(s["spectators"], 1)
# spectators can't move
await c.send_json({"type": "move", "cell": 0})
await b.send_json({"type": "ping"})
await self.settle(a)
self.assertEqual(server.rooms[token].game.board, [None] * 9)
# a player leaves; the spectator refreshes and takes the seat
await a.close()
s = await self.settle(b)
self.assertEqual(s["players"], ["empty", "connected"])
await c.close()
d = await self.join(token)
self.assertEqual((await self.settle(d))["seat"], 0)
async def test_moves_need_both_players(self):
token = await self.new_game()
a = await self.join(token)
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
msg = await asyncio.wait_for(a.receive_json(), 2)
self.assertEqual(msg, {"type": "error", "message": "Waiting for an opponent"})
b = await self.join(token)
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
s = await self.settle(b)
self.assertEqual(s["board"][4], 0)
self.assertEqual(s["turn"], 1)
async def test_reconnect_reclaims_own_slot(self):
token = await self.new_game()
a = await self.join(token, "page-a")
await self.settle(a)
b = await self.join(token, "page-b")
await self.settle(b)
# a's old socket is still "alive" from the server's view; same page reconnects
a2 = await self.join(token, "page-a")
s = await self.settle(a2)
self.assertEqual(s["seat"], 0)
self.assertEqual(s["spectators"], 0)
async def test_silent_client_is_dropped(self):
token = await self.new_game()
with mock.patch.object(server, "HEARTBEAT_TIMEOUT", 0.5):
a = await self.join(token)
await self.settle(a)
b = await self.join(token)
await self.settle(b)
# b keeps pinging, a goes silent
for _ in range(8):
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
self.assertEqual(server.rooms[token].players_present(), [False, True])
async def test_silent_seat_shows_disconnected_then_recovers(self):
token = await self.new_game()
with mock.patch.object(server, "STALE_AFTER", 0.5):
a = await self.join(token)
await self.settle(a)
b = await self.join(token)
await self.settle(b)
for _ in range(8): # b keeps pinging, a goes quiet
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
s = await self.settle(b)
self.assertEqual(s["players"], ["disconnected", "connected"])
for _ in range(5): # a comes back
await a.send_json({"type": "ping"})
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
s = await self.settle(b)
self.assertEqual(s["players"], ["connected", "connected"])
if __name__ == "__main__":
unittest.main()