Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
server/tests/__init__.py
Normal file
1
server/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package for Golf game."""
|
||||
431
server/tests/test_event_replay.py
Normal file
431
server/tests/test_event_replay.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Tests for event sourcing and state replay.
|
||||
|
||||
These tests verify that:
|
||||
1. Events are emitted correctly from game actions
|
||||
2. State can be rebuilt from events
|
||||
3. Rebuilt state matches original game state
|
||||
4. Events are applied in correct sequence order
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Optional
|
||||
|
||||
from game import Game, GamePhase, GameOptions, Player
|
||||
from models.events import GameEvent, EventType
|
||||
from models.game_state import RebuiltGameState, rebuild_state
|
||||
|
||||
|
||||
class EventCollector:
|
||||
"""Helper class to collect events from a game."""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[GameEvent] = []
|
||||
|
||||
def collect(self, event: GameEvent) -> None:
|
||||
"""Callback to collect an event."""
|
||||
self.events.append(event)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear collected events."""
|
||||
self.events = []
|
||||
|
||||
|
||||
def create_test_game(
|
||||
num_players: int = 2,
|
||||
options: Optional[GameOptions] = None,
|
||||
) -> tuple[Game, EventCollector]:
|
||||
"""
|
||||
Create a game with event collection enabled.
|
||||
|
||||
Returns:
|
||||
Tuple of (Game, EventCollector).
|
||||
"""
|
||||
game = Game()
|
||||
collector = EventCollector()
|
||||
game.set_event_emitter(collector.collect)
|
||||
|
||||
# Emit game created
|
||||
game.emit_game_created("TEST", "p1")
|
||||
|
||||
# Add players
|
||||
for i in range(num_players):
|
||||
player = Player(id=f"p{i+1}", name=f"Player {i+1}")
|
||||
game.add_player(player)
|
||||
|
||||
return game, collector
|
||||
|
||||
|
||||
class TestEventEmission:
|
||||
"""Test that events are emitted correctly."""
|
||||
|
||||
def test_game_created_event(self):
|
||||
"""Game created event should be first event."""
|
||||
game, collector = create_test_game(num_players=0)
|
||||
|
||||
assert len(collector.events) == 1
|
||||
event = collector.events[0]
|
||||
assert event.event_type == EventType.GAME_CREATED
|
||||
assert event.sequence_num == 1
|
||||
assert event.data["room_code"] == "TEST"
|
||||
|
||||
def test_player_joined_events(self):
|
||||
"""Player joined events should be emitted for each player."""
|
||||
game, collector = create_test_game(num_players=3)
|
||||
|
||||
# game_created + 3 player_joined
|
||||
assert len(collector.events) == 4
|
||||
|
||||
joined_events = [e for e in collector.events if e.event_type == EventType.PLAYER_JOINED]
|
||||
assert len(joined_events) == 3
|
||||
|
||||
for i, event in enumerate(joined_events):
|
||||
assert event.player_id == f"p{i+1}"
|
||||
assert event.data["player_name"] == f"Player {i+1}"
|
||||
|
||||
def test_game_started_and_round_started_events(self):
|
||||
"""Starting game should emit game_started and round_started."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
initial_count = len(collector.events)
|
||||
|
||||
game.start_game(num_decks=1, num_rounds=3, options=GameOptions())
|
||||
|
||||
new_events = collector.events[initial_count:]
|
||||
|
||||
# Should have game_started and round_started
|
||||
event_types = [e.event_type for e in new_events]
|
||||
assert EventType.GAME_STARTED in event_types
|
||||
assert EventType.ROUND_STARTED in event_types
|
||||
|
||||
# Verify round_started has deck_seed
|
||||
round_started = next(e for e in new_events if e.event_type == EventType.ROUND_STARTED)
|
||||
assert "deck_seed" in round_started.data
|
||||
assert "dealt_cards" in round_started.data
|
||||
assert "first_discard" in round_started.data
|
||||
|
||||
def test_initial_flip_event(self):
|
||||
"""Initial flip should emit event with card positions."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
|
||||
|
||||
initial_count = len(collector.events)
|
||||
game.flip_initial_cards("p1", [0, 1])
|
||||
|
||||
new_events = collector.events[initial_count:]
|
||||
flip_events = [e for e in new_events if e.event_type == EventType.INITIAL_FLIP]
|
||||
|
||||
assert len(flip_events) == 1
|
||||
event = flip_events[0]
|
||||
assert event.player_id == "p1"
|
||||
assert event.data["positions"] == [0, 1]
|
||||
assert len(event.data["cards"]) == 2
|
||||
|
||||
def test_draw_card_event(self):
|
||||
"""Drawing a card should emit card_drawn event."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
initial_count = len(collector.events)
|
||||
card = game.draw_card("p1", "deck")
|
||||
|
||||
assert card is not None
|
||||
new_events = collector.events[initial_count:]
|
||||
draw_events = [e for e in new_events if e.event_type == EventType.CARD_DRAWN]
|
||||
|
||||
assert len(draw_events) == 1
|
||||
event = draw_events[0]
|
||||
assert event.player_id == "p1"
|
||||
assert event.data["source"] == "deck"
|
||||
assert event.data["card"]["rank"] == card.rank.value
|
||||
|
||||
def test_swap_card_event(self):
|
||||
"""Swapping a card should emit card_swapped event."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
game.draw_card("p1", "deck")
|
||||
|
||||
initial_count = len(collector.events)
|
||||
old_card = game.swap_card("p1", 0)
|
||||
|
||||
assert old_card is not None
|
||||
new_events = collector.events[initial_count:]
|
||||
swap_events = [e for e in new_events if e.event_type == EventType.CARD_SWAPPED]
|
||||
|
||||
assert len(swap_events) == 1
|
||||
event = swap_events[0]
|
||||
assert event.player_id == "p1"
|
||||
assert event.data["position"] == 0
|
||||
|
||||
def test_discard_card_event(self):
|
||||
"""Discarding drawn card should emit card_discarded event."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
drawn = game.draw_card("p1", "deck")
|
||||
|
||||
initial_count = len(collector.events)
|
||||
game.discard_drawn("p1")
|
||||
|
||||
new_events = collector.events[initial_count:]
|
||||
discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED]
|
||||
|
||||
assert len(discard_events) == 1
|
||||
event = discard_events[0]
|
||||
assert event.player_id == "p1"
|
||||
assert event.data["card"]["rank"] == drawn.rank.value
|
||||
|
||||
|
||||
class TestDeckSeeding:
|
||||
"""Test deterministic deck shuffling."""
|
||||
|
||||
def test_same_seed_same_order(self):
|
||||
"""Same seed should produce same card order."""
|
||||
from game import Deck
|
||||
|
||||
deck1 = Deck(num_decks=1, seed=12345)
|
||||
deck2 = Deck(num_decks=1, seed=12345)
|
||||
|
||||
cards1 = [deck1.draw() for _ in range(10)]
|
||||
cards2 = [deck2.draw() for _ in range(10)]
|
||||
|
||||
for c1, c2 in zip(cards1, cards2):
|
||||
assert c1.rank == c2.rank
|
||||
assert c1.suit == c2.suit
|
||||
|
||||
def test_different_seed_different_order(self):
|
||||
"""Different seeds should produce different order."""
|
||||
from game import Deck
|
||||
|
||||
deck1 = Deck(num_decks=1, seed=12345)
|
||||
deck2 = Deck(num_decks=1, seed=54321)
|
||||
|
||||
cards1 = [deck1.draw() for _ in range(52)]
|
||||
cards2 = [deck2.draw() for _ in range(52)]
|
||||
|
||||
# At least some cards should be different
|
||||
differences = sum(
|
||||
1 for c1, c2 in zip(cards1, cards2)
|
||||
if c1.rank != c2.rank or c1.suit != c2.suit
|
||||
)
|
||||
assert differences > 10 # Very unlikely to have <10 differences
|
||||
|
||||
|
||||
class TestEventSequencing:
|
||||
"""Test event sequence ordering."""
|
||||
|
||||
def test_sequence_numbers_increment(self):
|
||||
"""Event sequence numbers should increment monotonically."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
# Play a few turns
|
||||
game.draw_card("p1", "deck")
|
||||
game.discard_drawn("p1")
|
||||
game.draw_card("p2", "deck")
|
||||
game.swap_card("p2", 0)
|
||||
|
||||
sequences = [e.sequence_num for e in collector.events]
|
||||
for i in range(1, len(sequences)):
|
||||
assert sequences[i] == sequences[i-1] + 1, \
|
||||
f"Sequence gap: {sequences[i-1]} -> {sequences[i]}"
|
||||
|
||||
def test_all_events_have_game_id(self):
|
||||
"""All events should have the same game_id."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
game_id = game.game_id
|
||||
for event in collector.events:
|
||||
assert event.game_id == game_id
|
||||
|
||||
|
||||
class TestStateRebuilder:
|
||||
"""Test rebuilding state from events."""
|
||||
|
||||
def test_rebuild_empty_events_raises(self):
|
||||
"""Cannot rebuild from empty event list."""
|
||||
with pytest.raises(ValueError):
|
||||
rebuild_state([])
|
||||
|
||||
def test_rebuild_basic_game(self):
|
||||
"""Can rebuild state from basic game events."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
|
||||
|
||||
# Do initial flips
|
||||
game.flip_initial_cards("p1", [0, 1])
|
||||
game.flip_initial_cards("p2", [0, 1])
|
||||
|
||||
# Rebuild state
|
||||
state = rebuild_state(collector.events)
|
||||
|
||||
assert state.game_id == game.game_id
|
||||
assert state.room_code == "TEST"
|
||||
assert len(state.players) == 2
|
||||
# Compare enum values since they're from different modules
|
||||
assert state.phase.value == "playing"
|
||||
assert state.current_round == 1
|
||||
|
||||
def test_rebuild_matches_player_cards(self):
|
||||
"""Rebuilt player cards should match original."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
|
||||
|
||||
game.flip_initial_cards("p1", [0, 1])
|
||||
game.flip_initial_cards("p2", [0, 1])
|
||||
|
||||
# Rebuild and compare
|
||||
state = rebuild_state(collector.events)
|
||||
|
||||
for player in game.players:
|
||||
rebuilt_player = state.get_player(player.id)
|
||||
assert rebuilt_player is not None
|
||||
assert len(rebuilt_player.cards) == 6
|
||||
|
||||
for i, (orig, rebuilt) in enumerate(zip(player.cards, rebuilt_player.cards)):
|
||||
assert rebuilt.rank == orig.rank.value, f"Rank mismatch at position {i}"
|
||||
assert rebuilt.suit == orig.suit.value, f"Suit mismatch at position {i}"
|
||||
assert rebuilt.face_up == orig.face_up, f"Face up mismatch at position {i}"
|
||||
|
||||
def test_rebuild_after_turns(self):
|
||||
"""Rebuilt state should match after several turns."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
# Play several turns
|
||||
for _ in range(5):
|
||||
current = game.current_player()
|
||||
if not current:
|
||||
break
|
||||
|
||||
game.draw_card(current.id, "deck")
|
||||
game.discard_drawn(current.id)
|
||||
|
||||
if game.phase == GamePhase.ROUND_OVER:
|
||||
break
|
||||
|
||||
# Rebuild and verify
|
||||
state = rebuild_state(collector.events)
|
||||
|
||||
assert state.current_player_idx == game.current_player_index
|
||||
assert len(state.discard_pile) > 0
|
||||
|
||||
def test_rebuild_sequence_validation(self):
|
||||
"""Applying events out of order should fail."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
# Skip first event
|
||||
events = collector.events[1:]
|
||||
|
||||
with pytest.raises(ValueError, match="Expected sequence"):
|
||||
rebuild_state(events)
|
||||
|
||||
|
||||
class TestFullGameReplay:
|
||||
"""Test complete game replay scenarios."""
|
||||
|
||||
def test_play_and_replay_single_round(self):
|
||||
"""Play a full round and verify replay matches."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
|
||||
|
||||
# Initial flips
|
||||
game.flip_initial_cards("p1", [0, 1])
|
||||
game.flip_initial_cards("p2", [0, 1])
|
||||
|
||||
# Play until round ends
|
||||
turn_count = 0
|
||||
max_turns = 100
|
||||
while game.phase not in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) and turn_count < max_turns:
|
||||
current = game.current_player()
|
||||
if not current:
|
||||
break
|
||||
|
||||
game.draw_card(current.id, "deck")
|
||||
game.discard_drawn(current.id)
|
||||
turn_count += 1
|
||||
|
||||
# Rebuild and verify final state
|
||||
state = rebuild_state(collector.events)
|
||||
|
||||
# Phase should match
|
||||
assert state.phase.value == game.phase.value
|
||||
|
||||
# Scores should match (if round is over)
|
||||
if game.phase == GamePhase.ROUND_OVER:
|
||||
for player in game.players:
|
||||
rebuilt_player = state.get_player(player.id)
|
||||
assert rebuilt_player is not None
|
||||
assert rebuilt_player.score == player.score
|
||||
|
||||
def test_partial_replay(self):
|
||||
"""Can replay to any point in the game."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
# Play several turns
|
||||
for _ in range(10):
|
||||
current = game.current_player()
|
||||
if not current or game.phase == GamePhase.ROUND_OVER:
|
||||
break
|
||||
game.draw_card(current.id, "deck")
|
||||
game.discard_drawn(current.id)
|
||||
|
||||
# Replay to different points
|
||||
for n in range(1, len(collector.events) + 1):
|
||||
partial_events = collector.events[:n]
|
||||
state = rebuild_state(partial_events)
|
||||
assert state.sequence_num == n
|
||||
|
||||
def test_swap_action_replay(self):
|
||||
"""Verify swap actions are correctly replayed."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
# Do a swap
|
||||
drawn = game.draw_card("p1", "deck")
|
||||
old_card = game.get_player("p1").cards[0]
|
||||
game.swap_card("p1", 0)
|
||||
|
||||
# Rebuild and verify
|
||||
state = rebuild_state(collector.events)
|
||||
rebuilt_player = state.get_player("p1")
|
||||
|
||||
# The swapped card should be in the hand
|
||||
assert rebuilt_player.cards[0].rank == drawn.rank.value
|
||||
assert rebuilt_player.cards[0].face_up is True
|
||||
|
||||
# The old card should be on discard pile
|
||||
assert state.discard_pile[-1].rank == old_card.rank.value
|
||||
|
||||
|
||||
class TestEventSerialization:
|
||||
"""Test event serialization/deserialization."""
|
||||
|
||||
def test_event_to_dict_roundtrip(self):
|
||||
"""Events can be serialized and deserialized."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
for event in collector.events:
|
||||
event_dict = event.to_dict()
|
||||
restored = GameEvent.from_dict(event_dict)
|
||||
|
||||
assert restored.event_type == event.event_type
|
||||
assert restored.game_id == event.game_id
|
||||
assert restored.sequence_num == event.sequence_num
|
||||
assert restored.player_id == event.player_id
|
||||
assert restored.data == event.data
|
||||
|
||||
def test_event_to_json_roundtrip(self):
|
||||
"""Events can be JSON serialized and deserialized."""
|
||||
game, collector = create_test_game(num_players=2)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||
|
||||
for event in collector.events:
|
||||
json_str = event.to_json()
|
||||
restored = GameEvent.from_json(json_str)
|
||||
|
||||
assert restored.event_type == event.event_type
|
||||
assert restored.game_id == event.game_id
|
||||
assert restored.sequence_num == event.sequence_num
|
||||
564
server/tests/test_persistence.py
Normal file
564
server/tests/test_persistence.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
Tests for V2 Persistence & Recovery components.
|
||||
|
||||
These tests cover:
|
||||
- StateCache: Redis-backed game state caching
|
||||
- GamePubSub: Cross-server event broadcasting
|
||||
- RecoveryService: Game recovery from event store
|
||||
|
||||
Tests use fakeredis for isolated Redis testing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Import the modules under test
|
||||
from stores.state_cache import StateCache
|
||||
from stores.pubsub import GamePubSub, PubSubMessage, MessageType
|
||||
from services.recovery_service import RecoveryService, RecoveryResult
|
||||
from models.events import (
|
||||
GameEvent, EventType,
|
||||
game_created, player_joined, game_started, round_started,
|
||||
)
|
||||
from models.game_state import RebuiltGameState, GamePhase
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Create a mock Redis client for testing."""
|
||||
mock = AsyncMock()
|
||||
|
||||
# Track stored data
|
||||
data = {}
|
||||
sets = {}
|
||||
hashes = {}
|
||||
|
||||
async def mock_set(key, value, ex=None):
|
||||
data[key] = value
|
||||
|
||||
async def mock_get(key):
|
||||
return data.get(key)
|
||||
|
||||
async def mock_delete(*keys):
|
||||
for key in keys:
|
||||
data.pop(key, None)
|
||||
sets.pop(key, None)
|
||||
hashes.pop(key, None)
|
||||
|
||||
async def mock_exists(key):
|
||||
return 1 if key in data or key in hashes else 0
|
||||
|
||||
async def mock_sadd(key, *values):
|
||||
if key not in sets:
|
||||
sets[key] = set()
|
||||
sets[key].update(values)
|
||||
return len(values)
|
||||
|
||||
async def mock_srem(key, *values):
|
||||
if key in sets:
|
||||
for v in values:
|
||||
sets[key].discard(v)
|
||||
|
||||
async def mock_smembers(key):
|
||||
return sets.get(key, set())
|
||||
|
||||
async def mock_hset(key, field=None, value=None, mapping=None, **kwargs):
|
||||
"""Mock hset supporting both hset(key, field, value) and hset(key, mapping={})"""
|
||||
if key not in hashes:
|
||||
hashes[key] = {}
|
||||
if mapping:
|
||||
for k, v in mapping.items():
|
||||
hashes[key][k.encode() if isinstance(k, str) else k] = v.encode() if isinstance(v, str) else v
|
||||
elif field is not None and value is not None:
|
||||
hashes[key][field.encode() if isinstance(field, str) else field] = value.encode() if isinstance(value, str) else value
|
||||
|
||||
async def mock_hgetall(key):
|
||||
return hashes.get(key, {})
|
||||
|
||||
async def mock_expire(key, seconds):
|
||||
pass # No-op for testing
|
||||
|
||||
def mock_pipeline():
|
||||
pipe = AsyncMock()
|
||||
|
||||
async def pipe_hset(key, field=None, value=None, mapping=None, **kwargs):
|
||||
await mock_hset(key, field, value, mapping, **kwargs)
|
||||
|
||||
async def pipe_sadd(key, *values):
|
||||
await mock_sadd(key, *values)
|
||||
|
||||
async def pipe_set(key, value, ex=None):
|
||||
await mock_set(key, value, ex)
|
||||
|
||||
pipe.hset = pipe_hset
|
||||
pipe.expire = AsyncMock()
|
||||
pipe.sadd = pipe_sadd
|
||||
pipe.set = pipe_set
|
||||
pipe.srem = AsyncMock()
|
||||
pipe.delete = AsyncMock()
|
||||
|
||||
async def execute():
|
||||
return []
|
||||
|
||||
pipe.execute = execute
|
||||
return pipe
|
||||
|
||||
mock.set = mock_set
|
||||
mock.get = mock_get
|
||||
mock.delete = mock_delete
|
||||
mock.exists = mock_exists
|
||||
mock.sadd = mock_sadd
|
||||
mock.srem = mock_srem
|
||||
mock.smembers = mock_smembers
|
||||
mock.hset = mock_hset
|
||||
mock.hgetall = mock_hgetall
|
||||
mock.expire = mock_expire
|
||||
mock.pipeline = mock_pipeline
|
||||
mock.ping = AsyncMock(return_value=True)
|
||||
mock.close = AsyncMock()
|
||||
|
||||
# Store references for assertions
|
||||
mock._data = data
|
||||
mock._sets = sets
|
||||
mock._hashes = hashes
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_cache(mock_redis):
|
||||
"""Create a StateCache with mock Redis."""
|
||||
return StateCache(mock_redis)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_store():
|
||||
"""Create a mock EventStore."""
|
||||
mock = AsyncMock()
|
||||
mock.get_events = AsyncMock(return_value=[])
|
||||
mock.get_active_games = AsyncMock(return_value=[])
|
||||
return mock
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# StateCache Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestStateCache:
|
||||
"""Tests for StateCache class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_room(self, state_cache, mock_redis):
|
||||
"""Test creating a new room."""
|
||||
await state_cache.create_room(
|
||||
room_code="ABCD",
|
||||
game_id="game-123",
|
||||
host_id="player-1",
|
||||
server_id="server-1",
|
||||
)
|
||||
|
||||
# Verify room was created via pipeline
|
||||
# (Pipeline operations are mocked, just verify no errors)
|
||||
assert True # Room creation succeeded
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_exists_true(self, state_cache, mock_redis):
|
||||
"""Test room_exists returns True when room exists."""
|
||||
mock_redis._hashes["golf:room:ABCD"] = {b"game_id": b"123"}
|
||||
|
||||
result = await state_cache.room_exists("ABCD")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_exists_false(self, state_cache, mock_redis):
|
||||
"""Test room_exists returns False when room doesn't exist."""
|
||||
result = await state_cache.room_exists("XXXX")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_rooms(self, state_cache, mock_redis):
|
||||
"""Test getting active rooms."""
|
||||
mock_redis._sets["golf:rooms:active"] = {"ABCD", "EFGH"}
|
||||
|
||||
rooms = await state_cache.get_active_rooms()
|
||||
assert rooms == {"ABCD", "EFGH"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_and_get_game_state(self, state_cache, mock_redis):
|
||||
"""Test saving and retrieving game state."""
|
||||
state = {
|
||||
"game_id": "game-123",
|
||||
"phase": "playing",
|
||||
"players": {"p1": {"name": "Alice"}},
|
||||
}
|
||||
|
||||
await state_cache.save_game_state("game-123", state)
|
||||
|
||||
# Verify it was stored
|
||||
key = "golf:game:game-123"
|
||||
assert key in mock_redis._data
|
||||
|
||||
# Retrieve it
|
||||
retrieved = await state_cache.get_game_state("game-123")
|
||||
assert retrieved == state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_game_state(self, state_cache, mock_redis):
|
||||
"""Test getting state for non-existent game returns None."""
|
||||
result = await state_cache.get_game_state("nonexistent")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_player_to_room(self, state_cache, mock_redis):
|
||||
"""Test adding a player to a room."""
|
||||
await state_cache.add_player_to_room("ABCD", "player-2")
|
||||
|
||||
# Pipeline was used successfully (no exception thrown)
|
||||
# The actual data verification would require integration tests
|
||||
assert True # add_player_to_room completed without error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_room_players(self, state_cache, mock_redis):
|
||||
"""Test getting players in a room."""
|
||||
mock_redis._sets["golf:room:ABCD:players"] = {"player-1", "player-2"}
|
||||
|
||||
players = await state_cache.get_room_players("ABCD")
|
||||
assert players == {"player-1", "player-2"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_player_room(self, state_cache, mock_redis):
|
||||
"""Test getting the room a player is in."""
|
||||
mock_redis._data["golf:player:player-1:room"] = b"ABCD"
|
||||
|
||||
room = await state_cache.get_player_room("player-1")
|
||||
assert room == "ABCD"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_player_room_not_in_room(self, state_cache, mock_redis):
|
||||
"""Test getting room for player not in any room."""
|
||||
room = await state_cache.get_player_room("unknown-player")
|
||||
assert room is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GamePubSub Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGamePubSub:
|
||||
"""Tests for GamePubSub class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub_redis(self):
|
||||
"""Create mock Redis with pubsub support."""
|
||||
mock = AsyncMock()
|
||||
mock_pubsub = AsyncMock()
|
||||
mock_pubsub.subscribe = AsyncMock()
|
||||
mock_pubsub.unsubscribe = AsyncMock()
|
||||
mock_pubsub.get_message = AsyncMock(return_value=None)
|
||||
mock_pubsub.close = AsyncMock()
|
||||
mock.pubsub = MagicMock(return_value=mock_pubsub)
|
||||
mock.publish = AsyncMock(return_value=1)
|
||||
return mock, mock_pubsub
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_to_room(self, mock_pubsub_redis):
|
||||
"""Test subscribing to room events."""
|
||||
redis_client, mock_ps = mock_pubsub_redis
|
||||
pubsub = GamePubSub(redis_client, server_id="test-server")
|
||||
|
||||
handler = AsyncMock()
|
||||
await pubsub.subscribe("ABCD", handler)
|
||||
|
||||
mock_ps.subscribe.assert_called_once_with("golf:room:ABCD")
|
||||
assert "golf:room:ABCD" in pubsub._handlers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_from_room(self, mock_pubsub_redis):
|
||||
"""Test unsubscribing from room events."""
|
||||
redis_client, mock_ps = mock_pubsub_redis
|
||||
pubsub = GamePubSub(redis_client, server_id="test-server")
|
||||
|
||||
handler = AsyncMock()
|
||||
await pubsub.subscribe("ABCD", handler)
|
||||
await pubsub.unsubscribe("ABCD")
|
||||
|
||||
mock_ps.unsubscribe.assert_called_once_with("golf:room:ABCD")
|
||||
assert "golf:room:ABCD" not in pubsub._handlers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_message(self, mock_pubsub_redis):
|
||||
"""Test publishing a message."""
|
||||
redis_client, _ = mock_pubsub_redis
|
||||
pubsub = GamePubSub(redis_client, server_id="test-server")
|
||||
|
||||
message = PubSubMessage(
|
||||
type=MessageType.GAME_STATE_UPDATE,
|
||||
room_code="ABCD",
|
||||
data={"phase": "playing"},
|
||||
)
|
||||
count = await pubsub.publish(message)
|
||||
|
||||
assert count == 1
|
||||
redis_client.publish.assert_called_once()
|
||||
call_args = redis_client.publish.call_args
|
||||
assert call_args[0][0] == "golf:room:ABCD"
|
||||
|
||||
def test_pubsub_message_serialization(self):
|
||||
"""Test PubSubMessage JSON serialization."""
|
||||
message = PubSubMessage(
|
||||
type=MessageType.PLAYER_JOINED,
|
||||
room_code="ABCD",
|
||||
data={"player_name": "Alice"},
|
||||
sender_id="server-1",
|
||||
)
|
||||
|
||||
json_str = message.to_json()
|
||||
parsed = PubSubMessage.from_json(json_str)
|
||||
|
||||
assert parsed.type == MessageType.PLAYER_JOINED
|
||||
assert parsed.room_code == "ABCD"
|
||||
assert parsed.data == {"player_name": "Alice"}
|
||||
assert parsed.sender_id == "server-1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RecoveryService Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRecoveryService:
|
||||
"""Tests for RecoveryService class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dependencies(self, mock_event_store, state_cache):
|
||||
"""Create mocked dependencies for RecoveryService."""
|
||||
return mock_event_store, state_cache
|
||||
|
||||
def create_test_events(self, game_id: str = "game-123") -> list[GameEvent]:
|
||||
"""Create a sequence of test events for recovery."""
|
||||
return [
|
||||
game_created(
|
||||
game_id=game_id,
|
||||
sequence_num=1,
|
||||
room_code="ABCD",
|
||||
host_id="player-1",
|
||||
options={"rounds": 9},
|
||||
),
|
||||
player_joined(
|
||||
game_id=game_id,
|
||||
sequence_num=2,
|
||||
player_id="player-1",
|
||||
player_name="Alice",
|
||||
),
|
||||
player_joined(
|
||||
game_id=game_id,
|
||||
sequence_num=3,
|
||||
player_id="player-2",
|
||||
player_name="Bob",
|
||||
),
|
||||
game_started(
|
||||
game_id=game_id,
|
||||
sequence_num=4,
|
||||
player_order=["player-1", "player-2"],
|
||||
num_decks=1,
|
||||
num_rounds=9,
|
||||
options={"rounds": 9},
|
||||
),
|
||||
round_started(
|
||||
game_id=game_id,
|
||||
sequence_num=5,
|
||||
round_num=1,
|
||||
deck_seed=12345,
|
||||
dealt_cards={
|
||||
"player-1": [
|
||||
{"rank": "K", "suit": "hearts"},
|
||||
{"rank": "5", "suit": "diamonds"},
|
||||
{"rank": "A", "suit": "clubs"},
|
||||
{"rank": "7", "suit": "spades"},
|
||||
{"rank": "Q", "suit": "hearts"},
|
||||
{"rank": "3", "suit": "clubs"},
|
||||
],
|
||||
"player-2": [
|
||||
{"rank": "10", "suit": "spades"},
|
||||
{"rank": "2", "suit": "hearts"},
|
||||
{"rank": "J", "suit": "diamonds"},
|
||||
{"rank": "9", "suit": "clubs"},
|
||||
{"rank": "4", "suit": "hearts"},
|
||||
{"rank": "8", "suit": "spades"},
|
||||
],
|
||||
},
|
||||
first_discard={"rank": "6", "suit": "diamonds"},
|
||||
),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_game_success(self, mock_dependencies):
|
||||
"""Test successful game recovery."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
events = self.create_test_events()
|
||||
event_store.get_events.return_value = events
|
||||
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
result = await recovery.recover_game("game-123", "ABCD")
|
||||
|
||||
assert result.success is True
|
||||
assert result.game_id == "game-123"
|
||||
assert result.room_code == "ABCD"
|
||||
assert result.phase == "initial_flip"
|
||||
assert result.sequence_num == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_game_no_events(self, mock_dependencies):
|
||||
"""Test recovery with no events returns failure."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
event_store.get_events.return_value = []
|
||||
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
result = await recovery.recover_game("game-123")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "no_events"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_game_already_ended(self, mock_dependencies):
|
||||
"""Test recovery skips ended games."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
|
||||
# Create events ending with GAME_ENDED
|
||||
events = self.create_test_events()
|
||||
events.append(GameEvent(
|
||||
event_type=EventType.GAME_ENDED,
|
||||
game_id="game-123",
|
||||
sequence_num=6,
|
||||
data={"final_scores": {}, "rounds_won": {}},
|
||||
))
|
||||
event_store.get_events.return_value = events
|
||||
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
result = await recovery.recover_game("game-123")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "game_ended"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_all_games(self, mock_dependencies):
|
||||
"""Test recovering multiple games."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
|
||||
# Set up two active games
|
||||
event_store.get_active_games.return_value = [
|
||||
{"id": "game-1", "room_code": "AAAA"},
|
||||
{"id": "game-2", "room_code": "BBBB"},
|
||||
]
|
||||
|
||||
# Each game has events
|
||||
event_store.get_events.side_effect = [
|
||||
self.create_test_events("game-1"),
|
||||
self.create_test_events("game-2"),
|
||||
]
|
||||
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
results = await recovery.recover_all_games()
|
||||
|
||||
assert results["recovered"] == 2
|
||||
assert results["failed"] == 0
|
||||
assert results["skipped"] == 0
|
||||
assert len(results["games"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_to_dict_conversion(self, mock_dependencies):
|
||||
"""Test state to dict conversion for caching."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
events = self.create_test_events()
|
||||
event_store.get_events.return_value = events
|
||||
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
result = await recovery.recover_game("game-123")
|
||||
|
||||
# Verify recovery succeeded
|
||||
assert result.success is True
|
||||
|
||||
# Verify state was cached (game_id key should be set)
|
||||
game_key = "golf:game:game-123"
|
||||
assert game_key in state_cache.redis._data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_to_state_conversion(self, mock_dependencies):
|
||||
"""Test dict to state conversion for recovery."""
|
||||
event_store, state_cache = mock_dependencies
|
||||
recovery = RecoveryService(event_store, state_cache)
|
||||
|
||||
state_dict = {
|
||||
"game_id": "game-123",
|
||||
"room_code": "ABCD",
|
||||
"phase": "playing",
|
||||
"current_round": 1,
|
||||
"total_rounds": 9,
|
||||
"current_player_idx": 0,
|
||||
"player_order": ["player-1", "player-2"],
|
||||
"deck_remaining": 40,
|
||||
"options": {},
|
||||
"sequence_num": 5,
|
||||
"finisher_id": None,
|
||||
"host_id": "player-1",
|
||||
"initial_flips_done": ["player-1"],
|
||||
"players_with_final_turn": [],
|
||||
"drawn_from_discard": False,
|
||||
"players": {
|
||||
"player-1": {
|
||||
"id": "player-1",
|
||||
"name": "Alice",
|
||||
"cards": [
|
||||
{"rank": "K", "suit": "hearts", "face_up": True},
|
||||
],
|
||||
"score": 0,
|
||||
"total_score": 0,
|
||||
"rounds_won": 0,
|
||||
"is_cpu": False,
|
||||
"cpu_profile": None,
|
||||
},
|
||||
},
|
||||
"discard_pile": [{"rank": "6", "suit": "diamonds", "face_up": True}],
|
||||
"drawn_card": None,
|
||||
}
|
||||
|
||||
state = recovery._dict_to_state(state_dict)
|
||||
|
||||
assert state.game_id == "game-123"
|
||||
assert state.room_code == "ABCD"
|
||||
assert state.phase == GamePhase.PLAYING
|
||||
assert state.current_round == 1
|
||||
assert "player-1" in state.players
|
||||
assert state.players["player-1"].name == "Alice"
|
||||
assert len(state.discard_pile) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests (require actual Redis - skip if not available)
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.skip(reason="Requires actual Redis - run manually with docker-compose")
|
||||
class TestIntegration:
|
||||
"""Integration tests requiring actual Redis."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_recovery_cycle(self):
|
||||
"""Test complete recovery cycle with real Redis."""
|
||||
# This would test the actual flow:
|
||||
# 1. Create game events
|
||||
# 2. Store in PostgreSQL
|
||||
# 3. Cache state in Redis
|
||||
# 4. "Restart" - clear local state
|
||||
# 5. Recover from PostgreSQL
|
||||
# 6. Verify state matches
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
302
server/tests/test_replay.py
Normal file
302
server/tests/test_replay.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Tests for the replay service.
|
||||
|
||||
Verifies:
|
||||
- Replay building from events
|
||||
- Share link creation and retrieval
|
||||
- Export/import roundtrip
|
||||
- Access control
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from models.events import GameEvent, EventType
|
||||
from models.game_state import RebuiltGameState, rebuild_state
|
||||
|
||||
|
||||
class TestReplayBuilding:
|
||||
"""Test replay construction from events."""
|
||||
|
||||
def test_rebuild_state_from_events(self):
|
||||
"""Verify state can be rebuilt from a sequence of events."""
|
||||
events = [
|
||||
GameEvent(
|
||||
event_type=EventType.GAME_CREATED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=1,
|
||||
player_id=None,
|
||||
data={
|
||||
"room_code": "ABCD",
|
||||
"host_id": "player-1",
|
||||
"options": {},
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
GameEvent(
|
||||
event_type=EventType.PLAYER_JOINED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=2,
|
||||
player_id="player-1",
|
||||
data={
|
||||
"player_name": "Alice",
|
||||
"is_cpu": False,
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
GameEvent(
|
||||
event_type=EventType.PLAYER_JOINED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=3,
|
||||
player_id="player-2",
|
||||
data={
|
||||
"player_name": "Bob",
|
||||
"is_cpu": False,
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
state = rebuild_state(events)
|
||||
|
||||
assert state.game_id == "test-game-1"
|
||||
assert state.room_code == "ABCD"
|
||||
assert len(state.players) == 2
|
||||
assert "player-1" in state.players
|
||||
assert "player-2" in state.players
|
||||
assert state.players["player-1"].name == "Alice"
|
||||
assert state.players["player-2"].name == "Bob"
|
||||
assert state.sequence_num == 3
|
||||
|
||||
def test_rebuild_state_partial(self):
|
||||
"""Can rebuild state to any point in event history."""
|
||||
events = [
|
||||
GameEvent(
|
||||
event_type=EventType.GAME_CREATED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=1,
|
||||
player_id=None,
|
||||
data={
|
||||
"room_code": "ABCD",
|
||||
"host_id": "player-1",
|
||||
"options": {},
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
GameEvent(
|
||||
event_type=EventType.PLAYER_JOINED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=2,
|
||||
player_id="player-1",
|
||||
data={
|
||||
"player_name": "Alice",
|
||||
"is_cpu": False,
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
GameEvent(
|
||||
event_type=EventType.PLAYER_JOINED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=3,
|
||||
player_id="player-2",
|
||||
data={
|
||||
"player_name": "Bob",
|
||||
"is_cpu": False,
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Rebuild only first 2 events
|
||||
state = rebuild_state(events[:2])
|
||||
assert len(state.players) == 1
|
||||
assert state.sequence_num == 2
|
||||
|
||||
# Rebuild all events
|
||||
state = rebuild_state(events)
|
||||
assert len(state.players) == 2
|
||||
assert state.sequence_num == 3
|
||||
|
||||
|
||||
class TestExportImport:
|
||||
"""Test game export and import."""
|
||||
|
||||
def test_export_format(self):
|
||||
"""Verify exported format matches expected structure."""
|
||||
export_data = {
|
||||
"version": "1.0",
|
||||
"exported_at": "2024-01-15T12:00:00Z",
|
||||
"game": {
|
||||
"id": "test-game-1",
|
||||
"room_code": "ABCD",
|
||||
"players": ["Alice", "Bob"],
|
||||
"winner": "Alice",
|
||||
"final_scores": {"Alice": 15, "Bob": 23},
|
||||
"duration_seconds": 300.5,
|
||||
"total_rounds": 1,
|
||||
"options": {},
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "game_created",
|
||||
"sequence": 1,
|
||||
"player_id": None,
|
||||
"data": {"room_code": "ABCD", "host_id": "p1", "options": {}},
|
||||
"timestamp": 0.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
assert export_data["version"] == "1.0"
|
||||
assert "exported_at" in export_data
|
||||
assert "game" in export_data
|
||||
assert "events" in export_data
|
||||
assert export_data["game"]["players"] == ["Alice", "Bob"]
|
||||
|
||||
def test_import_validates_version(self):
|
||||
"""Import should reject unsupported versions."""
|
||||
invalid_export = {
|
||||
"version": "2.0", # Unsupported version
|
||||
"events": [],
|
||||
}
|
||||
|
||||
# This would be tested with the actual service
|
||||
assert invalid_export["version"] != "1.0"
|
||||
|
||||
|
||||
class TestShareLinks:
|
||||
"""Test share link functionality."""
|
||||
|
||||
def test_share_code_format(self):
|
||||
"""Share codes should be 12 characters."""
|
||||
import secrets
|
||||
share_code = secrets.token_urlsafe(9)[:12]
|
||||
|
||||
assert len(share_code) == 12
|
||||
# URL-safe characters only
|
||||
assert all(c.isalnum() or c in '-_' for c in share_code)
|
||||
|
||||
def test_expiry_calculation(self):
|
||||
"""Verify expiry date calculation."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_days = 7
|
||||
expires_at = now + timedelta(days=expires_days)
|
||||
|
||||
assert expires_at > now
|
||||
assert (expires_at - now).days == 7
|
||||
|
||||
|
||||
class TestSpectatorManager:
|
||||
"""Test spectator management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_remove_spectator(self):
|
||||
"""Test adding and removing spectators."""
|
||||
from services.spectator import SpectatorManager
|
||||
|
||||
manager = SpectatorManager()
|
||||
ws = AsyncMock()
|
||||
|
||||
# Add spectator
|
||||
result = await manager.add_spectator("game-1", ws, user_id="user-1")
|
||||
assert result is True
|
||||
assert manager.get_spectator_count("game-1") == 1
|
||||
|
||||
# Remove spectator
|
||||
await manager.remove_spectator("game-1", ws)
|
||||
assert manager.get_spectator_count("game-1") == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spectator_limit(self):
|
||||
"""Test spectator limit enforcement."""
|
||||
from services.spectator import SpectatorManager, MAX_SPECTATORS_PER_GAME
|
||||
|
||||
manager = SpectatorManager()
|
||||
|
||||
# Add max spectators
|
||||
for i in range(MAX_SPECTATORS_PER_GAME):
|
||||
ws = AsyncMock()
|
||||
result = await manager.add_spectator("game-1", ws)
|
||||
assert result is True
|
||||
|
||||
# Try to add one more
|
||||
ws = AsyncMock()
|
||||
result = await manager.add_spectator("game-1", ws)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_to_spectators(self):
|
||||
"""Test broadcasting messages to spectators."""
|
||||
from services.spectator import SpectatorManager
|
||||
|
||||
manager = SpectatorManager()
|
||||
ws1 = AsyncMock()
|
||||
ws2 = AsyncMock()
|
||||
|
||||
await manager.add_spectator("game-1", ws1)
|
||||
await manager.add_spectator("game-1", ws2)
|
||||
|
||||
message = {"type": "game_update", "data": "test"}
|
||||
await manager.broadcast_to_spectators("game-1", message)
|
||||
|
||||
ws1.send_json.assert_called_once_with(message)
|
||||
ws2.send_json.assert_called_once_with(message)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dead_connection_cleanup(self):
|
||||
"""Test cleanup of dead WebSocket connections."""
|
||||
from services.spectator import SpectatorManager
|
||||
|
||||
manager = SpectatorManager()
|
||||
|
||||
# Add a spectator that will fail on send
|
||||
ws = AsyncMock()
|
||||
ws.send_json.side_effect = Exception("Connection closed")
|
||||
|
||||
await manager.add_spectator("game-1", ws)
|
||||
assert manager.get_spectator_count("game-1") == 1
|
||||
|
||||
# Broadcast should clean up dead connection
|
||||
await manager.broadcast_to_spectators("game-1", {"type": "test"})
|
||||
assert manager.get_spectator_count("game-1") == 0
|
||||
|
||||
|
||||
class TestReplayFrames:
|
||||
"""Test replay frame construction."""
|
||||
|
||||
def test_frame_timestamps(self):
|
||||
"""Verify frame timestamps are relative to game start."""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
events = [
|
||||
GameEvent(
|
||||
event_type=EventType.GAME_CREATED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=1,
|
||||
player_id=None,
|
||||
data={"room_code": "ABCD", "host_id": "p1", "options": {}},
|
||||
timestamp=start_time,
|
||||
),
|
||||
GameEvent(
|
||||
event_type=EventType.PLAYER_JOINED,
|
||||
game_id="test-game-1",
|
||||
sequence_num=2,
|
||||
player_id="player-1",
|
||||
data={"player_name": "Alice", "is_cpu": False},
|
||||
timestamp=start_time + timedelta(seconds=5),
|
||||
),
|
||||
]
|
||||
|
||||
# First event should have timestamp 0
|
||||
elapsed_0 = (events[0].timestamp - start_time).total_seconds()
|
||||
assert elapsed_0 == 0.0
|
||||
|
||||
# Second event should have timestamp 5
|
||||
elapsed_1 = (events[1].timestamp - start_time).total_seconds()
|
||||
assert elapsed_1 == 5.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user