- Rename data/cards/ to data/raw/ for scraped data - Add data/definitions/ as authoritative card data source - Add convert_cards.py script to transform raw -> definitions - Generate 378 card definitions (344 Pokemon, 24 Trainers, 10 Energy) - Add CardService for loading and querying card definitions - In-memory indexes for fast lookups by type, set, pokemon_type - search() with multiple filter criteria - get_all_cards() for GameEngine integration - Add SetInfo model for set metadata - Update Attack model with damage_display field for variable damage - Update CardDefinition with image_path, illustrator, flavor_text - Add 45 tests (21 converter + 24 CardService) - Update scraper output path to data/raw/ Card data is JSON-authoritative (no database) to support offline fork goal.
540 lines
17 KiB
Python
540 lines
17 KiB
Python
"""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
|