Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled
Some checks failed
ci / tests (push) Has been cancelled
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test utilities package for Catan project."""
|
||||
39
tests/conftest.py
Normal file
39
tests/conftest.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sqlite_url(tmp_path_factory: pytest.TempPathFactory) -> str:
|
||||
db_path = tmp_path_factory.mktemp("db") / "test.db"
|
||||
return f"sqlite:///{db_path}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sqlite_db(sqlite_url: str) -> Generator[None, None, None]:
|
||||
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
||||
from services.common import db as common_db
|
||||
|
||||
common_db.engine = engine
|
||||
common_db.SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
|
||||
import services.api.app as api_app
|
||||
import services.game.app as game_app
|
||||
import services.analytics.app as analytics_app
|
||||
import services.api.models # noqa: F401
|
||||
import services.game.models # noqa: F401
|
||||
|
||||
api_app.engine = engine
|
||||
api_app.SessionLocal = common_db.SessionLocal
|
||||
game_app.engine = engine
|
||||
analytics_app.engine = engine
|
||||
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
yield
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
72
tests/e2e/test_api_flow.py
Normal file
72
tests/e2e/test_api_flow.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.api import app as api_app
|
||||
from services.game import app as game_app
|
||||
from services.analytics import app as analytics_app
|
||||
|
||||
|
||||
def _install_asgi_clients() -> list[httpx.AsyncClient]:
|
||||
created: list[httpx.AsyncClient] = []
|
||||
|
||||
def make_client(app: FastAPI, base_url: str) -> httpx.AsyncClient:
|
||||
client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url=base_url)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
api_app.clients["game"] = make_client(game_app.app, "http://game")
|
||||
api_app.clients["analytics"] = make_client(analytics_app.app, "http://analytics")
|
||||
|
||||
dummy_ai = FastAPI()
|
||||
|
||||
@dummy_ai.get("/models")
|
||||
def _models():
|
||||
return {"models": []}
|
||||
|
||||
@dummy_ai.post("/act")
|
||||
def _act():
|
||||
return {"action": {"type": "end_turn", "payload": {}}, "debug": {}}
|
||||
|
||||
api_app.clients["ai"] = make_client(dummy_ai, "http://ai")
|
||||
return created
|
||||
|
||||
|
||||
def _close_clients(clients: list[httpx.AsyncClient]) -> None:
|
||||
for client in clients:
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(client.aclose())
|
||||
except RuntimeError:
|
||||
asyncio.run(client.aclose())
|
||||
|
||||
|
||||
def test_api_game_flow() -> None:
|
||||
with TestClient(api_app.app) as client:
|
||||
created = _install_asgi_clients()
|
||||
response = client.post("/api/auth/register", json={"username": "eve", "password": "secret"})
|
||||
assert response.status_code == 200
|
||||
token = response.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = client.post("/api/games", json={"name": "Arena", "max_players": 2}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
game_id = response.json()["id"]
|
||||
|
||||
response = client.post(f"/api/games/{game_id}/join", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/games/{game_id}/add-ai",
|
||||
json={"ai_type": "random"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(f"/api/games/{game_id}/start", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "running"
|
||||
_close_clients(created)
|
||||
52
tests/test_cli.py
Normal file
52
tests/test_cli.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from catan.cli import CLIController
|
||||
from catan.game import Game, GameConfig, Phase
|
||||
from catan.data import Resource
|
||||
from tests.utils import find_initial_spot, find_connected_edge, grant_resources
|
||||
|
||||
|
||||
def make_controller(seed: int = 7) -> CLIController:
|
||||
game = Game(GameConfig(player_names=["Alice", "Bob"], seed=seed))
|
||||
controller = CLIController(game)
|
||||
_complete_setup(controller)
|
||||
return controller
|
||||
|
||||
|
||||
def _complete_setup(controller: CLIController) -> None:
|
||||
game = controller.game
|
||||
while game.phase != Phase.MAIN:
|
||||
corner, road = find_initial_spot(game)
|
||||
controller.handle_command(f"place {corner} {road}")
|
||||
|
||||
|
||||
def test_cli_blocks_building_before_roll() -> None:
|
||||
controller = make_controller()
|
||||
grant_resources(controller.game, controller.game.current_player.name)
|
||||
edge_label = find_connected_edge(controller.game)
|
||||
with pytest.raises(ValueError):
|
||||
controller.handle_command(f"build road {edge_label}")
|
||||
|
||||
|
||||
def test_cli_allows_build_after_roll_and_cost_paid() -> None:
|
||||
controller = make_controller()
|
||||
player_name = controller.game.current_player.name
|
||||
grant_resources(controller.game, player_name)
|
||||
edge_label = find_connected_edge(controller.game)
|
||||
before = len(controller.game.player_by_name(player_name).roads)
|
||||
controller.handle_command("roll")
|
||||
controller.handle_command(f"build road {edge_label}")
|
||||
player = controller.game.player_by_name(player_name)
|
||||
assert len(player.roads) == before + 1
|
||||
# Ensure resources spent
|
||||
assert player.resources[Resource.BRICK] == 4
|
||||
assert player.resources[Resource.LUMBER] == 4
|
||||
|
||||
|
||||
def test_cli_prevents_double_roll() -> None:
|
||||
controller = make_controller()
|
||||
controller.handle_command("roll")
|
||||
with pytest.raises(ValueError):
|
||||
controller.handle_command("roll")
|
||||
34
tests/test_cli_bot.py
Normal file
34
tests/test_cli_bot.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from catan.cli import CLIController
|
||||
from catan.game import Game, GameConfig, Phase
|
||||
from catan.ml.agents import RandomAgent
|
||||
|
||||
from tests.utils import find_initial_spot
|
||||
|
||||
|
||||
def test_human_vs_random_bot_session() -> None:
|
||||
controller = CLIController(
|
||||
Game(GameConfig(player_names=["Human", "Bot"], seed=9)),
|
||||
bots={"Bot": RandomAgent(seed=3)},
|
||||
)
|
||||
|
||||
# Complete setup: human manually places, bot uses random agent.
|
||||
while controller.game.phase != Phase.MAIN:
|
||||
current = controller.game.current_player.name
|
||||
if current == "Human":
|
||||
corner, road = find_initial_spot(controller.game)
|
||||
controller.handle_command(f"place {corner} {road}")
|
||||
else:
|
||||
controller._run_bot_turn(current) # noqa: SLF001 - exercising CLI internals
|
||||
|
||||
# Play a few turns: human rolls and ends, bot plays automatically.
|
||||
for _ in range(3):
|
||||
assert controller.game.current_player.name == "Human"
|
||||
controller.handle_command("roll")
|
||||
controller.handle_command("end")
|
||||
assert controller.game.current_player.name == "Bot"
|
||||
controller._run_bot_turn("Bot") # noqa: SLF001
|
||||
|
||||
# Ensure bot activity is recorded in history.
|
||||
assert any(log.player == "Bot" for log in controller.game.history)
|
||||
80
tests/test_env_actions.py
Normal file
80
tests/test_env_actions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from catan.data import Resource
|
||||
from catan.game import GameConfig, Phase
|
||||
from catan.sdk import ActionType, CatanEnv
|
||||
|
||||
from tests.utils import find_initial_spot
|
||||
|
||||
|
||||
def _auto_setup(env: CatanEnv) -> None:
|
||||
game = env.game
|
||||
while game.phase != Phase.MAIN:
|
||||
corner, road = find_initial_spot(game)
|
||||
game.place_initial_settlement(corner, road)
|
||||
|
||||
|
||||
def test_bank_trade_action_and_port_ratio() -> None:
|
||||
env = CatanEnv(GameConfig(player_names=["Alice", "Bob"], seed=5))
|
||||
_auto_setup(env)
|
||||
game = env.game
|
||||
game.phase = Phase.MAIN
|
||||
game.has_rolled = True
|
||||
alice = game.players[0]
|
||||
board = game.board
|
||||
# Remove any accidental port bonuses from initial placements.
|
||||
for corner_id in list(alice.settlements):
|
||||
board.corners[corner_id].port = None
|
||||
|
||||
# Base bank trade (4:1)
|
||||
alice.resources[Resource.BRICK] = 4
|
||||
actions = env.legal_actions()
|
||||
bank_actions = [
|
||||
action for action in actions if action.type == ActionType.TRADE_BANK
|
||||
]
|
||||
base_ratios = [
|
||||
action.payload["ratio"]
|
||||
for action in bank_actions
|
||||
if action.payload["give"] == "brick"
|
||||
]
|
||||
assert base_ratios and min(base_ratios) == 4
|
||||
|
||||
# Attach Alice to a brick port for 2:1 ratio.
|
||||
port_corner = next(
|
||||
corner_id
|
||||
for corner_id, corner in board.corners.items()
|
||||
if corner.port and corner.port.resource == Resource.BRICK
|
||||
)
|
||||
board.corners[port_corner].owner = alice.name
|
||||
board.corners[port_corner].building = "settlement"
|
||||
alice.settlements.add(port_corner)
|
||||
alice.resources[Resource.BRICK] = 2
|
||||
|
||||
actions = env.legal_actions()
|
||||
bank_actions = [
|
||||
action for action in actions if action.type == ActionType.TRADE_BANK
|
||||
]
|
||||
assert any(action.payload["give"] == "brick" and action.payload["ratio"] == 2 for action in bank_actions)
|
||||
|
||||
|
||||
def test_player_trade_actions_available() -> None:
|
||||
env = CatanEnv(GameConfig(player_names=["Alice", "Bob"], seed=11))
|
||||
_auto_setup(env)
|
||||
game = env.game
|
||||
game.phase = Phase.MAIN
|
||||
game.has_rolled = True
|
||||
alice, bob = game.players
|
||||
alice.resources[Resource.WOOL] = 1
|
||||
bob.resources[Resource.ORE] = 1
|
||||
|
||||
actions = env.legal_actions()
|
||||
trade_actions = [
|
||||
action for action in actions if action.type == ActionType.TRADE_PLAYER
|
||||
]
|
||||
assert trade_actions, "Expected player trade actions"
|
||||
assert any(
|
||||
action.payload["target"] == "Bob"
|
||||
and action.payload["offer"] == {"wool": 1}
|
||||
and action.payload["request"] == {"ore": 1}
|
||||
for action in trade_actions
|
||||
)
|
||||
24
tests/unit/test_api_auth.py
Normal file
24
tests/unit/test_api_auth.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.api.app import app
|
||||
|
||||
|
||||
def test_register_and_login() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/auth/register", json={"username": "alice", "password": "secret"})
|
||||
assert response.status_code == 200
|
||||
token = response.json().get("token")
|
||||
assert token
|
||||
|
||||
response = client.post("/api/auth/login", json={"username": "alice", "password": "secret"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_register_duplicate() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/auth/register", json={"username": "bob", "password": "secret"})
|
||||
assert response.status_code == 200
|
||||
response = client.post("/api/auth/register", json={"username": "bob", "password": "secret"})
|
||||
assert response.status_code == 400
|
||||
17
tests/unit/test_auth.py
Normal file
17
tests/unit/test_auth.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from services.common.auth import create_token, decode_token, hash_password, verify_password
|
||||
|
||||
|
||||
def test_password_hash_roundtrip() -> None:
|
||||
secret = "dragon"
|
||||
hashed = hash_password(secret)
|
||||
assert verify_password(secret, hashed)
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
|
||||
def test_token_roundtrip() -> None:
|
||||
token = create_token(42, "alice")
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == "42"
|
||||
assert payload["username"] == "alice"
|
||||
38
tests/utils.py
Normal file
38
tests/utils.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from catan.data import Resource
|
||||
from catan.game import Game
|
||||
|
||||
|
||||
def find_initial_spot(game: Game) -> Tuple[int, int]:
|
||||
board = game.board
|
||||
for corner_id in sorted(board.corners, key=board.corner_label):
|
||||
if not board.can_place_settlement(corner_id, True):
|
||||
continue
|
||||
edges = sorted(board.corners[corner_id].edges, key=board.edge_label)
|
||||
for edge_id in edges:
|
||||
if board.edges[edge_id].owner:
|
||||
continue
|
||||
return board.corner_label(corner_id), board.edge_label(edge_id)
|
||||
raise RuntimeError("No available initial placement")
|
||||
|
||||
|
||||
def find_connected_edge(game: Game) -> int:
|
||||
player = game.current_player
|
||||
board = game.board
|
||||
for edge_id, edge in board.edges.items():
|
||||
if edge.owner:
|
||||
continue
|
||||
if game._road_connection_valid(player, edge): # noqa: SLF001 - test helper
|
||||
return board.edge_label(edge_id)
|
||||
raise RuntimeError("No valid edge to extend road network")
|
||||
|
||||
|
||||
def grant_resources(game: Game, player_name: str, amount: int = 5) -> None:
|
||||
player = game.player_by_name(player_name)
|
||||
for resource in Resource:
|
||||
if resource == Resource.DESERT:
|
||||
continue
|
||||
player.resources[resource] = amount
|
||||
Reference in New Issue
Block a user