test(policy): add Step 4 board-stats tests + fix schema alias (#83 Step 4)
- TestBoardStatsWiring: verifies creaturesOnField, landCount, totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField, enchantmentsOnField are accepted without 422 and survive a round-trip through the fallback encoder path - TestBoardStatsFallback: verifies resolver fallbacks when Forge fields are absent (creatures inferred from bf length, total_power falls back to creatures * 3 heuristic, lands defaults to 0) - TestSchemaAlias: verifies snapshot_schema Field(alias='schema') still accepts JSON key 'schema' without 422 - Removes now-irrelevant UserWarning (schema shadow fixed)
This commit is contained in:
1 parent
bff7b3a81e
commit
73d8e8d220
1 file changed
+256
-149
+256
-149
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
tests/test_policy_decide.py
|
||||
|
||||
Round-trip tests for POST /api/policy/decide introduced in Issue #83 Step 2.
|
||||
Round-trip tests for POST /api/policy/decide (Issue #83 Steps 2 + 4).
|
||||
|
||||
Coverage:
|
||||
- Health endpoint sanity
|
||||
@@ -11,6 +11,10 @@ Coverage:
|
||||
- Fallback path: no state_vector → vector_source="encoder"
|
||||
- Fallback path: wrong-length state_vector → vector_source="encoder"
|
||||
- GameSession field name aliases accepted without 422
|
||||
- Step 4: real Forge board stats (creaturesOnField, landCount,
|
||||
totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField,
|
||||
enchantmentsOnField) flow through without 422 and reach the encoder
|
||||
- Step 4: resolver fallbacks when Forge fields absent
|
||||
- 503 returned when model not loaded
|
||||
- /api/policy/stats tracks hit/fallback counters
|
||||
- Phase string variants (MAIN1, main_1, COMBAT) round-trip cleanly
|
||||
@@ -20,9 +24,8 @@ All tests use a MockPolicyInferenceService — no real checkpoint required.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import types
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -92,15 +95,13 @@ class MockPolicyInferenceService:
|
||||
self._build_mock_model()
|
||||
|
||||
def _build_mock_model(self):
|
||||
"""Build a callable mock that records input shapes and returns logits."""
|
||||
captured = self.captured_shapes
|
||||
import torch
|
||||
|
||||
def fake_forward(tensor):
|
||||
captured.append(tuple(tensor.shape))
|
||||
# Return uniform logits favouring action 0
|
||||
logits = torch.zeros(tensor.shape[0], NUM_ACTIONS)
|
||||
logits[0, 0] = 2.0 # cast_creature wins softmax
|
||||
logits[0, 0] = 2.0
|
||||
return logits
|
||||
|
||||
self.model = MagicMock(side_effect=fake_forward)
|
||||
@@ -113,7 +114,6 @@ class MockPolicyInferenceService:
|
||||
state_vec = self.encoder.encode(decision_snapshot, playstyle)
|
||||
state_tensor = torch.from_numpy(state_vec.astype(np.float32)).unsqueeze(0)
|
||||
self.captured_shapes.append(tuple(state_tensor.shape))
|
||||
# Always return cast_creature
|
||||
return {
|
||||
"action": IDX_TO_ACTION[0].value,
|
||||
"action_index": 0,
|
||||
@@ -128,12 +128,10 @@ class MockPolicyInferenceService:
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_app(loaded: bool = True) -> tuple[FastAPI, MockPolicyInferenceService]:
|
||||
"""Create a fresh FastAPI app with the policy router registered."""
|
||||
def _make_app(loaded: bool = True):
|
||||
from routes.policy import register_policy_routes
|
||||
import routes.policy as policy_mod
|
||||
|
||||
# Reset module-level state between tests
|
||||
policy_mod._policy_service = None
|
||||
policy_mod._online_store = None
|
||||
policy_mod._session_stats = {
|
||||
@@ -154,60 +152,75 @@ def _make_app(loaded: bool = True) -> tuple[FastAPI, MockPolicyInferenceService]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: a realistic GameSession snapshot
|
||||
# Snapshot builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _game_session_snapshot(
|
||||
state_vector: Optional[List[float]] = None,
|
||||
include_aliases: bool = True,
|
||||
board_stats: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mimics GameSession.buildStateSnapshot() output at turn 3, MAIN1.
|
||||
Includes the state_vector field added in Step 1.
|
||||
Builds a realistic GameSession.buildStateSnapshot() payload.
|
||||
|
||||
board_stats: optional dict of Forge board stat overrides for players[0].
|
||||
e.g. {"creaturesOnField": 3, "landCount": 5, "totalPowerOnBoard": 9}
|
||||
"""
|
||||
sv = state_vector if state_vector is not None else _valid_state_vector()
|
||||
players = [
|
||||
{
|
||||
"seat": 0,
|
||||
"name": "Human",
|
||||
"isAI": False,
|
||||
"deckName": "Atraxa Superfriends",
|
||||
"life": 37,
|
||||
"poison": 0,
|
||||
"commanderTax": 0,
|
||||
"handCount": 6,
|
||||
"hand": ["Doubling Season", "Deepglow Skate", "Sol Ring",
|
||||
"Atraxa, Praetors' Voice", "Swamp", "Forest"],
|
||||
"battlefield": ["Sol Ring", "Atraxa, Praetors' Voice"],
|
||||
"graveyard": [],
|
||||
"commandZone": ["Atraxa, Praetors' Voice"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
},
|
||||
{
|
||||
"seat": 1,
|
||||
"name": "AI-1",
|
||||
"isAI": True,
|
||||
"deckName": "Ur-Dragon",
|
||||
"life": 40,
|
||||
"poison": 0,
|
||||
"commanderTax": 2,
|
||||
"handCount": 7,
|
||||
"hand": [],
|
||||
"battlefield": ["Dragon's Hoard", "Savage Ventmaw"],
|
||||
"graveyard": ["Swords to Plowshares"],
|
||||
"commandZone": ["The Ur-Dragon"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
},
|
||||
]
|
||||
|
||||
snap: Dict[str, Any] = {
|
||||
p0: Dict[str, Any] = {
|
||||
"seat": 0,
|
||||
"name": "Human",
|
||||
"life": 37,
|
||||
"poison": 0,
|
||||
"commanderTax": 0,
|
||||
"handCount": 6,
|
||||
"hand": ["Doubling Season", "Deepglow Skate", "Sol Ring",
|
||||
"Atraxa, Praetors' Voice", "Swamp", "Forest"],
|
||||
"battlefield": ["Sol Ring", "Atraxa, Praetors' Voice"],
|
||||
"graveyard": [],
|
||||
"commandZone": ["Atraxa, Praetors' Voice"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
# Step 4: real Forge board stats
|
||||
"creaturesOnField": 1,
|
||||
"landCount": 4,
|
||||
"totalPowerOnBoard": 4,
|
||||
"totalToughnessOnBoard": 4,
|
||||
"artifactsOnField": 1,
|
||||
"enchantmentsOnField": 0,
|
||||
"manaAvailable": 4,
|
||||
}
|
||||
if board_stats:
|
||||
p0.update(board_stats)
|
||||
|
||||
p1: Dict[str, Any] = {
|
||||
"seat": 1,
|
||||
"name": "AI-1",
|
||||
"life": 40,
|
||||
"poison": 0,
|
||||
"commanderTax": 2,
|
||||
"handCount": 7,
|
||||
"hand": [],
|
||||
"battlefield": ["Dragon's Hoard", "Savage Ventmaw"],
|
||||
"graveyard": ["Swords to Plowshares"],
|
||||
"commandZone": ["The Ur-Dragon"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
"creaturesOnField": 1,
|
||||
"landCount": 5,
|
||||
"totalPowerOnBoard": 5,
|
||||
"totalToughnessOnBoard": 4,
|
||||
"artifactsOnField": 1,
|
||||
"enchantmentsOnField": 0,
|
||||
"manaAvailable": 5,
|
||||
}
|
||||
|
||||
return {
|
||||
"schema": "1.1.0",
|
||||
"phase": "MAIN1",
|
||||
"turnNumber": 3,
|
||||
"activePlayer": 0,
|
||||
"priorityPlayer": 0,
|
||||
"awaitingInput": True,
|
||||
"players": players,
|
||||
"players": [p0, p1],
|
||||
"stack": [],
|
||||
"legalActions": [
|
||||
{"type": "PASS_PRIORITY", "label": "Pass Priority"},
|
||||
@@ -218,26 +231,22 @@ def _game_session_snapshot(
|
||||
"state_vector": sv,
|
||||
"state_vector_dim": len(sv),
|
||||
}
|
||||
return snap
|
||||
|
||||
|
||||
def _valid_state_vector() -> List[float]:
|
||||
"""A plausible float[29] global scalar vector from buildStateVector(seat=0)."""
|
||||
# life=37/40, everything else normalised appropriately
|
||||
sv = [0.0] * 29
|
||||
sv[0] = 37 / 40.0 # self life
|
||||
sv[3] = 0.0 # cmdr_tax
|
||||
sv[4] = 6 / 15.0 # cards_in_hand
|
||||
sv[6] = 2 / 30.0 # creatures
|
||||
sv[7] = 2 / 100.0 # power proxy
|
||||
sv[8] = 1.0 # is_active_player
|
||||
sv[9] = 1.0 # phase_onehot[main1]
|
||||
# opponent block: indices 14..27
|
||||
sv[14] = 40 / 40.0 # opp life
|
||||
sv[17] = 2 / 10.0 # opp cmdr_tax
|
||||
sv[18] = 7 / 15.0 # opp hand
|
||||
sv[20] = 2 / 30.0 # opp creatures
|
||||
# turn
|
||||
sv[0] = 37 / 40.0
|
||||
sv[3] = 0.0
|
||||
sv[4] = 6 / 15.0
|
||||
sv[6] = 2 / 30.0
|
||||
sv[7] = 2 / 100.0
|
||||
sv[8] = 1.0
|
||||
sv[9] = 1.0
|
||||
sv[14] = 40 / 40.0
|
||||
sv[17] = 2 / 10.0
|
||||
sv[18] = 7 / 15.0
|
||||
sv[20] = 2 / 30.0
|
||||
sv[28] = 3 / 25.0
|
||||
return sv
|
||||
|
||||
@@ -248,7 +257,7 @@ def _valid_state_vector() -> List[float]:
|
||||
|
||||
class TestPolicyHealth:
|
||||
def test_health_model_loaded(self):
|
||||
app, _ = _make_app(loaded=True)
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/policy/health")
|
||||
assert resp.status_code == 200
|
||||
@@ -268,88 +277,70 @@ class TestPolicyHealth:
|
||||
|
||||
|
||||
class TestDecideFastPath:
|
||||
"""Precomputed state_vector present → vector_source=precomputed."""
|
||||
|
||||
def test_precomputed_path_used(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["vector_source"] == "precomputed"
|
||||
assert resp.json()["vector_source"] == "precomputed"
|
||||
|
||||
def test_action_is_valid_macro_action(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["action"] in VALID_MACRO_ACTIONS
|
||||
|
||||
def test_action_index_in_range(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
assert 0 <= resp.json()["action_index"] < NUM_ACTIONS
|
||||
|
||||
def test_confidence_is_probability(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
conf = resp.json()["confidence"]
|
||||
assert 0.0 <= conf <= 1.0
|
||||
assert 0.0 <= resp.json()["confidence"] <= 1.0
|
||||
|
||||
def test_log_prob_is_negative(self):
|
||||
"""log(p) for p in (0,1] must be <= 0."""
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["log_prob"] <= 0.0
|
||||
|
||||
def test_tensor_shape_is_6177(self):
|
||||
"""The mock model must receive a (1, 6177) tensor."""
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert len(svc.captured_shapes) > 0
|
||||
shape = svc.captured_shapes[-1]
|
||||
assert shape == (1, EXPECTED_STATE_DIM), (
|
||||
f"Expected (1, {EXPECTED_STATE_DIM}), got {shape}"
|
||||
assert svc.captured_shapes[-1] == (1, EXPECTED_STATE_DIM), (
|
||||
f"Expected (1, {EXPECTED_STATE_DIM}), got {svc.captured_shapes[-1]}"
|
||||
)
|
||||
|
||||
def test_probabilities_sum_to_one(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
probs = resp.json().get("probabilities", {})
|
||||
if probs:
|
||||
total = sum(probs.values())
|
||||
assert abs(total - 1.0) < 0.01, f"Probs sum to {total}"
|
||||
assert abs(sum(probs.values()) - 1.0) < 0.01
|
||||
|
||||
def test_inference_ms_is_positive(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["inference_ms"] >= 0.0
|
||||
|
||||
|
||||
class TestDecideFallbackPath:
|
||||
"""No / wrong state_vector → falls back to full encoder."""
|
||||
|
||||
def test_fallback_when_no_state_vector(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
@@ -359,8 +350,7 @@ class TestDecideFallbackPath:
|
||||
assert resp.json()["vector_source"] == "encoder"
|
||||
|
||||
def test_fallback_when_wrong_length_vector(self):
|
||||
"""A 10-float vector is too short — must fall back."""
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(state_vector=[0.5] * 10)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
@@ -368,7 +358,7 @@ class TestDecideFallbackPath:
|
||||
assert resp.json()["vector_source"] == "encoder"
|
||||
|
||||
def test_fallback_still_returns_valid_action(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
@@ -377,63 +367,191 @@ class TestDecideFallbackPath:
|
||||
assert resp.json()["action"] in VALID_MACRO_ACTIONS
|
||||
|
||||
|
||||
class TestGameSessionAliases:
|
||||
"""GameSession field name aliases must not cause 422 validation errors."""
|
||||
class TestBoardStatsWiring:
|
||||
"""Step 4: real Forge board stats flow through PlayerZoneState without 422."""
|
||||
|
||||
def test_creatures_on_field_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"creaturesOnField": 5})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_land_count_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"landCount": 8})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_total_power_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"totalPowerOnBoard": 15})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_total_toughness_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"totalToughnessOnBoard": 12})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_artifacts_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"artifactsOnField": 3})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_enchantments_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"enchantmentsOnField": 2})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_mana_available_accepted(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={"manaAvailable": 7})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_all_board_stats_together(self):
|
||||
"""Full Forge board stat block round-trips cleanly."""
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={
|
||||
"creaturesOnField": 4,
|
||||
"landCount": 7,
|
||||
"totalPowerOnBoard": 16,
|
||||
"totalToughnessOnBoard": 14,
|
||||
"artifactsOnField": 2,
|
||||
"enchantmentsOnField": 1,
|
||||
"manaAvailable": 7,
|
||||
})
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["action"] in VALID_MACRO_ACTIONS
|
||||
|
||||
def test_board_stats_reach_fallback_encoder(self):
|
||||
"""Board stats survive through _build_encoder_snapshot on fallback path."""
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(board_stats={
|
||||
"creaturesOnField": 6,
|
||||
"landCount": 9,
|
||||
"totalPowerOnBoard": 24,
|
||||
})
|
||||
snap.pop("state_vector", None) # force fallback encoder path
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["vector_source"] == "encoder"
|
||||
|
||||
|
||||
class TestBoardStatsFallback:
|
||||
"""Resolver fallbacks when Forge board stat fields are absent."""
|
||||
|
||||
def test_creatures_inferred_from_battlefield_names(self):
|
||||
"""When creaturesOnField=0, battlefield name count is the fallback."""
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(
|
||||
seat=0,
|
||||
battlefield=["Atraxa, Praetors' Voice", "Sol Ring"],
|
||||
creaturesOnField=0,
|
||||
)
|
||||
# Should fall back to len(battlefield names) = 2
|
||||
assert p.resolved_creatures() == 2
|
||||
|
||||
def test_creatures_uses_real_value_when_present(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(
|
||||
seat=0,
|
||||
battlefield=["Atraxa, Praetors' Voice"], # 1 name
|
||||
creaturesOnField=3, # 3 actual creatures (tokens not in list)
|
||||
)
|
||||
assert p.resolved_creatures() == 3
|
||||
|
||||
def test_lands_default_zero_when_absent(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0)
|
||||
assert p.resolved_lands() == 0
|
||||
|
||||
def test_lands_uses_real_value(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0, landCount=6)
|
||||
assert p.resolved_lands() == 6
|
||||
|
||||
def test_total_power_fallback_is_creatures_times_3(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0, creaturesOnField=4, totalPowerOnBoard=0)
|
||||
assert p.resolved_total_power() == 12 # 4 * 3
|
||||
|
||||
def test_total_power_uses_real_value(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0, creaturesOnField=4, totalPowerOnBoard=19)
|
||||
assert p.resolved_total_power() == 19
|
||||
|
||||
def test_mana_resolves_mana_available_alias(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0, manaAvailable=6)
|
||||
assert p.resolved_mana() == 6
|
||||
|
||||
def test_mana_resolves_snake_case_alias(self):
|
||||
from routes.policy import PlayerZoneState
|
||||
p = PlayerZoneState(seat=0, mana_available=5)
|
||||
assert p.resolved_mana() == 5
|
||||
|
||||
|
||||
class TestGameSessionAliases:
|
||||
def test_turnNumber_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["turnNumber"] = 5
|
||||
snap.pop("turn", None)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
def test_activePlayer_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["activePlayer"] = 0
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
def test_commanderTax_in_player(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["players"][1]["commanderTax"] = 4
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
def test_commandZone_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["players"][0]["commandZone"] = ["Atraxa, Praetors' Voice"]
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
def test_legalActions_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["legalActions"] = snap.pop("legal_actions", snap.get("legalActions", []))
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
def test_schema_110_accepted(self):
|
||||
"""schema=1.1.0 (GameSession Step 1 bump) is accepted without error."""
|
||||
"""'schema' JSON key accepted via Field(alias='schema') on snapshot_schema."""
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["schema"] = "1.1.0"
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200
|
||||
|
||||
|
||||
class TestPhaseVariants:
|
||||
"""Both Forge-style (MAIN1) and ML-style (main_1) phase strings work."""
|
||||
|
||||
@pytest.mark.parametrize("phase", [
|
||||
"MAIN1", "main_1", "MAIN2", "main_2",
|
||||
"BEGIN_COMBAT", "combat", "END", "end", "CLEANUP",
|
||||
@@ -443,38 +561,33 @@ class TestPhaseVariants:
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["phase"] = phase
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, f"Phase '{phase}' failed: {resp.text}"
|
||||
assert client.post("/api/policy/decide", json=snap).status_code == 200, phase
|
||||
|
||||
|
||||
class TestDecideModelNotLoaded:
|
||||
def test_503_when_not_loaded(self):
|
||||
app, _ = _make_app(loaded=False)
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
resp = client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
assert resp.status_code == 503
|
||||
assert "not loaded" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestStats:
|
||||
def test_precomputed_hits_incremented(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
# Fire 3 precomputed requests
|
||||
for _ in range(3):
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["precomputed_vector_hits"] == 3
|
||||
assert stats["total_decisions"] == 3
|
||||
|
||||
def test_encoder_fallbacks_incremented(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
# Fire 2 fallback requests
|
||||
for _ in range(2):
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
@@ -482,11 +595,11 @@ class TestStats:
|
||||
assert stats["precomputed_vector_hits"] == 0
|
||||
|
||||
def test_mixed_hits_and_fallbacks(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap_pre = _game_session_snapshot() # has state_vector
|
||||
snap_pre = _game_session_snapshot()
|
||||
snap_enc = _game_session_snapshot()
|
||||
snap_enc.pop("state_vector", None) # no state_vector
|
||||
snap_enc.pop("state_vector", None)
|
||||
client.post("/api/policy/decide", json=snap_pre)
|
||||
client.post("/api/policy/decide", json=snap_enc)
|
||||
client.post("/api/policy/decide", json=snap_pre)
|
||||
@@ -496,35 +609,29 @@ class TestStats:
|
||||
assert stats["total_decisions"] == 3
|
||||
|
||||
def test_decisions_per_second_non_negative(self):
|
||||
app, svc = _make_app()
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
client.post("/api/policy/decide", json=_game_session_snapshot())
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["decisions_per_second"] >= 0.0
|
||||
|
||||
|
||||
class TestStateVectorDimension:
|
||||
"""Explicit dim assertions on the precomputed encoding path."""
|
||||
|
||||
def test_global_block_length_is_29(self):
|
||||
sv = _valid_state_vector()
|
||||
assert len(sv) == EXPECTED_GLOBAL_DIM
|
||||
assert len(_valid_state_vector()) == EXPECTED_GLOBAL_DIM
|
||||
|
||||
def test_life_normalisation(self):
|
||||
sv = _valid_state_vector()
|
||||
assert 0.0 <= sv[0] <= 1.0, "life not normalised"
|
||||
assert 0.0 <= sv[0] <= 1.0
|
||||
|
||||
def test_turn_normalisation(self):
|
||||
sv = _valid_state_vector()
|
||||
assert 0.0 <= sv[28] <= 1.0, "turn not normalised"
|
||||
assert 0.0 <= sv[28] <= 1.0
|
||||
|
||||
def test_is_active_player_binary(self):
|
||||
sv = _valid_state_vector()
|
||||
assert sv[8] in (0.0, 1.0), "is_active_player must be 0 or 1"
|
||||
assert sv[8] in (0.0, 1.0)
|
||||
|
||||
def test_phase_onehot_at_most_one_hot(self):
|
||||
"""Indices 9-12 are the phase one-hot: at most one should be 1."""
|
||||
sv = _valid_state_vector()
|
||||
phase_bits = sv[9:13]
|
||||
assert sum(phase_bits) <= 1.0, f"Multiple phase bits set: {phase_bits}"
|
||||
assert sum(sv[9:13]) <= 1.0, f"Multiple phase bits: {sv[9:13]}"
|
||||
Reference in new issue
Block a user