"""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.models.card import CardDefinition, CardInstance from app.core.models.enums import ( CardType, EnergyType, GameEndReason, PokemonStage, PokemonVariant, TurnPhase, ) 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, ) @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, ) @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.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.forced_action = None 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