Centralize and enhance test stubs to implement the full CommanderGameState interface (battlefield(), living_players(), stack_is_empty(), sim_state, active_player(), priority_player(), to_dict()). Fix all dependent test failures across 12 files: - conftest.py: Enhanced StubCard/StubPlayer/StubGameState with full API compatibility; added basic_creature fixture; fixed fresh_app singleton leak - test_phase3.py: Added battlefield(), active_player(), prompt_builder compat - test_integration.py: Fixed ThreatAssessor import (class→function), added threat_fn dict wrapper, sim_state delegate - test_phase6.py/test_phase8.py: Replaced deprecated get_event_loop() with asyncio.run() - test_benchmark.py: Same async fix + ThreatAssessor functional API - sim/test_engine.py: Removed nonexistent seed= param, use run() method - sim/test_models.py: basic_creature fixture now in conftest - deck_builder/test_models.py: Updated ValidationError regex match - state_logger.py: Added monotonic counter for unique snapshot filenames - ai_opponent.py: Moved _CANNED dict to module-level constant (dataclass fix) - web/app.py: Mount lobby router (fixes 6 LobbyAPI test failures) - test_phase5.py: SessionStore + api._store reset fixture Result: 76 failures + 2 errors → 0 failures, 0 errors (331 passed, 30 skipped) Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
284 lines
8.7 KiB
Python
284 lines
8.7 KiB
Python
"""
|
|
Commander AI Lab — Shared Test Fixtures (Phase 7)
|
|
==================================================
|
|
Provides pytest fixtures reused across all phase test modules.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
# Headless Pygame for GUI tests
|
|
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
|
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub domain objects (shared across all phases)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class StubCard:
|
|
name: str
|
|
type: str = "creature"
|
|
type_line: str = ""
|
|
pt: Optional[str] = "2/2"
|
|
cmc: int = 3
|
|
tapped: bool = False
|
|
is_commander: bool = False
|
|
oracle: str = ""
|
|
oracle_text: str = ""
|
|
power: str = ""
|
|
toughness: str = ""
|
|
keywords: list = field(default_factory=list)
|
|
|
|
def get_power(self) -> int:
|
|
if self.power:
|
|
try:
|
|
return int(self.power)
|
|
except ValueError:
|
|
return 0
|
|
if not self.pt:
|
|
return 0
|
|
parts = self.pt.split("/")
|
|
try:
|
|
return int(parts[0])
|
|
except (ValueError, IndexError):
|
|
return 0
|
|
|
|
def get_toughness(self) -> int:
|
|
if self.toughness:
|
|
try:
|
|
return int(self.toughness)
|
|
except ValueError:
|
|
return 0
|
|
if not self.pt:
|
|
return 0
|
|
parts = self.pt.split("/")
|
|
try:
|
|
return int(parts[1])
|
|
except (ValueError, IndexError):
|
|
return 0
|
|
|
|
def is_land(self) -> bool:
|
|
return bool(self.type_line and "land" in self.type_line.lower())
|
|
|
|
def is_creature(self) -> bool:
|
|
return bool(self.type_line and "creature" in self.type_line.lower())
|
|
|
|
def has_keyword(self, kw: str) -> bool:
|
|
kw_lower = kw.lower()
|
|
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
|
|
return True
|
|
return kw_lower in (self.oracle_text or "").lower()
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"name": self.name,
|
|
"type": self.type,
|
|
"pt": self.pt,
|
|
"cmc": self.cmc,
|
|
"tapped": self.tapped,
|
|
"is_commander": self.is_commander,
|
|
"oracle": self.oracle,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class StubPlayer:
|
|
name: str
|
|
seat: int = 0
|
|
life: int = 40
|
|
eliminated: bool = False
|
|
hand: list = field(default_factory=list)
|
|
battlefield: list = field(default_factory=list)
|
|
graveyard: list = field(default_factory=list)
|
|
exile: list = field(default_factory=list)
|
|
command_zone: list = field(default_factory=list)
|
|
library: list = field(default_factory=list)
|
|
commander_damage: dict = field(default_factory=dict)
|
|
mana_pool: dict = field(default_factory=dict)
|
|
|
|
def to_dict(self, private: bool = False) -> dict:
|
|
return {
|
|
"name": self.name,
|
|
"seat": self.seat,
|
|
"life": self.life,
|
|
"eliminated": self.eliminated,
|
|
"hand_count": len(self.hand),
|
|
"hand": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.hand]
|
|
if private else [],
|
|
"battlefield": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.battlefield],
|
|
"graveyard": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.graveyard],
|
|
"exile": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.exile],
|
|
"command_zone":[c.to_dict() if hasattr(c, 'to_dict') else c for c in self.command_zone],
|
|
}
|
|
|
|
|
|
class _StubSimState:
|
|
"""Minimal SimState-compatible stub — delegates to player battlefields."""
|
|
|
|
def __init__(self, players: list) -> None:
|
|
self._players = players
|
|
|
|
def get_battlefield(self, seat: int) -> list:
|
|
if 0 <= seat < len(self._players):
|
|
return self._players[seat].battlefield
|
|
return []
|
|
|
|
def add_to_battlefield(self, seat: int, card) -> None:
|
|
if 0 <= seat < len(self._players):
|
|
self._players[seat].battlefield.append(card)
|
|
|
|
|
|
class StubGameState:
|
|
"""
|
|
Shared test stub that implements the CommanderGameState interface
|
|
used by ThreatAssessor, TurnManager, and other subsystems.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
num_players: int = 4,
|
|
names: Optional[list[str]] = None,
|
|
) -> None:
|
|
names = names or [f"P{i}" for i in range(num_players)]
|
|
self.players = [StubPlayer(name=n, seat=i) for i, n in enumerate(names)]
|
|
self.turn = 1
|
|
self.current_phase = "main1"
|
|
self.active_player_seat = 0
|
|
self.priority_seat = 0
|
|
self.stack: list = []
|
|
self.game_over = False
|
|
self.winner = None
|
|
self.commander_damage: dict[tuple, int] = {}
|
|
|
|
# SimState-compatible sub-object for turn_manager._phase_untap() etc.
|
|
self.sim_state = _StubSimState(self.players)
|
|
|
|
# -- CommanderGameState interface methods --
|
|
|
|
def battlefield(self, seat: int) -> list:
|
|
"""Return the battlefield for *seat* (delegates to sim_state)."""
|
|
return self.sim_state.get_battlefield(seat)
|
|
|
|
def stack_is_empty(self) -> bool:
|
|
return len(self.stack) == 0
|
|
|
|
def living_players(self) -> list:
|
|
return [p for p in self.players if not p.eliminated]
|
|
|
|
def get_legal_moves(self, seat: int) -> list[dict]:
|
|
return [
|
|
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
|
|
{"id": 2, "category": "play_land", "description": "Play a land"},
|
|
{"id": 3, "category": "cast_spell", "description": "Cast a spell"},
|
|
]
|
|
|
|
def apply_move(self, seat: int, move_id: int) -> None:
|
|
pass
|
|
|
|
def advance_turn(self) -> None:
|
|
self.active_player_seat = (self.active_player_seat + 1) % len(self.players)
|
|
if self.active_player_seat == 0:
|
|
self.turn += 1
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"turn": self.turn,
|
|
"current_phase": self.current_phase,
|
|
"active_player_seat": self.active_player_seat,
|
|
"players": [p.to_dict() for p in self.players],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pytest fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def game_state():
|
|
return StubGameState()
|
|
|
|
|
|
@pytest.fixture
|
|
def game_state_2p():
|
|
return StubGameState(num_players=2, names=["Alice", "Bob"])
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_creature():
|
|
"""A simple Card instance for clone and attribute tests."""
|
|
from commander_ai_lab.sim.models import Card
|
|
return Card(
|
|
name="Grizzly Bears",
|
|
type_line="Creature — Bear",
|
|
cmc=2,
|
|
pt="2/2",
|
|
power="2",
|
|
toughness="2",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def targeting_memory():
|
|
from commander_ai_lab.sim.politics.memory import TargetingMemory
|
|
return TargetingMemory(num_players=4)
|
|
|
|
|
|
@pytest.fixture
|
|
def comms_channel():
|
|
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
|
|
return PoliticsCommsChannel()
|
|
|
|
|
|
@pytest.fixture
|
|
def politics_engine(targeting_memory, comms_channel):
|
|
from commander_ai_lab.sim.politics.engine import PoliticsEngine
|
|
return PoliticsEngine(
|
|
num_players=4,
|
|
memory=targeting_memory,
|
|
comms=comms_channel,
|
|
personalities={0: "timmy", 1: "spike", 2: "johnny", 3: "aggressive"},
|
|
threat_fn=lambda s: {i: 0.3 for i in range(4) if i != s},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def threat_assessor():
|
|
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
|
gs = StubGameState()
|
|
return ThreatAssessor(game_state=gs, num_players=4)
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_app():
|
|
"""FastAPI test app with a clean session store."""
|
|
from commander_ai_lab.web.session_store import SessionStore
|
|
SessionStore._instance = None
|
|
import commander_ai_lab.web.routers.api as api_mod
|
|
api_mod._store = SessionStore()
|
|
from commander_ai_lab.web.app import create_app
|
|
app = create_app()
|
|
yield app
|
|
SessionStore._instance = None
|
|
|
|
|
|
@pytest.fixture
|
|
def http_client(fresh_app):
|
|
from fastapi.testclient import TestClient
|
|
return TestClient(fresh_app)
|
|
|
|
|
|
@pytest.fixture
|
|
def event_loop():
|
|
"""Provide a fresh event loop for async tests."""
|
|
loop = asyncio.new_event_loop()
|
|
yield loop
|
|
loop.close()
|