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>
842 lines
29 KiB
Python
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
|