mtg_python_deckbuilder/code/tests/test_validation.py

341 lines
12 KiB
Python
Raw Normal View History

"""Tests for validation framework (models, validators, card names)."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from code.web.validation.models import (
BuildRequest,
CommanderSearchRequest,
ThemeValidationRequest,
OwnedCardsImportRequest,
BatchBuildRequest,
CardReplacementRequest,
PowerBracket,
OwnedMode,
CommanderPartnerType,
)
from code.web.validation.card_names import CardNameValidator
from code.web.validation.validators import (
ThemeValidator,
PowerBracketValidator,
ColorIdentityValidator,
)
from code.web.validation.messages import ValidationMessages, MSG
class TestBuildRequest:
"""Test BuildRequest Pydantic model."""
def test_minimal_valid_request(self):
"""Test minimal valid build request."""
req = BuildRequest(commander="Atraxa, Praetors' Voice")
assert req.commander == "Atraxa, Praetors' Voice"
assert req.themes == []
assert req.power_bracket == PowerBracket.BRACKET_2
assert req.owned_mode == OwnedMode.OFF
def test_full_valid_request(self):
"""Test fully populated build request."""
req = BuildRequest(
commander="Kess, Dissident Mage",
themes=["Spellslinger", "Graveyard"],
power_bracket=PowerBracket.BRACKET_3,
owned_mode=OwnedMode.PREFER,
must_include=["Counterspell", "Lightning Bolt"],
must_exclude=["Armageddon"]
)
assert req.commander == "Kess, Dissident Mage"
assert len(req.themes) == 2
assert req.power_bracket == PowerBracket.BRACKET_3
assert len(req.must_include) == 2
def test_commander_whitespace_stripped(self):
"""Test commander name whitespace is stripped."""
req = BuildRequest(commander=" Atraxa ")
assert req.commander == "Atraxa"
def test_commander_empty_fails(self):
"""Test empty commander name fails validation."""
with pytest.raises(ValidationError):
BuildRequest(commander="")
with pytest.raises(ValidationError):
BuildRequest(commander=" ")
def test_themes_deduplicated(self):
"""Test themes are deduplicated case-insensitively."""
req = BuildRequest(
commander="Test",
themes=["Spellslinger", "spellslinger", "SPELLSLINGER", "Tokens"]
)
assert len(req.themes) == 2
assert "Spellslinger" in req.themes
assert "Tokens" in req.themes
def test_partner_validation_requires_name(self):
"""Test partner mode requires partner name."""
with pytest.raises(ValidationError, match="Partner mode requires partner_name"):
BuildRequest(
commander="Kydele, Chosen of Kruphix",
partner_mode=CommanderPartnerType.PARTNER
)
def test_partner_valid_with_name(self):
"""Test partner mode valid with name."""
req = BuildRequest(
commander="Kydele, Chosen of Kruphix",
partner_mode=CommanderPartnerType.PARTNER,
partner_name="Thrasios, Triton Hero"
)
assert req.partner_mode == CommanderPartnerType.PARTNER
assert req.partner_name == "Thrasios, Triton Hero"
def test_background_requires_name(self):
"""Test background mode requires background name."""
with pytest.raises(ValidationError, match="Background mode requires background_name"):
BuildRequest(
commander="Erinis, Gloom Stalker",
partner_mode=CommanderPartnerType.BACKGROUND
)
def test_custom_theme_requires_both(self):
"""Test custom theme requires both name and tags."""
with pytest.raises(ValidationError, match="Custom theme requires both name and tags"):
BuildRequest(
commander="Test",
custom_theme_name="My Theme"
)
with pytest.raises(ValidationError, match="Custom theme tags require theme name"):
BuildRequest(
commander="Test",
custom_theme_tags=["Tag1", "Tag2"]
)
class TestCommanderSearchRequest:
"""Test CommanderSearchRequest model."""
def test_valid_search(self):
"""Test valid search request."""
req = CommanderSearchRequest(query="Atraxa")
assert req.query == "Atraxa"
assert req.limit == 10
def test_custom_limit(self):
"""Test custom limit."""
req = CommanderSearchRequest(query="Test", limit=25)
assert req.limit == 25
def test_empty_query_fails(self):
"""Test empty query fails."""
with pytest.raises(ValidationError):
CommanderSearchRequest(query="")
def test_limit_bounds(self):
"""Test limit must be within bounds."""
with pytest.raises(ValidationError):
CommanderSearchRequest(query="Test", limit=0)
with pytest.raises(ValidationError):
CommanderSearchRequest(query="Test", limit=101)
class TestCardNameValidator:
"""Test card name validation and normalization."""
def test_normalize_lowercase(self):
"""Test normalization converts to lowercase."""
assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == "atraxa, praetors' voice"
def test_normalize_removes_diacritics(self):
"""Test normalization removes diacritics."""
assert CardNameValidator.normalize("Dánitha Capashen") == "danitha capashen"
assert CardNameValidator.normalize("Gisela, the Broken Blade") == "gisela, the broken blade"
def test_normalize_standardizes_apostrophes(self):
"""Test normalization standardizes apostrophes."""
assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice")
assert CardNameValidator.normalize("Atraxa, Praetors` Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice")
def test_normalize_collapses_whitespace(self):
"""Test normalization collapses whitespace."""
assert CardNameValidator.normalize("Test Card") == "test card"
assert CardNameValidator.normalize(" Test ") == "test"
def test_validator_caches_normalization(self):
"""Test validator caches normalized lookups."""
validator = CardNameValidator()
validator._card_names = {"Atraxa, Praetors' Voice"}
validator._normalized_map = {
"atraxa, praetors' voice": "Atraxa, Praetors' Voice"
}
validator._loaded = True
# Should find exact match
assert validator.is_valid("Atraxa, Praetors' Voice")
class TestThemeValidator:
"""Test theme validation."""
def test_validate_themes_separates_valid_invalid(self):
"""Test validation separates valid from invalid themes."""
validator = ThemeValidator()
validator._themes = {"Spellslinger", "spellslinger", "Tokens", "tokens"}
validator._loaded = True
valid, invalid = validator.validate_themes(["Spellslinger", "Invalid", "Tokens"])
assert "Spellslinger" in valid
assert "Tokens" in valid
assert "Invalid" in invalid
class TestPowerBracketValidator:
"""Test power bracket validation."""
def test_valid_brackets(self):
"""Test valid bracket values (1-4)."""
assert PowerBracketValidator.is_valid_bracket(1)
assert PowerBracketValidator.is_valid_bracket(2)
assert PowerBracketValidator.is_valid_bracket(3)
assert PowerBracketValidator.is_valid_bracket(4)
def test_invalid_brackets(self):
"""Test invalid bracket values."""
assert not PowerBracketValidator.is_valid_bracket(0)
assert not PowerBracketValidator.is_valid_bracket(5)
assert not PowerBracketValidator.is_valid_bracket(-1)
class TestColorIdentityValidator:
"""Test color identity validation."""
def test_parse_comma_separated(self):
"""Test parsing comma-separated colors."""
colors = ColorIdentityValidator.parse_colors("W,U,B")
assert colors == {"W", "U", "B"}
def test_parse_concatenated(self):
"""Test parsing concatenated colors."""
colors = ColorIdentityValidator.parse_colors("WUB")
assert colors == {"W", "U", "B"}
def test_parse_empty(self):
"""Test parsing empty string."""
colors = ColorIdentityValidator.parse_colors("")
assert colors == set()
def test_colorless_subset_any(self):
"""Test colorless cards valid in any deck."""
validator = ColorIdentityValidator()
assert validator.is_subset({"C"}, {"W", "U"})
assert validator.is_subset(set(), {"R", "G"})
def test_subset_validation(self):
"""Test subset validation."""
validator = ColorIdentityValidator()
# Valid: card colors subset of commander
assert validator.is_subset({"W", "U"}, {"W", "U", "B"})
# Invalid: card has colors not in commander
assert not validator.is_subset({"W", "U", "B"}, {"W", "U"})
class TestValidationMessages:
"""Test validation message formatting."""
def test_format_commander_invalid(self):
"""Test commander invalid message formatting."""
msg = MSG.format_commander_invalid("Test Commander")
assert "Test Commander" in msg
assert "not found" in msg
def test_format_themes_invalid(self):
"""Test multiple invalid themes formatting."""
msg = MSG.format_themes_invalid(["Theme1", "Theme2"])
assert "Theme1" in msg
assert "Theme2" in msg
def test_format_bracket_exceeded(self):
"""Test bracket exceeded message formatting."""
msg = MSG.format_bracket_exceeded("Mana Crypt", 4, 2)
assert "Mana Crypt" in msg
assert "4" in msg
assert "2" in msg
def test_format_color_mismatch(self):
"""Test color mismatch message formatting."""
msg = MSG.format_color_mismatch("Card", "WUB", "WU")
assert "Card" in msg
assert "WUB" in msg
assert "WU" in msg
class TestBatchBuildRequest:
"""Test batch build request validation."""
def test_valid_batch(self):
"""Test valid batch request."""
base = BuildRequest(commander="Test")
req = BatchBuildRequest(base_config=base, count=5)
assert req.count == 5
assert req.base_config.commander == "Test"
def test_count_limit(self):
"""Test batch count limit."""
base = BuildRequest(commander="Test")
with pytest.raises(ValidationError):
BatchBuildRequest(base_config=base, count=11)
class TestCardReplacementRequest:
"""Test card replacement request validation."""
def test_valid_replacement(self):
"""Test valid replacement request."""
req = CardReplacementRequest(card_name="Sol Ring", reason="Too powerful")
assert req.card_name == "Sol Ring"
assert req.reason == "Too powerful"
def test_whitespace_stripped(self):
"""Test whitespace is stripped."""
req = CardReplacementRequest(card_name=" Sol Ring ")
assert req.card_name == "Sol Ring"
def test_empty_name_fails(self):
"""Test empty card name fails."""
with pytest.raises(ValidationError):
CardReplacementRequest(card_name="")
class TestOwnedCardsImportRequest:
"""Test owned cards import validation."""
def test_valid_import(self):
"""Test valid import request."""
req = OwnedCardsImportRequest(format_type="csv", content="Name\nSol Ring\n")
assert req.format_type == "csv"
assert "Sol Ring" in req.content
def test_invalid_format(self):
"""Test invalid format fails."""
with pytest.raises(ValidationError):
OwnedCardsImportRequest(format_type="invalid", content="test")
def test_empty_content_fails(self):
"""Test empty content fails."""
with pytest.raises(ValidationError):
OwnedCardsImportRequest(format_type="csv", content="")