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
290
code/tests/test_include_exclude_ordering.py
Normal file
290
code/tests/test_include_exclude_ordering.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""
|
||||
Tests for include/exclude card ordering and injection logic (M2).
|
||||
|
||||
Tests the core M2 requirement that includes are injected after lands,
|
||||
before creature/spell fills, and that the ordering is invariant.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
import pandas as pd
|
||||
from typing import List
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
|
||||
|
||||
class TestIncludeExcludeOrdering(unittest.TestCase):
|
||||
"""Test ordering invariants and include injection logic."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Mock input/output functions to avoid interactive prompts
|
||||
self.mock_input = Mock(return_value="")
|
||||
self.mock_output = Mock()
|
||||
|
||||
# Create test card data
|
||||
self.test_cards_df = pd.DataFrame([
|
||||
{
|
||||
'name': 'Lightning Bolt',
|
||||
'type': 'Instant',
|
||||
'mana_cost': '{R}',
|
||||
'manaValue': 1,
|
||||
'themeTags': ['burn'],
|
||||
'colorIdentity': ['R']
|
||||
},
|
||||
{
|
||||
'name': 'Sol Ring',
|
||||
'type': 'Artifact',
|
||||
'mana_cost': '{1}',
|
||||
'manaValue': 1,
|
||||
'themeTags': ['ramp'],
|
||||
'colorIdentity': []
|
||||
},
|
||||
{
|
||||
'name': 'Llanowar Elves',
|
||||
'type': 'Creature — Elf Druid',
|
||||
'mana_cost': '{G}',
|
||||
'manaValue': 1,
|
||||
'themeTags': ['ramp', 'elves'],
|
||||
'colorIdentity': ['G'],
|
||||
'creatureTypes': ['Elf', 'Druid']
|
||||
},
|
||||
{
|
||||
'name': 'Forest',
|
||||
'type': 'Basic Land — Forest',
|
||||
'mana_cost': '',
|
||||
'manaValue': 0,
|
||||
'themeTags': [],
|
||||
'colorIdentity': ['G']
|
||||
},
|
||||
{
|
||||
'name': 'Command Tower',
|
||||
'type': 'Land',
|
||||
'mana_cost': '',
|
||||
'manaValue': 0,
|
||||
'themeTags': [],
|
||||
'colorIdentity': []
|
||||
}
|
||||
])
|
||||
|
||||
def _create_test_builder(self, include_cards: List[str] = None, exclude_cards: List[str] = None) -> DeckBuilder:
|
||||
"""Create a DeckBuilder instance for testing."""
|
||||
builder = DeckBuilder(
|
||||
input_func=self.mock_input,
|
||||
output_func=self.mock_output,
|
||||
log_outputs=False,
|
||||
headless=True
|
||||
)
|
||||
|
||||
# Set up basic configuration
|
||||
builder.color_identity = ['R', 'G']
|
||||
builder.color_identity_key = 'R, G'
|
||||
builder._combined_cards_df = self.test_cards_df.copy()
|
||||
builder._full_cards_df = self.test_cards_df.copy()
|
||||
|
||||
# Set include/exclude cards
|
||||
builder.include_cards = include_cards or []
|
||||
builder.exclude_cards = exclude_cards or []
|
||||
|
||||
# Set ideal counts to small values for testing
|
||||
builder.ideal_counts = {
|
||||
'lands': 5,
|
||||
'creatures': 3,
|
||||
'ramp': 2,
|
||||
'removal': 1,
|
||||
'wipes': 1,
|
||||
'card_advantage': 1,
|
||||
'protection': 1
|
||||
}
|
||||
|
||||
return builder
|
||||
|
||||
def test_include_injection_happens_after_lands(self):
|
||||
"""Test that includes are injected after lands are added."""
|
||||
builder = self._create_test_builder(include_cards=['Sol Ring', 'Lightning Bolt'])
|
||||
|
||||
# Track the order of additions by patching add_card
|
||||
original_add_card = builder.add_card
|
||||
addition_order = []
|
||||
|
||||
def track_add_card(card_name, **kwargs):
|
||||
addition_order.append({
|
||||
'name': card_name,
|
||||
'type': kwargs.get('card_type', ''),
|
||||
'added_by': kwargs.get('added_by', 'normal'),
|
||||
'role': kwargs.get('role', 'normal')
|
||||
})
|
||||
return original_add_card(card_name, **kwargs)
|
||||
|
||||
builder.add_card = track_add_card
|
||||
|
||||
# Mock the land building to add some lands
|
||||
def mock_run_land_steps():
|
||||
builder.add_card('Forest', card_type='Basic Land — Forest', added_by='land_phase')
|
||||
builder.add_card('Command Tower', card_type='Land', added_by='land_phase')
|
||||
|
||||
builder._run_land_build_steps = mock_run_land_steps
|
||||
|
||||
# Mock creature/spell phases to add some creatures/spells
|
||||
def mock_add_creatures():
|
||||
builder.add_card('Llanowar Elves', card_type='Creature — Elf Druid', added_by='creature_phase')
|
||||
|
||||
def mock_add_spells():
|
||||
pass # Lightning Bolt should already be added by includes
|
||||
|
||||
builder.add_creatures_phase = mock_add_creatures
|
||||
builder.add_spells_phase = mock_add_spells
|
||||
|
||||
# Run the injection process
|
||||
builder._inject_includes_after_lands()
|
||||
|
||||
# Verify includes were added with correct metadata
|
||||
self.assertIn('Sol Ring', builder.card_library)
|
||||
self.assertIn('Lightning Bolt', builder.card_library)
|
||||
|
||||
# Verify role marking
|
||||
self.assertEqual(builder.card_library['Sol Ring']['Role'], 'include')
|
||||
self.assertEqual(builder.card_library['Sol Ring']['AddedBy'], 'include_injection')
|
||||
self.assertEqual(builder.card_library['Lightning Bolt']['Role'], 'include')
|
||||
|
||||
# Verify diagnostics
|
||||
self.assertIsNotNone(builder.include_exclude_diagnostics)
|
||||
include_added = builder.include_exclude_diagnostics.get('include_added', [])
|
||||
self.assertIn('Sol Ring', include_added)
|
||||
self.assertIn('Lightning Bolt', include_added)
|
||||
|
||||
def test_ordering_invariant_lands_includes_rest(self):
|
||||
"""Test the ordering invariant: lands -> includes -> creatures/spells."""
|
||||
builder = self._create_test_builder(include_cards=['Sol Ring'])
|
||||
|
||||
# Track addition order with timestamps
|
||||
addition_log = []
|
||||
original_add_card = builder.add_card
|
||||
|
||||
def log_add_card(card_name, **kwargs):
|
||||
phase = kwargs.get('added_by', 'unknown')
|
||||
addition_log.append((card_name, phase))
|
||||
return original_add_card(card_name, **kwargs)
|
||||
|
||||
builder.add_card = log_add_card
|
||||
|
||||
# Simulate the complete build process with phase tracking
|
||||
# 1. Lands phase
|
||||
builder.add_card('Forest', card_type='Basic Land — Forest', added_by='lands')
|
||||
|
||||
# 2. Include injection phase
|
||||
builder._inject_includes_after_lands()
|
||||
|
||||
# 3. Creatures phase
|
||||
builder.add_card('Llanowar Elves', card_type='Creature — Elf Druid', added_by='creatures')
|
||||
|
||||
# Verify ordering: lands -> includes -> creatures
|
||||
land_indices = [i for i, (name, phase) in enumerate(addition_log) if phase == 'lands']
|
||||
include_indices = [i for i, (name, phase) in enumerate(addition_log) if phase == 'include_injection']
|
||||
creature_indices = [i for i, (name, phase) in enumerate(addition_log) if phase == 'creatures']
|
||||
|
||||
# Verify all lands come before all includes
|
||||
if land_indices and include_indices:
|
||||
self.assertLess(max(land_indices), min(include_indices),
|
||||
"All lands should be added before includes")
|
||||
|
||||
# Verify all includes come before all creatures
|
||||
if include_indices and creature_indices:
|
||||
self.assertLess(max(include_indices), min(creature_indices),
|
||||
"All includes should be added before creatures")
|
||||
|
||||
def test_include_over_ideal_tracking(self):
|
||||
"""Test that includes going over ideal counts are properly tracked."""
|
||||
builder = self._create_test_builder(include_cards=['Sol Ring', 'Lightning Bolt'])
|
||||
|
||||
# Set very low ideal counts to trigger over-ideal
|
||||
builder.ideal_counts['creatures'] = 0 # Force any creature include to be over-ideal
|
||||
|
||||
# Add a creature first to reach the limit
|
||||
builder.add_card('Llanowar Elves', card_type='Creature — Elf Druid')
|
||||
|
||||
# Now inject includes - should detect over-ideal condition
|
||||
builder._inject_includes_after_lands()
|
||||
|
||||
# Verify over-ideal tracking
|
||||
self.assertIsNotNone(builder.include_exclude_diagnostics)
|
||||
over_ideal = builder.include_exclude_diagnostics.get('include_over_ideal', {})
|
||||
|
||||
# Should track artifacts/instants appropriately based on categorization
|
||||
self.assertIsInstance(over_ideal, dict)
|
||||
|
||||
def test_include_injection_skips_already_present_cards(self):
|
||||
"""Test that include injection skips cards already in the library."""
|
||||
builder = self._create_test_builder(include_cards=['Sol Ring', 'Lightning Bolt'])
|
||||
|
||||
# Pre-add one of the include cards
|
||||
builder.add_card('Sol Ring', card_type='Artifact')
|
||||
|
||||
# Inject includes
|
||||
builder._inject_includes_after_lands()
|
||||
|
||||
# Verify only the new card was added
|
||||
include_added = builder.include_exclude_diagnostics.get('include_added', [])
|
||||
self.assertEqual(len(include_added), 1)
|
||||
self.assertIn('Lightning Bolt', include_added)
|
||||
self.assertNotIn('Sol Ring', include_added) # Should be skipped
|
||||
|
||||
# Verify Sol Ring count didn't change (still 1)
|
||||
self.assertEqual(builder.card_library['Sol Ring']['Count'], 1)
|
||||
|
||||
def test_include_injection_with_empty_include_list(self):
|
||||
"""Test that include injection handles empty include lists gracefully."""
|
||||
builder = self._create_test_builder(include_cards=[])
|
||||
|
||||
# Should complete without error
|
||||
builder._inject_includes_after_lands()
|
||||
|
||||
# Should not create diagnostics for empty list
|
||||
if builder.include_exclude_diagnostics:
|
||||
include_added = builder.include_exclude_diagnostics.get('include_added', [])
|
||||
self.assertEqual(len(include_added), 0)
|
||||
|
||||
def test_categorization_for_limits(self):
|
||||
"""Test card categorization for ideal count tracking."""
|
||||
builder = self._create_test_builder()
|
||||
|
||||
# Test various card type categorizations
|
||||
test_cases = [
|
||||
('Creature — Human Wizard', 'creatures'),
|
||||
('Instant', 'spells'),
|
||||
('Sorcery', 'spells'),
|
||||
('Artifact', 'spells'),
|
||||
('Enchantment', 'spells'),
|
||||
('Planeswalker', 'spells'),
|
||||
('Land', 'lands'),
|
||||
('Basic Land — Forest', 'lands'),
|
||||
('Unknown Type', 'other'),
|
||||
('', None)
|
||||
]
|
||||
|
||||
for card_type, expected_category in test_cases:
|
||||
with self.subTest(card_type=card_type):
|
||||
result = builder._categorize_card_for_limits(card_type)
|
||||
self.assertEqual(result, expected_category)
|
||||
|
||||
def test_count_cards_in_category(self):
|
||||
"""Test counting cards by category in the library."""
|
||||
builder = self._create_test_builder()
|
||||
|
||||
# Add cards of different types
|
||||
builder.add_card('Lightning Bolt', card_type='Instant')
|
||||
builder.add_card('Llanowar Elves', card_type='Creature — Elf Druid')
|
||||
builder.add_card('Sol Ring', card_type='Artifact')
|
||||
builder.add_card('Forest', card_type='Basic Land — Forest')
|
||||
builder.add_card('Island', card_type='Basic Land — Island') # Add multiple basics
|
||||
|
||||
# Test category counts
|
||||
self.assertEqual(builder._count_cards_in_category('spells'), 2) # Lightning Bolt + Sol Ring
|
||||
self.assertEqual(builder._count_cards_in_category('creatures'), 1) # Llanowar Elves
|
||||
self.assertEqual(builder._count_cards_in_category('lands'), 2) # Forest + Island
|
||||
self.assertEqual(builder._count_cards_in_category('other'), 0) # None added
|
||||
self.assertEqual(builder._count_cards_in_category('nonexistent'), 0) # Invalid category
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue