- Add GameEndResult dataclass with winner, loser, final views, duration - Add _map_end_reason() to map core GameEndReason to DB EndReason (raises ValueError for unknown reasons to catch missing enum sync) - Enhance end_game() to build replay data and return comprehensive result - Add archive_to_history() to GameStateManager for complete game archival: - Creates GameHistory record with replay data - Deletes ActiveGame record - Clears Redis cache - All in single transaction - Add ArchiveResult dataclass for archive operation metadata - Add TODO for session_factory DI refactor in GameStateManager - Update tests: 5 new end_game tests, 6 new archive_to_history tests Phase 4 progress: 10/18 tasks complete Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Tests for GameStateManager.
|
|
|
|
This module tests the game state persistence layer that manages
|
|
the Redis-primary, Postgres-backup storage strategy.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
|
|
from app.core.enums import TurnPhase
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
from app.db.models.game import EndReason, GameType
|
|
from app.services.game_state_manager import ArchiveResult, GameStateManager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_redis() -> AsyncMock:
|
|
"""Create a mock RedisHelper.
|
|
|
|
The RedisHelper handles cache operations for game state.
|
|
"""
|
|
redis = AsyncMock()
|
|
redis.set_json = AsyncMock()
|
|
redis.get_json = AsyncMock(return_value=None)
|
|
redis.delete = AsyncMock(return_value=True)
|
|
redis.exists = AsyncMock(return_value=False)
|
|
return redis
|
|
|
|
|
|
@pytest.fixture
|
|
def game_state_manager(mock_redis: AsyncMock) -> GameStateManager:
|
|
"""Create a GameStateManager with mock Redis."""
|
|
return GameStateManager(redis=mock_redis)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_game_state() -> GameState:
|
|
"""Create a sample game state for testing."""
|
|
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=10,
|
|
phase=TurnPhase.MAIN,
|
|
action_log=[{"type": "attack", "index": 0}],
|
|
)
|
|
|
|
|
|
class TestArchiveToHistory:
|
|
"""Tests for the archive_to_history method.
|
|
|
|
This method handles the complete game ending process:
|
|
- Creates GameHistory record
|
|
- Deletes ActiveGame record
|
|
- Clears Redis cache
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_returns_result(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that archive_to_history returns ArchiveResult.
|
|
|
|
The result should contain game metadata and success status.
|
|
"""
|
|
from app.db.models.game import ActiveGame
|
|
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
player1_id = uuid4()
|
|
player2_id = uuid4()
|
|
|
|
# Create mock ActiveGame
|
|
mock_active_game = MagicMock(spec=ActiveGame)
|
|
mock_active_game.id = UUID(game_id)
|
|
mock_active_game.game_type = GameType.FREEPLAY
|
|
mock_active_game.player1_id = player1_id
|
|
mock_active_game.player2_id = player2_id
|
|
mock_active_game.npc_id = None
|
|
mock_active_game.started_at = datetime.now(UTC)
|
|
|
|
# Mock database session
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_active_game
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.delete = AsyncMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
result = await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=player1_id,
|
|
winner_is_npc=False,
|
|
end_reason=EndReason.PRIZES_TAKEN,
|
|
replay_data={"action_log": sample_game_state.action_log},
|
|
)
|
|
|
|
assert isinstance(result, ArchiveResult)
|
|
assert result.success is True
|
|
assert result.game_id == game_id
|
|
assert result.history_id == game_id
|
|
assert result.turn_count == 10
|
|
assert result.game_type == GameType.FREEPLAY
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_creates_game_history(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that archive_to_history creates a GameHistory record.
|
|
|
|
The GameHistory record should be added to the session with
|
|
all the required fields populated.
|
|
"""
|
|
from app.db.models.game import ActiveGame, GameHistory
|
|
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
player1_id = uuid4()
|
|
|
|
# Create mock ActiveGame
|
|
mock_active_game = MagicMock(spec=ActiveGame)
|
|
mock_active_game.id = UUID(game_id)
|
|
mock_active_game.game_type = GameType.CAMPAIGN
|
|
mock_active_game.player1_id = player1_id
|
|
mock_active_game.player2_id = None
|
|
mock_active_game.npc_id = "grass_trainer_1"
|
|
mock_active_game.started_at = datetime.now(UTC)
|
|
|
|
# Mock database session
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_active_game
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.delete = AsyncMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=player1_id,
|
|
winner_is_npc=False,
|
|
end_reason=EndReason.RESIGNATION,
|
|
replay_data={"action_log": []},
|
|
)
|
|
|
|
# Verify GameHistory was added
|
|
mock_session.add.assert_called_once()
|
|
added_obj = mock_session.add.call_args[0][0]
|
|
assert isinstance(added_obj, GameHistory)
|
|
assert added_obj.id == UUID(game_id)
|
|
assert added_obj.game_type == GameType.CAMPAIGN
|
|
assert added_obj.npc_id == "grass_trainer_1"
|
|
assert added_obj.end_reason == EndReason.RESIGNATION
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_deletes_active_game(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that archive_to_history deletes the ActiveGame record.
|
|
|
|
After archiving to history, the game should be removed from
|
|
the active games table.
|
|
"""
|
|
from app.db.models.game import ActiveGame
|
|
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
|
|
# Create mock ActiveGame
|
|
mock_active_game = MagicMock(spec=ActiveGame)
|
|
mock_active_game.id = UUID(game_id)
|
|
mock_active_game.game_type = GameType.FREEPLAY
|
|
mock_active_game.player1_id = uuid4()
|
|
mock_active_game.player2_id = uuid4()
|
|
mock_active_game.npc_id = None
|
|
mock_active_game.started_at = datetime.now(UTC)
|
|
|
|
# Mock database session
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_active_game
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.delete = AsyncMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=uuid4(),
|
|
winner_is_npc=False,
|
|
end_reason=EndReason.PRIZES_TAKEN,
|
|
)
|
|
|
|
# Verify ActiveGame was deleted
|
|
mock_session.delete.assert_called_once_with(mock_active_game)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_deletes_from_cache(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
mock_redis: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that archive_to_history deletes from Redis cache.
|
|
|
|
After archiving, the game state should be removed from the
|
|
Redis cache to free up memory.
|
|
"""
|
|
from app.db.models.game import ActiveGame
|
|
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
|
|
# Create mock ActiveGame
|
|
mock_active_game = MagicMock(spec=ActiveGame)
|
|
mock_active_game.id = UUID(game_id)
|
|
mock_active_game.game_type = GameType.FREEPLAY
|
|
mock_active_game.player1_id = uuid4()
|
|
mock_active_game.player2_id = uuid4()
|
|
mock_active_game.npc_id = None
|
|
mock_active_game.started_at = datetime.now(UTC)
|
|
|
|
# Mock database session
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_active_game
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.delete = AsyncMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=uuid4(),
|
|
winner_is_npc=False,
|
|
end_reason=EndReason.TIMEOUT,
|
|
)
|
|
|
|
# Verify Redis cache was deleted
|
|
mock_redis.delete.assert_called_once_with(f"game:{game_id}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_handles_missing_active_game(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that archive handles case when ActiveGame is not found.
|
|
|
|
This shouldn't normally happen, but the method should handle
|
|
it gracefully by using defaults from the game state.
|
|
"""
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
|
|
# Mock database session - ActiveGame not found
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None # Not found
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
result = await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=None,
|
|
winner_is_npc=False,
|
|
end_reason=EndReason.DRAW,
|
|
)
|
|
|
|
# Should still succeed with defaults
|
|
assert result.success is True
|
|
assert result.game_type == GameType.FREEPLAY # Default
|
|
assert result.duration_seconds == 0 # Default when no started_at
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_archive_to_history_npc_winner(
|
|
self,
|
|
game_state_manager: GameStateManager,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test archiving when NPC is the winner.
|
|
|
|
In campaign mode, the NPC can win. The winner_is_npc flag
|
|
should be set correctly in GameHistory.
|
|
"""
|
|
from app.db.models.game import ActiveGame, GameHistory
|
|
|
|
game_id = str(uuid4())
|
|
sample_game_state.game_id = game_id
|
|
player1_id = uuid4()
|
|
|
|
# Create mock ActiveGame for campaign
|
|
mock_active_game = MagicMock(spec=ActiveGame)
|
|
mock_active_game.id = UUID(game_id)
|
|
mock_active_game.game_type = GameType.CAMPAIGN
|
|
mock_active_game.player1_id = player1_id
|
|
mock_active_game.player2_id = None
|
|
mock_active_game.npc_id = "fire_leader"
|
|
mock_active_game.started_at = datetime.now(UTC)
|
|
|
|
# Mock database session
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_active_game
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.add = MagicMock()
|
|
mock_session.delete = AsyncMock()
|
|
mock_session.flush = AsyncMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
|
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
await game_state_manager.archive_to_history(
|
|
game_id=game_id,
|
|
state=sample_game_state,
|
|
winner_id=None, # NPC won, so no UUID
|
|
winner_is_npc=True,
|
|
end_reason=EndReason.NO_POKEMON,
|
|
)
|
|
|
|
# Verify GameHistory has NPC winner flag
|
|
added_obj = mock_session.add.call_args[0][0]
|
|
assert isinstance(added_obj, GameHistory)
|
|
assert added_obj.winner_is_npc is True
|
|
assert added_obj.winner_id is None
|