Files
AetherTable/python/tests/test_engine_proxy.py
T
John SmithandClaude d2af1487bd feat(wave9): resolve all dice through the authoritative Rust rules engine
- vtt-server: new POST /api/v1/actions/{save,concentration,death-save}
  handlers backed by vtt-core RulesEvaluator; /actions/check now honors
  advantage/disadvantage
- orchestrator: routing/engine_client.py + /api/v1/engine/* proxy routes
  (session, attack, check, save, concentration, death-save, map/generate)
  with 502 on engine-down; ENGINE_API_URL env-configured
- client: App.tsx attack/spell/check/macro rolls now resolve server-side
  via api/rules_engine.ts (local dice fallback offline); WfcStudioView
  hits the real engine WFC route and maps its Vec<Vec<u8>> tile grid
- tests: 8 proxy tests (live-engine integration + 502 contract), all
  suites green: 41 pytest pass engine-up / 34+7 skip engine-down,
  cargo workspace clean, client builds in ~1s, MCR 100% / HCI 1.0
- fix misleading "ALL PHASES 1-7" benchmark banner

Co-Authored-By: Claude <[email protected]>
2026-08-23 01:00:30 -04:00

115 lines
4.0 KiB
Python

"""Tests for the authoritative rules engine proxy (/api/v1/engine/*).
Engine-up cases run against a live vtt-server (crates/vtt-server) when one is
reachable on ENGINE_API_URL; they skip otherwise so CI without the Rust binary
still passes. The engine-down case always runs and asserts the 502 contract.
"""
import os
import httpx
import pytest
from fastapi.testclient import TestClient
from vtt_orchestrator.routing import engine_client
from vtt_orchestrator.server import app
client = TestClient(app)
ENGINE_URL = os.environ.get("ENGINE_API_URL", "http://localhost:8088")
def _engine_up() -> bool:
try:
httpx.get(f"{ENGINE_URL}/health", timeout=1.0)
return True
except httpx.HTTPError:
return False
@pytest.fixture()
def live_engine():
if not _engine_up():
pytest.skip("vtt-server engine not running")
return None
class TestEngineDown:
def test_unreachable_engine_returns_502(self, monkeypatch):
monkeypatch.setattr(engine_client, "ENGINE_API_URL", "http://localhost:59999")
response = client.post("/api/v1/engine/check", json={"modifier": 3, "dc": 12})
assert response.status_code == 502
assert "unreachable" in response.json()["detail"].lower()
class TestEngineProxy:
def test_create_session(self, live_engine):
resp = client.post("/api/v1/engine/session", json={"session_name": "pytest"})
assert resp.status_code == 200
assert resp.json()["session_id"]
def test_attack_resolution_contract(self, live_engine):
session_id = client.post("/api/v1/engine/session", json={}).json()["session_id"]
resp = client.post(
"/api/v1/engine/attack",
json={
"session_id": session_id,
"attacker_id": "thorin",
"target_id": "orc-warlord",
"attack_bonus": 7,
"target_ac": 15,
"damage_expression": "1d12+3",
"damage_type": "slashing",
},
)
assert resp.status_code == 200
body = resp.json()
assert 1 <= body["natural_roll"] <= 20
assert isinstance(body["is_hit"], bool)
if body["is_critical_hit"]:
# Critical hits double damage dice; minimum expression is still respected.
assert body["total_damage"] >= 2
elif not body["is_hit"]:
assert body["total_damage"] == 0
def test_check_with_advantage_stays_bounded(self, live_engine):
resp = client.post(
"/api/v1/engine/check", json={"modifier": 5, "dc": 13, "advantage": True}
)
assert resp.status_code == 200
assert 1 <= resp.json()["roll"] <= 20
def test_save_normalizes_ability_casing(self, live_engine):
resp = client.post(
"/api/v1/engine/save", json={"save_modifier": 2, "dc": 10, "ability": "wisdom"}
)
assert resp.status_code == 200
assert resp.json()["ability"] == "WISDOM"
def test_concentration_dc_is_max_of_half_damage_or_ten(self, live_engine):
resp = client.post(
"/api/v1/engine/concentration", json={"con_modifier": 0, "damage_taken": 30}
)
assert resp.status_code == 200
assert resp.json()["dc"] == 15
def test_death_save_state_machine_tracks_failures(self, live_engine):
resp = client.post(
"/api/v1/engine/death-save",
json={"successes": 0, "failures": 0, "natural_roll": 1},
)
assert resp.status_code == 200
body = resp.json()
assert body["failures"] == 2, "a natural 1 counts as two failures"
def test_map_generation_returns_wall_grid(self, live_engine):
resp = client.post(
"/api/v1/engine/map/generate", json={"width": 16, "height": 12, "seed": 42}
)
assert resp.status_code == 200
tiles = resp.json()["tiles"]
assert len(tiles) == 12 and len(tiles[0]) == 16
# Perimeter must be sealed.
assert all(cell == 1 for cell in tiles[0])
assert any(1 in row for row in tiles)