Three changes to fail fast instead of silently degrading: 1. GameService.create_game: Raise GameCreationError when energy card definition not found instead of logging warning and continuing. A deck with missing energy cards is fundamentally broken. 2. CardService.load_all: Collect all card file load failures and raise CardServiceLoadError at end with comprehensive error report. Prevents startup with partial card data that causes cryptic runtime errors. New exceptions: CardLoadError, CardServiceLoadError 3. GameStateManager.recover_active_games: Return RecoveryResult dataclass with recovered count, failed game IDs with error messages, and total. Enables proper monitoring and alerting for corrupted game state. Tests added for energy card error case. Existing tests updated for new RecoveryResult return type. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1389 lines
48 KiB
Python
1389 lines
48 KiB
Python
"""Tests for GameService.
|
|
|
|
This module tests the game service layer that orchestrates between
|
|
WebSocket communication and the core GameEngine.
|
|
|
|
The GameService is STATELESS regarding game rules:
|
|
- No GameEngine is stored in the service
|
|
- Engine is created per-operation using rules from GameState
|
|
- Rules come from frontend at game creation, stored in GameState
|
|
|
|
Uses dependency injection pattern - no monkey patching required.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.core.engine import ActionResult
|
|
from app.core.enums import GameEndReason, TurnPhase
|
|
from app.core.models.actions import AttackAction, PassAction, ResignAction, SelectActiveAction
|
|
from app.core.models.game_state import ForcedAction, GameState, PlayerState
|
|
from app.core.win_conditions import WinResult
|
|
from app.services.game_service import (
|
|
ForcedActionRequiredError,
|
|
GameAlreadyEndedError,
|
|
GameCreationError,
|
|
GameNotFoundError,
|
|
GameService,
|
|
InvalidActionError,
|
|
NotPlayerTurnError,
|
|
PlayerNotInGameError,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_state_manager() -> AsyncMock:
|
|
"""Create a mock GameStateManager.
|
|
|
|
The state manager handles persistence to Redis (cache) and
|
|
Postgres (durable storage).
|
|
"""
|
|
manager = AsyncMock()
|
|
manager.load_state = AsyncMock(return_value=None)
|
|
manager.save_to_cache = AsyncMock()
|
|
manager.persist_to_db = AsyncMock()
|
|
manager.cache_exists = AsyncMock(return_value=False)
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_card_service() -> MagicMock:
|
|
"""Create a mock CardService.
|
|
|
|
CardService provides card definitions for game creation.
|
|
"""
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_engine() -> MagicMock:
|
|
"""Create a mock GameEngine.
|
|
|
|
The engine handles game logic - executing actions and checking
|
|
win conditions.
|
|
"""
|
|
engine = MagicMock()
|
|
engine.execute_action = AsyncMock(
|
|
return_value=ActionResult(success=True, message="Action executed")
|
|
)
|
|
return engine
|
|
|
|
|
|
@pytest.fixture
|
|
def game_service(
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
mock_engine: MagicMock,
|
|
) -> GameService:
|
|
"""Create a GameService with injected mock dependencies.
|
|
|
|
The mock engine factory is injected via the engine_factory parameter,
|
|
eliminating the need for monkey patching in tests.
|
|
"""
|
|
return GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
engine_factory=lambda game: mock_engine,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_game_state() -> GameState:
|
|
"""Create a sample game state for testing.
|
|
|
|
The game state includes two players and basic turn tracking.
|
|
The rules are stored in the state itself (default RulesConfig).
|
|
"""
|
|
player1 = PlayerState(player_id="player-1")
|
|
player2 = PlayerState(player_id="player-2")
|
|
|
|
return GameState(
|
|
game_id="game-123",
|
|
players={"player-1": player1, "player-2": player2},
|
|
current_player_id="player-1",
|
|
turn_number=1,
|
|
phase=TurnPhase.MAIN,
|
|
)
|
|
|
|
|
|
class TestGameStateAccess:
|
|
"""Tests for game state access methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_state_returns_state(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_game_state returns the game state when found.
|
|
|
|
The state manager should be called to load the state, and the
|
|
result should be returned to the caller.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.get_game_state("game-123")
|
|
|
|
assert result == sample_game_state
|
|
mock_state_manager.load_state.assert_called_once_with("game-123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_state_raises_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_game_state raises GameNotFoundError when not found.
|
|
|
|
When the state manager returns None, we should raise a specific
|
|
exception rather than returning None.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError) as exc_info:
|
|
await game_service.get_game_state("nonexistent")
|
|
|
|
assert exc_info.value.game_id == "nonexistent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_view_returns_visible_state(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_player_view returns visibility-filtered state.
|
|
|
|
The returned state should be filtered for the requesting player,
|
|
hiding the opponent's private information.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.get_player_view("game-123", "player-1")
|
|
|
|
assert result.game_id == "game-123"
|
|
assert result.viewer_id == "player-1"
|
|
assert result.is_my_turn is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_view_raises_not_in_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_player_view raises error for non-participants.
|
|
|
|
Only players in the game should be able to view its state.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(PlayerNotInGameError) as exc_info:
|
|
await game_service.get_player_view("game-123", "stranger")
|
|
|
|
assert exc_info.value.player_id == "stranger"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_player_turn_returns_true_for_current_player(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_player_turn returns True for the current player.
|
|
|
|
The current player is determined by the game state's current_player_id.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.is_player_turn("game-123", "player-1")
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_player_turn_returns_false_for_other_player(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_player_turn returns False for non-current player.
|
|
|
|
Players who are not the current player should get False.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.is_player_turn("game-123", "player-2")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_game_exists_returns_true_when_cached(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test that game_exists returns True when game is in cache.
|
|
|
|
This is a quick check that doesn't need to load the full state.
|
|
"""
|
|
mock_state_manager.cache_exists.return_value = True
|
|
|
|
result = await game_service.game_exists("game-123")
|
|
|
|
assert result is True
|
|
mock_state_manager.cache_exists.assert_called_once_with("game-123")
|
|
|
|
|
|
class TestJoinGame:
|
|
"""Tests for the join_game method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_success(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful game join returns visible state.
|
|
|
|
When a player joins their game, they should receive the
|
|
visibility-filtered state and know if it's their turn.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.player_id == "player-1"
|
|
assert result.visible_state is not None
|
|
assert result.is_your_turn is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_your_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_your_turn is False when it's opponent's turn.
|
|
|
|
The second player should see is_your_turn=False when the first
|
|
player is the current player.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-2")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test join_game returns failure for non-existent game.
|
|
|
|
Rather than raising an exception, join_game returns a failed
|
|
result for better WebSocket error handling.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
result = await game_service.join_game("nonexistent", "player-1")
|
|
|
|
assert result.success is False
|
|
assert "not found" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_participant(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test join_game returns failure for non-participants.
|
|
|
|
Players who are not in the game should not be able to join.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "stranger")
|
|
|
|
assert result.success is False
|
|
assert "not a participant" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_ended_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test joining an ended game still succeeds but indicates game over.
|
|
|
|
Players should be able to rejoin ended games to see the final
|
|
state, but is_your_turn should be False.
|
|
"""
|
|
sample_game_state.winner_id = "player-1"
|
|
sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-2")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is False
|
|
assert "ended" in result.message.lower()
|
|
|
|
|
|
class TestExecuteAction:
|
|
"""Tests for the execute_action method.
|
|
|
|
These tests verify action execution through GameService. The mock engine
|
|
is injected via the engine_factory parameter in the game_service fixture.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_success(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful action execution.
|
|
|
|
A valid action by the current player should be executed and
|
|
the state should be saved to cache.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Attack executed",
|
|
state_changes=[{"type": "damage", "amount": 30}],
|
|
)
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "attack"
|
|
mock_state_manager.save_to_cache.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test execute_action raises error when game not found.
|
|
|
|
Missing games should raise GameNotFoundError for proper
|
|
error handling in the WebSocket layer.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError):
|
|
await game_service.execute_action("nonexistent", "player-1", PassAction())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_not_in_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test execute_action raises error for non-participants.
|
|
|
|
Only players in the game can execute actions.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(PlayerNotInGameError):
|
|
await game_service.execute_action("game-123", "stranger", PassAction())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_game_ended(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test execute_action raises error on ended games.
|
|
|
|
No actions should be allowed once a game has ended.
|
|
"""
|
|
sample_game_state.winner_id = "player-1"
|
|
sample_game_state.end_reason = GameEndReason.RESIGNATION
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(GameAlreadyEndedError):
|
|
await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_not_your_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test execute_action raises error when not player's turn.
|
|
|
|
Only the current player can execute actions.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(NotPlayerTurnError) as exc_info:
|
|
await game_service.execute_action("game-123", "player-2", PassAction())
|
|
|
|
assert exc_info.value.player_id == "player-2"
|
|
assert exc_info.value.current_player_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_resign_allowed_out_of_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that resignation is allowed even when not your turn.
|
|
|
|
Resignation is a special action that can be executed anytime
|
|
by either player.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Player resigned",
|
|
win_result=WinResult(
|
|
winner_id="player-1",
|
|
loser_id="player-2",
|
|
end_reason=GameEndReason.RESIGNATION,
|
|
reason="Player resigned",
|
|
),
|
|
)
|
|
|
|
# player-2 resigns even though it's player-1's turn
|
|
result = await game_service.execute_action("game-123", "player-2", ResignAction())
|
|
|
|
assert result.success is True
|
|
assert result.game_over is True
|
|
assert result.winner_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_invalid_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test execute_action raises error for invalid actions.
|
|
|
|
When the GameEngine rejects an action, we should raise
|
|
InvalidActionError with the reason.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=False,
|
|
message="Not enough energy to attack",
|
|
)
|
|
|
|
with pytest.raises(InvalidActionError) as exc_info:
|
|
await game_service.execute_action("game-123", "player-1", AttackAction(attack_index=0))
|
|
|
|
assert "Not enough energy" in exc_info.value.reason
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_game_over(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test execute_action detects game over and persists to DB.
|
|
|
|
When an action results in a win, the state should be persisted
|
|
to the database for durability.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Final prize taken!",
|
|
win_result=WinResult(
|
|
winner_id="player-1",
|
|
loser_id="player-2",
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
reason="All prizes taken",
|
|
),
|
|
)
|
|
|
|
result = await game_service.execute_action(
|
|
"game-123", "player-1", AttackAction(attack_index=0)
|
|
)
|
|
|
|
assert result.game_over is True
|
|
assert result.winner_id == "player-1"
|
|
assert result.end_reason == GameEndReason.PRIZES_TAKEN
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
|
|
class TestForcedActions:
|
|
"""Tests for forced action enforcement in execute_action.
|
|
|
|
When a forced action is pending (e.g., select new active after KO),
|
|
only the specified player can act and only with the specified action type.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def game_state_with_forced_action(self) -> GameState:
|
|
"""Create a game state with a pending forced action.
|
|
|
|
The forced action requires player-1 to select a new active Pokemon,
|
|
simulating the situation after their active was knocked out.
|
|
"""
|
|
player1 = PlayerState(player_id="player-1")
|
|
player2 = PlayerState(player_id="player-2")
|
|
|
|
state = GameState(
|
|
game_id="game-123",
|
|
players={"player-1": player1, "player-2": player2},
|
|
current_player_id="player-2", # It's player-2's turn
|
|
turn_number=3,
|
|
phase=TurnPhase.MAIN,
|
|
)
|
|
|
|
# Add forced action for player-1 (select new active after KO)
|
|
state.forced_actions = [
|
|
ForcedAction(
|
|
player_id="player-1",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out. Select a new active.",
|
|
params={"available_bench_ids": ["bench-pokemon-1", "bench-pokemon-2"]},
|
|
)
|
|
]
|
|
|
|
return state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forced_action_wrong_player_raises_error(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
game_state_with_forced_action: GameState,
|
|
) -> None:
|
|
"""Test that wrong player attempting action during forced action raises error.
|
|
|
|
When player-1 has a forced action pending, player-2 cannot act
|
|
(even though it's player-2's normal turn) except for resignation.
|
|
"""
|
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
|
|
|
# player-2 tries to pass, but player-1 has forced action pending
|
|
with pytest.raises(NotPlayerTurnError) as exc_info:
|
|
await game_service.execute_action("game-123", "player-2", PassAction())
|
|
|
|
# Error should indicate player-1 must act (forced action player)
|
|
assert exc_info.value.player_id == "player-2"
|
|
assert exc_info.value.current_player_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forced_action_wrong_action_type_raises_error(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
game_state_with_forced_action: GameState,
|
|
) -> None:
|
|
"""Test that wrong action type during forced action raises error.
|
|
|
|
When a forced action requires 'select_active', attempting any other
|
|
action type (like 'pass') should raise ForcedActionRequiredError.
|
|
"""
|
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
|
|
|
# player-1 tries to pass instead of selecting active
|
|
with pytest.raises(ForcedActionRequiredError) as exc_info:
|
|
await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
assert exc_info.value.required_action_type == "select_active"
|
|
assert exc_info.value.attempted_action_type == "pass"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forced_action_correct_action_succeeds(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
game_state_with_forced_action: GameState,
|
|
) -> None:
|
|
"""Test that correct action during forced action succeeds.
|
|
|
|
When player-1 submits the required select_active action,
|
|
it should be executed successfully.
|
|
"""
|
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="New active Pokemon selected",
|
|
)
|
|
|
|
action = SelectActiveAction(pokemon_id="bench-pokemon-1")
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "select_active"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_allowed_during_forced_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
game_state_with_forced_action: GameState,
|
|
) -> None:
|
|
"""Test that resignation is always allowed, even during forced action.
|
|
|
|
A player should always be able to resign, regardless of forced
|
|
action state. This is a special exception to the forced action rule.
|
|
"""
|
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Player resigned",
|
|
win_result=WinResult(
|
|
winner_id="player-2",
|
|
loser_id="player-1",
|
|
end_reason=GameEndReason.RESIGNATION,
|
|
reason="Player resigned",
|
|
),
|
|
)
|
|
|
|
# player-1 resigns instead of selecting active
|
|
result = await game_service.execute_action("game-123", "player-1", ResignAction())
|
|
|
|
assert result.success is True
|
|
assert result.game_over is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opponent_resign_allowed_during_forced_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
game_state_with_forced_action: GameState,
|
|
) -> None:
|
|
"""Test that opponent can resign while other player has forced action.
|
|
|
|
When player-1 has a forced action pending (select active), player-2
|
|
should still be able to resign. Resignation is always allowed for
|
|
any player at any time.
|
|
"""
|
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Player resigned",
|
|
win_result=WinResult(
|
|
winner_id="player-1",
|
|
loser_id="player-2",
|
|
end_reason=GameEndReason.RESIGNATION,
|
|
reason="Player resigned",
|
|
),
|
|
)
|
|
|
|
# player-2 resigns while player-1 has forced action pending
|
|
result = await game_service.execute_action("game-123", "player-2", ResignAction())
|
|
|
|
assert result.success is True
|
|
assert result.game_over is True
|
|
assert result.winner_id == "player-1"
|
|
|
|
|
|
class TestTurnBoundaryPersistence:
|
|
"""Tests for turn boundary detection and database persistence.
|
|
|
|
The game state should be persisted to the database when the turn
|
|
changes (either turn number or current player changes).
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_change_triggers_db_persistence(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that turn change triggers database persistence.
|
|
|
|
When an action causes the turn to change (e.g., pass action
|
|
ends turn), the state should be persisted to the database
|
|
for durability.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Simulate turn change: pass action ends turn
|
|
state.current_player_id = "player-2"
|
|
state.turn_number = 2
|
|
return ActionResult(success=True, message="Turn ended")
|
|
|
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
|
|
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
assert result.success is True
|
|
assert result.turn_changed is True
|
|
assert result.current_player_id == "player-2"
|
|
|
|
# DB persistence should have been called due to turn change
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_turn_change_skips_db_persistence(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that actions without turn change don't persist to DB.
|
|
|
|
For performance, we only persist to DB at turn boundaries.
|
|
Actions within the same turn only update the cache.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Attack executed",
|
|
state_changes=[{"type": "damage", "amount": 30}],
|
|
)
|
|
|
|
result = await game_service.execute_action(
|
|
"game-123", "player-1", AttackAction(attack_index=0)
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.turn_changed is False
|
|
|
|
# Cache should be updated, but DB should NOT be persisted
|
|
mock_state_manager.save_to_cache.assert_called_once()
|
|
mock_state_manager.persist_to_db.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_current_player_change_is_turn_change(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that current player change counts as turn change.
|
|
|
|
Turn boundaries are detected by either turn number change or
|
|
current player change (e.g., forced action completion might
|
|
return control to another player).
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Only current player changes, turn number stays same
|
|
state.current_player_id = "player-2"
|
|
return ActionResult(success=True, message="Player changed")
|
|
|
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
|
|
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
assert result.turn_changed is True
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
|
|
class TestPendingForcedActionInResult:
|
|
"""Tests for pending forced action inclusion in GameActionResult.
|
|
|
|
After executing an action, if there's a new forced action pending,
|
|
it should be included in the result so the client knows what's next.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_forced_action_included_in_result(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that pending forced action is included in result.
|
|
|
|
When an action results in a forced action being queued (e.g.,
|
|
knockout triggers select_active), the result should include
|
|
the pending forced action details for the client.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Action execution adds a forced action (simulating KO)
|
|
state.forced_actions = [
|
|
ForcedAction(
|
|
player_id="player-2",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out.",
|
|
params={"available_bench_ids": ["bench-1", "bench-2"]},
|
|
)
|
|
]
|
|
return ActionResult(
|
|
success=True,
|
|
message="Attack knocked out opponent's Pokemon",
|
|
state_changes=[{"type": "knockout"}],
|
|
)
|
|
|
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
|
|
|
result = await game_service.execute_action(
|
|
"game-123", "player-1", AttackAction(attack_index=0)
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.pending_forced_action is not None
|
|
assert result.pending_forced_action.player_id == "player-2"
|
|
assert result.pending_forced_action.action_type == "select_active"
|
|
assert result.pending_forced_action.reason == "Your active Pokemon was knocked out."
|
|
assert "bench-1" in result.pending_forced_action.params["available_bench_ids"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_pending_forced_action_when_queue_empty(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that pending_forced_action is None when no forced action.
|
|
|
|
Normal actions that don't trigger forced actions should return
|
|
None for the pending_forced_action field.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Action executed normally",
|
|
)
|
|
|
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
assert result.pending_forced_action is None
|
|
|
|
|
|
class TestResignGame:
|
|
"""Tests for the resign_game convenience method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_game_executes_resign_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that resign_game is a convenience wrapper for execute_action.
|
|
|
|
The resign_game method should internally create a ResignAction
|
|
and call execute_action.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Player resigned",
|
|
win_result=WinResult(
|
|
winner_id="player-2",
|
|
loser_id="player-1",
|
|
end_reason=GameEndReason.RESIGNATION,
|
|
reason="Player resigned",
|
|
),
|
|
)
|
|
|
|
result = await game_service.resign_game("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "resign"
|
|
assert result.game_over is True
|
|
|
|
|
|
class TestEndGame:
|
|
"""Tests for the end_game method (forced ending)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_sets_winner_and_reason(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that end_game sets winner and end reason.
|
|
|
|
Used for timeout or disconnection scenarios where the game
|
|
needs to be forcibly ended.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.end_game(
|
|
"game-123",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.TIMEOUT,
|
|
)
|
|
|
|
# Verify state was modified
|
|
assert sample_game_state.winner_id == "player-1"
|
|
assert sample_game_state.end_reason == GameEndReason.TIMEOUT
|
|
|
|
# Verify persistence
|
|
mock_state_manager.save_to_cache.assert_called_once()
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_draw(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test ending a game as a draw (no winner).
|
|
|
|
Some scenarios (timeout with equal scores) can result in a draw.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.end_game(
|
|
"game-123",
|
|
winner_id=None,
|
|
end_reason=GameEndReason.DRAW,
|
|
)
|
|
|
|
assert sample_game_state.winner_id is None
|
|
assert sample_game_state.end_reason == GameEndReason.DRAW
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test end_game raises error when game not found."""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError):
|
|
await game_service.end_game(
|
|
"nonexistent",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.TIMEOUT,
|
|
)
|
|
|
|
|
|
class TestCreateGame:
|
|
"""Tests for the create_game method."""
|
|
|
|
@pytest.fixture
|
|
def mock_deck_service(self) -> AsyncMock:
|
|
"""Create a mock DeckService for game creation tests."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from app.core.enums import CardType, EnergyType, PokemonStage
|
|
from app.core.models.card import Attack, CardDefinition
|
|
from app.repositories.protocols import DeckEntry
|
|
|
|
# Create sample card definitions - need enough for a valid deck (40 cards)
|
|
pikachu = CardDefinition(
|
|
id="pikachu-001",
|
|
name="Pikachu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=60,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[Attack(name="Thunder Shock", damage=20)],
|
|
)
|
|
|
|
service = AsyncMock()
|
|
# get_deck_for_game returns expanded list of CardDefinitions (40 cards)
|
|
service.get_deck_for_game = AsyncMock(return_value=[pikachu] * 40)
|
|
|
|
# get_deck returns DeckEntry with energy_cards (20 energy for energy deck)
|
|
deck_entry = MagicMock(spec=DeckEntry)
|
|
deck_entry.energy_cards = {"lightning": 20}
|
|
service.get_deck = AsyncMock(return_value=deck_entry)
|
|
|
|
return service
|
|
|
|
@pytest.fixture
|
|
def mock_card_service_with_energy(self) -> MagicMock:
|
|
"""CardService that can resolve energy card IDs."""
|
|
from app.core.enums import CardType, EnergyType
|
|
from app.core.models.card import CardDefinition
|
|
|
|
energy = CardDefinition(
|
|
id="energy-basic-lightning",
|
|
name="Lightning Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.LIGHTNING,
|
|
)
|
|
|
|
service = MagicMock()
|
|
service.get_card = MagicMock(return_value=energy)
|
|
service.get_all_cards = MagicMock(return_value={})
|
|
return service
|
|
|
|
@pytest.fixture
|
|
def mock_engine_for_create(self) -> MagicMock:
|
|
"""GameEngine mock that returns a successful GameCreationResult.
|
|
|
|
The mock dynamically creates a game with the player IDs that were
|
|
passed to create_game, ensuring visibility checks pass.
|
|
"""
|
|
from app.core.engine import GameCreationResult
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
|
|
def create_game_side_effect(player_ids, decks, card_registry, **kwargs):
|
|
"""Create a game with the provided player IDs."""
|
|
p1, p2 = player_ids[0], player_ids[1]
|
|
game = GameState(
|
|
game_id=kwargs.get("game_id") or "test-game-123",
|
|
players={
|
|
p1: PlayerState(player_id=p1),
|
|
p2: PlayerState(player_id=p2),
|
|
},
|
|
current_player_id=p1,
|
|
card_registry=card_registry,
|
|
)
|
|
return GameCreationResult(success=True, game=game)
|
|
|
|
engine = MagicMock()
|
|
engine.create_game = MagicMock(side_effect=create_game_side_effect)
|
|
return engine
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game_success(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service_with_energy: MagicMock,
|
|
mock_engine_for_create: MagicMock,
|
|
mock_deck_service: AsyncMock,
|
|
) -> None:
|
|
"""Test successful game creation.
|
|
|
|
Verifies that create_game:
|
|
1. Loads decks via DeckService
|
|
2. Converts cards to instances
|
|
3. Calls GameEngine.create_game
|
|
4. Persists to cache and database
|
|
5. Returns GameCreateResult with player views
|
|
"""
|
|
from app.core.config import RulesConfig
|
|
from app.services.game_service import GameService
|
|
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service_with_energy,
|
|
engine_factory=lambda game: mock_engine_for_create,
|
|
creation_engine_factory=lambda rules: mock_engine_for_create,
|
|
)
|
|
|
|
player1_id = uuid4()
|
|
player2_id = uuid4()
|
|
deck1_id = uuid4()
|
|
deck2_id = uuid4()
|
|
|
|
result = await service.create_game(
|
|
player1_id=player1_id,
|
|
player2_id=player2_id,
|
|
deck1_id=deck1_id,
|
|
deck2_id=deck2_id,
|
|
rules_config=RulesConfig(),
|
|
deck_service=mock_deck_service,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "test-game-123"
|
|
assert result.starting_player_id is not None
|
|
assert result.player1_view is not None
|
|
assert result.player2_view is not None
|
|
|
|
# Verify decks were loaded
|
|
assert mock_deck_service.get_deck_for_game.call_count == 2
|
|
assert mock_deck_service.get_deck.call_count == 2
|
|
|
|
# Verify state was persisted
|
|
mock_state_manager.save_to_cache.assert_called_once()
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game_deck_load_failure(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
mock_engine: MagicMock,
|
|
) -> None:
|
|
"""Test that deck loading failure raises GameCreationError.
|
|
|
|
If DeckService fails to load a deck (not found, not owned, etc.),
|
|
create_game should raise a clear error.
|
|
"""
|
|
from app.core.config import RulesConfig
|
|
from app.services.game_service import GameService
|
|
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
engine_factory=lambda game: mock_engine,
|
|
)
|
|
|
|
# Mock DeckService that fails
|
|
mock_deck_service = AsyncMock()
|
|
mock_deck_service.get_deck_for_game = AsyncMock(side_effect=ValueError("Deck not found"))
|
|
|
|
with pytest.raises(GameCreationError) as exc_info:
|
|
await service.create_game(
|
|
player1_id=uuid4(),
|
|
player2_id=uuid4(),
|
|
deck1_id=uuid4(),
|
|
deck2_id=uuid4(),
|
|
rules_config=RulesConfig(),
|
|
deck_service=mock_deck_service,
|
|
)
|
|
|
|
assert "Failed to load decks" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game_engine_failure(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service_with_energy: MagicMock,
|
|
mock_deck_service: AsyncMock,
|
|
) -> None:
|
|
"""Test that GameEngine failure raises GameCreationError.
|
|
|
|
If GameEngine.create_game fails (invalid decks, rule violations),
|
|
create_game should raise a clear error.
|
|
"""
|
|
from app.core.config import RulesConfig
|
|
from app.core.engine import GameCreationResult
|
|
from app.services.game_service import GameService
|
|
|
|
# Engine that fails to create game
|
|
mock_engine = MagicMock()
|
|
mock_engine.create_game = MagicMock(
|
|
return_value=GameCreationResult(
|
|
success=False, game=None, message="No basic Pokemon in deck"
|
|
)
|
|
)
|
|
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service_with_energy,
|
|
creation_engine_factory=lambda rules: mock_engine,
|
|
)
|
|
|
|
with pytest.raises(GameCreationError) as exc_info:
|
|
await service.create_game(
|
|
player1_id=uuid4(),
|
|
player2_id=uuid4(),
|
|
deck1_id=uuid4(),
|
|
deck2_id=uuid4(),
|
|
rules_config=RulesConfig(),
|
|
deck_service=mock_deck_service,
|
|
)
|
|
|
|
assert "No basic Pokemon" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game_missing_energy_card_raises_error(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_deck_service: AsyncMock,
|
|
) -> None:
|
|
"""Test that missing energy card definition raises GameCreationError.
|
|
|
|
If an energy card definition cannot be found (e.g., 'energy-basic-fire'
|
|
is not in the card registry), game creation should fail immediately
|
|
with a clear error rather than silently creating a broken game.
|
|
"""
|
|
from app.core.config import RulesConfig
|
|
from app.services.game_service import GameService
|
|
|
|
# CardService that returns None for energy cards (simulating missing data)
|
|
mock_card_service = MagicMock()
|
|
mock_card_service.get_card = MagicMock(return_value=None)
|
|
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
|
|
with pytest.raises(GameCreationError) as exc_info:
|
|
await service.create_game(
|
|
player1_id=uuid4(),
|
|
player2_id=uuid4(),
|
|
deck1_id=uuid4(),
|
|
deck2_id=uuid4(),
|
|
rules_config=RulesConfig(),
|
|
deck_service=mock_deck_service,
|
|
)
|
|
|
|
assert "Energy card definition not found" in str(exc_info.value)
|
|
assert "energy-basic-" in str(exc_info.value)
|
|
|
|
|
|
class TestDefaultEngineFactory:
|
|
"""Tests for the _default_engine_factory method.
|
|
|
|
This method is responsible for creating a GameEngine configured
|
|
with the rules from a specific game's state.
|
|
"""
|
|
|
|
def test_default_engine_uses_game_rules(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that default engine is created with the game's rules.
|
|
|
|
The engine should use the RulesConfig stored in the game state,
|
|
not any default configuration.
|
|
"""
|
|
# Create service without mock engine to test default factory
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
|
|
engine = service._default_engine_factory(sample_game_state)
|
|
|
|
# Engine should have the game's rules
|
|
assert engine.rules == sample_game_state.rules
|
|
|
|
def test_default_engine_with_rng_seed(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that engine uses seeded RNG when game has rng_seed.
|
|
|
|
When a game has an rng_seed set, the engine should use a
|
|
deterministic RNG for replay support.
|
|
"""
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
sample_game_state.rng_seed = 12345
|
|
|
|
engine = service._default_engine_factory(sample_game_state)
|
|
|
|
# Engine should have been created successfully
|
|
assert engine is not None
|
|
|
|
def test_default_engine_without_rng_seed(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that engine uses secure RNG when no seed is set.
|
|
|
|
Without an rng_seed, the engine should use cryptographically
|
|
secure random number generation.
|
|
"""
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
sample_game_state.rng_seed = None
|
|
|
|
engine = service._default_engine_factory(sample_game_state)
|
|
|
|
assert engine is not None
|
|
|
|
def test_default_engine_derives_unique_seed_per_action(
|
|
self,
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that different action counts produce different RNG sequences.
|
|
|
|
For deterministic replay, each action needs a unique but
|
|
reproducible RNG seed based on game seed + action count.
|
|
"""
|
|
service = GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
sample_game_state.rng_seed = 12345
|
|
|
|
# Simulate first action (action_log is empty)
|
|
sample_game_state.action_log = []
|
|
engine1 = service._default_engine_factory(sample_game_state)
|
|
|
|
# Simulate second action (one action in log)
|
|
sample_game_state.action_log = [{"type": "pass"}]
|
|
engine2 = service._default_engine_factory(sample_game_state)
|
|
|
|
# Both engines should be created successfully
|
|
# (They will have different seeds due to action count)
|
|
assert engine1 is not None
|
|
assert engine2 is not None
|
|
|
|
|
|
class TestExceptionMessages:
|
|
"""Tests for exception message formatting."""
|
|
|
|
def test_game_not_found_error_message(self) -> None:
|
|
"""Test GameNotFoundError has descriptive message."""
|
|
error = GameNotFoundError("game-123")
|
|
assert "game-123" in str(error)
|
|
assert error.game_id == "game-123"
|
|
|
|
def test_not_player_turn_error_message(self) -> None:
|
|
"""Test NotPlayerTurnError has descriptive message."""
|
|
error = NotPlayerTurnError("game-123", "player-2", "player-1")
|
|
assert "player-2" in str(error)
|
|
assert "player-1" in str(error)
|
|
assert error.game_id == "game-123"
|
|
|
|
def test_invalid_action_error_message(self) -> None:
|
|
"""Test InvalidActionError has descriptive message."""
|
|
error = InvalidActionError("game-123", "player-1", "Not enough energy")
|
|
assert "Not enough energy" in str(error)
|
|
assert error.reason == "Not enough energy"
|
|
|
|
def test_player_not_in_game_error_message(self) -> None:
|
|
"""Test PlayerNotInGameError has descriptive message."""
|
|
error = PlayerNotInGameError("game-123", "stranger")
|
|
assert "stranger" in str(error)
|
|
assert "game-123" in str(error)
|
|
|
|
def test_game_already_ended_error_message(self) -> None:
|
|
"""Test GameAlreadyEndedError has descriptive message."""
|
|
error = GameAlreadyEndedError("game-123")
|
|
assert "game-123" in str(error)
|
|
assert "ended" in str(error).lower()
|
|
|
|
def test_forced_action_required_error_message(self) -> None:
|
|
"""Test ForcedActionRequiredError has descriptive message.
|
|
|
|
The error should clearly indicate what action was required
|
|
and what action was incorrectly attempted.
|
|
"""
|
|
error = ForcedActionRequiredError("game-123", "player-1", "select_active", "pass")
|
|
assert "select_active" in str(error)
|
|
assert "pass" in str(error)
|
|
assert error.required_action_type == "select_active"
|
|
assert error.attempted_action_type == "pass"
|