Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 tests passing.
804 lines
26 KiB
Python
804 lines
26 KiB
Python
"""Tests for the visibility filter module.
|
|
|
|
This module contains SECURITY-CRITICAL tests that verify hidden information
|
|
is never leaked to unauthorized viewers. These tests are essential for
|
|
preventing cheating in multiplayer games.
|
|
|
|
Test categories:
|
|
- Own information visibility (player can see their own hand, etc.)
|
|
- Opponent information hiding (opponent's hand, deck hidden)
|
|
- Public information visibility (battlefield, discard, scores)
|
|
- Spectator mode (no hands visible)
|
|
- Edge cases and error handling
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.core.config import RulesConfig
|
|
from app.core.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
GameEndReason,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.card import CardDefinition, CardInstance
|
|
from app.core.models.game_state import ForcedAction, GameState, PlayerState
|
|
from app.core.visibility import (
|
|
VisibleGameState,
|
|
get_spectator_state,
|
|
get_visible_state,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def pokemon_def() -> CardDefinition:
|
|
"""Create a basic Pokemon card definition."""
|
|
return CardDefinition(
|
|
id="pikachu-001",
|
|
name="Pikachu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.NORMAL,
|
|
hp=60,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def energy_def() -> CardDefinition:
|
|
"""Create a basic energy card definition."""
|
|
return CardDefinition(
|
|
id="lightning-energy-001",
|
|
name="Lightning Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.LIGHTNING,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def trainer_def() -> CardDefinition:
|
|
"""Create a trainer card definition."""
|
|
return CardDefinition(
|
|
id="potion-001",
|
|
name="Potion",
|
|
card_type=CardType.TRAINER,
|
|
trainer_type=TrainerType.ITEM,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def full_game(
|
|
pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
trainer_def: CardDefinition,
|
|
) -> GameState:
|
|
"""Create a full game state with cards in various zones.
|
|
|
|
Player 1 has:
|
|
- 3 cards in hand (including a secret trainer)
|
|
- 1 active Pokemon
|
|
- 2 benched Pokemon
|
|
- 5 cards in deck
|
|
- 3 prize cards
|
|
- 2 cards in discard
|
|
- 3 cards in energy deck
|
|
- 1 card in energy zone
|
|
|
|
Player 2 has:
|
|
- 4 cards in hand
|
|
- 1 active Pokemon
|
|
- 1 benched Pokemon
|
|
- 4 cards in deck
|
|
- 3 prize cards
|
|
- 1 card in discard
|
|
- 2 cards in energy deck
|
|
- 2 cards in energy zone
|
|
"""
|
|
# Create card instances for player 1
|
|
p1_hand = [
|
|
CardInstance(instance_id="p1-hand-0", definition_id=pokemon_def.id),
|
|
CardInstance(instance_id="p1-hand-1", definition_id=energy_def.id),
|
|
CardInstance(instance_id="p1-hand-2", definition_id=trainer_def.id), # Secret!
|
|
]
|
|
p1_active = CardInstance(instance_id="p1-active", definition_id=pokemon_def.id)
|
|
p1_bench = [
|
|
CardInstance(instance_id="p1-bench-0", definition_id=pokemon_def.id),
|
|
CardInstance(instance_id="p1-bench-1", definition_id=pokemon_def.id),
|
|
]
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=pokemon_def.id) for i in range(5)
|
|
]
|
|
p1_prizes = [
|
|
CardInstance(instance_id=f"p1-prize-{i}", definition_id=pokemon_def.id) for i in range(3)
|
|
]
|
|
p1_discard = [
|
|
CardInstance(instance_id="p1-discard-0", definition_id=energy_def.id),
|
|
CardInstance(instance_id="p1-discard-1", definition_id=trainer_def.id),
|
|
]
|
|
p1_energy_deck = [
|
|
CardInstance(instance_id=f"p1-energy-deck-{i}", definition_id=energy_def.id)
|
|
for i in range(3)
|
|
]
|
|
p1_energy_zone = [
|
|
CardInstance(instance_id="p1-energy-zone-0", definition_id=energy_def.id),
|
|
]
|
|
|
|
# Create card instances for player 2
|
|
p2_hand = [
|
|
CardInstance(instance_id=f"p2-hand-{i}", definition_id=pokemon_def.id) for i in range(4)
|
|
]
|
|
p2_active = CardInstance(instance_id="p2-active", definition_id=pokemon_def.id)
|
|
p2_bench = [
|
|
CardInstance(instance_id="p2-bench-0", definition_id=pokemon_def.id),
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=pokemon_def.id) for i in range(4)
|
|
]
|
|
p2_prizes = [
|
|
CardInstance(instance_id=f"p2-prize-{i}", definition_id=pokemon_def.id) for i in range(3)
|
|
]
|
|
p2_discard = [
|
|
CardInstance(instance_id="p2-discard-0", definition_id=energy_def.id),
|
|
]
|
|
p2_energy_deck = [
|
|
CardInstance(instance_id=f"p2-energy-deck-{i}", definition_id=energy_def.id)
|
|
for i in range(2)
|
|
]
|
|
p2_energy_zone = [
|
|
CardInstance(instance_id=f"p2-energy-zone-{i}", definition_id=energy_def.id)
|
|
for i in range(2)
|
|
]
|
|
|
|
# Build player states
|
|
p1 = PlayerState(player_id="player1", score=2)
|
|
for card in p1_hand:
|
|
p1.hand.add(card)
|
|
p1.active.add(p1_active)
|
|
for card in p1_bench:
|
|
p1.bench.add(card)
|
|
for card in p1_deck:
|
|
p1.deck.add(card)
|
|
for card in p1_prizes:
|
|
p1.prizes.add(card)
|
|
for card in p1_discard:
|
|
p1.discard.add(card)
|
|
for card in p1_energy_deck:
|
|
p1.energy_deck.add(card)
|
|
for card in p1_energy_zone:
|
|
p1.energy_zone.add(card)
|
|
|
|
p2 = PlayerState(player_id="player2", score=1)
|
|
for card in p2_hand:
|
|
p2.hand.add(card)
|
|
p2.active.add(p2_active)
|
|
for card in p2_bench:
|
|
p2.bench.add(card)
|
|
for card in p2_deck:
|
|
p2.deck.add(card)
|
|
for card in p2_prizes:
|
|
p2.prizes.add(card)
|
|
for card in p2_discard:
|
|
p2.discard.add(card)
|
|
for card in p2_energy_deck:
|
|
p2.energy_deck.add(card)
|
|
for card in p2_energy_zone:
|
|
p2.energy_zone.add(card)
|
|
|
|
# Build game state
|
|
game = GameState(
|
|
game_id="test-game",
|
|
rules=RulesConfig(),
|
|
card_registry={
|
|
pokemon_def.id: pokemon_def,
|
|
energy_def.id: energy_def,
|
|
trainer_def.id: trainer_def,
|
|
},
|
|
players={"player1": p1, "player2": p2},
|
|
current_player_id="player1",
|
|
turn_number=3,
|
|
phase=TurnPhase.MAIN,
|
|
turn_order=["player1", "player2"],
|
|
)
|
|
|
|
return game
|
|
|
|
|
|
# =============================================================================
|
|
# Own Information Visibility Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestOwnInformationVisibility:
|
|
"""Tests verifying players can see their own information."""
|
|
|
|
def test_player_sees_own_hand_contents(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see the full contents of their own hand.
|
|
|
|
This is essential for gameplay - players must know what cards
|
|
they can play.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.hand.count == 3
|
|
assert len(own_state.hand.cards) == 3
|
|
# Verify specific cards are visible
|
|
hand_ids = [c.instance_id for c in own_state.hand.cards]
|
|
assert "p1-hand-0" in hand_ids
|
|
assert "p1-hand-1" in hand_ids
|
|
assert "p1-hand-2" in hand_ids
|
|
|
|
def test_player_sees_own_active_pokemon(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see their own active Pokemon.
|
|
|
|
Active Pokemon details are always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.active.count == 1
|
|
assert len(own_state.active.cards) == 1
|
|
assert own_state.active.cards[0].instance_id == "p1-active"
|
|
|
|
def test_player_sees_own_bench(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see their own benched Pokemon.
|
|
|
|
Bench contents are always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.bench.count == 2
|
|
assert len(own_state.bench.cards) == 2
|
|
|
|
def test_player_sees_own_discard(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see their own discard pile.
|
|
|
|
Discard piles are always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.discard.count == 2
|
|
assert len(own_state.discard.cards) == 2
|
|
|
|
def test_player_sees_own_deck_count_only(self, full_game: GameState):
|
|
"""
|
|
Test that a player sees their deck count but not contents.
|
|
|
|
Even your own deck order is hidden to maintain game integrity.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.deck_count == 5
|
|
|
|
def test_player_sees_own_prize_count_only(self, full_game: GameState):
|
|
"""
|
|
Test that a player sees their prize count but not contents.
|
|
|
|
Prize cards are revealed only when taken.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.prizes_count == 3
|
|
|
|
def test_player_sees_own_energy_zone(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see their energy zone (available energy).
|
|
|
|
Energy zone is public - it shows what energy can be attached.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.energy_zone.count == 1
|
|
assert len(own_state.energy_zone.cards) == 1
|
|
|
|
def test_player_sees_own_score(self, full_game: GameState):
|
|
"""
|
|
Test that a player can see their own score.
|
|
|
|
Scores are always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
own_state = visible.players["player1"]
|
|
|
|
assert own_state.score == 2
|
|
|
|
def test_is_current_player_flag(self, full_game: GameState):
|
|
"""
|
|
Test that is_current_player is set correctly for self.
|
|
|
|
This flag helps the UI know which player state is "mine".
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert visible.players["player1"].is_current_player is True
|
|
assert visible.players["player2"].is_current_player is False
|
|
|
|
|
|
# =============================================================================
|
|
# Opponent Information Hiding Tests (SECURITY CRITICAL)
|
|
# =============================================================================
|
|
|
|
|
|
class TestOpponentInformationHiding:
|
|
"""SECURITY CRITICAL: Tests verifying opponent information is hidden."""
|
|
|
|
def test_opponent_hand_contents_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's hand contents must NEVER be visible.
|
|
|
|
This is the most critical security test. Leaking hand contents
|
|
would allow cheating.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
# Count should be visible
|
|
assert opponent_state.hand.count == 4
|
|
|
|
# Contents must be empty
|
|
assert len(opponent_state.hand.cards) == 0
|
|
|
|
def test_opponent_hand_card_ids_not_leaked(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's hand card IDs must not be exposed anywhere.
|
|
|
|
Verify no hand card IDs appear in the visible state.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
# Serialize to check for any leakage
|
|
json_str = visible.model_dump_json()
|
|
|
|
# These are player 2's hand cards - they should not appear
|
|
assert "p2-hand-0" not in json_str
|
|
assert "p2-hand-1" not in json_str
|
|
assert "p2-hand-2" not in json_str
|
|
assert "p2-hand-3" not in json_str
|
|
|
|
def test_opponent_deck_order_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's deck order must not be visible.
|
|
|
|
Only the count is allowed.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
# Only count, no card data
|
|
assert opponent_state.deck_count == 4
|
|
|
|
def test_opponent_deck_cards_not_leaked(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's deck card IDs must not appear anywhere.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
json_str = visible.model_dump_json()
|
|
|
|
# Player 2's deck cards should not appear
|
|
for i in range(4):
|
|
assert f"p2-deck-{i}" not in json_str
|
|
|
|
def test_opponent_prizes_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's prize card contents must be hidden.
|
|
|
|
Only count is visible.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.prizes_count == 3
|
|
|
|
def test_opponent_prize_cards_not_leaked(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's prize card IDs must not appear anywhere.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
json_str = visible.model_dump_json()
|
|
|
|
for i in range(3):
|
|
assert f"p2-prize-{i}" not in json_str
|
|
|
|
def test_opponent_energy_deck_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Opponent's energy deck order must be hidden.
|
|
|
|
Only count visible.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.energy_deck_count == 2
|
|
|
|
def test_own_deck_contents_also_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Even own deck contents are hidden (integrity).
|
|
|
|
This prevents any deck manipulation or tracking beyond what
|
|
cards have been seen.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
json_str = visible.model_dump_json()
|
|
|
|
# Own deck cards should also not appear
|
|
for i in range(5):
|
|
assert f"p1-deck-{i}" not in json_str
|
|
|
|
def test_own_prize_contents_hidden(self, full_game: GameState):
|
|
"""
|
|
SECURITY: Own prize card contents are hidden until taken.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
json_str = visible.model_dump_json()
|
|
|
|
for i in range(3):
|
|
assert f"p1-prize-{i}" not in json_str
|
|
|
|
|
|
# =============================================================================
|
|
# Public Information Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestPublicInformation:
|
|
"""Tests verifying public information is correctly visible."""
|
|
|
|
def test_opponent_active_visible(self, full_game: GameState):
|
|
"""
|
|
Test that opponent's active Pokemon is fully visible.
|
|
|
|
Active Pokemon are on the battlefield - always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.active.count == 1
|
|
assert len(opponent_state.active.cards) == 1
|
|
assert opponent_state.active.cards[0].instance_id == "p2-active"
|
|
|
|
def test_opponent_bench_visible(self, full_game: GameState):
|
|
"""
|
|
Test that opponent's benched Pokemon are fully visible.
|
|
|
|
Bench is part of the battlefield - always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.bench.count == 1
|
|
assert len(opponent_state.bench.cards) == 1
|
|
assert opponent_state.bench.cards[0].instance_id == "p2-bench-0"
|
|
|
|
def test_opponent_discard_visible(self, full_game: GameState):
|
|
"""
|
|
Test that opponent's discard pile is fully visible.
|
|
|
|
Discard piles are always public knowledge.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.discard.count == 1
|
|
assert len(opponent_state.discard.cards) == 1
|
|
assert opponent_state.discard.cards[0].instance_id == "p2-discard-0"
|
|
|
|
def test_opponent_energy_zone_visible(self, full_game: GameState):
|
|
"""
|
|
Test that opponent's energy zone is visible.
|
|
|
|
Energy zone shows available energy - public information.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.energy_zone.count == 2
|
|
assert len(opponent_state.energy_zone.cards) == 2
|
|
|
|
def test_opponent_score_visible(self, full_game: GameState):
|
|
"""
|
|
Test that opponent's score is visible.
|
|
|
|
Scores are always public.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
opponent_state = visible.players["player2"]
|
|
|
|
assert opponent_state.score == 1
|
|
|
|
def test_stadium_visible(self, full_game: GameState, pokemon_def: CardDefinition):
|
|
"""
|
|
Test that stadium in play is visible to all.
|
|
"""
|
|
stadium = CardInstance(instance_id="stadium-1", definition_id=pokemon_def.id)
|
|
full_game.stadium_in_play = stadium
|
|
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert visible.stadium_in_play is not None
|
|
assert visible.stadium_in_play.instance_id == "stadium-1"
|
|
|
|
def test_card_registry_included(self, full_game: GameState):
|
|
"""
|
|
Test that card registry is included for display purposes.
|
|
|
|
Clients need card definitions to render cards properly.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert len(visible.card_registry) == 3
|
|
assert "pikachu-001" in visible.card_registry
|
|
assert "lightning-energy-001" in visible.card_registry
|
|
assert "potion-001" in visible.card_registry
|
|
|
|
|
|
# =============================================================================
|
|
# Game State Information Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestGameStateInformation:
|
|
"""Tests for game-level visible information."""
|
|
|
|
def test_game_id_visible(self, full_game: GameState):
|
|
"""
|
|
Test that game ID is included.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
assert visible.game_id == "test-game"
|
|
|
|
def test_viewer_id_set(self, full_game: GameState):
|
|
"""
|
|
Test that viewer_id identifies who the view is for.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
assert visible.viewer_id == "player1"
|
|
|
|
def test_current_player_visible(self, full_game: GameState):
|
|
"""
|
|
Test that current player is visible.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
assert visible.current_player_id == "player1"
|
|
|
|
def test_turn_number_visible(self, full_game: GameState):
|
|
"""
|
|
Test that turn number is visible.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
assert visible.turn_number == 3
|
|
|
|
def test_phase_visible(self, full_game: GameState):
|
|
"""
|
|
Test that current phase is visible.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
assert visible.phase == TurnPhase.MAIN
|
|
|
|
def test_is_my_turn_flag(self, full_game: GameState):
|
|
"""
|
|
Test that is_my_turn is correctly set.
|
|
"""
|
|
visible_p1 = get_visible_state(full_game, "player1")
|
|
visible_p2 = get_visible_state(full_game, "player2")
|
|
|
|
assert visible_p1.is_my_turn is True
|
|
assert visible_p2.is_my_turn is False
|
|
|
|
def test_winner_visible_when_game_over(self, full_game: GameState):
|
|
"""
|
|
Test that winner is visible when game ends.
|
|
"""
|
|
full_game.winner_id = "player1"
|
|
full_game.end_reason = GameEndReason.PRIZES_TAKEN
|
|
|
|
visible = get_visible_state(full_game, "player2")
|
|
|
|
assert visible.winner_id == "player1"
|
|
assert visible.end_reason == GameEndReason.PRIZES_TAKEN
|
|
|
|
def test_forced_action_visible(self, full_game: GameState):
|
|
"""
|
|
Test that forced action information is visible.
|
|
|
|
Both players need to know when a forced action is pending.
|
|
"""
|
|
full_game.add_forced_action(
|
|
ForcedAction(
|
|
player_id="player1",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out.",
|
|
)
|
|
)
|
|
|
|
visible = get_visible_state(full_game, "player2")
|
|
|
|
assert visible.forced_action_player == "player1"
|
|
assert visible.forced_action_type == "select_active"
|
|
assert visible.forced_action_reason == "Your active Pokemon was knocked out."
|
|
|
|
|
|
# =============================================================================
|
|
# Spectator Mode Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestSpectatorMode:
|
|
"""Tests for spectator view (no hands visible)."""
|
|
|
|
def test_spectator_sees_no_hands(self, full_game: GameState):
|
|
"""
|
|
Test that spectators cannot see any player's hand.
|
|
"""
|
|
visible = get_spectator_state(full_game)
|
|
|
|
# Both hands should show count only
|
|
assert visible.players["player1"].hand.count == 3
|
|
assert len(visible.players["player1"].hand.cards) == 0
|
|
|
|
assert visible.players["player2"].hand.count == 4
|
|
assert len(visible.players["player2"].hand.cards) == 0
|
|
|
|
def test_spectator_sees_battlefield(self, full_game: GameState):
|
|
"""
|
|
Test that spectators can see all battlefield information.
|
|
"""
|
|
visible = get_spectator_state(full_game)
|
|
|
|
# Active Pokemon visible
|
|
assert len(visible.players["player1"].active.cards) == 1
|
|
assert len(visible.players["player2"].active.cards) == 1
|
|
|
|
# Bench visible
|
|
assert len(visible.players["player1"].bench.cards) == 2
|
|
assert len(visible.players["player2"].bench.cards) == 1
|
|
|
|
# Discard visible
|
|
assert len(visible.players["player1"].discard.cards) == 2
|
|
assert len(visible.players["player2"].discard.cards) == 1
|
|
|
|
def test_spectator_is_not_current_player(self, full_game: GameState):
|
|
"""
|
|
Test that spectator is never marked as current player.
|
|
"""
|
|
visible = get_spectator_state(full_game)
|
|
|
|
assert visible.is_my_turn is False
|
|
assert visible.players["player1"].is_current_player is False
|
|
assert visible.players["player2"].is_current_player is False
|
|
|
|
def test_spectator_viewer_id(self, full_game: GameState):
|
|
"""
|
|
Test that spectator has special viewer ID.
|
|
"""
|
|
visible = get_spectator_state(full_game)
|
|
assert visible.viewer_id == "__spectator__"
|
|
|
|
|
|
# =============================================================================
|
|
# Edge Cases and Error Handling
|
|
# =============================================================================
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for edge cases and error handling."""
|
|
|
|
def test_invalid_viewer_raises_error(self, full_game: GameState):
|
|
"""
|
|
Test that requesting view for non-existent player raises error.
|
|
"""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
get_visible_state(full_game, "nonexistent")
|
|
|
|
assert "nonexistent" in str(exc_info.value)
|
|
assert "not a player" in str(exc_info.value)
|
|
|
|
def test_empty_zones_handled(self):
|
|
"""
|
|
Test that empty zones are handled correctly.
|
|
"""
|
|
game = GameState(
|
|
game_id="empty-game",
|
|
players={
|
|
"player1": PlayerState(player_id="player1"),
|
|
"player2": PlayerState(player_id="player2"),
|
|
},
|
|
current_player_id="player1",
|
|
turn_order=["player1", "player2"],
|
|
)
|
|
|
|
visible = get_visible_state(game, "player1")
|
|
|
|
assert visible.players["player1"].hand.count == 0
|
|
assert visible.players["player1"].deck_count == 0
|
|
assert visible.players["player1"].active.count == 0
|
|
|
|
def test_no_stadium_in_play(self, full_game: GameState):
|
|
"""
|
|
Test that missing stadium is handled correctly.
|
|
"""
|
|
full_game.stadium_in_play = None
|
|
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert visible.stadium_in_play is None
|
|
|
|
def test_no_forced_action(self, full_game: GameState):
|
|
"""
|
|
Test that missing forced action is handled correctly.
|
|
"""
|
|
full_game.clear_forced_actions()
|
|
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert visible.forced_action_player is None
|
|
assert visible.forced_action_type is None
|
|
assert visible.forced_action_reason is None
|
|
|
|
def test_gx_vstar_flags_visible(self, full_game: GameState):
|
|
"""
|
|
Test that GX/VSTAR usage flags are visible.
|
|
|
|
These are public - opponents need to know if you've used
|
|
your once-per-game abilities.
|
|
"""
|
|
full_game.players["player1"].gx_attack_used = True
|
|
full_game.players["player2"].vstar_power_used = True
|
|
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
assert visible.players["player1"].gx_attack_used is True
|
|
assert visible.players["player2"].vstar_power_used is True
|
|
|
|
|
|
# =============================================================================
|
|
# Serialization Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestSerialization:
|
|
"""Tests verifying visible state serializes correctly."""
|
|
|
|
def test_visible_state_serializes_to_json(self, full_game: GameState):
|
|
"""
|
|
Test that VisibleGameState can be serialized to JSON.
|
|
|
|
This is essential for sending to clients over WebSocket.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
|
|
# Should not raise
|
|
json_str = visible.model_dump_json()
|
|
|
|
assert isinstance(json_str, str)
|
|
assert len(json_str) > 0
|
|
|
|
def test_visible_state_roundtrips(self, full_game: GameState):
|
|
"""
|
|
Test that VisibleGameState can be deserialized from JSON.
|
|
"""
|
|
visible = get_visible_state(full_game, "player1")
|
|
json_str = visible.model_dump_json()
|
|
|
|
# Should not raise
|
|
restored = VisibleGameState.model_validate_json(json_str)
|
|
|
|
assert restored.game_id == visible.game_id
|
|
assert restored.viewer_id == visible.viewer_id
|
|
assert restored.turn_number == visible.turn_number
|