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>
This commit is contained in:
parent
3ec670753b
commit
7d397a2e22
@ -20,6 +20,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CardServiceDep, CollectionServiceDep, CurrentUser, DeckServiceDep
|
||||
from app.repositories.protocols import UNSET
|
||||
from app.schemas.deck import (
|
||||
DeckCreateRequest,
|
||||
DeckListResponse,
|
||||
@ -183,6 +184,9 @@ async def update_deck(
|
||||
Only provided fields are updated. If cards or energy_cards change,
|
||||
the deck is re-validated with the provided deck_config.
|
||||
|
||||
To clear the description, explicitly send `"description": null` in the request.
|
||||
Omitting the field keeps the existing description.
|
||||
|
||||
Args:
|
||||
deck_id: The deck's UUID.
|
||||
deck_in: Update request with optional fields.
|
||||
@ -193,6 +197,10 @@ async def update_deck(
|
||||
Raises:
|
||||
404: If deck not found or not owned by user.
|
||||
"""
|
||||
# Check if description was explicitly provided (even if null)
|
||||
# UNSET means "keep existing", whereas None means "clear"
|
||||
description = deck_in.description if "description" in deck_in.model_fields_set else UNSET
|
||||
|
||||
try:
|
||||
deck = await deck_service.update_deck(
|
||||
user_id=current_user.id,
|
||||
@ -202,7 +210,7 @@ async def update_deck(
|
||||
cards=deck_in.cards,
|
||||
energy_cards=deck_in.energy_cards,
|
||||
validate_ownership=deck_in.validate_ownership,
|
||||
description=deck_in.description,
|
||||
description=description,
|
||||
)
|
||||
except DeckNotFoundError:
|
||||
raise HTTPException(
|
||||
|
||||
@ -15,7 +15,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.deck import Deck
|
||||
from app.repositories.protocols import DeckEntry
|
||||
from app.repositories.protocols import UNSET, DeckEntry
|
||||
|
||||
|
||||
def _to_dto(model: Deck) -> DeckEntry:
|
||||
@ -164,21 +164,22 @@ class PostgresDeckRepository:
|
||||
cards: dict[str, int] | None = None,
|
||||
energy_cards: dict[str, int] | None = None,
|
||||
is_valid: bool | None = None,
|
||||
validation_errors: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
validation_errors: list[str] | None = UNSET, # type: ignore[assignment]
|
||||
description: str | None = UNSET, # type: ignore[assignment]
|
||||
) -> DeckEntry | None:
|
||||
"""Update an existing deck.
|
||||
|
||||
Only provided (non-None) fields are updated.
|
||||
Only provided (non-UNSET) fields are updated.
|
||||
Use UNSET (default) to keep existing value, or None to clear.
|
||||
|
||||
Args:
|
||||
deck_id: The deck's UUID.
|
||||
name: New name (optional).
|
||||
cards: New card composition (optional).
|
||||
energy_cards: New energy composition (optional).
|
||||
is_valid: New validation status (optional).
|
||||
validation_errors: New validation errors (optional).
|
||||
description: New description (optional).
|
||||
name: New name (optional, None keeps existing).
|
||||
cards: New card composition (optional, None keeps existing).
|
||||
energy_cards: New energy composition (optional, None keeps existing).
|
||||
is_valid: New validation status (optional, None keeps existing).
|
||||
validation_errors: New errors (UNSET=keep, None=clear, list=set).
|
||||
description: New description (UNSET=keep, None=clear, str=set).
|
||||
|
||||
Returns:
|
||||
Updated DeckEntry, or None if deck not found.
|
||||
@ -197,9 +198,10 @@ class PostgresDeckRepository:
|
||||
deck.energy_cards = energy_cards
|
||||
if is_valid is not None:
|
||||
deck.is_valid = is_valid
|
||||
if validation_errors is not None:
|
||||
# Use UNSET pattern for nullable fields that can be cleared
|
||||
if validation_errors is not UNSET:
|
||||
deck.validation_errors = validation_errors
|
||||
if description is not None:
|
||||
if description is not UNSET:
|
||||
deck.description = description
|
||||
|
||||
await self._db.commit()
|
||||
|
||||
@ -24,11 +24,38 @@ Example:
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from app.db.models.collection import CardSource
|
||||
|
||||
# =============================================================================
|
||||
# Sentinel Value for Optional Updates
|
||||
# =============================================================================
|
||||
# UNSET is used to distinguish between "not provided" (keep existing)
|
||||
# and "explicitly set to None" (clear the field).
|
||||
#
|
||||
# Example usage:
|
||||
# async def update(description: str | None = UNSET):
|
||||
# if description is not UNSET:
|
||||
# # User explicitly provided a value (could be None to clear)
|
||||
# record.description = description
|
||||
|
||||
|
||||
class _UnsetType:
|
||||
"""Sentinel class for unset optional parameters."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "UNSET"
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
UNSET: Any = _UnsetType()
|
||||
|
||||
# =============================================================================
|
||||
# Data Transfer Objects (DTOs)
|
||||
# =============================================================================
|
||||
@ -272,21 +299,22 @@ class DeckRepository(Protocol):
|
||||
cards: dict[str, int] | None = None,
|
||||
energy_cards: dict[str, int] | None = None,
|
||||
is_valid: bool | None = None,
|
||||
validation_errors: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
validation_errors: list[str] | None = UNSET, # type: ignore[assignment]
|
||||
description: str | None = UNSET, # type: ignore[assignment]
|
||||
) -> DeckEntry | None:
|
||||
"""Update an existing deck.
|
||||
|
||||
Only provided (non-None) fields are updated.
|
||||
Only provided (non-UNSET) fields are updated.
|
||||
Use UNSET (default) to keep existing value, or None to clear.
|
||||
|
||||
Args:
|
||||
deck_id: The deck's UUID.
|
||||
name: New name (optional).
|
||||
cards: New card composition (optional).
|
||||
energy_cards: New energy composition (optional).
|
||||
is_valid: New validation status (optional).
|
||||
validation_errors: New validation errors (optional).
|
||||
description: New description (optional).
|
||||
name: New name (optional, None keeps existing).
|
||||
cards: New card composition (optional, None keeps existing).
|
||||
energy_cards: New energy composition (optional, None keeps existing).
|
||||
is_valid: New validation status (optional, None keeps existing).
|
||||
validation_errors: New errors (UNSET=keep, None=clear, list=set).
|
||||
description: New description (UNSET=keep, None=clear, str=set).
|
||||
|
||||
Returns:
|
||||
Updated DeckEntry, or None if deck not found.
|
||||
|
||||
@ -28,10 +28,14 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.config import DeckConfig
|
||||
from app.core.enums import EnergyType
|
||||
|
||||
# Card ID format: set-number-name (e.g., a1-001-bulbasaur, a1-094-pikachu-ex)
|
||||
CARD_ID_PATTERN = re.compile(r"^[a-z0-9]+-\d{3}-[a-z0-9-]+$")
|
||||
|
||||
# Set of valid energy type names (lowercase values from EnergyType enum)
|
||||
VALID_ENERGY_TYPES: frozenset[str] = frozenset(e.value for e in EnergyType)
|
||||
|
||||
|
||||
def validate_card_quantities(cards: dict[str, int], field_name: str) -> dict[str, int]:
|
||||
"""Validate card quantities are within valid range.
|
||||
@ -58,6 +62,31 @@ def validate_card_quantities(cards: dict[str, int], field_name: str) -> dict[str
|
||||
return cards
|
||||
|
||||
|
||||
def validate_energy_types(energy_cards: dict[str, int], field_name: str) -> dict[str, int]:
|
||||
"""Validate energy type names are valid.
|
||||
|
||||
Args:
|
||||
energy_cards: Mapping of energy type names to quantities.
|
||||
field_name: Name of the field for error messages.
|
||||
|
||||
Returns:
|
||||
The validated energy card dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If any energy type is invalid.
|
||||
"""
|
||||
invalid_types = [et for et in energy_cards if et not in VALID_ENERGY_TYPES]
|
||||
if invalid_types:
|
||||
display_types = invalid_types[:5]
|
||||
more = len(invalid_types) - 5
|
||||
error_msg = f"{field_name}: invalid energy types: {', '.join(display_types)}"
|
||||
if more > 0:
|
||||
error_msg += f" (and {more} more)"
|
||||
error_msg += f". Valid types: {', '.join(sorted(VALID_ENERGY_TYPES))}"
|
||||
raise ValueError(error_msg)
|
||||
return energy_cards
|
||||
|
||||
|
||||
def validate_card_id_format(cards: dict[str, int], field_name: str) -> dict[str, int]:
|
||||
"""Validate card IDs match expected format.
|
||||
|
||||
@ -116,7 +145,8 @@ class DeckCreateRequest(BaseModel):
|
||||
@field_validator("energy_cards")
|
||||
@classmethod
|
||||
def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]:
|
||||
"""Validate energy card quantities."""
|
||||
"""Validate energy card types and quantities."""
|
||||
validate_energy_types(v, "energy_cards")
|
||||
validate_card_quantities(v, "energy_cards")
|
||||
return v
|
||||
|
||||
@ -161,8 +191,9 @@ class DeckUpdateRequest(BaseModel):
|
||||
@field_validator("energy_cards")
|
||||
@classmethod
|
||||
def validate_energy_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None:
|
||||
"""Validate energy card quantities if provided."""
|
||||
"""Validate energy card types and quantities if provided."""
|
||||
if v is not None:
|
||||
validate_energy_types(v, "energy_cards")
|
||||
validate_card_quantities(v, "energy_cards")
|
||||
return v
|
||||
|
||||
@ -249,7 +280,8 @@ class DeckValidateRequest(BaseModel):
|
||||
@field_validator("energy_cards")
|
||||
@classmethod
|
||||
def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]:
|
||||
"""Validate energy card quantities."""
|
||||
"""Validate energy card types and quantities."""
|
||||
validate_energy_types(v, "energy_cards")
|
||||
validate_card_quantities(v, "energy_cards")
|
||||
return v
|
||||
|
||||
|
||||
@ -209,6 +209,11 @@ class CollectionService:
|
||||
) -> list[CollectionEntry]:
|
||||
"""Grant all cards from a starter deck to user's collection.
|
||||
|
||||
.. deprecated::
|
||||
Use DeckService.select_and_grant_starter_deck() instead, which
|
||||
atomically creates the starter deck AND grants the cards with
|
||||
race condition protection.
|
||||
|
||||
Uses CardSource.STARTER for all granted cards.
|
||||
|
||||
Args:
|
||||
@ -243,19 +248,6 @@ class CollectionService:
|
||||
|
||||
return entries
|
||||
|
||||
async def has_starter_deck(self, user_id: UUID) -> bool:
|
||||
"""Check if user has already received a starter deck.
|
||||
|
||||
Checks for any cards with STARTER source in collection.
|
||||
|
||||
Args:
|
||||
user_id: The user's UUID.
|
||||
|
||||
Returns:
|
||||
True if user has starter cards, False otherwise.
|
||||
"""
|
||||
return await self._repo.exists_with_source(user_id, CardSource.STARTER)
|
||||
|
||||
async def get_collection_stats(self, user_id: UUID) -> dict[str, int]:
|
||||
"""Get aggregate statistics for user's collection.
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from app.core.config import DeckConfig
|
||||
from app.core.models.card import CardDefinition
|
||||
from app.repositories.protocols import (
|
||||
UNSET,
|
||||
CollectionRepository,
|
||||
DeckEntry,
|
||||
DeckRepository,
|
||||
@ -186,7 +187,7 @@ class DeckService:
|
||||
cards: dict[str, int] | None = None,
|
||||
energy_cards: dict[str, int] | None = None,
|
||||
validate_ownership: bool = True,
|
||||
description: str | None = None,
|
||||
description: str | None = UNSET, # type: ignore[assignment]
|
||||
) -> DeckEntry:
|
||||
"""Update an existing deck.
|
||||
|
||||
@ -196,11 +197,11 @@ class DeckService:
|
||||
user_id: The user's UUID (for ownership verification).
|
||||
deck_id: The deck's UUID.
|
||||
deck_config: Deck rules from the caller (frontend provides this).
|
||||
name: New name (optional).
|
||||
cards: New card composition (optional).
|
||||
energy_cards: New energy composition (optional).
|
||||
name: New name (optional, None keeps existing).
|
||||
cards: New card composition (optional, None keeps existing).
|
||||
energy_cards: New energy composition (optional, None keeps existing).
|
||||
validate_ownership: If True, checks card ownership (campaign mode).
|
||||
description: New description (optional).
|
||||
description: New description (UNSET=keep, None=clear, str=set).
|
||||
|
||||
Returns:
|
||||
The updated DeckEntry.
|
||||
@ -370,14 +371,31 @@ class DeckService:
|
||||
|
||||
Raises:
|
||||
DeckNotFoundError: If deck not found or not owned by user.
|
||||
ValueError: If deck contains invalid card IDs that cannot be resolved.
|
||||
"""
|
||||
deck = await self.get_deck(user_id, deck_id)
|
||||
|
||||
cards: list[CardDefinition] = []
|
||||
invalid_card_ids: list[str] = []
|
||||
|
||||
for card_id, quantity in deck.cards.items():
|
||||
card_def = self._card_service.get_card(card_id)
|
||||
if card_def is not None:
|
||||
cards.extend([card_def] * quantity)
|
||||
else:
|
||||
invalid_card_ids.append(card_id)
|
||||
|
||||
if invalid_card_ids:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Deck {deck_id} contains invalid card IDs: {invalid_card_ids[:5]}"
|
||||
+ (f" (and {len(invalid_card_ids) - 5} more)" if len(invalid_card_ids) > 5 else "")
|
||||
)
|
||||
raise ValueError(
|
||||
f"Deck contains {len(invalid_card_ids)} invalid card(s) that cannot be found: "
|
||||
f"{', '.join(invalid_card_ids[:5])}"
|
||||
+ (f" (and {len(invalid_card_ids) - 5} more)" if len(invalid_card_ids) > 5 else "")
|
||||
)
|
||||
|
||||
return cards
|
||||
|
||||
@ -401,8 +419,11 @@ class DeckService:
|
||||
) -> DeckEntry:
|
||||
"""Create a starter deck for a user.
|
||||
|
||||
.. deprecated::
|
||||
Use select_and_grant_starter_deck() instead, which atomically
|
||||
creates the deck AND grants cards with race condition protection.
|
||||
|
||||
This creates the deck but does NOT grant the cards to collection.
|
||||
Use CollectionService.grant_starter_deck() to grant the cards.
|
||||
|
||||
Args:
|
||||
user_id: The user's UUID.
|
||||
|
||||
@ -25,8 +25,12 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.core.config import DeckConfig
|
||||
from app.core.enums import EnergyType
|
||||
from app.core.models.card import CardDefinition
|
||||
|
||||
# Set of valid energy type names (lowercase values from EnergyType enum)
|
||||
VALID_ENERGY_TYPES: frozenset[str] = frozenset(e.value for e in EnergyType)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
@ -93,6 +97,16 @@ def validate_deck(
|
||||
f"got {total_energy}"
|
||||
)
|
||||
|
||||
# 2b. Validate energy types are valid
|
||||
invalid_energy_types = [et for et in energy_cards if et not in VALID_ENERGY_TYPES]
|
||||
if invalid_energy_types:
|
||||
display_types = invalid_energy_types[:5]
|
||||
more = len(invalid_energy_types) - 5
|
||||
error_msg = f"Invalid energy types: {', '.join(display_types)}"
|
||||
if more > 0:
|
||||
error_msg += f" (and {more} more)"
|
||||
result.add_error(error_msg)
|
||||
|
||||
# 3. Validate max copies per card
|
||||
for card_id, quantity in cards.items():
|
||||
if quantity > deck_config.max_copies_per_card:
|
||||
@ -182,3 +196,19 @@ def count_basic_pokemon(
|
||||
if card_def and card_def.is_basic_pokemon():
|
||||
count += quantity
|
||||
return count
|
||||
|
||||
|
||||
def validate_energy_types(energy_types: list[str]) -> list[str]:
|
||||
"""Check which energy type names are invalid.
|
||||
|
||||
Args:
|
||||
energy_types: List of energy type names to check.
|
||||
|
||||
Returns:
|
||||
List of invalid energy type names (empty if all valid).
|
||||
|
||||
Example:
|
||||
invalid = validate_energy_types(["grass", "fire", "invalid"])
|
||||
# Returns: ["invalid"]
|
||||
"""
|
||||
return [et for et in energy_types if et not in VALID_ENERGY_TYPES]
|
||||
|
||||
@ -801,3 +801,83 @@ class TestOwnershipValidation:
|
||||
# Should NOT have ownership errors
|
||||
ownership_errors = [e for e in (deck.validation_errors or []) if "Insufficient" in e]
|
||||
assert len(ownership_errors) == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Deck For Game Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetDeckForGame:
|
||||
"""Tests for expanding decks to CardDefinition lists for gameplay."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_deck_for_game_returns_card_definitions(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
deck_service: DeckService,
|
||||
):
|
||||
"""
|
||||
Test that get_deck_for_game returns CardDefinition objects.
|
||||
|
||||
Expands the deck dict into a list of CardDefinition with duplicates
|
||||
based on quantity.
|
||||
"""
|
||||
user = await UserFactory.create(db_session)
|
||||
# Create a deck with valid cards (a1-001-bulbasaur exists in test data)
|
||||
deck = await DeckFactory.create_for_user(
|
||||
db_session,
|
||||
user,
|
||||
cards={"a1-001-bulbasaur": 4},
|
||||
energy_cards={"grass": 20},
|
||||
)
|
||||
|
||||
cards = await deck_service.get_deck_for_game(user.id, deck.id)
|
||||
|
||||
assert len(cards) == 4
|
||||
assert all(card.id == "a1-001-bulbasaur" for card in cards)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_deck_for_game_deck_not_found(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
deck_service: DeckService,
|
||||
):
|
||||
"""
|
||||
Test that get_deck_for_game raises DeckNotFoundError.
|
||||
|
||||
Should fail for non-existent deck.
|
||||
"""
|
||||
user = await UserFactory.create(db_session)
|
||||
from uuid import uuid4
|
||||
|
||||
with pytest.raises(DeckNotFoundError):
|
||||
await deck_service.get_deck_for_game(user.id, uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_deck_for_game_invalid_card_raises(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
deck_service: DeckService,
|
||||
):
|
||||
"""
|
||||
Test that get_deck_for_game raises ValueError for invalid cards.
|
||||
|
||||
If the deck contains card IDs that don't exist in the card database,
|
||||
the method should raise ValueError rather than silently skipping.
|
||||
This prevents games from starting with incomplete decks.
|
||||
"""
|
||||
user = await UserFactory.create(db_session)
|
||||
# Create a deck with invalid card ID
|
||||
deck = await DeckFactory.create_for_user(
|
||||
db_session,
|
||||
user,
|
||||
cards={"nonexistent-card-id": 4, "also-invalid": 2},
|
||||
energy_cards={"grass": 20},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await deck_service.get_deck_for_game(user.id, deck.id)
|
||||
|
||||
assert "invalid card" in str(exc_info.value).lower()
|
||||
assert "nonexistent-card-id" in str(exc_info.value)
|
||||
|
||||
@ -18,10 +18,12 @@ 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,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
@ -209,6 +211,64 @@ class TestEnergyCountValidation:
|
||||
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
|
||||
# =============================================================================
|
||||
@ -513,3 +573,36 @@ class TestUtilityFunctions:
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user