mantimon-tcg/backend/tests/unit/services/test_deck_validator.py
Cal Corum 58349c126a Phase 3: Collections + Decks - Services and DI architecture
Implemented with Repository Protocol pattern for offline fork support:
- CollectionService with PostgresCollectionRepository
- DeckService with PostgresDeckRepository
- DeckValidator with DeckConfig + CardService injection
- Starter deck definitions (5 types: grass, fire, water, psychic, lightning)
- Pydantic schemas for collection and deck APIs
- Unit tests for DeckValidator (32 tests passing)

Architecture follows pure dependency injection - no service locator patterns.
Added CLAUDE.md documenting DI requirements and patterns.

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

842 lines
29 KiB
Python

"""Tests for the DeckValidator service.
These tests verify deck validation logic without database dependencies.
CardService is mocked and injected to isolate the validation logic and
enable fast, deterministic unit tests.
The DeckValidator enforces 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
- Campaign mode: must own all cards in deck
- Freeplay mode: ownership validation skipped
"""
from unittest.mock import MagicMock
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 (
DeckValidationResult,
DeckValidator,
)
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def mock_card_service():
"""Create a mock CardService with test cards.
Provides a set of test cards:
- 3 Basic Pokemon (pikachu, bulbasaur, charmander)
- 2 Stage 1 Pokemon (raichu, ivysaur)
- 2 Trainer cards (potion, professor-oak)
This allows testing deck validation without loading real card data.
"""
service = MagicMock()
# Define test cards
cards = {
"test-001-pikachu": CardDefinition(
id="test-001-pikachu",
name="Pikachu",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
),
"test-002-bulbasaur": CardDefinition(
id="test-002-bulbasaur",
name="Bulbasaur",
card_type=CardType.POKEMON,
hp=70,
pokemon_type=EnergyType.GRASS,
stage=PokemonStage.BASIC,
),
"test-003-charmander": CardDefinition(
id="test-003-charmander",
name="Charmander",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.FIRE,
stage=PokemonStage.BASIC,
),
"test-004-raichu": CardDefinition(
id="test-004-raichu",
name="Raichu",
card_type=CardType.POKEMON,
hp=100,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="Pikachu",
),
"test-005-ivysaur": CardDefinition(
id="test-005-ivysaur",
name="Ivysaur",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.GRASS,
stage=PokemonStage.STAGE_1,
evolves_from="Bulbasaur",
),
"test-101-potion": CardDefinition(
id="test-101-potion",
name="Potion",
card_type=CardType.TRAINER,
trainer_type="item",
),
"test-102-professor-oak": CardDefinition(
id="test-102-professor-oak",
name="Professor Oak",
card_type=CardType.TRAINER,
trainer_type="supporter",
),
}
service.get_card = lambda card_id: cards.get(card_id)
return service
@pytest.fixture
def valid_energy_deck() -> dict[str, int]:
"""Create a valid 20-card energy deck."""
return {
"lightning": 10,
"grass": 6,
"colorless": 4,
}
@pytest.fixture
def default_config() -> DeckConfig:
"""Create a default DeckConfig for testing."""
return DeckConfig()
@pytest.fixture
def validator(default_config, mock_card_service) -> DeckValidator:
"""Create a DeckValidator with default config and mock card service."""
return DeckValidator(default_config, mock_card_service)
def create_basic_pokemon_mock():
"""Create a mock that returns Basic Pokemon for any card ID.
Useful for tests that need all cards to be valid Basic Pokemon.
"""
mock = MagicMock()
mock.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
return mock
# =============================================================================
# DeckValidationResult Tests
# =============================================================================
class TestDeckValidationResult:
"""Tests for the DeckValidationResult dataclass."""
def test_default_is_valid(self):
"""Test that a new result starts as valid with no errors.
A fresh DeckValidationResult should indicate validity until
errors are explicitly added.
"""
result = DeckValidationResult()
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.
Once any error is added, is_valid should be False.
"""
result = DeckValidationResult()
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.
All errors should be collected, not just the first one,
to give users complete feedback on what needs fixing.
"""
result = DeckValidationResult()
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
assert "Error 1" in result.errors
assert "Error 2" in result.errors
assert "Error 3" in result.errors
# =============================================================================
# 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.
The main deck must have exactly 40 cards per Mantimon house rules.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
# Should pass card count check
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.
One card short of the required 40 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards
cards["card-extra"] = 3 # 39 total
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "got 39" in e for e in result.errors)
def test_41_cards_fails(self, default_config):
"""Test that 41 cards fails validation.
One card over the required 40 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
cards["card-extra"] = 1 # 41 total
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "got 41" in e for e in result.errors)
def test_empty_deck_fails(self, default_config):
"""Test that an empty deck fails validation.
A deck with no cards should fail the card count check.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
result = validator.validate_deck({}, {"lightning": 20})
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "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.
The energy deck must have exactly 20 cards per Mantimon house rules.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 14, "colorless": 6} # 20 total
result = validator.validate_deck(cards, energy)
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.
One energy short of the required 20 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 19}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 19" in e for e in result.errors)
def test_21_energy_fails(self, default_config):
"""Test that 21 energy cards fails validation.
One energy over the required 20 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 21}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 21" in e for e in result.errors)
def test_empty_energy_deck_fails(self, default_config):
"""Test that an empty energy deck fails validation."""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
result = validator.validate_deck(cards, {})
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 0" 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.
The maximum of 4 copies per card should pass validation.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"test-001-pikachu": 4, # Max allowed
}
# Pad to 40 cards
for i in range(9):
cards[f"filler-{i:03d}"] = 4
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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.
One copy over the maximum should produce an error identifying the card.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"test-001-pikachu": 5, # One over max
}
# Pad to 40 (5 + 35)
for i in range(7):
cards[f"filler-{i:03d}"] = 5
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any(
"test-001-pikachu" in e and "5 copies" in e and "max allowed is 4" 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.
Each card exceeding the limit should generate its own error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"card-a": 5,
"card-b": 6,
"card-c": 4, # OK
}
# Pad to 40
cards["filler"] = 25
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
# Both card-a and card-b should be reported
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.
Having at least 1 Basic Pokemon satisfies this requirement.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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.
A deck composed entirely of Stage 1/2 Pokemon and Trainers
cannot start a game and should fail validation.
"""
# Create a mock that returns Stage 1 pokemon for all IDs
mock_service = MagicMock()
mock_service.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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.
Trainers don't count toward the Basic Pokemon requirement.
"""
# Create a mock that returns Trainers for all IDs
mock_service = MagicMock()
mock_service.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Trainer {cid}",
card_type=CardType.TRAINER,
trainer_type="item",
)
validator = DeckValidator(default_config, mock_service)
cards = {f"trainer-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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.
All card IDs in the deck should exist in the CardService.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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.
Card IDs not found in CardService should produce an error.
"""
# Create a mock that returns None for specific cards
mock_service = MagicMock()
def mock_get_card(cid):
if cid == "nonexistent-card":
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
mock_service.get_card = mock_get_card
validator = DeckValidator(default_config, mock_service)
cards = {
"valid-card": 4,
"nonexistent-card": 4,
}
# Pad to 40
for i in range(8):
cards[f"card-{i:03d}"] = 4
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("Invalid card IDs" in e and "nonexistent-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.
The error message should list multiple invalid IDs (up to a limit).
"""
# Create a mock that returns None for "bad-*" cards
mock_service = MagicMock()
def mock_get_card(cid):
if cid.startswith("bad"):
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
mock_service.get_card = mock_get_card
validator = DeckValidator(default_config, mock_service)
cards = {
"bad-1": 4,
"bad-2": 4,
"bad-3": 4,
"good": 28,
}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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 (campaign mode)."""
def test_owned_cards_pass(self, default_config):
"""Test that deck passes when user owns all cards.
In campaign mode, user must own sufficient copies of each card.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# User owns 10 copies of each card
owned = {f"card-{i:03d}": 10 for i in range(10)}
result = validator.validate_deck(cards, energy, 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.
Needing 4 copies but only owning 2 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# User owns only 2 copies of first card
owned = {f"card-{i:03d}": 10 for i in range(10)}
owned["card-000"] = 2 # Need 4, only have 2
result = validator.validate_deck(cards, energy, owned_cards=owned)
assert result.is_valid is False
assert any(
"Insufficient cards" in e and "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.
A card with 0 owned copies should fail ownership validation.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {"owned-card": 20, "unowned-card": 20}
energy = {"lightning": 20}
owned = {"owned-card": 20} # Missing unowned-card entirely
result = validator.validate_deck(cards, energy, owned_cards=owned)
assert result.is_valid is False
assert any(
"Insufficient cards" in e and "unowned-card" in e and "own 0" in e
for e in result.errors
)
def test_freeplay_skips_ownership(self, default_config):
"""Test that passing None for owned_cards skips ownership validation.
In freeplay mode, users have access to all cards regardless of
their actual collection.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# owned_cards=None means freeplay mode
result = validator.validate_deck(cards, energy, 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.
When a deck has multiple issues, all should be reported so the
user can fix everything at once rather than iteratively.
"""
# Create a mock that returns None for all cards
mock_service = MagicMock()
mock_service.get_card = lambda cid: None
validator = DeckValidator(default_config, mock_service)
cards = {
"bad-card": 5, # Invalid ID + over copy limit
}
# Total is 5 (not 40)
energy = {"lightning": 10} # Only 10 (not 20)
owned = {} # Empty ownership
result = validator.validate_deck(cards, energy, owned_cards=owned)
assert result.is_valid is False
# Should have multiple errors
assert len(result.errors) >= 3
error_str = str(result.errors)
# Card count error
assert "40 cards" in error_str
# Energy count error
assert "20" in error_str
# Invalid card ID or max copies
assert "bad-card" in error_str
# =============================================================================
# Custom Config Tests
# =============================================================================
class TestCustomConfig:
"""Tests for using custom DeckConfig values."""
def test_custom_deck_size(self):
"""Test that custom deck size is respected.
Different game modes might use different deck sizes (e.g., 60-card).
"""
custom_config = DeckConfig(min_size=60, max_size=60)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
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)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20} # 20, but need 30
result = validator.validate_deck(cards, energy)
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)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
assert result.is_valid is False
assert any("max allowed is 2" in e for e in result.errors)
# =============================================================================
# Utility Method Tests
# =============================================================================
class TestUtilityMethods:
"""Tests for utility methods on DeckValidator."""
def test_validate_cards_exist_all_valid(self, default_config):
"""Test validate_cards_exist returns empty list when all valid."""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
card_ids = ["card-1", "card-2", "card-3"]
invalid = validator.validate_cards_exist(card_ids)
assert invalid == []
def test_validate_cards_exist_some_invalid(self, default_config):
"""Test validate_cards_exist returns invalid IDs."""
mock_service = MagicMock()
def mock_get(cid):
if cid.startswith("bad"):
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
mock_service.get_card = mock_get
validator = DeckValidator(default_config, mock_service)
card_ids = ["good-1", "bad-1", "good-2", "bad-2"]
invalid = validator.validate_cards_exist(card_ids)
assert set(invalid) == {"bad-1", "bad-2"}
def test_count_basic_pokemon(self, default_config):
"""Test count_basic_pokemon returns correct count."""
mock_service = MagicMock()
def mock_get(cid):
if cid.startswith("basic"):
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
elif cid.startswith("stage1"):
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
else:
return CardDefinition(
id=cid,
name=f"Trainer {cid}",
card_type=CardType.TRAINER,
trainer_type="item",
)
mock_service.get_card = mock_get
validator = DeckValidator(default_config, mock_service)
cards = {
"basic-1": 4,
"basic-2": 3,
"stage1-1": 4,
"trainer-1": 4,
}
count = validator.count_basic_pokemon(cards)
# basic-1: 4 + basic-2: 3 = 7
assert count == 7
def test_config_property(self, default_config, mock_card_service):
"""Test that config property returns the injected DeckConfig."""
validator = DeckValidator(default_config, mock_card_service)
assert validator.config is default_config
assert validator.config.min_size == 40
assert validator.config.energy_deck_size == 20