mantimon-tcg/backend/tests/unit/services/test_deck_validator.py
Cal Corum 7d397a2e22 Fix medium priority issues from code review
UNSET sentinel pattern:
- Add UNSET sentinel in protocols.py for nullable field updates
- Fix inability to clear deck description (UNSET=keep, None=clear)
- Fix repository inability to clear validation_errors

Starter deck improvements:
- Remove unused has_starter_deck from CollectionService
- Add deprecation notes to old starter deck methods

Validation improvements:
- Add energy type validation in deck_validator.py
- Add energy type validation in deck schemas
- Add VALID_ENERGY_TYPES constant

Game loading fix:
- Fix get_deck_for_game silently skipping invalid cards
- Now raises ValueError with clear error message

Tests:
- Add TestEnergyTypeValidation test class
- Add TestGetDeckForGame test class
- Add tests for validate_energy_types utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:32:08 -06:00

609 lines
21 KiB
Python

"""Tests for the deck validation functions.
These tests verify deck validation logic without database dependencies.
Card lookup is mocked to isolate the validation logic and enable fast,
deterministic unit tests.
The validation functions enforce Mantimon TCG house rules:
- 40 cards in main deck
- 20 energy cards in separate energy deck
- Max 4 copies of any single card
- At least 1 Basic Pokemon required
- Ownership check when owned_cards is provided
"""
import pytest
from app.core.config import DeckConfig
from app.core.enums import CardType, EnergyType, PokemonStage
from app.core.models.card import CardDefinition
from app.services.deck_validator import (
VALID_ENERGY_TYPES,
ValidationResult,
count_basic_pokemon,
validate_cards_exist,
validate_deck,
validate_energy_types,
)
# =============================================================================
# Test Fixtures
# =============================================================================
def make_basic_pokemon(card_id: str) -> CardDefinition:
"""Create a Basic Pokemon card definition."""
return CardDefinition(
id=card_id,
name=f"Pokemon {card_id}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
def make_stage1_pokemon(card_id: str) -> CardDefinition:
"""Create a Stage 1 Pokemon card definition."""
return CardDefinition(
id=card_id,
name=f"Pokemon {card_id}",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
def make_trainer(card_id: str) -> CardDefinition:
"""Create a Trainer card definition."""
return CardDefinition(
id=card_id,
name=f"Trainer {card_id}",
card_type=CardType.TRAINER,
trainer_type="item",
)
def basic_pokemon_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Basic Pokemon for any ID."""
return make_basic_pokemon(card_id)
def stage1_pokemon_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Stage 1 Pokemon for any ID."""
return make_stage1_pokemon(card_id)
def trainer_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Trainers for any ID."""
return make_trainer(card_id)
def null_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns None for any ID."""
return None
@pytest.fixture
def default_config() -> DeckConfig:
"""Create a default DeckConfig for testing."""
return DeckConfig()
# =============================================================================
# ValidationResult Tests
# =============================================================================
class TestValidationResult:
"""Tests for the ValidationResult dataclass."""
def test_default_is_valid(self):
"""Test that a new result starts as valid with no errors."""
result = ValidationResult()
assert result.is_valid is True
assert result.errors == []
def test_add_error_marks_invalid(self):
"""Test that adding an error marks the result as invalid."""
result = ValidationResult()
result.add_error("Test error")
assert result.is_valid is False
assert "Test error" in result.errors
def test_add_multiple_errors(self):
"""Test that multiple errors can be accumulated."""
result = ValidationResult()
result.add_error("Error 1")
result.add_error("Error 2")
result.add_error("Error 3")
assert result.is_valid is False
assert len(result.errors) == 3
# =============================================================================
# Card Count Validation Tests
# =============================================================================
class TestCardCountValidation:
"""Tests for main deck card count validation (40 cards required)."""
def test_valid_card_count_passes(self, default_config):
"""Test that exactly 40 cards passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "must have exactly 40 cards" not in str(result.errors)
def test_39_cards_fails(self, default_config):
"""Test that 39 cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards
cards["card-extra"] = 3 # 39 total
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("got 39" in e for e in result.errors)
def test_41_cards_fails(self, default_config):
"""Test that 41 cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
cards["card-extra"] = 1 # 41 total
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("got 41" in e for e in result.errors)
def test_empty_deck_fails(self, default_config):
"""Test that an empty deck fails validation."""
result = validate_deck({}, {"lightning": 20}, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("got 0" in e for e in result.errors)
# =============================================================================
# Energy Count Validation Tests
# =============================================================================
class TestEnergyCountValidation:
"""Tests for energy deck card count validation (20 energy required)."""
def test_valid_energy_count_passes(self, default_config):
"""Test that exactly 20 energy cards passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 14, "colorless": 6} # 20 total
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "Energy deck must have exactly 20" not in str(result.errors)
def test_19_energy_fails(self, default_config):
"""Test that 19 energy cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 19}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("got 19" in e for e in result.errors)
def test_21_energy_fails(self, default_config):
"""Test that 21 energy cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 21}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("got 21" in e for e in result.errors)
class TestEnergyTypeValidation:
"""Tests for energy type name validation."""
def test_valid_energy_types_pass(self, default_config):
"""Test that valid energy type names pass validation.
Uses the standard EnergyType enum values (lowercase).
"""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"fire": 10, "water": 5, "grass": 5} # All valid types
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "Invalid energy types" not in str(result.errors)
def test_invalid_energy_type_fails(self, default_config):
"""Test that invalid energy type names fail validation.
Energy types must match EnergyType enum values (e.g., 'fire', not 'FIRE').
"""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"invalid_type": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("Invalid energy types" in e for e in result.errors)
assert any("invalid_type" in e for e in result.errors)
def test_multiple_invalid_energy_types_reported(self, default_config):
"""Test that multiple invalid energy types are all reported.
The error message should list up to 5 invalid types.
"""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"bad_type1": 10, "bad_type2": 5, "grass": 5} # 2 invalid
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
error_str = str(result.errors)
assert "bad_type1" in error_str
assert "bad_type2" in error_str
def test_case_sensitive_energy_types(self, default_config):
"""Test that energy type validation is case-sensitive.
EnergyType uses lowercase values, so 'FIRE' should fail.
"""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"FIRE": 10, "Fire": 10} # Both wrong case
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("Invalid energy types" in e for e in result.errors)
# =============================================================================
# Max Copies Per Card Tests
# =============================================================================
class TestMaxCopiesValidation:
"""Tests for maximum copies per card validation (4 max)."""
def test_4_copies_allowed(self, default_config):
"""Test that 4 copies of a card is allowed."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "max allowed is 4" not in str(result.errors)
def test_5_copies_fails(self, default_config):
"""Test that 5 copies of a card fails validation."""
cards = {"over-limit": 5}
# Pad to 40 cards
for i in range(7):
cards[f"card-{i:03d}"] = 5
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("over-limit" in e and "5 copies" in e for e in result.errors)
def test_multiple_cards_over_limit(self, default_config):
"""Test that multiple cards over limit all get reported."""
cards = {"card-a": 5, "card-b": 6, "card-c": 4} # a and b over
cards["filler"] = 25
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
error_str = str(result.errors)
assert "card-a" in error_str
assert "card-b" in error_str
# =============================================================================
# Basic Pokemon Requirement Tests
# =============================================================================
class TestBasicPokemonRequirement:
"""Tests for minimum Basic Pokemon requirement (at least 1)."""
def test_deck_with_basic_pokemon_passes(self, default_config):
"""Test that a deck with Basic Pokemon passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "at least 1 Basic Pokemon" not in str(result.errors)
def test_deck_without_basic_pokemon_fails(self, default_config):
"""Test that a deck without Basic Pokemon fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, stage1_pokemon_lookup)
assert result.is_valid is False
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
def test_deck_with_only_trainers_fails(self, default_config):
"""Test that a deck with only Trainers fails Basic Pokemon check."""
cards = {f"trainer-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, trainer_lookup)
assert result.is_valid is False
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
# =============================================================================
# Card ID Validation Tests
# =============================================================================
class TestCardIdValidation:
"""Tests for card ID existence validation."""
def test_valid_card_ids_pass(self, default_config):
"""Test that valid card IDs pass validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "Invalid card IDs" not in str(result.errors)
def test_invalid_card_id_fails(self, default_config):
"""Test that an invalid card ID fails validation."""
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id == "bad-card":
return None
return make_basic_pokemon(card_id)
cards = {"good-card": 4, "bad-card": 4}
for i in range(8):
cards[f"card-{i:03d}"] = 4
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, partial_lookup)
assert result.is_valid is False
assert any("Invalid card IDs" in e and "bad-card" in e for e in result.errors)
def test_multiple_invalid_ids_reported(self, default_config):
"""Test that multiple invalid IDs are reported together."""
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("bad"):
return None
return make_basic_pokemon(card_id)
cards = {"bad-1": 4, "bad-2": 4, "bad-3": 4, "good": 28}
energy = {"lightning": 20}
result = validate_deck(cards, energy, default_config, partial_lookup)
assert result.is_valid is False
error_str = str(result.errors)
assert "bad-1" in error_str
assert "bad-2" in error_str
assert "bad-3" in error_str
# =============================================================================
# Ownership Validation Tests
# =============================================================================
class TestOwnershipValidation:
"""Tests for card ownership validation."""
def test_owned_cards_pass(self, default_config):
"""Test that deck passes when user owns all cards."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
owned = {f"card-{i:03d}": 10 for i in range(10)}
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
assert "Insufficient cards" not in str(result.errors)
def test_insufficient_ownership_fails(self, default_config):
"""Test that deck fails when user doesn't own enough copies."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
owned = {f"card-{i:03d}": 10 for i in range(10)}
owned["card-000"] = 2 # Need 4, only have 2
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
assert result.is_valid is False
assert any("card-000" in e and "need 4" in e and "own 2" in e for e in result.errors)
def test_unowned_card_fails(self, default_config):
"""Test that deck fails when user doesn't own a card at all."""
cards = {"owned-card": 20, "unowned-card": 20}
energy = {"lightning": 20}
owned = {"owned-card": 20} # Missing unowned-card
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
assert result.is_valid is False
assert any("unowned-card" in e and "own 0" in e for e in result.errors)
def test_none_owned_cards_skips_ownership_check(self, default_config):
"""Test that passing None for owned_cards skips ownership validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=None
)
assert "Insufficient cards" not in str(result.errors)
# =============================================================================
# Multiple Errors Tests
# =============================================================================
class TestMultipleErrors:
"""Tests for returning all errors at once."""
def test_multiple_errors_returned_together(self, default_config):
"""Test that multiple validation errors are all returned."""
cards = {"bad-card": 5} # Invalid ID + over copy limit + wrong count
energy = {"lightning": 10} # Only 10 (not 20)
owned = {}
result = validate_deck(cards, energy, default_config, null_lookup, owned_cards=owned)
assert result.is_valid is False
assert len(result.errors) >= 3
# =============================================================================
# Custom Config Tests
# =============================================================================
class TestCustomConfig:
"""Tests for using custom DeckConfig values."""
def test_custom_deck_size(self):
"""Test that custom deck size is respected."""
custom_config = DeckConfig(min_size=60, max_size=60)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 60 cards" in e for e in result.errors)
def test_custom_energy_size(self):
"""Test that custom energy deck size is respected."""
custom_config = DeckConfig(energy_deck_size=30)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 30" in e for e in result.errors)
def test_custom_max_copies(self):
"""Test that custom max copies per card is respected."""
custom_config = DeckConfig(max_copies_per_card=2)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
energy = {"lightning": 20}
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("max allowed is 2" in e for e in result.errors)
# =============================================================================
# Utility Function Tests
# =============================================================================
class TestUtilityFunctions:
"""Tests for utility functions."""
def test_validate_cards_exist_all_valid(self):
"""Test validate_cards_exist returns empty list when all valid."""
card_ids = ["card-1", "card-2", "card-3"]
invalid = validate_cards_exist(card_ids, basic_pokemon_lookup)
assert invalid == []
def test_validate_cards_exist_some_invalid(self):
"""Test validate_cards_exist returns invalid IDs."""
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("bad"):
return None
return make_basic_pokemon(card_id)
card_ids = ["good-1", "bad-1", "good-2", "bad-2"]
invalid = validate_cards_exist(card_ids, partial_lookup)
assert set(invalid) == {"bad-1", "bad-2"}
def test_count_basic_pokemon(self):
"""Test count_basic_pokemon returns correct count."""
def mixed_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("basic"):
return make_basic_pokemon(card_id)
elif card_id.startswith("stage1"):
return make_stage1_pokemon(card_id)
else:
return make_trainer(card_id)
cards = {"basic-1": 4, "basic-2": 3, "stage1-1": 4, "trainer-1": 4}
count = count_basic_pokemon(cards, mixed_lookup)
assert count == 7 # basic-1: 4 + basic-2: 3
def test_validate_energy_types_all_valid(self):
"""Test validate_energy_types returns empty list when all valid."""
energy_types = ["fire", "water", "grass", "colorless"]
invalid = validate_energy_types(energy_types)
assert invalid == []
def test_validate_energy_types_some_invalid(self):
"""Test validate_energy_types returns invalid types."""
energy_types = ["fire", "bad_type", "water", "invalid"]
invalid = validate_energy_types(energy_types)
assert set(invalid) == {"bad_type", "invalid"}
def test_valid_energy_types_constant(self):
"""Test VALID_ENERGY_TYPES matches EnergyType enum."""
expected = {
"colorless",
"darkness",
"dragon",
"fighting",
"fire",
"grass",
"lightning",
"metal",
"psychic",
"water",
}
assert expected == VALID_ENERGY_TYPES