mantimon-tcg/backend/tests/scripts/test_convert_cards.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

487 lines
15 KiB
Python

"""Tests for the card conversion script.
These tests verify the transformation logic that converts raw scraped card data
into the CardDefinition format used by the game engine.
"""
import sys
from pathlib import Path
import pytest
# Add scripts directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
from convert_cards import (
generate_energy_cards,
transform_attack,
transform_card,
transform_pokemon_card,
transform_trainer_card,
transform_weakness_resistance,
validate_card,
)
class TestTransformAttack:
"""Tests for the transform_attack function."""
def test_basic_attack_with_damage(self):
"""Test transforming a simple attack with base damage only.
Verifies that an attack with just damage and no modifier
produces a damage_display of just the number.
"""
raw_attack = {
"name": "Tackle",
"cost": ["colorless"],
"damage": 20,
"damage_modifier": None,
"effect_text": None,
"effect_id": None,
}
result = transform_attack(raw_attack)
assert result["name"] == "Tackle"
assert result["cost"] == ["colorless"]
assert result["damage"] == 20
assert result["damage_display"] == "20"
assert "effect_description" not in result
assert "effect_params" not in result
def test_attack_with_plus_modifier(self):
"""Test transforming an attack with '+' damage modifier.
Attacks like '50+' deal base damage plus additional damage
based on some condition (e.g., coin flips).
"""
raw_attack = {
"name": "Double Horn",
"cost": ["grass", "grass"],
"damage": 50,
"damage_modifier": "+",
"effect_text": "Flip 2 coins. This attack does 50 damage for each heads.",
"effect_id": None,
}
result = transform_attack(raw_attack)
assert result["damage"] == 50
assert result["damage_display"] == "50+"
assert result["effect_params"]["damage_modifier"] == "+"
assert "Flip 2 coins" in result["effect_description"]
def test_attack_with_x_modifier(self):
"""Test transforming an attack with 'x' damage modifier.
Attacks like '50x' deal damage multiplied by some factor
(e.g., number of coin flip heads).
"""
raw_attack = {
"name": "Psychic",
"cost": ["psychic", "colorless"],
"damage": 20,
"damage_modifier": "x",
"effect_text": "Does 20 damage times the number of Energy attached to your opponent's Active Pokemon.",
"effect_id": None,
}
result = transform_attack(raw_attack)
assert result["damage"] == 20
assert result["damage_display"] == "20x"
assert result["effect_params"]["damage_modifier"] == "x"
def test_attack_no_damage_effect_only(self):
"""Test transforming an attack that deals no damage.
Some attacks only have effects (like status conditions)
and don't deal direct damage.
"""
raw_attack = {
"name": "Confuse Ray",
"cost": ["psychic"],
"damage": 0,
"damage_modifier": None,
"effect_text": "The Defending Pokemon is now Confused.",
"effect_id": None,
}
result = transform_attack(raw_attack)
assert result["damage"] == 0
assert "damage_display" not in result # No display for 0 damage
assert result["effect_description"] == "The Defending Pokemon is now Confused."
def test_attack_with_effect_id(self):
"""Test that existing effect_id is preserved.
Some scraped cards may already have effect IDs mapped.
"""
raw_attack = {
"name": "Thunder Shock",
"cost": ["lightning"],
"damage": 20,
"damage_modifier": None,
"effect_text": "Flip a coin. If heads, the Defending Pokemon is now Paralyzed.",
"effect_id": "may_paralyze",
}
result = transform_attack(raw_attack)
assert result["effect_id"] == "may_paralyze"
class TestTransformWeaknessResistance:
"""Tests for the transform_weakness_resistance function."""
def test_transform_weakness(self):
"""Test transforming a typical weakness.
Weaknesses have a type and a value (damage modifier).
"""
raw_weakness = {"type": "fire", "value": 20}
result = transform_weakness_resistance(raw_weakness)
assert result["energy_type"] == "fire"
assert result["value"] == 20
def test_transform_resistance(self):
"""Test transforming a typical resistance.
Resistances reduce damage taken from a specific type.
"""
raw_resistance = {"type": "fighting", "value": -30}
result = transform_weakness_resistance(raw_resistance)
assert result["energy_type"] == "fighting"
assert result["value"] == -30
def test_transform_none(self):
"""Test that None input returns None.
Not all Pokemon have weakness or resistance.
"""
result = transform_weakness_resistance(None)
assert result is None
class TestTransformPokemonCard:
"""Tests for transforming Pokemon cards."""
def test_basic_pokemon(self):
"""Test transforming a basic Pokemon card.
Verifies all fields are correctly mapped from raw to definition format.
"""
raw_card = {
"id": "a1-001-bulbasaur",
"name": "Bulbasaur",
"set_code": "a1",
"set_name": "Genetic Apex",
"card_number": 1,
"rarity": "Common",
"card_type": "pokemon",
"image_url": "https://example.com/bulbasaur.webp",
"image_file": "a1/001-bulbasaur.webp",
"hp": 70,
"pokemon_type": "grass",
"stage": "basic",
"evolves_from": None,
"is_ex": False,
"abilities": [],
"attacks": [
{
"name": "Vine Whip",
"cost": ["grass", "colorless"],
"damage": 40,
"damage_modifier": None,
"effect_text": None,
"effect_id": None,
}
],
"weakness": {"type": "fire", "value": 20},
"resistance": None,
"retreat_cost": 1,
"flavor_text": None,
"illustrator": "Narumi Sato",
}
result = transform_pokemon_card(raw_card)
assert result["id"] == "a1-001-bulbasaur"
assert result["name"] == "Bulbasaur"
assert result["card_type"] == "pokemon"
assert result["hp"] == 70
assert result["pokemon_type"] == "grass"
assert result["stage"] == "basic"
assert result["variant"] == "normal"
assert result["set_id"] == "a1"
assert result["rarity"] == "common"
assert result["retreat_cost"] == 1
assert result["weakness"]["energy_type"] == "fire"
assert len(result["attacks"]) == 1
assert result["illustrator"] == "Narumi Sato"
assert result["image_path"] == "pokemon/a1/001-bulbasaur.webp"
assert "cdn.mantimon.com" in result["image_url"]
def test_ex_pokemon(self):
"""Test transforming an EX Pokemon card.
EX Pokemon have is_ex=True in raw data, which maps to variant='ex'.
"""
raw_card = {
"id": "a1-004-venusaur-ex",
"name": "Venusaur ex",
"set_code": "a1",
"card_type": "pokemon",
"image_file": "a1/004-venusaur-ex.webp",
"hp": 190,
"pokemon_type": "grass",
"stage": "stage_2",
"evolves_from": "Ivysaur",
"is_ex": True,
"abilities": [],
"attacks": [],
"weakness": {"type": "fire", "value": 20},
"resistance": None,
"retreat_cost": 3,
"rarity": "Double Rare",
"illustrator": "PLANETA CG Works",
}
result = transform_pokemon_card(raw_card)
assert result["variant"] == "ex"
assert result["evolves_from"] == "Ivysaur"
assert result["rarity"] == "double rare"
def test_evolution_pokemon(self):
"""Test transforming an evolution Pokemon.
Stage 1 and Stage 2 Pokemon must have evolves_from set.
"""
raw_card = {
"id": "a1-002-ivysaur",
"name": "Ivysaur",
"set_code": "a1",
"card_type": "pokemon",
"image_file": None,
"hp": 90,
"pokemon_type": "grass",
"stage": "stage_1",
"evolves_from": "Bulbasaur",
"is_ex": False,
"abilities": [],
"attacks": [],
"weakness": {"type": "fire", "value": 20},
"resistance": None,
"retreat_cost": 2,
"rarity": "Uncommon",
}
result = transform_pokemon_card(raw_card)
assert result["stage"] == "stage_1"
assert result["evolves_from"] == "Bulbasaur"
assert "image_path" not in result # No image file
class TestTransformTrainerCard:
"""Tests for transforming Trainer cards."""
def test_supporter_card(self):
"""Test transforming a Supporter trainer card.
Supporters are trainer cards limited to one per turn.
"""
raw_card = {
"id": "a1-220-misty",
"name": "Misty",
"set_code": "a1",
"card_type": "trainer",
"trainer_type": "supporter",
"rarity": "Uncommon",
"effect_text": "Choose 1 of your Pokemon...",
"illustrator": "Sanosuke Sakuma",
"image_file": "a1/220-misty.webp",
}
result = transform_trainer_card(raw_card)
assert result["id"] == "a1-220-misty"
assert result["name"] == "Misty"
assert result["card_type"] == "trainer"
assert result["trainer_type"] == "supporter"
assert result["set_id"] == "a1"
assert result["effect_description"] == "Choose 1 of your Pokemon..."
assert result["image_path"] == "trainer/a1/220-misty.webp"
class TestTransformCard:
"""Tests for the main transform_card dispatcher."""
def test_dispatches_to_pokemon(self):
"""Test that Pokemon cards are routed to transform_pokemon_card."""
raw_card = {
"id": "test-pokemon",
"name": "Test Pokemon",
"set_code": "test",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "fire",
"stage": "basic",
"is_ex": False,
"attacks": [],
"abilities": [],
"weakness": None,
"resistance": None,
"retreat_cost": 1,
"rarity": "Common",
}
result = transform_card(raw_card)
assert result["card_type"] == "pokemon"
def test_dispatches_to_trainer(self):
"""Test that Trainer cards are routed to transform_trainer_card."""
raw_card = {
"id": "test-trainer",
"name": "Test Trainer",
"set_code": "test",
"card_type": "trainer",
"trainer_type": "item",
"rarity": "Common",
}
result = transform_card(raw_card)
assert result["card_type"] == "trainer"
def test_unsupported_card_type_raises(self):
"""Test that unsupported card types raise ValueError."""
raw_card = {
"id": "test-unknown",
"card_type": "unknown",
}
with pytest.raises(ValueError, match="Unsupported card type"):
transform_card(raw_card)
class TestValidateCard:
"""Tests for card validation against the CardDefinition model."""
def test_valid_pokemon_card(self):
"""Test that a valid Pokemon card passes validation."""
card_dict = {
"id": "test-pokemon",
"name": "Test Pokemon",
"card_type": "pokemon",
"hp": 60,
"pokemon_type": "fire",
"stage": "basic",
"variant": "normal",
"set_id": "test",
"rarity": "common",
"retreat_cost": 1,
}
result = validate_card(card_dict)
assert result.id == "test-pokemon"
assert result.hp == 60
def test_valid_energy_card(self):
"""Test that a valid Energy card passes validation."""
card_dict = {
"id": "energy-basic-fire",
"name": "Fire Energy",
"card_type": "energy",
"energy_type": "fire",
"energy_provides": ["fire"],
"set_id": "basic",
"rarity": "common",
}
result = validate_card(card_dict)
assert result.id == "energy-basic-fire"
assert result.energy_type.value == "fire"
def test_invalid_pokemon_missing_hp(self):
"""Test that Pokemon without HP fails validation."""
card_dict = {
"id": "invalid-pokemon",
"name": "Invalid Pokemon",
"card_type": "pokemon",
"hp": None,
"pokemon_type": "fire",
"stage": "basic",
}
with pytest.raises(ValueError):
validate_card(card_dict)
class TestGenerateEnergyCards:
"""Tests for basic energy card generation."""
def test_generates_all_energy_types(self):
"""Test that all 10 basic energy types are generated.
The game uses 10 energy types: colorless, darkness, dragon,
fighting, fire, grass, lightning, metal, psychic, water.
"""
cards = generate_energy_cards()
assert len(cards) == 10
# Check all types are present
energy_types = {card["energy_type"] for card in cards}
expected_types = {
"colorless",
"darkness",
"dragon",
"fighting",
"fire",
"grass",
"lightning",
"metal",
"psychic",
"water",
}
assert energy_types == expected_types
def test_energy_card_structure(self):
"""Test that generated energy cards have correct structure.
Each energy card should have id, name, card_type, energy_type,
energy_provides, rarity, set_id, and image paths.
"""
cards = generate_energy_cards()
grass_energy = next(c for c in cards if c["energy_type"] == "grass")
assert grass_energy["id"] == "energy-basic-grass"
assert grass_energy["name"] == "Grass Energy"
assert grass_energy["card_type"] == "energy"
assert grass_energy["energy_provides"] == ["grass"]
assert grass_energy["rarity"] == "common"
assert grass_energy["set_id"] == "basic"
assert "energy/basic/grass.webp" in grass_energy["image_path"]
def test_energy_cards_validate(self):
"""Test that all generated energy cards pass validation.
Each card should be valid against the CardDefinition model.
"""
cards = generate_energy_cards()
for card in cards:
result = validate_card(card)
assert result.card_type.value == "energy"