mantimon-tcg/backend/tests/core/test_visibility.py
Cal Corum e7431e2d1f Move enums to app/core/enums.py and set up clean module exports
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.
2026-01-26 14:45:26 -06:00

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