- Add GameEndResult dataclass with winner, loser, final views, duration - Add _map_end_reason() to map core GameEndReason to DB EndReason (raises ValueError for unknown reasons to catch missing enum sync) - Enhance end_game() to build replay data and return comprehensive result - Add archive_to_history() to GameStateManager for complete game archival: - Creates GameHistory record with replay data - Deletes ActiveGame record - Clears Redis cache - All in single transaction - Add ArchiveResult dataclass for archive operation metadata - Add TODO for session_factory DI refactor in GameStateManager - Update tests: 5 new end_game tests, 6 new archive_to_history tests Phase 4 progress: 10/18 tasks complete Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1559 lines
54 KiB
Python
1559 lines
54 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).
|
|
"""
|
|
from app.db.models.game import GameType
|
|
from app.services.game_state_manager import ArchiveResult
|
|
|
|
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)
|
|
manager.archive_to_history = AsyncMock(
|
|
return_value=ArchiveResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
history_id="game-123",
|
|
duration_seconds=300,
|
|
turn_count=10,
|
|
game_type=GameType.FREEPLAY,
|
|
)
|
|
)
|
|
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 and game_over should be True.
|
|
"""
|
|
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 result.game_over is True
|
|
assert "ended" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_includes_pending_forced_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that join_game includes pending forced action in result.
|
|
|
|
When a player rejoins mid-game and there's a pending forced action
|
|
(e.g., select new active after KO), the result should include that
|
|
information so the client knows what action is required.
|
|
"""
|
|
# Add forced action to the game state
|
|
sample_game_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-1", "bench-2"]},
|
|
)
|
|
]
|
|
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.pending_forced_action is not None
|
|
assert result.pending_forced_action.player_id == "player-1"
|
|
assert result.pending_forced_action.action_type == "select_active"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_forced_action_sets_is_your_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_your_turn is True when player has forced action.
|
|
|
|
Even if it's technically the opponent's turn, if this player has
|
|
a forced action pending, is_your_turn should be True so they know
|
|
they must act.
|
|
"""
|
|
# Set current player to player-2, but forced action is for player-1
|
|
sample_game_state.current_player_id = "player-2"
|
|
sample_game_state.forced_actions = [
|
|
ForcedAction(
|
|
player_id="player-1",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out.",
|
|
)
|
|
]
|
|
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
|
|
# Player-1 has forced action, so it's their turn to act
|
|
assert result.is_your_turn is True
|
|
assert result.pending_forced_action is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_active_game_not_over(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that game_over is False for active games.
|
|
|
|
When joining an ongoing game, game_over should be False.
|
|
"""
|
|
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_over is False
|
|
|
|
|
|
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.
|
|
|
|
The end_game method handles complete game ending:
|
|
- Sets winner and end reason on game state
|
|
- Archives game to history via state manager
|
|
- Returns GameEndResult with final state
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_returns_result_with_winner(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that end_game returns GameEndResult with winner info.
|
|
|
|
The result should include winner, loser, end reason, and
|
|
game statistics from the archive operation.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = 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 result
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.winner_id == "player-1"
|
|
assert result.loser_id == "player-2"
|
|
assert result.end_reason == GameEndReason.TIMEOUT
|
|
assert result.duration_seconds == 300
|
|
assert result.turn_count == 10
|
|
assert result.history_id == "game-123"
|
|
|
|
# Verify archive was called
|
|
mock_state_manager.archive_to_history.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_includes_final_player_views(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that end_game result includes visibility-filtered final states.
|
|
|
|
Each player should receive their own view of the final game state
|
|
for displaying the game-over screen.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.end_game(
|
|
"game-123",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
)
|
|
|
|
# Both players should have final views
|
|
assert result.player1_final_view is not None
|
|
assert result.player2_final_view is not None
|
|
assert result.player1_final_view.viewer_id == "player-1"
|
|
assert result.player2_final_view.viewer_id == "player-2"
|
|
|
|
@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.
|
|
In this case, winner_id and loser_id should both be None.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = 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
|
|
assert result.winner_id is None
|
|
assert result.loser_id is None
|
|
assert result.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,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_archives_with_replay_data(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that end_game passes replay data to archive_to_history.
|
|
|
|
Replay data includes the action log, final turn number, and
|
|
rules configuration for future replay functionality.
|
|
"""
|
|
from app.db.models.game import EndReason
|
|
|
|
sample_game_state.action_log = [{"type": "attack", "index": 0}]
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.end_game(
|
|
"game-123",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
)
|
|
|
|
# Verify archive was called with replay data
|
|
call_kwargs = mock_state_manager.archive_to_history.call_args.kwargs
|
|
assert call_kwargs["game_id"] == "game-123"
|
|
assert call_kwargs["end_reason"] == EndReason.PRIZES_TAKEN
|
|
assert call_kwargs["replay_data"] is not None
|
|
assert "action_log" in call_kwargs["replay_data"]
|
|
assert call_kwargs["replay_data"]["action_log"] == [{"type": "attack", "index": 0}]
|
|
|
|
|
|
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"
|