- TurnTimeoutService with percentage-based warnings (35 tests) - ConnectionManager enhancements for spectators and reconnection - GameService with timer integration, spectator support, handle_timeout - GameNamespace with spectate/leave_spectate handlers, reconnection - WebSocket message schemas for spectator events - WinConditionsConfig additions for turn timer thresholds - 83 GameService tests, 37 ConnectionManager tests, 37 GameNamespace tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2435 lines
86 KiB
Python
2435 lines
86 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 (
|
|
CannotSpectateOwnGameError,
|
|
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 mock_timeout_service() -> AsyncMock:
|
|
"""Create a mock TurnTimeoutService.
|
|
|
|
The timeout service manages turn timers using Redis.
|
|
For tests, we mock all Redis interactions.
|
|
"""
|
|
from app.services.turn_timeout_service import TurnTimeoutInfo
|
|
|
|
service = AsyncMock()
|
|
service.start_turn_timer = AsyncMock(
|
|
return_value=TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=0.0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=180,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
)
|
|
service.cancel_timer = AsyncMock(return_value=True)
|
|
service.extend_timer = AsyncMock(return_value=None)
|
|
service.get_timeout_info = AsyncMock(return_value=None)
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def game_service(
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
) -> 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,
|
|
timeout_service=mock_timeout_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"
|
|
|
|
|
|
class TestTurnTimerIntegration:
|
|
"""Tests for turn timer integration in GameService.
|
|
|
|
The turn timer should:
|
|
- NOT start during SETUP phase (when selecting basic pokemon)
|
|
- Start when SETUP phase ends (first real turn begins)
|
|
- Start when turn changes during normal play
|
|
- Be canceled when game ends
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def game_state_in_setup(self) -> GameState:
|
|
"""Create a game state in SETUP phase.
|
|
|
|
During SETUP, players are selecting their basic pokemon.
|
|
Timer should NOT be running yet.
|
|
"""
|
|
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=0, # Setup phase
|
|
phase=TurnPhase.SETUP,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timer_starts_when_setup_ends(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
game_state_in_setup: GameState,
|
|
) -> None:
|
|
"""Test that turn timer starts when SETUP phase ends.
|
|
|
|
When an action causes the phase to transition from SETUP to
|
|
a real game phase (DRAW/MAIN), the turn timer should start.
|
|
This ensures players have unlimited time for initial pokemon
|
|
selection but are timed once actual gameplay begins.
|
|
"""
|
|
# Enable turn timer in rules
|
|
game_state_in_setup.rules.win_conditions.turn_timer_enabled = True
|
|
game_state_in_setup.rules.win_conditions.turn_timer_seconds = 180
|
|
|
|
mock_state_manager.load_state.return_value = game_state_in_setup
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Simulate SETUP ending - phase transitions to MAIN
|
|
state.phase = TurnPhase.MAIN
|
|
state.turn_number = 1
|
|
return ActionResult(success=True, message="Setup complete, game started")
|
|
|
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
|
|
|
result = await game_service.execute_action(
|
|
"game-123", "player-1", SelectActiveAction(pokemon_id="basic-1")
|
|
)
|
|
|
|
assert result.success is True
|
|
# Timer should have been started
|
|
mock_timeout_service.start_turn_timer.assert_called_once_with(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
assert result.turn_timeout_seconds == 180
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timer_not_started_during_setup(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
game_state_in_setup: GameState,
|
|
) -> None:
|
|
"""Test that turn timer is NOT started during SETUP phase actions.
|
|
|
|
When actions are executed during SETUP (before both players
|
|
have selected basic pokemon), the timer should not start.
|
|
"""
|
|
# Enable turn timer in rules
|
|
game_state_in_setup.rules.win_conditions.turn_timer_enabled = True
|
|
|
|
mock_state_manager.load_state.return_value = game_state_in_setup
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Action during SETUP - phase stays in SETUP (e.g., first player selected)
|
|
# Phase does NOT change
|
|
return ActionResult(success=True, message="First player selected basic")
|
|
|
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
|
|
|
await game_service.execute_action(
|
|
"game-123", "player-1", SelectActiveAction(pokemon_id="basic-1")
|
|
)
|
|
|
|
# Timer should NOT have been started (still in SETUP)
|
|
mock_timeout_service.start_turn_timer.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timer_starts_on_turn_change(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that turn timer starts when turn changes during normal play.
|
|
|
|
When a player ends their turn (e.g., via pass action), the
|
|
timer should start for the next player.
|
|
"""
|
|
# Enable turn timer in rules
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = True
|
|
sample_game_state.rules.win_conditions.turn_timer_seconds = 180
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
def mock_execute(state, player_id, action):
|
|
# Pass action ends turn - next player's 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
|
|
# Timer should have been started for the new current player
|
|
mock_timeout_service.start_turn_timer.assert_called_once_with(
|
|
game_id="game-123",
|
|
player_id="player-2", # New current player
|
|
timeout_seconds=180,
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timer_not_started_when_disabled(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that timer is not started when turn timer is disabled.
|
|
|
|
If turn_timer_enabled is False in rules, no timer operations
|
|
should be performed even on turn changes.
|
|
"""
|
|
# Disable turn timer (default)
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = False
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
def mock_execute(state, player_id, action):
|
|
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
|
|
# Timer should NOT have been started (disabled in rules)
|
|
mock_timeout_service.start_turn_timer.assert_not_called()
|
|
assert result.turn_timeout_seconds is None
|
|
assert result.turn_deadline is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timer_canceled_on_game_over(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that turn timer is canceled when game ends.
|
|
|
|
When an action results in game over (win condition met),
|
|
the timer should be canceled to prevent spurious timeout events.
|
|
"""
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = True
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Game over",
|
|
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.success is True
|
|
assert result.game_over is True
|
|
# Timer should have been canceled
|
|
mock_timeout_service.cancel_timer.assert_called_once_with("game-123")
|
|
|
|
|
|
class TestSpectateGame:
|
|
"""Tests for the spectate_game method.
|
|
|
|
Spectator mode allows users to watch games they are not participating in.
|
|
Spectators receive a filtered view with no hands visible.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectate_game_success(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful game spectating returns spectator-filtered state.
|
|
|
|
When a non-participant spectates a game, they should receive a
|
|
SpectateResult with the game state filtered to hide all hands.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.spectate_game("game-123", "spectator-user")
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.visible_state is not None
|
|
assert result.game_over is False
|
|
# Spectator view should have special viewer_id
|
|
assert result.visible_state.viewer_id == "__spectator__"
|
|
# Spectator should not see any hands (is_my_turn always False)
|
|
assert result.visible_state.is_my_turn is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectate_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test spectate_game raises GameNotFoundError when game doesn't exist.
|
|
|
|
Spectating a non-existent game should raise an appropriate error.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError) as exc_info:
|
|
await game_service.spectate_game("nonexistent", "spectator-user")
|
|
|
|
assert exc_info.value.game_id == "nonexistent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectate_own_game_raises_error(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that players cannot spectate their own game.
|
|
|
|
A player who is participating in the game should not be able to
|
|
spectate it - they should use join_game instead.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(CannotSpectateOwnGameError) as exc_info:
|
|
await game_service.spectate_game("game-123", "player-1")
|
|
|
|
assert exc_info.value.game_id == "game-123"
|
|
assert exc_info.value.player_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectate_ended_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test spectating an ended game succeeds but indicates game_over.
|
|
|
|
Users should be able to spectate completed games to view the
|
|
final state, but 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.spectate_game("game-123", "spectator-user")
|
|
|
|
assert result.success is True
|
|
assert result.game_over is True
|
|
assert "ended" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectate_game_hides_both_hands(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that spectator view hides both players' hands.
|
|
|
|
Unlike player views where you can see your own hand, spectators
|
|
cannot see either player's hand.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.spectate_game("game-123", "spectator-user")
|
|
|
|
assert result.visible_state is not None
|
|
# Both players' hands should show count only, no cards
|
|
for _player_id, player_state in result.visible_state.players.items():
|
|
assert player_state.hand.cards == []
|
|
# is_current_player should be False for all players from spectator view
|
|
assert player_state.is_current_player is False
|
|
|
|
|
|
class TestCannotSpectateOwnGameError:
|
|
"""Tests for CannotSpectateOwnGameError exception."""
|
|
|
|
def test_error_message_contains_game_and_player(self) -> None:
|
|
"""Test CannotSpectateOwnGameError has descriptive message.
|
|
|
|
The error message should clearly indicate which game and player
|
|
triggered the error.
|
|
"""
|
|
error = CannotSpectateOwnGameError("game-123", "player-1")
|
|
|
|
assert "game-123" in str(error)
|
|
assert error.game_id == "game-123"
|
|
assert error.player_id == "player-1"
|
|
assert "spectate" in str(error).lower()
|
|
|
|
|
|
class TestHandleTimeout:
|
|
"""Tests for the handle_timeout method.
|
|
|
|
handle_timeout is called by the background timeout polling task when
|
|
a player's turn timer expires. It should end the game with the
|
|
timed-out player as the loser.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_timeout_declares_opponent_winner(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that handle_timeout declares the opponent as winner.
|
|
|
|
When a player times out, their opponent should win by timeout.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.handle_timeout("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.winner_id == "player-2" # Opponent wins
|
|
assert result.loser_id == "player-1" # Timed out player loses
|
|
assert result.end_reason == GameEndReason.TIMEOUT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_timeout_calls_end_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that handle_timeout archives the game to history.
|
|
|
|
The game should be properly archived with timeout as the end reason.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.handle_timeout("game-123", "player-2")
|
|
|
|
# Should archive to history
|
|
mock_state_manager.archive_to_history.assert_called_once()
|
|
call_kwargs = mock_state_manager.archive_to_history.call_args.kwargs
|
|
assert call_kwargs["game_id"] == "game-123"
|
|
assert call_kwargs["end_reason"].value == "timeout"
|
|
|
|
# Winner should be the non-timed-out player
|
|
assert result.winner_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_timeout_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test handle_timeout raises error when game doesn't exist.
|
|
|
|
If the game was already cleaned up or never existed, we should
|
|
get a GameNotFoundError.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError) as exc_info:
|
|
await game_service.handle_timeout("nonexistent", "player-1")
|
|
|
|
assert exc_info.value.game_id == "nonexistent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_timeout_cancels_timer(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that handle_timeout cancels the turn timer.
|
|
|
|
The timer should be canceled to prevent any further timeout events.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.handle_timeout("game-123", "player-1")
|
|
|
|
# Timer should have been canceled via end_game
|
|
mock_timeout_service.cancel_timer.assert_called_with("game-123")
|
|
|
|
|
|
class TestJoinGameTimerExtension:
|
|
"""Tests for timer extension during reconnection in join_game.
|
|
|
|
When a player reconnects mid-turn with an active timer, the timer
|
|
should be extended by the grace period to give them time to act.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_extends_timer_on_reconnect(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that joining a game extends turn timer on reconnect.
|
|
|
|
When a player reconnects and it's their turn, the timer should
|
|
be extended by the grace period (default 15 seconds).
|
|
"""
|
|
from app.services.turn_timeout_service import TurnTimeoutInfo
|
|
|
|
# Enable turn timer
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = True
|
|
sample_game_state.rules.win_conditions.turn_timer_grace_seconds = 15
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
# Mock existing timer for current player
|
|
existing_timer = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=1000.0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=60,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
mock_timeout_service.get_timeout_info.return_value = existing_timer
|
|
|
|
# Mock extended timer
|
|
extended_timer = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=1015.0, # Extended by grace period
|
|
timeout_seconds=180,
|
|
remaining_seconds=75, # 60 + 15
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
mock_timeout_service.extend_timer.return_value = extended_timer
|
|
|
|
# Player-1 reconnects (it's their turn)
|
|
result = await game_service.join_game("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is True
|
|
# Timer should have been extended
|
|
mock_timeout_service.extend_timer.assert_called_once_with("game-123", 15)
|
|
# Result should show extended timer info
|
|
assert result.turn_timeout_seconds == 75
|
|
assert result.turn_deadline == 1015.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_no_extension_when_not_your_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that timer is not extended when it's not your turn.
|
|
|
|
Only the current player's reconnection should extend the timer.
|
|
The opponent reconnecting should not affect the timer.
|
|
"""
|
|
from app.services.turn_timeout_service import TurnTimeoutInfo
|
|
|
|
# Enable turn timer, player-1's turn
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = True
|
|
sample_game_state.current_player_id = "player-1"
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
# Mock timer for player-1 (current player)
|
|
existing_timer = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=1000.0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=60,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
mock_timeout_service.get_timeout_info.return_value = existing_timer
|
|
|
|
# Player-2 reconnects (NOT their turn)
|
|
result = await game_service.join_game("game-123", "player-2")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is False
|
|
# Timer should NOT have been extended (not player-2's turn)
|
|
mock_timeout_service.extend_timer.assert_not_called()
|
|
# Result should still show timer info
|
|
assert result.turn_timeout_seconds == 60
|
|
assert result.turn_deadline == 1000.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_no_timer_info_when_disabled(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_timeout_service: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that timer info is None when timer is disabled.
|
|
|
|
If turn_timer_enabled is False, no timer operations should occur.
|
|
"""
|
|
# Timer disabled (default)
|
|
sample_game_state.rules.win_conditions.turn_timer_enabled = 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
|
|
# No timer operations
|
|
mock_timeout_service.get_timeout_info.assert_not_called()
|
|
mock_timeout_service.extend_timer.assert_not_called()
|
|
# Timer fields should be None
|
|
assert result.turn_timeout_seconds is None
|
|
assert result.turn_deadline is None
|
|
|
|
|
|
class TestAdditionalActionTypes:
|
|
"""Tests for additional action types in execute_action.
|
|
|
|
These tests verify that various action types are properly passed through
|
|
to the engine and handled correctly by the service layer.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_play_pokemon_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing a PlayPokemonAction.
|
|
|
|
Playing a Pokemon from hand should be validated and executed
|
|
through the engine like any other action.
|
|
"""
|
|
from app.core.models.actions import PlayPokemonAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Pikachu placed on bench",
|
|
state_changes=[{"type": "play_pokemon", "zone": "bench"}],
|
|
)
|
|
|
|
action = PlayPokemonAction(card_instance_id="pikachu-001")
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "play_pokemon"
|
|
mock_engine.execute_action.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_attach_energy_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing an AttachEnergyAction.
|
|
|
|
Attaching energy should update the Pokemon's energy and be
|
|
reflected in the state changes.
|
|
"""
|
|
from app.core.models.actions import AttachEnergyAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Lightning energy attached to Pikachu",
|
|
state_changes=[{"type": "attach_energy", "energy_type": "lightning"}],
|
|
)
|
|
|
|
action = AttachEnergyAction(energy_card_id="energy-001", target_pokemon_id="pikachu-001")
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "attach_energy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_retreat_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing a RetreatAction.
|
|
|
|
Retreating should switch the active Pokemon and discard energy
|
|
equal to the retreat cost.
|
|
"""
|
|
from app.core.models.actions import RetreatAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Retreated to Raichu",
|
|
state_changes=[
|
|
{"type": "retreat", "old_active": "pikachu-001", "new_active": "raichu-001"}
|
|
],
|
|
)
|
|
|
|
action = RetreatAction(new_active_id="raichu-001", energy_to_discard=["energy-001"])
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "retreat"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_evolve_pokemon_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing an EvolvePokemonAction.
|
|
|
|
Evolution should place the evolution card on top of the target
|
|
Pokemon and update its stats.
|
|
"""
|
|
from app.core.models.actions import EvolvePokemonAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Pikachu evolved into Raichu",
|
|
state_changes=[{"type": "evolve", "from": "pikachu-001", "to": "raichu-001"}],
|
|
)
|
|
|
|
action = EvolvePokemonAction(
|
|
evolution_card_id="raichu-001", target_pokemon_id="pikachu-001"
|
|
)
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "evolve"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_play_trainer_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing a PlayTrainerAction.
|
|
|
|
Trainer cards should be played and their effects resolved
|
|
through the effect handler system.
|
|
"""
|
|
from app.core.models.actions import PlayTrainerAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Professor Oak played - drew 7 cards",
|
|
state_changes=[{"type": "play_trainer", "cards_drawn": 7}],
|
|
)
|
|
|
|
action = PlayTrainerAction(card_instance_id="prof-oak-001")
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "play_trainer"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_select_prize_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing a SelectPrizeAction.
|
|
|
|
After a knockout, the player should be able to select a prize
|
|
card to add to their hand.
|
|
"""
|
|
from app.core.models.actions import SelectPrizeAction
|
|
|
|
# Set up forced action for prize selection
|
|
sample_game_state.forced_actions = [
|
|
ForcedAction(
|
|
player_id="player-1",
|
|
action_type="select_prize",
|
|
reason="Select a prize card",
|
|
)
|
|
]
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Prize card taken",
|
|
state_changes=[{"type": "select_prize", "prize_index": 2}],
|
|
)
|
|
|
|
action = SelectPrizeAction(prize_index=2)
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "select_prize"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_use_ability_action(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
mock_engine: MagicMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test executing a UseAbilityAction.
|
|
|
|
Pokemon abilities should be activated and their effects resolved.
|
|
"""
|
|
from app.core.models.actions import UseAbilityAction
|
|
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
mock_engine.execute_action.return_value = ActionResult(
|
|
success=True,
|
|
message="Energy Trans activated",
|
|
state_changes=[{"type": "use_ability", "ability": "Energy Trans"}],
|
|
)
|
|
|
|
action = UseAbilityAction(pokemon_id="venusaur-001", ability_index=0)
|
|
result = await game_service.execute_action("game-123", "player-1", action)
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "use_ability"
|
|
|
|
|
|
class TestEndReasonMapping:
|
|
"""Tests for the _map_end_reason helper function.
|
|
|
|
This function maps core GameEndReason to database EndReason,
|
|
ensuring proper enum synchronization between modules.
|
|
"""
|
|
|
|
def test_map_all_end_reasons(self) -> None:
|
|
"""Test that all GameEndReason values can be mapped.
|
|
|
|
Every core end reason should have a corresponding database
|
|
end reason to prevent runtime errors during game archival.
|
|
"""
|
|
from app.db.models.game import EndReason
|
|
from app.services.game_service import _map_end_reason
|
|
|
|
# All core end reasons should be mappable
|
|
for core_reason in GameEndReason:
|
|
db_reason = _map_end_reason(core_reason)
|
|
assert isinstance(db_reason, EndReason)
|
|
|
|
def test_map_prizes_taken(self) -> None:
|
|
"""Test mapping PRIZES_TAKEN end reason."""
|
|
from app.db.models.game import EndReason
|
|
from app.services.game_service import _map_end_reason
|
|
|
|
result = _map_end_reason(GameEndReason.PRIZES_TAKEN)
|
|
assert result == EndReason.PRIZES_TAKEN
|
|
|
|
def test_map_resignation(self) -> None:
|
|
"""Test mapping RESIGNATION end reason."""
|
|
from app.db.models.game import EndReason
|
|
from app.services.game_service import _map_end_reason
|
|
|
|
result = _map_end_reason(GameEndReason.RESIGNATION)
|
|
assert result == EndReason.RESIGNATION
|
|
|
|
def test_map_timeout(self) -> None:
|
|
"""Test mapping TIMEOUT end reason."""
|
|
from app.db.models.game import EndReason
|
|
from app.services.game_service import _map_end_reason
|
|
|
|
result = _map_end_reason(GameEndReason.TIMEOUT)
|
|
assert result == EndReason.TIMEOUT
|
|
|
|
def test_map_deck_empty_to_cannot_draw(self) -> None:
|
|
"""Test mapping DECK_EMPTY to CANNOT_DRAW.
|
|
|
|
The core uses DECK_EMPTY for clarity, but the DB schema
|
|
uses CANNOT_DRAW as the canonical name.
|
|
"""
|
|
from app.db.models.game import EndReason
|
|
from app.services.game_service import _map_end_reason
|
|
|
|
result = _map_end_reason(GameEndReason.DECK_EMPTY)
|
|
assert result == EndReason.CANNOT_DRAW
|