mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +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
270
code/tests/test_include_exclude_validation.py
Normal file
270
code/tests/test_include_exclude_validation.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""
|
||||
Unit tests for include/exclude card validation and processing functionality.
|
||||
|
||||
Tests schema integration, validation utilities, fuzzy matching, strict enforcement,
|
||||
and JSON export behavior for the include/exclude card system.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder.include_exclude_utils import (
|
||||
IncludeExcludeDiagnostics,
|
||||
validate_list_sizes,
|
||||
collapse_duplicates,
|
||||
parse_card_list_input
|
||||
)
|
||||
|
||||
|
||||
class TestIncludeExcludeSchema:
|
||||
"""Test that DeckBuilder properly supports include/exclude configuration."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test that DeckBuilder has correct default values for include/exclude fields."""
|
||||
builder = DeckBuilder()
|
||||
|
||||
assert builder.include_cards == []
|
||||
assert builder.exclude_cards == []
|
||||
assert builder.enforcement_mode == "warn"
|
||||
assert builder.allow_illegal is False
|
||||
assert builder.fuzzy_matching is True
|
||||
assert builder.include_exclude_diagnostics is None
|
||||
|
||||
def test_field_assignment(self):
|
||||
"""Test that include/exclude fields can be assigned."""
|
||||
builder = DeckBuilder()
|
||||
|
||||
builder.include_cards = ["Sol Ring", "Lightning Bolt"]
|
||||
builder.exclude_cards = ["Chaos Orb", "Shaharazad"]
|
||||
builder.enforcement_mode = "strict"
|
||||
builder.allow_illegal = True
|
||||
builder.fuzzy_matching = False
|
||||
|
||||
assert builder.include_cards == ["Sol Ring", "Lightning Bolt"]
|
||||
assert builder.exclude_cards == ["Chaos Orb", "Shaharazad"]
|
||||
assert builder.enforcement_mode == "strict"
|
||||
assert builder.allow_illegal is True
|
||||
assert builder.fuzzy_matching is False
|
||||
|
||||
|
||||
class TestProcessIncludesExcludes:
|
||||
"""Test the _process_includes_excludes method."""
|
||||
|
||||
def test_basic_processing(self):
|
||||
"""Test basic include/exclude processing."""
|
||||
builder = DeckBuilder()
|
||||
builder.include_cards = ["Sol Ring", "Lightning Bolt"]
|
||||
builder.exclude_cards = ["Chaos Orb"]
|
||||
|
||||
# Mock output function to capture messages
|
||||
output_messages = []
|
||||
builder.output_func = lambda msg: output_messages.append(msg)
|
||||
|
||||
diagnostics = builder._process_includes_excludes()
|
||||
|
||||
assert isinstance(diagnostics, IncludeExcludeDiagnostics)
|
||||
assert builder.include_exclude_diagnostics is not None
|
||||
|
||||
def test_duplicate_collapse(self):
|
||||
"""Test that duplicates are properly collapsed."""
|
||||
builder = DeckBuilder()
|
||||
builder.include_cards = ["Sol Ring", "Sol Ring", "Lightning Bolt"]
|
||||
builder.exclude_cards = ["Chaos Orb", "Chaos Orb", "Chaos Orb"]
|
||||
|
||||
output_messages = []
|
||||
builder.output_func = lambda msg: output_messages.append(msg)
|
||||
|
||||
diagnostics = builder._process_includes_excludes()
|
||||
|
||||
# After processing, duplicates should be removed
|
||||
assert builder.include_cards == ["Sol Ring", "Lightning Bolt"]
|
||||
assert builder.exclude_cards == ["Chaos Orb"]
|
||||
|
||||
# Duplicates should be tracked in diagnostics
|
||||
assert diagnostics.duplicates_collapsed["Sol Ring"] == 2
|
||||
assert diagnostics.duplicates_collapsed["Chaos Orb"] == 3
|
||||
|
||||
def test_exclude_overrides_include(self):
|
||||
"""Test that exclude takes precedence over include."""
|
||||
builder = DeckBuilder()
|
||||
builder.include_cards = ["Sol Ring", "Lightning Bolt"]
|
||||
builder.exclude_cards = ["Sol Ring"] # Sol Ring appears in both lists
|
||||
|
||||
output_messages = []
|
||||
builder.output_func = lambda msg: output_messages.append(msg)
|
||||
|
||||
diagnostics = builder._process_includes_excludes()
|
||||
|
||||
# Sol Ring should be removed from includes due to exclude precedence
|
||||
assert "Sol Ring" not in builder.include_cards
|
||||
assert "Lightning Bolt" in builder.include_cards
|
||||
assert "Sol Ring" in diagnostics.excluded_removed
|
||||
|
||||
|
||||
class TestValidationUtilities:
|
||||
"""Test the validation utility functions."""
|
||||
|
||||
def test_list_size_validation_valid(self):
|
||||
"""Test list size validation with valid sizes."""
|
||||
includes = ["Card A", "Card B"]
|
||||
excludes = ["Card X", "Card Y", "Card Z"]
|
||||
|
||||
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_list_size_validation_approaching_limit(self):
|
||||
"""Test list size validation warnings when approaching limits."""
|
||||
includes = ["Card"] * 8 # 80% of 10 = 8
|
||||
excludes = ["Card"] * 12 # 80% of 15 = 12
|
||||
|
||||
result = validate_list_sizes(includes, excludes)
|
||||
|
||||
assert result['valid'] is True # Still valid, just warnings
|
||||
assert 'includes_approaching_limit' in result['warnings']
|
||||
assert 'excludes_approaching_limit' in result['warnings']
|
||||
|
||||
def test_list_size_validation_over_limit(self):
|
||||
"""Test list size validation errors when over limits."""
|
||||
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]
|
||||
|
||||
def test_collapse_duplicates(self):
|
||||
"""Test duplicate collapse functionality."""
|
||||
card_names = ["Sol Ring", "Lightning Bolt", "Sol Ring", "Counterspell", "Lightning Bolt", "Lightning Bolt"]
|
||||
|
||||
unique_names, duplicates = collapse_duplicates(card_names)
|
||||
|
||||
assert len(unique_names) == 3
|
||||
assert "Sol Ring" in unique_names
|
||||
assert "Lightning Bolt" in unique_names
|
||||
assert "Counterspell" in unique_names
|
||||
|
||||
assert duplicates["Sol Ring"] == 2
|
||||
assert duplicates["Lightning Bolt"] == 3
|
||||
assert "Counterspell" not in duplicates # Only appeared once
|
||||
|
||||
def test_parse_card_list_input_newlines(self):
|
||||
"""Test parsing card list input with newlines."""
|
||||
input_text = "Sol Ring\nLightning Bolt\nCounterspell"
|
||||
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert result == ["Sol Ring", "Lightning Bolt", "Counterspell"]
|
||||
|
||||
def test_parse_card_list_input_commas(self):
|
||||
"""Test parsing card list input with commas (when no newlines)."""
|
||||
input_text = "Sol Ring, Lightning Bolt, Counterspell"
|
||||
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
assert result == ["Sol Ring", "Lightning Bolt", "Counterspell"]
|
||||
|
||||
def test_parse_card_list_input_mixed_prefers_newlines(self):
|
||||
"""Test that newlines take precedence over commas to avoid splitting names with commas."""
|
||||
input_text = "Sol Ring\nKrenko, Mob Boss\nLightning Bolt"
|
||||
|
||||
result = parse_card_list_input(input_text)
|
||||
|
||||
# Should not split "Krenko, Mob Boss" because newlines are present
|
||||
assert result == ["Sol Ring", "Krenko, Mob Boss", "Lightning Bolt"]
|
||||
|
||||
|
||||
class TestStrictEnforcement:
|
||||
"""Test strict enforcement functionality."""
|
||||
|
||||
def test_strict_enforcement_with_missing_includes(self):
|
||||
"""Test that strict mode raises error when includes are missing."""
|
||||
builder = DeckBuilder()
|
||||
builder.enforcement_mode = "strict"
|
||||
builder.include_exclude_diagnostics = {
|
||||
'missing_includes': ['Missing Card'],
|
||||
'ignored_color_identity': [],
|
||||
'illegal_dropped': [],
|
||||
'illegal_allowed': [],
|
||||
'excluded_removed': [],
|
||||
'duplicates_collapsed': {},
|
||||
'include_added': [],
|
||||
'include_over_ideal': {},
|
||||
'fuzzy_corrections': {},
|
||||
'confirmation_needed': [],
|
||||
'list_size_warnings': {}
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Strict mode: Failed to include required cards: Missing Card"):
|
||||
builder._enforce_includes_strict()
|
||||
|
||||
def test_strict_enforcement_with_no_missing_includes(self):
|
||||
"""Test that strict mode passes when all includes are present."""
|
||||
builder = DeckBuilder()
|
||||
builder.enforcement_mode = "strict"
|
||||
builder.include_exclude_diagnostics = {
|
||||
'missing_includes': [],
|
||||
'ignored_color_identity': [],
|
||||
'illegal_dropped': [],
|
||||
'illegal_allowed': [],
|
||||
'excluded_removed': [],
|
||||
'duplicates_collapsed': {},
|
||||
'include_added': ['Sol Ring'],
|
||||
'include_over_ideal': {},
|
||||
'fuzzy_corrections': {},
|
||||
'confirmation_needed': [],
|
||||
'list_size_warnings': {}
|
||||
}
|
||||
|
||||
# Should not raise any exception
|
||||
builder._enforce_includes_strict()
|
||||
|
||||
def test_warn_mode_does_not_enforce(self):
|
||||
"""Test that warn mode does not raise errors."""
|
||||
builder = DeckBuilder()
|
||||
builder.enforcement_mode = "warn"
|
||||
builder.include_exclude_diagnostics = {
|
||||
'missing_includes': ['Missing Card'],
|
||||
}
|
||||
|
||||
# Should not raise any exception
|
||||
builder._enforce_includes_strict()
|
||||
|
||||
|
||||
class TestJSONRoundTrip:
|
||||
"""Test JSON export/import round-trip functionality."""
|
||||
|
||||
def test_json_export_includes_new_fields(self):
|
||||
"""Test that JSON export includes include/exclude fields."""
|
||||
builder = DeckBuilder()
|
||||
builder.include_cards = ["Sol Ring", "Lightning Bolt"]
|
||||
builder.exclude_cards = ["Chaos Orb"]
|
||||
builder.enforcement_mode = "strict"
|
||||
builder.allow_illegal = True
|
||||
builder.fuzzy_matching = False
|
||||
|
||||
# Create temporary directory for export
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
json_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
|
||||
|
||||
# Read the exported JSON
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
exported_data = json.load(f)
|
||||
|
||||
# Verify include/exclude fields are present
|
||||
assert exported_data['include_cards'] == ["Sol Ring", "Lightning Bolt"]
|
||||
assert exported_data['exclude_cards'] == ["Chaos Orb"]
|
||||
assert exported_data['enforcement_mode'] == "strict"
|
||||
assert exported_data['allow_illegal'] is True
|
||||
assert exported_data['fuzzy_matching'] is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
Loading…
Add table
Add a link
Reference in a new issue