WebSocket Message Schemas (WS-002): - Add Pydantic models for all client/server WebSocket messages - Implement discriminated unions for message type parsing - Include JoinGame, Action, Resign, Heartbeat client messages - Include GameState, ActionResult, Error, TurnStart server messages Connection Manager (WS-003): - Add Redis-backed WebSocket connection tracking - Implement user-to-sid mapping with TTL management - Support game room association and opponent lookup - Add heartbeat tracking for connection health Socket.IO Authentication (WS-004): - Add JWT-based authentication middleware - Support token extraction from multiple formats - Implement session setup with ConnectionManager integration - Add require_auth helper for event handlers Socket.IO Server Setup (WS-001): - Configure AsyncServer with ASGI mode - Register /game namespace with event handlers - Integrate with FastAPI via ASGIApp wrapper - Configure CORS from application settings Game Service (GS-001): - Add stateless GameService for game lifecycle orchestration - Create engine per-operation using rules from GameState - Implement action-based RNG seeding for deterministic replay - Add rng_seed field to GameState for replay support Architecture verified: - Core module independence (no forbidden imports) - Config from request pattern (rules in GameState) - Dependency injection (constructor deps, method config) - All 1090 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
810 lines
27 KiB
Python
810 lines
27 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
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
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
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
from app.core.win_conditions import WinResult
|
|
from app.services.game_service import (
|
|
GameAlreadyEndedError,
|
|
GameNotFoundError,
|
|
GameService,
|
|
InvalidActionError,
|
|
NotPlayerTurnError,
|
|
PlayerNotInGameError,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_state_manager() -> AsyncMock:
|
|
"""Create a mock GameStateManager.
|
|
|
|
The state manager handles persistence to Redis (cache) and
|
|
Postgres (durable storage).
|
|
"""
|
|
manager = AsyncMock()
|
|
manager.load_state = AsyncMock(return_value=None)
|
|
manager.save_to_cache = AsyncMock()
|
|
manager.persist_to_db = AsyncMock()
|
|
manager.cache_exists = AsyncMock(return_value=False)
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_card_service() -> MagicMock:
|
|
"""Create a mock CardService.
|
|
|
|
CardService provides card definitions for game creation.
|
|
"""
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def game_service(
|
|
mock_state_manager: AsyncMock,
|
|
mock_card_service: MagicMock,
|
|
) -> GameService:
|
|
"""Create a GameService with mocked dependencies.
|
|
|
|
Note: No engine is passed - GameService creates engines per-operation
|
|
using rules stored in each game's GameState.
|
|
"""
|
|
return GameService(
|
|
state_manager=mock_state_manager,
|
|
card_service=mock_card_service,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_game_state() -> GameState:
|
|
"""Create a sample game state for testing.
|
|
|
|
The game state includes two players and basic turn tracking.
|
|
The rules are stored in the state itself (default RulesConfig).
|
|
"""
|
|
player1 = PlayerState(player_id="player-1")
|
|
player2 = PlayerState(player_id="player-2")
|
|
|
|
return GameState(
|
|
game_id="game-123",
|
|
players={"player-1": player1, "player-2": player2},
|
|
current_player_id="player-1",
|
|
turn_number=1,
|
|
phase=TurnPhase.MAIN,
|
|
)
|
|
|
|
|
|
class TestGameStateAccess:
|
|
"""Tests for game state access methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_state_returns_state(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_game_state returns the game state when found.
|
|
|
|
The state manager should be called to load the state, and the
|
|
result should be returned to the caller.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.get_game_state("game-123")
|
|
|
|
assert result == sample_game_state
|
|
mock_state_manager.load_state.assert_called_once_with("game-123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_state_raises_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_game_state raises GameNotFoundError when not found.
|
|
|
|
When the state manager returns None, we should raise a specific
|
|
exception rather than returning None.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError) as exc_info:
|
|
await game_service.get_game_state("nonexistent")
|
|
|
|
assert exc_info.value.game_id == "nonexistent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_view_returns_visible_state(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_player_view returns visibility-filtered state.
|
|
|
|
The returned state should be filtered for the requesting player,
|
|
hiding the opponent's private information.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.get_player_view("game-123", "player-1")
|
|
|
|
assert result.game_id == "game-123"
|
|
assert result.viewer_id == "player-1"
|
|
assert result.is_my_turn is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_view_raises_not_in_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that get_player_view raises error for non-participants.
|
|
|
|
Only players in the game should be able to view its state.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
with pytest.raises(PlayerNotInGameError) as exc_info:
|
|
await game_service.get_player_view("game-123", "stranger")
|
|
|
|
assert exc_info.value.player_id == "stranger"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_player_turn_returns_true_for_current_player(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_player_turn returns True for the current player.
|
|
|
|
The current player is determined by the game state's current_player_id.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.is_player_turn("game-123", "player-1")
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_player_turn_returns_false_for_other_player(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_player_turn returns False for non-current player.
|
|
|
|
Players who are not the current player should get False.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.is_player_turn("game-123", "player-2")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_game_exists_returns_true_when_cached(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test that game_exists returns True when game is in cache.
|
|
|
|
This is a quick check that doesn't need to load the full state.
|
|
"""
|
|
mock_state_manager.cache_exists.return_value = True
|
|
|
|
result = await game_service.game_exists("game-123")
|
|
|
|
assert result is True
|
|
mock_state_manager.cache_exists.assert_called_once_with("game-123")
|
|
|
|
|
|
class TestJoinGame:
|
|
"""Tests for the join_game method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_success(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful game join returns visible state.
|
|
|
|
When a player joins their game, they should receive the
|
|
visibility-filtered state and know if it's their turn.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.player_id == "player-1"
|
|
assert result.visible_state is not None
|
|
assert result.is_your_turn is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_your_turn(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that is_your_turn is False when it's opponent's turn.
|
|
|
|
The second player should see is_your_turn=False when the first
|
|
player is the current player.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-2")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test join_game returns failure for non-existent game.
|
|
|
|
Rather than raising an exception, join_game returns a failed
|
|
result for better WebSocket error handling.
|
|
"""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
result = await game_service.join_game("nonexistent", "player-1")
|
|
|
|
assert result.success is False
|
|
assert "not found" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_participant(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test join_game returns failure for non-participants.
|
|
|
|
Players who are not in the game should not be able to join.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "stranger")
|
|
|
|
assert result.success is False
|
|
assert "not a participant" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_ended_game(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test joining an ended game still succeeds but indicates game over.
|
|
|
|
Players should be able to rejoin ended games to see the final
|
|
state, but is_your_turn should be False.
|
|
"""
|
|
sample_game_state.winner_id = "player-1"
|
|
sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
result = await game_service.join_game("game-123", "player-2")
|
|
|
|
assert result.success is True
|
|
assert result.is_your_turn is False
|
|
assert "ended" in result.message.lower()
|
|
|
|
|
|
class TestExecuteAction:
|
|
"""Tests for the execute_action method.
|
|
|
|
These tests verify action execution through GameService. Since GameService
|
|
creates engines per-operation, we patch _create_engine_for_game to return
|
|
a mock engine with controlled behavior.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_success(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
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 = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
return_value=ActionResult(
|
|
success=True,
|
|
message="Attack executed",
|
|
state_changes=[{"type": "damage", "amount": 30}],
|
|
)
|
|
)
|
|
|
|
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
|
|
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,
|
|
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 = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
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",
|
|
),
|
|
)
|
|
)
|
|
|
|
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
|
|
# 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,
|
|
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 = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
return_value=ActionResult(
|
|
success=False,
|
|
message="Not enough energy to attack",
|
|
)
|
|
)
|
|
|
|
with (
|
|
patch.object(game_service, "_create_engine_for_game", return_value=mock_engine),
|
|
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,
|
|
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 = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
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",
|
|
),
|
|
)
|
|
)
|
|
|
|
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
|
|
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()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_action_uses_game_rules(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that execute_action creates engine with game's rules.
|
|
|
|
The engine should be created using the rules stored in the game
|
|
state, not any service-level defaults.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
mock_engine = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
return_value=ActionResult(success=True, message="OK")
|
|
)
|
|
|
|
with patch.object(
|
|
game_service, "_create_engine_for_game", return_value=mock_engine
|
|
) as mock_create:
|
|
await game_service.execute_action("game-123", "player-1", PassAction())
|
|
|
|
# Verify engine was created with the game state
|
|
mock_create.assert_called_once_with(sample_game_state)
|
|
|
|
|
|
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,
|
|
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 = MagicMock()
|
|
mock_engine.execute_action = AsyncMock(
|
|
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",
|
|
),
|
|
)
|
|
)
|
|
|
|
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
|
|
result = await game_service.resign_game("game-123", "player-1")
|
|
|
|
assert result.success is True
|
|
assert result.action_type == "resign"
|
|
assert result.game_over is True
|
|
|
|
|
|
class TestEndGame:
|
|
"""Tests for the end_game method (forced ending)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_sets_winner_and_reason(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that end_game sets winner and end reason.
|
|
|
|
Used for timeout or disconnection scenarios where the game
|
|
needs to be forcibly ended.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.end_game(
|
|
"game-123",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.TIMEOUT,
|
|
)
|
|
|
|
# Verify state was modified
|
|
assert sample_game_state.winner_id == "player-1"
|
|
assert sample_game_state.end_reason == GameEndReason.TIMEOUT
|
|
|
|
# Verify persistence
|
|
mock_state_manager.save_to_cache.assert_called_once()
|
|
mock_state_manager.persist_to_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_draw(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test ending a game as a draw (no winner).
|
|
|
|
Some scenarios (timeout with equal scores) can result in a draw.
|
|
"""
|
|
mock_state_manager.load_state.return_value = sample_game_state
|
|
|
|
await game_service.end_game(
|
|
"game-123",
|
|
winner_id=None,
|
|
end_reason=GameEndReason.DRAW,
|
|
)
|
|
|
|
assert sample_game_state.winner_id is None
|
|
assert sample_game_state.end_reason == GameEndReason.DRAW
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_game_not_found(
|
|
self,
|
|
game_service: GameService,
|
|
mock_state_manager: AsyncMock,
|
|
) -> None:
|
|
"""Test end_game raises error when game not found."""
|
|
mock_state_manager.load_state.return_value = None
|
|
|
|
with pytest.raises(GameNotFoundError):
|
|
await game_service.end_game(
|
|
"nonexistent",
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.TIMEOUT,
|
|
)
|
|
|
|
|
|
class TestCreateGame:
|
|
"""Tests for the create_game method (skeleton)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game_raises_not_implemented(
|
|
self,
|
|
game_service: GameService,
|
|
) -> None:
|
|
"""Test that create_game raises NotImplementedError.
|
|
|
|
The full implementation will be done in GS-002. For now,
|
|
it should raise NotImplementedError with a clear message.
|
|
"""
|
|
with pytest.raises(NotImplementedError) as exc_info:
|
|
await game_service.create_game(
|
|
player1_id=str(uuid4()),
|
|
player2_id=str(uuid4()),
|
|
)
|
|
|
|
assert "GS-002" in str(exc_info.value)
|
|
|
|
|
|
class TestCreateEngineForGame:
|
|
"""Tests for the _create_engine_for_game method.
|
|
|
|
This method is responsible for creating a GameEngine configured
|
|
with the rules from a specific game's state.
|
|
"""
|
|
|
|
def test_create_engine_uses_game_rules(
|
|
self,
|
|
game_service: GameService,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that engine is created with the game's rules.
|
|
|
|
The engine should use the RulesConfig stored in the game state,
|
|
not any default configuration.
|
|
"""
|
|
engine = game_service._create_engine_for_game(sample_game_state)
|
|
|
|
# Engine should have the game's rules
|
|
assert engine.rules == sample_game_state.rules
|
|
|
|
def test_create_engine_with_rng_seed(
|
|
self,
|
|
game_service: GameService,
|
|
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.
|
|
"""
|
|
sample_game_state.rng_seed = 12345
|
|
|
|
engine = game_service._create_engine_for_game(sample_game_state)
|
|
|
|
# Engine should have been created (we can't easily verify seed,
|
|
# but we can verify it doesn't error)
|
|
assert engine is not None
|
|
|
|
def test_create_engine_without_rng_seed(
|
|
self,
|
|
game_service: GameService,
|
|
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.
|
|
"""
|
|
sample_game_state.rng_seed = None
|
|
|
|
engine = game_service._create_engine_for_game(sample_game_state)
|
|
|
|
assert engine is not None
|
|
|
|
def test_create_engine_derives_unique_seed_per_action(
|
|
self,
|
|
game_service: GameService,
|
|
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.
|
|
"""
|
|
sample_game_state.rng_seed = 12345
|
|
|
|
# Simulate first action (action_log is empty)
|
|
sample_game_state.action_log = []
|
|
engine1 = game_service._create_engine_for_game(sample_game_state)
|
|
|
|
# Simulate second action (one action in log)
|
|
sample_game_state.action_log = [{"type": "pass"}]
|
|
engine2 = game_service._create_engine_for_game(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()
|