mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 16:10:12 +01:00
feat: Add include/exclude card lists feature with web UI, validation, fuzzy matching, and JSON persistence (ALLOW_MUST_HAVES=1)
This commit is contained in:
parent
7ef45252f7
commit
0516260304
39 changed files with 3672 additions and 626 deletions
283
code/tests/test_include_exclude_utils.py
Normal file
283
code/tests/test_include_exclude_utils.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""
|
||||
Unit tests for include/exclude utilities.
|
||||
|
||||
Tests the fuzzy matching, normalization, and validation functions
|
||||
that support the must-include/must-exclude feature.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Set
|
||||
|
||||
from deck_builder.include_exclude_utils import (
|
||||
normalize_card_name,
|
||||
normalize_punctuation,
|
||||
fuzzy_match_card_name,
|
||||
validate_list_sizes,
|
||||
collapse_duplicates,
|
||||
parse_card_list_input,
|
||||
get_baseline_performance_metrics,
|
||||
FuzzyMatchResult,
|
||||
FUZZY_CONFIDENCE_THRESHOLD,
|
||||
MAX_INCLUDES,
|
||||
MAX_EXCLUDES
|
||||
)
|
||||
|
||||
|
||||
class TestNormalization:
|
||||
"""Test card name normalization functions."""
|
||||
|
||||
def test_normalize_card_name_basic(self):
|
||||
"""Test basic name normalization."""
|
||||
assert normalize_card_name("Lightning Bolt") == "lightning bolt"
|
||||
assert normalize_card_name(" Sol Ring ") == "sol ring"
|
||||
assert normalize_card_name("") == ""
|
||||
|
||||
def test_normalize_card_name_unicode(self):
|
||||
"""Test unicode character normalization."""
|
||||
# Curly apostrophe to straight
|
||||
assert normalize_card_name("Thassa's Oracle") == "thassa's oracle"
|
||||
# Test case from combo tag applier
|
||||
assert normalize_card_name("Thassa\u2019s Oracle") == "thassa's oracle"
|
||||
|
||||
def test_normalize_card_name_arena_prefix(self):
|
||||
"""Test Arena/Alchemy prefix removal."""
|
||||
assert normalize_card_name("A-Lightning Bolt") == "lightning bolt"
|
||||
assert normalize_card_name("A-") == "a-" # Edge case: too short
|
||||
|
||||
def test_normalize_punctuation_commas(self):
|
||||
"""Test punctuation normalization for commas."""
|
||||
assert normalize_punctuation("Krenko, Mob Boss") == "krenko mob boss"
|
||||
assert normalize_punctuation("Krenko Mob Boss") == "krenko mob boss"
|
||||
# Should be equivalent for fuzzy matching
|
||||
assert (normalize_punctuation("Krenko, Mob Boss") ==
|
||||
normalize_punctuation("Krenko Mob Boss"))
|
||||
|
||||
|
||||
class TestFuzzyMatching:
|
||||
"""Test fuzzy card name matching."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_card_names(self) -> Set[str]:
|
||||
"""Sample card names for testing."""
|
||||
return {
|
||||
"Lightning Bolt",
|
||||
"Lightning Strike",
|
||||
"Lightning Helix",
|
||||
"Krenko, Mob Boss",
|
||||
"Sol Ring",
|
||||
"Thassa's Oracle",
|
||||
"Demonic Consultation"
|
||||
}
|
||||
|
||||
def test_exact_match(self, sample_card_names):
|
||||
"""Test exact name matching."""
|
||||
result = fuzzy_match_card_name("Lightning Bolt", sample_card_names)
|
||||
assert result.matched_name == "Lightning Bolt"
|
||||
assert result.confidence == 1.0
|
||||
assert result.auto_accepted is True
|
||||
assert len(result.suggestions) == 0
|
||||
|
||||
def test_exact_match_after_normalization(self, sample_card_names):
|
||||
"""Test exact match after punctuation normalization."""
|
||||
result = fuzzy_match_card_name("Krenko Mob Boss", sample_card_names)
|
||||
assert result.matched_name == "Krenko, Mob Boss"
|
||||
assert result.confidence == 1.0
|
||||
assert result.auto_accepted is True
|
||||
|
||||
def test_typo_suggestion(self, sample_card_names):
|
||||
"""Test typo suggestions."""
|
||||
result = fuzzy_match_card_name("Lightnig Bolt", sample_card_names)
|
||||
assert "Lightning Bolt" in result.suggestions
|
||||
# Should have high confidence but maybe not auto-accepted depending on threshold
|
||||
assert result.confidence > 0.8
|
||||
|
||||
def test_ambiguous_match(self, sample_card_names):
|
||||
"""Test ambiguous input requiring confirmation."""
|
||||
result = fuzzy_match_card_name("Lightning", sample_card_names)
|
||||
# Should return multiple lightning-related suggestions
|
||||
lightning_suggestions = [s for s in result.suggestions if "Lightning" in s]
|
||||
assert len(lightning_suggestions) >= 2
|
||||
|
||||
def test_no_match(self, sample_card_names):
|
||||
"""Test input with no reasonable matches."""
|
||||
result = fuzzy_match_card_name("Completely Invalid Card", sample_card_names)
|
||||
assert result.matched_name is None
|
||||
assert result.confidence == 0.0
|
||||
assert result.auto_accepted is False
|
||||
|
||||
def test_empty_input(self, sample_card_names):
|
||||
"""Test empty input handling."""
|
||||
result = fuzzy_match_card_name("", sample_card_names)
|
||||
assert result.matched_name is None
|
||||
assert result.confidence == 0.0
|
||||
assert result.auto_accepted is False
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test validation functions."""
|
||||
|
||||
def test_validate_list_sizes_valid(self):
|
||||
"""Test validation with acceptable list sizes."""
|
||||
includes = ["Card A", "Card B"] # Well under limit
|
||||
excludes = ["Card X", "Card Y", "Card Z"] # Well under limit
|
||||
|
||||
result = validate_list_sizes(includes, excludes)
|
||||
assert result['valid'] is True
|
||||
assert len(result['errors']) == 0
|
||||
assert result['counts']['includes'] == 2
|
||||
assert result['counts']['excludes'] == 3
|
||||
|
||||
def test_validate_list_sizes_warnings(self):
|
||||
"""Test warning thresholds."""
|
||||
includes = ["Card"] * 8 # 80% of 10 = 8, should trigger warning
|
||||
excludes = ["Card"] * 12 # 80% of 15 = 12, should trigger warning
|
||||
|
||||
result = validate_list_sizes(includes, excludes)
|
||||
assert result['valid'] is True
|
||||
assert 'includes_approaching_limit' in result['warnings']
|
||||
assert 'excludes_approaching_limit' in result['warnings']
|
||||
|
||||
def test_validate_list_sizes_errors(self):
|
||||
"""Test size limit errors."""
|
||||
includes = ["Card"] * 15 # Over limit of 10
|
||||
excludes = ["Card"] * 20 # Over limit of 15
|
||||
|
||||
result = validate_list_sizes(includes, excludes)
|
||||
assert result['valid'] is False
|
||||
assert len(result['errors']) == 2
|
||||
assert "Too many include cards" in result['errors'][0]
|
||||
assert "Too many exclude cards" in result['errors'][1]
|
||||
|
||||
|
||||
class TestDuplicateCollapse:
|
||||
"""Test duplicate handling."""
|
||||
|
||||
def test_collapse_duplicates_basic(self):
|
||||
"""Test basic duplicate removal."""
|
||||
names = ["Lightning Bolt", "Sol Ring", "Lightning Bolt"]
|
||||
unique, duplicates = collapse_duplicates(names)
|
||||
|
||||
assert len(unique) == 2
|
||||
assert "Lightning Bolt" in unique
|
||||
assert "Sol Ring" in unique
|
||||
assert duplicates["Lightning Bolt"] == 2
|
||||
|
||||
def test_collapse_duplicates_case_insensitive(self):
|
||||
"""Test case-insensitive duplicate detection."""
|
||||
names = ["Lightning Bolt", "LIGHTNING BOLT", "lightning bolt"]
|
||||
unique, duplicates = collapse_duplicates(names)
|
||||
|
||||
assert len(unique) == 1
|
||||
assert duplicates[unique[0]] == 3
|
||||
|
||||
def test_collapse_duplicates_empty(self):
|
||||
"""Test empty input."""
|
||||
unique, duplicates = collapse_duplicates([])
|
||||
assert unique == []
|
||||
assert duplicates == {}
|
||||
|
||||
def test_collapse_duplicates_whitespace(self):
|
||||
"""Test whitespace handling."""
|
||||
names = ["Lightning Bolt", " Lightning Bolt ", "", " "]
|
||||
unique, duplicates = collapse_duplicates(names)
|
||||
|
||||
assert len(unique) == 1
|
||||
assert duplicates[unique[0]] == 2
|
||||
|
||||
|
||||
class TestInputParsing:
|
||||
"""Test input parsing functions."""
|
||||
|
||||
def test_parse_card_list_newlines(self):
|
||||
"""Test newline-separated input."""
|
||||
input_text = "Lightning Bolt\nSol Ring\nKrenko, Mob Boss"
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert len(result) == 3
|
||||
assert "Lightning Bolt" in result
|
||||
assert "Sol Ring" in result
|
||||
assert "Krenko, Mob Boss" in result
|
||||
|
||||
def test_parse_card_list_commas(self):
|
||||
"""Test comma-separated input (no newlines)."""
|
||||
input_text = "Lightning Bolt, Sol Ring, Thassa's Oracle"
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert len(result) == 3
|
||||
assert "Lightning Bolt" in result
|
||||
assert "Sol Ring" in result
|
||||
assert "Thassa's Oracle" in result
|
||||
|
||||
def test_parse_card_list_commas_in_names(self):
|
||||
"""Test that commas in card names are preserved when using newlines."""
|
||||
input_text = "Krenko, Mob Boss\nFinneas, Ace Archer"
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "Krenko, Mob Boss" in result
|
||||
assert "Finneas, Ace Archer" in result
|
||||
|
||||
def test_parse_card_list_mixed(self):
|
||||
"""Test that newlines take precedence over commas."""
|
||||
# When both separators present, newlines take precedence
|
||||
input_text = "Lightning Bolt\nKrenko, Mob Boss\nThassa's Oracle"
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert len(result) == 3
|
||||
assert "Lightning Bolt" in result
|
||||
assert "Krenko, Mob Boss" in result # Comma preserved in name
|
||||
assert "Thassa's Oracle" in result
|
||||
|
||||
def test_parse_card_list_empty(self):
|
||||
"""Test empty input."""
|
||||
assert parse_card_list_input("") == []
|
||||
assert parse_card_list_input(" ") == []
|
||||
assert parse_card_list_input("\n\n\n") == []
|
||||
assert parse_card_list_input(" , , ") == []
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
"""Test performance measurement functions."""
|
||||
|
||||
def test_baseline_performance_metrics(self):
|
||||
"""Test baseline performance measurement."""
|
||||
metrics = get_baseline_performance_metrics()
|
||||
|
||||
assert 'normalization_time_ms' in metrics
|
||||
assert 'operations_count' in metrics
|
||||
assert 'timestamp' in metrics
|
||||
|
||||
# Should be reasonably fast
|
||||
assert metrics['normalization_time_ms'] < 1000 # Less than 1 second
|
||||
assert metrics['operations_count'] > 0
|
||||
|
||||
|
||||
class TestFeatureFlagIntegration:
|
||||
"""Test feature flag integration."""
|
||||
|
||||
def test_constants_defined(self):
|
||||
"""Test that required constants are properly defined."""
|
||||
assert isinstance(FUZZY_CONFIDENCE_THRESHOLD, float)
|
||||
assert 0.0 <= FUZZY_CONFIDENCE_THRESHOLD <= 1.0
|
||||
|
||||
assert isinstance(MAX_INCLUDES, int)
|
||||
assert MAX_INCLUDES > 0
|
||||
|
||||
assert isinstance(MAX_EXCLUDES, int)
|
||||
assert MAX_EXCLUDES > 0
|
||||
|
||||
def test_fuzzy_match_result_structure(self):
|
||||
"""Test FuzzyMatchResult dataclass structure."""
|
||||
result = FuzzyMatchResult(
|
||||
input_name="test",
|
||||
matched_name="Test Card",
|
||||
confidence=0.95,
|
||||
suggestions=["Test Card", "Other Card"],
|
||||
auto_accepted=True
|
||||
)
|
||||
|
||||
assert result.input_name == "test"
|
||||
assert result.matched_name == "Test Card"
|
||||
assert result.confidence == 0.95
|
||||
assert len(result.suggestions) == 2
|
||||
assert result.auto_accepted is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue