mantimon-tcg/backend/tests/services/test_card_service.py
Cal Corum 934aa4c443 Add CardService and card data conversion pipeline
- 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.
2026-01-27 14:16:40 -06:00

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