treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-brain (pull_request) Successful in 1m0s
build_systems / build-bob (pull_request) Successful in 1m2s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m33s
build_systems / build-jeeves (pull_request) Successful in 2m25s
test ebook search / test-ebook-search (push) Successful in 35s
build_systems / build-brain (push) Successful in 37s
build_systems / build-bob (push) Successful in 37s
build_systems / build-jeeves (push) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-rhapsody-in-green (push) Successful in 49s
- implement FastAPI and HTMX lobby and game interfaces - support up to four human and AI-controlled players - add configurable rules, victory conditions, and expansion modules - support validated JSON cards, patrons, objectives, and outposts
292 lines
13 KiB
Python
292 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from python.gems.config import GemsConfig
|
|
from python.gems.main import create_app
|
|
|
|
|
|
def make_client(tmp_path):
|
|
app = create_app()
|
|
app.state.config = GemsConfig(
|
|
database_path=tmp_path / "gems.sqlite3",
|
|
key_path=tmp_path / "instance.key",
|
|
public_origin="http://testserver",
|
|
)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_create_join_upload_and_reconnect(tmp_path, content_pack) -> None:
|
|
with make_client(tmp_path) as host:
|
|
response = host.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
|
assert response.status_code == 303
|
|
assert response.cookies.get("gems_display_name") == "Host"
|
|
code = response.headers["location"].rsplit("/", 1)[1]
|
|
page = host.get(f"/rooms/{code}")
|
|
assert code in page.text
|
|
assert 'class="topbar room-topbar"' in page.text
|
|
assert page.text.count("<header") == 1
|
|
assert "No cards, artwork" not in page.text
|
|
assert 'value="Host"' in host.get("/").text
|
|
csrf = page.text.split('name="csrf" value="', 1)[1].split('"', 1)[0]
|
|
upload = host.post(
|
|
f"/rooms/{code}/pack",
|
|
data={"csrf": csrf},
|
|
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
|
)
|
|
assert upload.status_code == 200
|
|
assert "Loaded and validated" in upload.text
|
|
with make_client(tmp_path) as guest:
|
|
joined = guest.post(f"/join/{code}", data={"name": "Guest"}, follow_redirects=False)
|
|
assert joined.status_code == 303
|
|
assert "Guest" in guest.get(f"/rooms/{code}").text
|
|
|
|
|
|
def test_schema_and_security_headers(tmp_path) -> None:
|
|
with make_client(tmp_path) as client:
|
|
response = client.get("/schemas/content-pack-v1.json")
|
|
assert response.status_code == 200
|
|
assert "patrons" in response.json()["properties"]
|
|
home = client.get("/")
|
|
assert home.headers["x-frame-options"] == "DENY"
|
|
assert "script-src 'self'" in home.headers["content-security-policy"]
|
|
assert "style-src-attr 'unsafe-inline'" in home.headers["content-security-policy"]
|
|
assert '"code":"422","swap":true' in home.text
|
|
|
|
|
|
def test_solo_lobby_can_start_and_render_legal_actions(tmp_path, content_pack) -> None:
|
|
with make_client(tmp_path) as client:
|
|
created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
assert "Pack required · Waiting for every human to ready up" in page.text
|
|
assert '<button class="primary" disabled>Start game</button>' in page.text
|
|
assert 'name="first_player_mode"' in page.text
|
|
assert 'name="first_player_seat"' in page.text
|
|
uploaded = client.post(
|
|
f"/rooms/{code}/pack",
|
|
data={"csrf": csrf},
|
|
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
|
)
|
|
assert "Pack loaded · Waiting for every human to ready up" in uploaded.text
|
|
assert '<button class="primary" disabled>Start game</button>' in uploaded.text
|
|
ready = client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
|
assert ready.status_code == 200
|
|
assert "Pack loaded · Players ready · Rules set" in ready.text
|
|
assert '<button class="primary">Start game</button>' in ready.text
|
|
started = client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
|
assert started.status_code == 200
|
|
assert all(marker in started.text for marker in ("Gem piles", "Solo", "History", "Patrons", "Patron One"))
|
|
assert all(
|
|
marker in started.text
|
|
for marker in (
|
|
'class="players-stack"',
|
|
"Gems / Cards",
|
|
"Click for card details",
|
|
'class="supply right-supply panel compact"',
|
|
'class="player-gem-dock"',
|
|
'aria-label="Solo gems and cards"',
|
|
'class="hand-total"',
|
|
'class="gem-disc card-bonus-token"',
|
|
)
|
|
)
|
|
assert "players-grid" not in started.text
|
|
assert f"/rooms/{code}/take-gems" in started.text
|
|
assert 'name="resources"' in started.text
|
|
assert f"--gem-color:{content_pack.resources[0].color}" in started.text
|
|
assert '<button class="primary" disabled>Purchase</button>' in started.text
|
|
assert "<button>Reserve</button>" in started.text
|
|
assert "command_json" in started.text
|
|
|
|
pair_resource = content_pack.resource_ids[0]
|
|
paired = client.post(
|
|
f"/rooms/{code}/take-gems",
|
|
data={"csrf": csrf, "pair_resource": pair_resource},
|
|
)
|
|
assert paired.status_code == 200
|
|
assert f'value="{pair_resource}" disabled>Take 2</button>' in paired.text
|
|
|
|
reserve_commands = re.findall(r'name="command_json" value="([^"]+)"><button>Reserve</button>', paired.text)
|
|
reserve_command = next(
|
|
item
|
|
for item in reserve_commands
|
|
if content_pack.card(json.loads(html.unescape(item))["payload"]["card_id"]).bonus_resource != "pearl"
|
|
)
|
|
reserved = client.post(
|
|
f"/rooms/{code}/commands",
|
|
data={"csrf": csrf, "command_json": html.unescape(reserve_command)},
|
|
)
|
|
assert reserved.status_code == 200
|
|
assert "Your reserved cards" in reserved.text
|
|
assert 'class="bottom-reserved"' in reserved.text
|
|
assert ">Purchase</button>" in reserved.text
|
|
reserved_label = re.search(r'<div class="reserved-card-face"><strong>([^<]+)</strong>', reserved.text).group(1)
|
|
purchase_command = re.search(
|
|
r'name="command_json" value="([^"]+)"><button class="primary">Purchase</button>', reserved.text
|
|
).group(1)
|
|
purchased = client.post(
|
|
f"/rooms/{code}/commands",
|
|
data={"csrf": csrf, "command_json": html.unescape(purchase_command)},
|
|
)
|
|
assert purchased.status_code == 200
|
|
assert 'class="owned-card-inspector"' in purchased.text
|
|
assert reserved_label in purchased.text
|
|
|
|
taken = client.post(
|
|
f"/rooms/{code}/take-gems",
|
|
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])},
|
|
)
|
|
assert taken.status_code == 200
|
|
assert "Gem piles" in taken.text
|
|
|
|
invalid = client.post(
|
|
f"/rooms/{code}/take-gems",
|
|
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:2])},
|
|
)
|
|
assert invalid.status_code == 422
|
|
assert "Choose three different available gems" in invalid.text
|
|
|
|
|
|
def test_token_overflow_shows_and_processes_specific_discard_actions(tmp_path, content_pack) -> None:
|
|
with make_client(tmp_path) as client:
|
|
created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
client.post(
|
|
f"/rooms/{code}/pack",
|
|
data={"csrf": csrf},
|
|
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
|
)
|
|
client.post(
|
|
f"/rooms/{code}/settings",
|
|
data={
|
|
"csrf": csrf,
|
|
"token_limit": "1",
|
|
"first_player_mode": "selected",
|
|
"first_player_seat": "0",
|
|
},
|
|
)
|
|
client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
|
client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
|
|
|
overflow = client.post(
|
|
f"/rooms/{code}/take-gems",
|
|
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])},
|
|
)
|
|
assert overflow.status_code == 200
|
|
assert "Resolve: discard tokens" in overflow.text
|
|
assert f"/rooms/{code}/discard-tokens" in overflow.text
|
|
assert "Return exactly 2 excess gems" in overflow.text
|
|
returned_colors = content_pack.resource_ids[:2]
|
|
discarded = client.post(
|
|
f"/rooms/{code}/discard-tokens",
|
|
data={
|
|
"csrf": csrf,
|
|
f"token_{returned_colors[0]}": "1",
|
|
f"token_{returned_colors[1]}": "1",
|
|
},
|
|
)
|
|
assert discarded.status_code == 200
|
|
assert "Resolve: discard tokens" not in discarded.text
|
|
assert "1 / 1" in discarded.text
|
|
|
|
|
|
def test_cli_port_override_accepts_same_origin_mutations(tmp_path) -> None:
|
|
app = create_app()
|
|
app.state.config = GemsConfig(
|
|
database_path=tmp_path / "gems.sqlite3",
|
|
key_path=tmp_path / "instance.key",
|
|
)
|
|
with TestClient(app, base_url="http://127.0.0.1:8002") as client:
|
|
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
assert f"http://127.0.0.1:8002/join/{code}" in page.text
|
|
|
|
response = client.post(
|
|
f"/rooms/{code}/seats/ai",
|
|
data={"csrf": csrf, "difficulty": "medium"},
|
|
headers={"Origin": "http://127.0.0.1:8002"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert "Medium AI" in response.text
|
|
|
|
|
|
def test_cross_origin_mutation_is_rejected(tmp_path) -> None:
|
|
with make_client(tmp_path) as client:
|
|
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
|
|
response = client.post(
|
|
f"/rooms/{code}/seats/ai",
|
|
data={"csrf": csrf, "difficulty": "medium"},
|
|
headers={"Origin": "https://attacker.example"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "Request origin was rejected" in response.text
|
|
|
|
|
|
def test_invalid_content_pack_returns_visible_feedback(tmp_path) -> None:
|
|
with make_client(tmp_path) as client:
|
|
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
assert "No content pack is loaded" in page.text
|
|
|
|
response = client.post(
|
|
f"/rooms/{code}/pack",
|
|
data={"csrf": csrf},
|
|
files={"pack_file": ("broken.json", b"not json", "application/json")},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert 'role="alert"' in response.text
|
|
assert "Invalid JSON" in response.text
|
|
assert "No content pack is loaded" in response.text
|
|
|
|
|
|
def test_host_can_play_again_with_same_table_setup(tmp_path, content_pack) -> None:
|
|
with make_client(tmp_path) as client:
|
|
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
|
code = created.headers["location"].rsplit("/", 1)[1]
|
|
page = client.get(f"/rooms/{code}")
|
|
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
|
client.post(
|
|
f"/rooms/{code}/pack",
|
|
data={"csrf": csrf},
|
|
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
|
)
|
|
client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
|
client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
|
|
|
room = client.app.state.repository.get_room(code)
|
|
room.status = "finished"
|
|
room.state.finished = True
|
|
room.state.winners = [0]
|
|
room.state.revision += 1
|
|
room.revision = room.state.revision
|
|
client.app.state.repository.save_state(room, "test-finish", 0, {"type": "test_finish"})
|
|
|
|
finished = client.get(f"/rooms/{code}")
|
|
assert "Play again" in finished.text
|
|
replayed = client.post(f"/rooms/{code}/play-again", data={"csrf": csrf})
|
|
assert replayed.status_code == 200
|
|
assert "Round 1" in replayed.text
|
|
assert "Play again" not in replayed.text
|
|
fresh_room = client.app.state.repository.get_room(code)
|
|
assert fresh_room.status == "playing"
|
|
assert not fresh_room.state.finished
|
|
assert fresh_room.pack.metadata.id == content_pack.metadata.id
|