Files
commander-ai-lab/tests/test_phase8.py
T

421 lines
16 KiB
Python

"""
Commander AI Lab — Phase 8 Unit Tests
======================================
Covers: LobbyManager, ChatChannel, SpectatorManager,
LANBeacon, PrivateHandBus, Lobby REST API
Run with: pytest tests/test_phase8.py -v
"""
from __future__ import annotations
import asyncio
import json
import time
import pytest
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# LobbyManager
# ---------------------------------------------------------------------------
class TestLobbyManager:
def _mgr(self):
from commander_ai_lab.multiplayer.lobby import LobbyManager
return LobbyManager()
def test_create_room_returns_id(self):
mgr = self._mgr()
rid = run(mgr.create_room("Test Room", "Alice"))
assert isinstance(rid, str) and len(rid) > 0
def test_host_in_seat_0(self):
mgr = self._mgr()
rid = run(mgr.create_room("Test", "Alice"))
room = mgr.get_room(rid)
assert room.slots[0].player_name == "Alice"
assert room.slots[0].is_host is True
def test_ai_slot_filled(self):
mgr = self._mgr()
rid = run(mgr.create_room("Test", "Alice",
ai_slots=[{"seat": 2, "personality": "spike"}]))
room = mgr.get_room(rid)
assert room.slots[2].is_ai is True
assert room.slots[2].is_ready is True
def test_join_room_assigns_seat(self):
mgr = self._mgr()
rid = run(mgr.create_room("Test", "Alice"))
seat = run(mgr.join_room(rid, "Bob"))
assert seat in [1, 2, 3]
def test_join_full_room_raises(self):
mgr = self._mgr()
rid = run(mgr.create_room("Full", "A",
ai_slots=[{"seat":1,"personality":"spike"},
{"seat":2,"personality":"timmy"},
{"seat":3,"personality":"johnny"}]))
with pytest.raises(ValueError, match="full"):
run(mgr.join_room(rid, "Lateomer"))
def test_wrong_password_raises(self):
mgr = self._mgr()
rid = run(mgr.create_room("Secret", "Alice", password="hunter2"))
with pytest.raises(ValueError, match="password"):
run(mgr.join_room(rid, "Eve", password="wrong"))
def test_correct_password_joins(self):
mgr = self._mgr()
rid = run(mgr.create_room("Secret", "Alice", password="hunter2"))
seat = run(mgr.join_room(rid, "Bob", password="hunter2"))
assert isinstance(seat, int)
def test_set_ready_transitions_state(self):
from commander_ai_lab.multiplayer.lobby import RoomState
mgr = self._mgr()
rid = run(mgr.create_room("R", "Alice",
ai_slots=[{"seat":1,"personality":"spike"},
{"seat":2,"personality":"timmy"},
{"seat":3,"personality":"johnny"}]))
state = run(mgr.set_ready(rid, seat=0))
assert state == RoomState.READY
def test_launch_requires_ready_state(self):
mgr = self._mgr()
rid = run(mgr.create_room("R", "Alice"))
with pytest.raises(ValueError, match="ready"):
run(mgr.launch_game(rid))
def test_launch_fires_callback(self):
from commander_ai_lab.multiplayer.lobby import RoomState
fired = [False]
async def cb(rid, room): fired[0] = True
mgr = LobbyManager_with_cb(cb)
rid = run(mgr.create_room("R", "Alice",
ai_slots=[{"seat":1,"personality":"spike"},
{"seat":2,"personality":"timmy"},
{"seat":3,"personality":"johnny"}]))
run(mgr.set_ready(rid, 0))
run(mgr.launch_game(rid))
assert fired[0] is True
def test_list_open_rooms(self):
mgr = self._mgr()
run(mgr.create_room("Room1", "Alice"))
run(mgr.create_room("Room2", "Bob"))
rooms = mgr.list_open_rooms()
assert len(rooms) == 2
def test_spectator_limit(self):
from commander_ai_lab.multiplayer.lobby import MAX_SPECTATORS
mgr = self._mgr()
rid = run(mgr.create_room("Spec", "Alice"))
for i in range(MAX_SPECTATORS):
run(mgr.add_spectator(rid, f"spec-{i}"))
with pytest.raises(ValueError, match="limit"):
run(mgr.add_spectator(rid, "one-too-many"))
def LobbyManager_with_cb(cb):
from commander_ai_lab.multiplayer.lobby import LobbyManager
return LobbyManager(on_launch=cb)
# ---------------------------------------------------------------------------
# ChatChannel
# ---------------------------------------------------------------------------
class TestChatChannel:
def _ch(self):
from commander_ai_lab.multiplayer.chat import ChatChannel
return ChatChannel(room_id="test")
def test_send_stores_message(self):
ch = self._ch()
run(ch.send("Alice", "Hello!", seat=0))
assert len(ch.history()) == 1
def test_message_truncated(self):
from commander_ai_lab.multiplayer.chat import MAX_MSG_LENGTH
ch = self._ch()
msg = run(ch.send("Bob", "x" * (MAX_MSG_LENGTH + 100)))
assert len(msg.text) == MAX_MSG_LENGTH
def test_history_capped(self):
from commander_ai_lab.multiplayer.chat import MAX_HISTORY
ch = self._ch()
for i in range(MAX_HISTORY + 50):
run(ch.send("A", str(i)))
assert len(ch._history) == MAX_HISTORY
def test_system_message_role(self):
from commander_ai_lab.multiplayer.chat import ChatRole
ch = self._ch()
run(ch.system("Game started"))
h = ch.history(role_filter=ChatRole.SYSTEM)
assert len(h) == 1
assert h[0]["role"] == "SYSTEM"
def test_ai_message_role(self):
from commander_ai_lab.multiplayer.chat import ChatRole
ch = self._ch()
run(ch.ai_says("Timmy", 1, "I'll block!"))
h = ch.history(role_filter=ChatRole.AI)
assert h[0]["sender"] == "Timmy"
def test_handler_receives_message(self):
ch = self._ch()
received = []
async def handler(payload): received.append(payload)
ch.register(seat=0, send_fn=handler)
run(ch.send("A", "Test", seat=0))
assert len(received) == 1
assert received[0]["text"] == "Test"
def test_unregistered_handler_not_called(self):
ch = self._ch()
received = []
async def handler(payload): received.append(payload)
ch.register(seat=0, send_fn=handler)
ch.unregister(0)
run(ch.send("A", "Test"))
assert len(received) == 0
# ---------------------------------------------------------------------------
# SpectatorManager
# ---------------------------------------------------------------------------
class TestSpectatorManager:
def test_add_and_count(self):
from commander_ai_lab.multiplayer.spectator import SpectatorManager
mgr = SpectatorManager()
async def noop(s): pass
mgr.add("room1", "conn1", noop)
assert mgr.count("room1") == 1
def test_remove(self):
from commander_ai_lab.multiplayer.spectator import SpectatorManager
mgr = SpectatorManager()
async def noop(s): pass
mgr.add("room1", "conn1", noop)
mgr.remove("room1", "conn1")
assert mgr.count("room1") == 0
def test_broadcast_strips_hands(self):
from commander_ai_lab.multiplayer.spectator import SpectatorManager
mgr = SpectatorManager()
received = []
async def capture(s): received.append(s)
mgr.add("room1", "c1", capture)
snapshot = {
"players": [{"seat": 0, "life": 40, "hand": [{"name": "Sol Ring"}]}]
}
run(mgr.broadcast("room1", snapshot))
payload = json.loads(received[0])
assert "hand" not in payload["data"]["players"][0]
def test_dead_connection_pruned(self):
from commander_ai_lab.multiplayer.spectator import SpectatorManager
mgr = SpectatorManager()
async def fail(s): raise ConnectionError("dead")
mgr.add("room1", "dead_conn", fail)
run(mgr.broadcast("room1", {"players": []}))
assert mgr.count("room1") == 0
def test_multiple_spectators_all_receive(self):
from commander_ai_lab.multiplayer.spectator import SpectatorManager
mgr = SpectatorManager()
buckets = [[], [], []]
for i in range(3):
idx = i
async def cap(s, i=idx): buckets[i].append(s)
mgr.add("room1", f"c{i}", cap)
run(mgr.broadcast("room1", {"players": []}))
assert all(len(b) == 1 for b in buckets)
# ---------------------------------------------------------------------------
# LANBeacon
# ---------------------------------------------------------------------------
class TestLANBeacon:
def test_not_stale_immediately(self):
from commander_ai_lab.multiplayer.lan import LANBeacon
b = LANBeacon("192.168.1.1", 8000, "rid", "Room", 2, 4, False)
assert b.is_stale() is False
def test_stale_after_ttl(self):
from commander_ai_lab.multiplayer.lan import LANBeacon, BEACON_TTL
b = LANBeacon("192.168.1.1", 8000, "rid", "Room", 2, 4, False,
last_seen=time.time() - BEACON_TTL - 1)
assert b.is_stale() is True
def test_to_dict_fields(self):
from commander_ai_lab.multiplayer.lan import LANBeacon
b = LANBeacon("10.0.0.1", 8000, "abc", "My Room", 3, 4, True)
d = b.to_dict()
assert d["room_name"] == "My Room"
assert d["has_password"] is True
def test_discovery_build_beacon(self):
from commander_ai_lab.multiplayer.lan import LANDiscovery
ld = LANDiscovery(mode="host", room_info={
"room_id": "abc", "room_name": "Test",
"player_count": 2, "max_players": 4, "has_password": False,
})
payload = ld._build_beacon_payload()
assert payload["room_name"] == "Test"
assert payload["app"] == "commander-ai-lab"
def test_handle_beacon_stores(self):
from commander_ai_lab.multiplayer.lan import LANDiscovery, APP_ID
ld = LANDiscovery(mode="client")
raw = json.dumps({
"app": APP_ID, "version": "0.8.0",
"room_id": "xyz", "room_name": "LAN Game",
"player_count": 1, "max_players": 4,
"has_password": False, "host_port": 8000,
}).encode()
ld._handle_beacon(raw, "192.168.0.5")
assert "192.168.0.5" in ld._discovered
def test_unknown_app_ignored(self):
from commander_ai_lab.multiplayer.lan import LANDiscovery
ld = LANDiscovery(mode="client")
raw = json.dumps({"app": "some-other-app", "room_id": "x"}).encode()
ld._handle_beacon(raw, "10.0.0.1")
assert len(ld._discovered) == 0
def test_invalid_json_ignored(self):
from commander_ai_lab.multiplayer.lan import LANDiscovery
ld = LANDiscovery(mode="client")
ld._handle_beacon(b"not json{{{", "10.0.0.2")
assert len(ld._discovered) == 0
# ---------------------------------------------------------------------------
# PrivateHandBus
# ---------------------------------------------------------------------------
class TestPrivateHandBus:
def test_dispatch_sends_hand_to_human(self):
from commander_ai_lab.multiplayer.hands import PrivateHandBus
bus = PrivateHandBus()
received = []
async def send(s): received.append(json.loads(s))
bus.register(seat=0, send_fn=send, is_human=True)
snapshot = {
"players": [
{"seat": 0, "life": 40, "hand": [{"name": "Sol Ring"}]},
{"seat": 1, "life": 40, "hand": [{"name": "Command Tower"}]},
]
}
public = run(bus.dispatch(snapshot, current_turn=3))
assert len(received) == 1
assert received[0]["type"] == "hand"
assert received[0]["seat"] == 0
assert received[0]["cards"][0]["name"] == "Sol Ring"
def test_public_snapshot_has_no_hands(self):
from commander_ai_lab.multiplayer.hands import PrivateHandBus
bus = PrivateHandBus()
async def noop(s): pass
bus.register(seat=0, send_fn=noop)
snapshot = {"players": [{"seat": 0, "hand": [{"name": "Mox"}]}]}
public = run(bus.dispatch(snapshot, current_turn=1))
assert "hand" not in public["players"][0]
def test_ai_seat_not_called(self):
from commander_ai_lab.multiplayer.hands import PrivateHandBus
bus = PrivateHandBus()
received = []
async def send(s): received.append(s)
bus.register(seat=1, send_fn=send, is_human=False)
snapshot = {"players": [{"seat": 1, "hand": [{"name": "Island"}]}]}
run(bus.dispatch(snapshot, current_turn=2))
assert len(received) == 0
def test_registered_seats_sorted(self):
from commander_ai_lab.multiplayer.hands import PrivateHandBus
bus = PrivateHandBus()
async def noop(s): pass
bus.register(3, noop)
bus.register(1, noop)
bus.register(0, noop)
assert bus.registered_seats() == [0, 1, 3]
def test_unregister_removes_seat(self):
from commander_ai_lab.multiplayer.hands import PrivateHandBus
bus = PrivateHandBus()
async def noop(s): pass
bus.register(0, noop)
bus.unregister(0)
assert 0 not in bus.registered_seats()
# ---------------------------------------------------------------------------
# Lobby REST API
# ---------------------------------------------------------------------------
class TestLobbyAPI:
@pytest.fixture
def client(self):
from commander_ai_lab.web.routers.lobby import router, ws_router, _lobby
import commander_ai_lab.web.routers.lobby as lobby_mod
lobby_mod._lobby = None
lobby_mod._spectators = None
lobby_mod._chats = {}
lobby_mod._ws_rooms = {}
from commander_ai_lab.web.app import create_app
from fastapi.testclient import TestClient
app = create_app()
return TestClient(app)
def test_create_room(self, client):
r = client.post("/api/v1/lobby/rooms", json={
"room_name": "Friday", "host_name": "Alice"
})
assert r.status_code == 201
assert "room_id" in r.json()
def test_list_rooms(self, client):
client.post("/api/v1/lobby/rooms", json={"room_name": "A", "host_name": "X"})
client.post("/api/v1/lobby/rooms", json={"room_name": "B", "host_name": "Y"})
r = client.get("/api/v1/lobby/rooms")
assert len(r.json()["rooms"]) == 2
def test_get_room_not_found(self, client):
r = client.get("/api/v1/lobby/rooms/doesnotexist")
assert r.status_code == 404
def test_join_room(self, client):
rid = client.post("/api/v1/lobby/rooms",
json={"room_name": "J", "host_name": "A"}).json()["room_id"]
r = client.post(f"/api/v1/lobby/rooms/{rid}/join",
json={"player_name": "Bob"})
assert "seat" in r.json()
def test_ready_and_launch(self, client):
r = client.post("/api/v1/lobby/rooms", json={
"room_name": "Launch", "host_name": "H",
"ai_slots": [{"seat":1,"personality":"spike"},
{"seat":2,"personality":"timmy"},
{"seat":3,"personality":"johnny"}],
})
rid = r.json()["room_id"]
client.post(f"/api/v1/lobby/rooms/{rid}/ready", json={"seat": 0})
launch_r = client.post(f"/api/v1/lobby/rooms/{rid}/launch")
assert launch_r.json()["state"] == "IN_PROGRESS"
def test_launch_without_ready_fails(self, client):
rid = client.post("/api/v1/lobby/rooms",
json={"room_name": "X", "host_name": "Y"}).json()["room_id"]
r = client.post(f"/api/v1/lobby/rooms/{rid}/launch")
assert r.status_code == 400