"""Tests for the CardService. These tests verify that the CardService correctly loads card definitions from JSON files and provides efficient lookup and search operations. """ import json import tempfile from pathlib import Path import pytest from app.core.enums import CardType, EnergyType, PokemonStage, PokemonVariant from app.services.card_service import CardService, SetInfo @pytest.fixture def sample_pokemon_card() -> dict: """Sample Pokemon card definition for testing.""" return { "id": "test-001-pikachu", "name": "Pikachu", "card_type": "pokemon", "hp": 60, "pokemon_type": "lightning", "stage": "basic", "variant": "normal", "set_id": "test", "rarity": "common", "retreat_cost": 1, "attacks": [ { "name": "Thunder Shock", "cost": ["lightning"], "damage": 20, "damage_display": "20", } ], "weakness": {"energy_type": "fighting", "value": 20}, } @pytest.fixture def sample_ex_pokemon_card() -> dict: """Sample EX Pokemon card definition for testing.""" return { "id": "test-002-pikachu-ex", "name": "Pikachu ex", "card_type": "pokemon", "hp": 120, "pokemon_type": "lightning", "stage": "basic", "variant": "ex", "set_id": "test", "rarity": "double rare", "retreat_cost": 1, } @pytest.fixture def sample_trainer_card() -> dict: """Sample Trainer card definition for testing.""" return { "id": "test-101-potion", "name": "Potion", "card_type": "trainer", "trainer_type": "item", "set_id": "test", "rarity": "common", "effect_description": "Heal 30 damage from 1 of your Pokemon.", } @pytest.fixture def sample_energy_card() -> dict: """Sample Energy card definition for testing.""" return { "id": "energy-basic-lightning", "name": "Lightning Energy", "card_type": "energy", "energy_type": "lightning", "energy_provides": ["lightning"], "set_id": "basic", "rarity": "common", } @pytest.fixture def temp_definitions_dir( sample_pokemon_card, sample_ex_pokemon_card, sample_trainer_card, sample_energy_card, ) -> Path: """Create a temporary definitions directory with sample cards. This fixture creates a complete definitions directory structure with sample cards for testing CardService loading functionality. """ with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) # Create directory structure (root / "pokemon" / "test").mkdir(parents=True) (root / "trainer" / "test").mkdir(parents=True) (root / "energy" / "basic").mkdir(parents=True) # Write Pokemon cards with open(root / "pokemon" / "test" / "001-pikachu.json", "w") as f: json.dump(sample_pokemon_card, f) with open(root / "pokemon" / "test" / "002-pikachu-ex.json", "w") as f: json.dump(sample_ex_pokemon_card, f) # Write Trainer card with open(root / "trainer" / "test" / "101-potion.json", "w") as f: json.dump(sample_trainer_card, f) # Write Energy card with open(root / "energy" / "basic" / "lightning.json", "w") as f: json.dump(sample_energy_card, f) # Write index file index = { "generated_at": "2026-01-27T00:00:00Z", "schema_version": "1.0", "total_cards": 4, "sets": { "test": {"name": "Test Set", "card_count": 3}, "basic": {"name": "Basic Energy", "card_count": 1}, }, "cards": [], } with open(root / "_index.json", "w") as f: json.dump(index, f) yield root class TestCardServiceLoading: """Tests for CardService loading functionality.""" @pytest.mark.asyncio async def test_load_all_loads_cards(self, temp_definitions_dir): """Test that load_all loads all cards from the definitions directory. Verifies that Pokemon, Trainer, and Energy cards are all loaded from their respective subdirectories. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() assert service.card_count == 4 assert service.is_loaded is True @pytest.mark.asyncio async def test_load_all_creates_indexes(self, temp_definitions_dir): """Test that load_all creates all required indexes. The service should maintain indexes by type, set, and Pokemon type for efficient querying. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() # Check type index pokemon = service.search(card_type=CardType.POKEMON) assert len(pokemon) == 2 trainers = service.search(card_type=CardType.TRAINER) assert len(trainers) == 1 energy = service.search(card_type=CardType.ENERGY) assert len(energy) == 1 @pytest.mark.asyncio async def test_load_all_idempotent(self, temp_definitions_dir): """Test that calling load_all multiple times is safe. The service should only load cards once and warn on subsequent calls. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() initial_count = service.card_count await service.load_all() # Should be no-op assert service.card_count == initial_count @pytest.mark.asyncio async def test_load_all_missing_directory_raises(self): """Test that load_all raises FileNotFoundError for missing directory. If the definitions directory doesn't exist, the service should fail with a clear error message. """ service = CardService(definitions_dir=Path("/nonexistent/path")) with pytest.raises(FileNotFoundError): await service.load_all() @pytest.mark.asyncio async def test_load_all_loads_set_metadata(self, temp_definitions_dir): """Test that set metadata is loaded from the index file. The service should parse _index.json to get set names and counts. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() sets = service.get_sets() assert len(sets) == 2 test_set = next((s for s in sets if s.code == "test"), None) assert test_set is not None assert test_set.name == "Test Set" class TestCardServiceGetCard: """Tests for CardService.get_card().""" @pytest.mark.asyncio async def test_get_card_found(self, temp_definitions_dir): """Test getting a card that exists. Should return the CardDefinition for the given ID. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() card = service.get_card("test-001-pikachu") assert card is not None assert card.name == "Pikachu" assert card.hp == 60 @pytest.mark.asyncio async def test_get_card_not_found(self, temp_definitions_dir): """Test getting a card that doesn't exist. Should return None rather than raising an exception. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() card = service.get_card("nonexistent-card") assert card is None class TestCardServiceGetAllCards: """Tests for CardService.get_all_cards().""" @pytest.mark.asyncio async def test_get_all_cards_returns_registry(self, temp_definitions_dir): """Test that get_all_cards returns a complete card registry. The returned dict should be suitable for passing to GameEngine.create_game(). """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() registry = service.get_all_cards() assert len(registry) == 4 assert "test-001-pikachu" in registry assert "energy-basic-lightning" in registry @pytest.mark.asyncio async def test_get_all_cards_returns_copy(self, temp_definitions_dir): """Test that get_all_cards returns a copy, not the internal dict. Modifying the returned dict should not affect the service's state. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() registry = service.get_all_cards() registry["new-card"] = None # Try to modify # Service should be unaffected assert service.get_card("new-card") is None assert service.card_count == 4 class TestCardServiceGetCardsByIds: """Tests for CardService.get_cards_by_ids().""" @pytest.mark.asyncio async def test_get_cards_by_ids_all_found(self, temp_definitions_dir): """Test getting multiple cards by ID when all exist. Should return cards in the same order as the input IDs. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() ids = ["test-001-pikachu", "energy-basic-lightning"] cards = service.get_cards_by_ids(ids) assert len(cards) == 2 assert cards[0].id == "test-001-pikachu" assert cards[1].id == "energy-basic-lightning" @pytest.mark.asyncio async def test_get_cards_by_ids_missing_raises(self, temp_definitions_dir): """Test that missing card IDs raise KeyError. If any requested ID doesn't exist, the method should fail rather than return partial results. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() with pytest.raises(KeyError, match="nonexistent"): service.get_cards_by_ids(["test-001-pikachu", "nonexistent"]) class TestCardServiceSearch: """Tests for CardService.search().""" @pytest.mark.asyncio async def test_search_by_name(self, temp_definitions_dir): """Test searching cards by name substring. Should match cards whose name contains the search string (case-insensitive). """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() results = service.search(name="pika") assert len(results) == 2 # Pikachu and Pikachu ex @pytest.mark.asyncio async def test_search_by_card_type(self, temp_definitions_dir): """Test searching cards by type (Pokemon, Trainer, Energy). Uses the type index for efficient lookup. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() pokemon = service.search(card_type=CardType.POKEMON) trainers = service.search(card_type=CardType.TRAINER) energy = service.search(card_type=CardType.ENERGY) assert len(pokemon) == 2 assert len(trainers) == 1 assert len(energy) == 1 @pytest.mark.asyncio async def test_search_by_pokemon_type(self, temp_definitions_dir): """Test searching Pokemon by their energy type. Uses the pokemon_type index for efficient lookup. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() lightning_pokemon = service.search(pokemon_type=EnergyType.LIGHTNING) fire_pokemon = service.search(pokemon_type=EnergyType.FIRE) assert len(lightning_pokemon) == 2 assert len(fire_pokemon) == 0 @pytest.mark.asyncio async def test_search_by_set(self, temp_definitions_dir): """Test searching cards by set ID. Uses the set index for efficient lookup. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() test_cards = service.search(set_id="test") basic_cards = service.search(set_id="basic") assert len(test_cards) == 3 # 2 Pokemon + 1 Trainer assert len(basic_cards) == 1 # 1 Energy @pytest.mark.asyncio async def test_search_by_stage(self, temp_definitions_dir): """Test searching Pokemon by evolution stage.""" service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() basics = service.search(stage=PokemonStage.BASIC) assert len(basics) == 2 # Both Pikachus are basic @pytest.mark.asyncio async def test_search_by_variant(self, temp_definitions_dir): """Test searching Pokemon by variant (normal, ex, etc.). Note: variant defaults to 'normal' for non-Pokemon cards too, so we combine with card_type filter for accurate Pokemon counts. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() normal_pokemon = service.search( card_type=CardType.POKEMON, variant=PokemonVariant.NORMAL, ) ex_pokemon = service.search( card_type=CardType.POKEMON, variant=PokemonVariant.EX, ) assert len(normal_pokemon) == 1 # Regular Pikachu assert len(ex_pokemon) == 1 # Pikachu ex @pytest.mark.asyncio async def test_search_multiple_criteria(self, temp_definitions_dir): """Test searching with multiple criteria (AND logic). All criteria must match for a card to be included in results. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() results = service.search( card_type=CardType.POKEMON, pokemon_type=EnergyType.LIGHTNING, variant=PokemonVariant.EX, ) assert len(results) == 1 assert results[0].name == "Pikachu ex" @pytest.mark.asyncio async def test_search_no_results(self, temp_definitions_dir): """Test that search returns empty list when no cards match.""" service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() results = service.search(pokemon_type=EnergyType.FIRE) assert results == [] class TestCardServiceGetSetCards: """Tests for CardService.get_set_cards().""" @pytest.mark.asyncio async def test_get_set_cards(self, temp_definitions_dir): """Test getting all cards from a specific set. Returns all cards where set_id matches, regardless of card type. """ service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() cards = service.get_set_cards("test") assert len(cards) == 3 card_types = {c.card_type for c in cards} assert CardType.POKEMON in card_types assert CardType.TRAINER in card_types @pytest.mark.asyncio async def test_get_set_cards_nonexistent_set(self, temp_definitions_dir): """Test that nonexistent set returns empty list.""" service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() cards = service.get_set_cards("nonexistent") assert cards == [] class TestCardServiceGetSets: """Tests for CardService.get_sets().""" @pytest.mark.asyncio async def test_get_sets_returns_set_info(self, temp_definitions_dir): """Test that get_sets returns SetInfo objects with correct data.""" service = CardService(definitions_dir=temp_definitions_dir) await service.load_all() sets = service.get_sets() assert len(sets) == 2 assert all(isinstance(s, SetInfo) for s in sets) test_set = next((s for s in sets if s.code == "test"), None) assert test_set.name == "Test Set" assert test_set.card_count == 3 class TestCardServiceWithRealData: """Tests that run against the actual definitions directory. These tests verify that the CardService works with the real card definitions generated by the converter script. """ @pytest.mark.asyncio async def test_load_real_definitions(self): """Test loading the actual card definitions. This test uses the real data/definitions/ directory to verify that all cards load correctly in a realistic scenario. """ service = CardService() # Uses default path # Only run if definitions exist if not service._definitions_dir.exists(): pytest.skip("Definitions directory not found") await service.load_all() # Should have loaded cards assert service.card_count > 0 # Should have all card types pokemon = service.search(card_type=CardType.POKEMON) energy = service.search(card_type=CardType.ENERGY) assert len(pokemon) > 0 assert len(energy) == 10 # Basic energy @pytest.mark.asyncio async def test_real_card_lookup(self): """Test looking up a known card from real data.""" service = CardService() if not service._definitions_dir.exists(): pytest.skip("Definitions directory not found") await service.load_all() # Try to find Bulbasaur (should exist in a1) bulbasaur = service.get_card("a1-001-bulbasaur") if bulbasaur: assert bulbasaur.name == "Bulbasaur" assert bulbasaur.card_type == CardType.POKEMON assert bulbasaur.pokemon_type == EnergyType.GRASS