mtg_python_deckbuilder/code/tests/test_exclude_comprehensive.py

907 lines
36 KiB
Python
Raw Normal View History

"""
Comprehensive tests for exclude card functionality.
This file consolidates tests from multiple source files:
- test_comprehensive_exclude.py
- test_direct_exclude.py
- test_exclude_filtering.py
- test_exclude_integration.py
- test_exclude_cards_integration.py
- test_exclude_cards_compatibility.py
- test_exclude_reentry_prevention.py
Tests cover: exclude filtering, dataframe integration, manual lookups,
web flow integration, JSON persistence, compatibility, and re-entry prevention.
"""
import sys
import os
import time
import base64
import json
import unittest
from unittest.mock import Mock
import pandas as pd
import pytest
from typing import List
from starlette.testclient import TestClient
from deck_builder.builder import DeckBuilder
from deck_builder.include_exclude_utils import parse_card_list_input, normalize_card_name
# =============================================================================
# SECTION: Core Exclude Filtering Tests
# Source: test_comprehensive_exclude.py
# =============================================================================
def test_comprehensive_exclude_filtering():
"""Test that excluded cards are completely removed from all dataframe sources."""
print("=== Comprehensive Exclude Filtering Test ===")
# Create a test builder
builder = DeckBuilder(headless=True, output_func=lambda x: print(f"Builder: {x}"), input_func=lambda x: "")
# Set some common exclude patterns
exclude_list = ["Sol Ring", "Rhystic Study", "Cyclonic Rift"]
builder.exclude_cards = exclude_list
print(f"Testing exclusion of: {exclude_list}")
# Try to set up a simple commander to get dataframes loaded
try:
# Load commander data and select a commander first
cmd_df = builder.load_commander_data()
atraxa_row = cmd_df[cmd_df["name"] == "Atraxa, Praetors' Voice"]
if not atraxa_row.empty:
builder._apply_commander_selection(atraxa_row.iloc[0])
else:
# Fallback to any commander for testing
if not cmd_df.empty:
builder._apply_commander_selection(cmd_df.iloc[0])
print(f"Using fallback commander: {builder.commander_name}")
# Now determine color identity
builder.determine_color_identity()
# This should trigger the exclude filtering
combined_df = builder.setup_dataframes()
# Check that excluded cards are not in the combined dataframe
print(f"\n1. Checking combined dataframe (has {len(combined_df)} cards)...")
for exclude_card in exclude_list:
if 'name' in combined_df.columns:
matches = combined_df[combined_df['name'].str.contains(exclude_card, case=False, na=False)]
if len(matches) == 0:
print(f"'{exclude_card}' correctly excluded from combined_df")
else:
print(f"'{exclude_card}' still found in combined_df: {matches['name'].tolist()}")
# Check that excluded cards are not in the full dataframe either
print(f"\n2. Checking full dataframe (has {len(builder._full_cards_df)} cards)...")
for exclude_card in exclude_list:
if builder._full_cards_df is not None and 'name' in builder._full_cards_df.columns:
matches = builder._full_cards_df[builder._full_cards_df['name'].str.contains(exclude_card, case=False, na=False)]
if len(matches) == 0:
print(f"'{exclude_card}' correctly excluded from full_df")
else:
print(f"'{exclude_card}' still found in full_df: {matches['name'].tolist()}")
# Try to manually lookup excluded cards (this should fail)
print("\n3. Testing manual card lookups...")
for exclude_card in exclude_list:
# Simulate what the builder does when looking up cards
df_src = builder._full_cards_df if builder._full_cards_df is not None else builder._combined_cards_df
if df_src is not None and not df_src.empty and 'name' in df_src.columns:
lookup_result = df_src[df_src['name'].astype(str).str.lower() == exclude_card.lower()]
if lookup_result.empty:
print(f"'{exclude_card}' correctly not found in lookup")
else:
print(f"'{exclude_card}' incorrectly found in lookup: {lookup_result['name'].tolist()}")
print("\n=== Test Complete ===")
except Exception as e:
print(f"Test failed with error: {e}")
import traceback
print(traceback.format_exc())
assert False
# =============================================================================
# SECTION: Direct Exclude Flow Tests
# Source: test_direct_exclude.py
# =============================================================================
def test_direct_exclude_filtering():
"""Test exclude filtering directly on a DeckBuilder instance."""
print("=== Direct DeckBuilder Exclude Test ===")
# Create a builder instance
builder = DeckBuilder()
# Set exclude cards directly
exclude_list = [
"Sol Ring",
"Byrke, Long Ear of the Law",
"Burrowguard Mentor",
"Hare Apparent"
]
print(f"1. Setting exclude_cards: {exclude_list}")
builder.exclude_cards = exclude_list
print(f"2. Checking attribute: {getattr(builder, 'exclude_cards', 'NOT SET')}")
print(f"3. hasattr check: {hasattr(builder, 'exclude_cards')}")
# Mock some cards in the dataframe
test_cards = pd.DataFrame([
{"name": "Sol Ring", "color_identity": "", "type_line": "Artifact"},
{"name": "Byrke, Long Ear of the Law", "color_identity": "W", "type_line": "Legendary Creature"},
{"name": "Burrowguard Mentor", "color_identity": "W", "type_line": "Creature"},
{"name": "Hare Apparent", "color_identity": "W", "type_line": "Creature"},
{"name": "Lightning Bolt", "color_identity": "R", "type_line": "Instant"},
])
print(f"4. Test cards before filtering: {len(test_cards)}")
print(f" Cards: {test_cards['name'].tolist()}")
# Set the combined dataframe and call the filtering logic
builder._combined_cards_df = test_cards.copy()
# Apply the exclude filtering logic
combined = builder._combined_cards_df.copy()
if hasattr(builder, 'exclude_cards') and builder.exclude_cards:
print(" DEBUG: Exclude filtering condition met!")
try:
# Find name column
name_col = None
if 'name' in combined.columns:
name_col = 'name'
elif 'Card Name' in combined.columns:
name_col = 'Card Name'
if name_col is not None:
excluded_matches = []
original_count = len(combined)
# Normalize exclude patterns for matching
normalized_excludes = {normalize_card_name(pattern): pattern for pattern in builder.exclude_cards}
print(f" Normalized excludes: {normalized_excludes}")
# Create a mask to track which rows to exclude
exclude_mask = pd.Series([False] * len(combined), index=combined.index)
# Check each card against exclude patterns
for idx, card_name in combined[name_col].items():
if not exclude_mask[idx]: # Only check if not already excluded
normalized_card = normalize_card_name(str(card_name))
print(f" Checking card: '{card_name}' -> normalized: '{normalized_card}'")
# Check if this card matches any exclude pattern
for normalized_exclude, original_pattern in normalized_excludes.items():
if normalized_card == normalized_exclude:
print(f" MATCH: '{card_name}' matches pattern '{original_pattern}'")
excluded_matches.append({
'pattern': original_pattern,
'matched_card': str(card_name),
'similarity': 1.0
})
exclude_mask[idx] = True
break # Found a match, no need to check other patterns
# Apply the exclusions in one operation
if exclude_mask.any():
combined = combined[~exclude_mask].copy()
print(f" Excluded {len(excluded_matches)} cards from pool (was {original_count}, now {len(combined)})")
else:
print(f" No cards matched exclude patterns: {', '.join(builder.exclude_cards)}")
else:
print(" No recognizable name column found")
except Exception as e:
print(f" Error during exclude filtering: {e}")
import traceback
traceback.print_exc()
else:
print(" DEBUG: Exclude filtering condition NOT met!")
# Update the builder's dataframe
builder._combined_cards_df = combined
print(f"6. Cards after filtering: {len(combined)}")
print(f" Remaining cards: {combined['name'].tolist()}")
# Check if exclusions worked
remaining_cards = combined['name'].tolist()
failed_exclusions = []
for exclude_card in exclude_list:
if exclude_card in remaining_cards:
failed_exclusions.append(exclude_card)
print(f"{exclude_card} was NOT excluded!")
else:
print(f"{exclude_card} was properly excluded")
if failed_exclusions:
print(f"\n❌ FAILED: {len(failed_exclusions)} cards were not excluded: {failed_exclusions}")
assert False
else:
print(f"\n✅ SUCCESS: All {len(exclude_list)} cards were properly excluded")
# =============================================================================
# SECTION: Exclude Filtering Logic Tests
# Source: test_exclude_filtering.py
# =============================================================================
def test_exclude_filtering_logic():
"""Test that our exclude filtering logic works correctly."""
# Simulate the cards from user's test case
test_cards_df = pd.DataFrame([
{"name": "Sol Ring", "other_col": "value1"},
{"name": "Byrke, Long Ear of the Law", "other_col": "value2"},
{"name": "Burrowguard Mentor", "other_col": "value3"},
{"name": "Hare Apparent", "other_col": "value4"},
{"name": "Lightning Bolt", "other_col": "value5"},
{"name": "Counterspell", "other_col": "value6"},
])
# User's exclude list from their test
exclude_list = [
"Sol Ring",
"Byrke, Long Ear of the Law",
"Burrowguard Mentor",
"Hare Apparent"
]
print("Original cards:")
print(test_cards_df['name'].tolist())
print(f"\nExclude list: {exclude_list}")
# Apply the same filtering logic as in builder.py
if exclude_list:
normalized_excludes = {normalize_card_name(name): name for name in exclude_list}
print(f"\nNormalized excludes: {list(normalized_excludes.keys())}")
# Create exclude mask
exclude_mask = test_cards_df['name'].apply(
lambda x: normalize_card_name(x) not in normalized_excludes
)
print(f"\nExclude mask: {exclude_mask.tolist()}")
# Apply filtering
filtered_df = test_cards_df[exclude_mask].copy()
print(f"\nFiltered cards: {filtered_df['name'].tolist()}")
# Verify results
excluded_cards = test_cards_df[~exclude_mask]['name'].tolist()
print(f"Cards that were excluded: {excluded_cards}")
# Check if all exclude cards were properly removed
remaining_cards = filtered_df['name'].tolist()
for exclude_card in exclude_list:
if exclude_card in remaining_cards:
print(f"ERROR: {exclude_card} was NOT excluded!")
assert False
else:
print(f"{exclude_card} was properly excluded")
print(f"\n✓ SUCCESS: All {len(exclude_list)} cards were properly excluded")
print(f"✓ Remaining cards: {len(remaining_cards)} out of {len(test_cards_df)}")
else:
assert False
# =============================================================================
# SECTION: Exclude Integration Tests
# Source: test_exclude_integration.py
# =============================================================================
def test_exclude_integration():
"""Test that exclude functionality works end-to-end."""
print("=== M0.5 Exclude Integration Test ===")
# Test 1: Parse exclude list
print("\n1. Testing card list parsing...")
exclude_input = "Sol Ring\nRhystic Study\nSmothering Tithe"
exclude_list = parse_card_list_input(exclude_input)
print(f" Input: {repr(exclude_input)}")
print(f" Parsed: {exclude_list}")
assert len(exclude_list) == 3
assert "Sol Ring" in exclude_list
print(" ✓ Parsing works")
# Test 2: Check DeckBuilder has the exclude attribute
print("\n2. Testing DeckBuilder exclude attribute...")
builder = DeckBuilder(headless=True, output_func=lambda x: None, input_func=lambda x: "")
# Set exclude cards
builder.exclude_cards = exclude_list
print(f" Set exclude_cards: {builder.exclude_cards}")
assert hasattr(builder, 'exclude_cards')
assert builder.exclude_cards == exclude_list
print(" ✓ DeckBuilder accepts exclude_cards attribute")
print("\n=== All tests passed! ===")
print("M0.5 exclude functionality is ready for testing.")
# =============================================================================
# SECTION: Web Integration Tests
# Source: test_exclude_cards_integration.py
# =============================================================================
def test_exclude_cards_complete_integration():
"""Comprehensive test demonstrating all exclude card features working together."""
# Set up test client with feature enabled
import importlib
# Ensure project root is in sys.path for reliable imports
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Ensure feature flag is enabled
original_value = os.environ.get('ALLOW_MUST_HAVES')
os.environ['ALLOW_MUST_HAVES'] = '1'
try:
# Fresh import to pick up environment
try:
del importlib.sys.modules['code.web.app']
except KeyError:
pass
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
print("\n=== EXCLUDE CARDS INTEGRATION TEST ===")
# 1. Test file upload simulation (parsing multi-line input)
print("\n1. Testing exclude card parsing (file upload simulation):")
exclude_cards_content = """Sol Ring
Rhystic Study
Smothering Tithe
Lightning Bolt
Counterspell"""
parsed_cards = parse_card_list_input(exclude_cards_content)
print(f" Parsed {len(parsed_cards)} cards from input")
assert len(parsed_cards) == 5
assert "Sol Ring" in parsed_cards
assert "Rhystic Study" in parsed_cards
# 2. Test live validation endpoint
print("\n2. Testing live validation API:")
start_time = time.time()
response = client.post('/build/validate/exclude_cards',
data={'exclude_cards': exclude_cards_content})
validation_time = time.time() - start_time
assert response.status_code == 200
validation_data = response.json()
print(f" Validation response time: {validation_time*1000:.1f}ms")
print(f" Validated {validation_data['count']}/{validation_data['limit']} excludes")
assert validation_data["count"] == 5
assert validation_data["limit"] == 15
assert validation_data["over_limit"] is False
# 3. Test complete deck building workflow with excludes
print("\n3. Testing complete deck building with excludes:")
# Start session and create deck with excludes
r1 = client.get('/build')
assert r1.status_code == 200
form_data = {
"name": "Exclude Cards Integration Test",
"commander": "Inti, Seneschal of the Sun",
"primary_tag": "discard",
"bracket": 3,
"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28,
"removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4,
"exclude_cards": exclude_cards_content
}
build_start = time.time()
r2 = client.post('/build/new', data=form_data)
build_time = time.time() - build_start
assert r2.status_code == 200
print(f" Deck build completed in {build_time*1000:.0f}ms")
# 4. Test JSON export/import (permalinks)
print("\n4. Testing JSON export/import:")
# Get session cookie and export permalink
session_cookie = r2.cookies.get('sid')
# Set cookie on client to avoid per-request cookies deprecation
if session_cookie:
client.cookies.set('sid', session_cookie)
r3 = client.get('/build/permalink')
assert r3.status_code == 200
export_data = r3.json()
assert export_data["ok"] is True
assert "exclude_cards" in export_data["state"]
# Verify excluded cards are preserved
exported_excludes = export_data["state"]["exclude_cards"]
print(f" Exported {len(exported_excludes)} exclude cards in JSON")
for card in ["Sol Ring", "Rhystic Study", "Smothering Tithe"]:
assert card in exported_excludes
# Test import (round-trip)
token = export_data["permalink"].split("state=")[1]
r4 = client.get(f'/build/from?state={token}')
assert r4.status_code == 200
print(" JSON import successful - round-trip verified")
# 5. Test performance benchmarks
print("\n5. Testing performance benchmarks:")
# Parsing performance
parse_times = []
for _ in range(10):
start = time.time()
parse_card_list_input(exclude_cards_content)
parse_times.append((time.time() - start) * 1000)
avg_parse_time = sum(parse_times) / len(parse_times)
print(f" Average parse time: {avg_parse_time:.2f}ms (target: <10ms)")
assert avg_parse_time < 10.0
# Validation API performance
validation_times = []
for _ in range(5):
start = time.time()
client.post('/build/validate/exclude_cards', data={'exclude_cards': exclude_cards_content})
validation_times.append((time.time() - start) * 1000)
avg_validation_time = sum(validation_times) / len(validation_times)
print(f" Average validation time: {avg_validation_time:.1f}ms (target: <100ms)")
assert avg_validation_time < 100.0
# 6. Test backward compatibility
print("\n6. Testing backward compatibility:")
# Legacy config without exclude_cards
legacy_payload = {
"commander": "Inti, Seneschal of the Sun",
"tags": ["discard"],
"bracket": 3,
"ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28,
"removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4},
"tag_mode": "AND",
"flags": {"owned_only": False, "prefer_owned": False},
"locks": [],
}
raw = json.dumps(legacy_payload, separators=(",", ":")).encode('utf-8')
legacy_token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
r5 = client.get(f'/build/from?state={legacy_token}')
assert r5.status_code == 200
print(" Legacy config import works without exclude_cards")
print("\n=== ALL EXCLUDE CARD FEATURES VERIFIED ===")
print("✅ File upload parsing (simulated)")
print("✅ Live validation API with performance targets met")
print("✅ Complete deck building workflow with exclude filtering")
print("✅ JSON export/import with exclude_cards preservation")
print("✅ Performance benchmarks under targets")
print("✅ Backward compatibility with legacy configs")
print("\n🎉 EXCLUDE CARDS IMPLEMENTATION COMPLETE! 🎉")
finally:
# Restore environment
if original_value is not None:
os.environ['ALLOW_MUST_HAVES'] = original_value
else:
os.environ.pop('ALLOW_MUST_HAVES', None)
# =============================================================================
# SECTION: Compatibility Tests
# Source: test_exclude_cards_compatibility.py
# =============================================================================
@pytest.fixture
def client():
"""Test client with ALLOW_MUST_HAVES enabled."""
import importlib
# Ensure project root is in sys.path for reliable imports
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Ensure feature flag is enabled for tests
original_value = os.environ.get('ALLOW_MUST_HAVES')
os.environ['ALLOW_MUST_HAVES'] = '1'
# Force fresh import to pick up environment change
try:
del importlib.sys.modules['code.web.app']
except KeyError:
pass
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
yield client
# Restore original environment
if original_value is not None:
os.environ['ALLOW_MUST_HAVES'] = original_value
else:
os.environ.pop('ALLOW_MUST_HAVES', None)
def test_legacy_configs_build_unchanged(client):
"""Ensure existing deck configs (without exclude_cards) build identically."""
# Legacy payload without exclude_cards
legacy_payload = {
"commander": "Inti, Seneschal of the Sun",
"tags": ["discard"],
"bracket": 3,
"ideals": {
"ramp": 10, "lands": 36, "basic_lands": 18,
"creatures": 28, "removal": 10, "wipes": 3,
"card_advantage": 8, "protection": 4
},
"tag_mode": "AND",
"flags": {"owned_only": False, "prefer_owned": False},
"locks": [],
}
# Convert to permalink token
raw = json.dumps(legacy_payload, separators=(",", ":")).encode('utf-8')
token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
# Import the legacy config
response = client.get(f'/build/from?state={token}')
assert response.status_code == 200
def test_exclude_cards_json_roundtrip(client):
"""Test that exclude_cards are preserved in JSON export/import."""
# Start a session
r = client.get('/build')
assert r.status_code == 200
# Create a config with exclude_cards via form submission
form_data = {
"name": "Test Deck",
"commander": "Inti, Seneschal of the Sun",
"primary_tag": "discard",
"bracket": 3,
"ramp": 10,
"lands": 36,
"basic_lands": 18,
"creatures": 28,
"removal": 10,
"wipes": 3,
"card_advantage": 8,
"protection": 4,
"exclude_cards": "Sol Ring\nRhystic Study\nSmothering Tithe"
}
# Submit the form to create the config
r2 = client.post('/build/new', data=form_data)
assert r2.status_code == 200
# Get the session cookie for the next request
session_cookie = r2.cookies.get('sid')
assert session_cookie is not None, "Session cookie not found"
# Export permalink with exclude_cards
if session_cookie:
client.cookies.set('sid', session_cookie)
r3 = client.get('/build/permalink')
assert r3.status_code == 200
permalink_data = r3.json()
assert permalink_data["ok"] is True
assert "exclude_cards" in permalink_data["state"]
exported_excludes = permalink_data["state"]["exclude_cards"]
assert "Sol Ring" in exported_excludes
assert "Rhystic Study" in exported_excludes
assert "Smothering Tithe" in exported_excludes
# Test round-trip: import the exported config
token = permalink_data["permalink"].split("state=")[1]
r4 = client.get(f'/build/from?state={token}')
assert r4.status_code == 200
# Get new permalink to verify the exclude_cards were preserved
# (We need to get the session cookie from the import response)
import_cookie = r4.cookies.get('sid')
assert import_cookie is not None, "Import session cookie not found"
if import_cookie:
client.cookies.set('sid', import_cookie)
r5 = client.get('/build/permalink')
assert r5.status_code == 200
reimported_data = r5.json()
assert reimported_data["ok"] is True
assert "exclude_cards" in reimported_data["state"]
# Should be identical to the original export
reimported_excludes = reimported_data["state"]["exclude_cards"]
assert reimported_excludes == exported_excludes
def test_validation_endpoint_functionality(client):
"""Test the exclude cards validation endpoint."""
# Test empty input
r1 = client.post('/build/validate/exclude_cards', data={'exclude_cards': ''})
assert r1.status_code == 200
data1 = r1.json()
assert data1["count"] == 0
# Test valid input
exclude_text = "Sol Ring\nRhystic Study\nSmothering Tithe"
r2 = client.post('/build/validate/exclude_cards', data={'exclude_cards': exclude_text})
assert r2.status_code == 200
data2 = r2.json()
assert data2["count"] == 3
assert data2["limit"] == 15
assert data2["over_limit"] is False
assert len(data2["cards"]) == 3
# Test over-limit input (16 cards when limit is 15)
many_cards = "\n".join([f"Card {i}" for i in range(16)])
r3 = client.post('/build/validate/exclude_cards', data={'exclude_cards': many_cards})
assert r3.status_code == 200
data3 = r3.json()
assert data3["count"] == 16
assert data3["over_limit"] is True
assert len(data3["warnings"]) > 0
assert "Too many excludes" in data3["warnings"][0]
# =============================================================================
# SECTION: Re-entry Prevention Tests
# Source: test_exclude_reentry_prevention.py
# =============================================================================
class TestExcludeReentryPrevention(unittest.TestCase):
"""Test that excluded cards cannot re-enter the deck."""
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': 'Counterspell',
'type': 'Instant',
'mana_cost': '{U}{U}',
'manaValue': 2,
'themeTags': ['counterspell'],
'colorIdentity': ['U']
},
{
'name': 'Llanowar Elves',
'type': 'Creature — Elf Druid',
'mana_cost': '{G}',
'manaValue': 1,
'themeTags': ['ramp', 'elves'],
'colorIdentity': ['G'],
'creatureTypes': ['Elf', 'Druid']
}
])
def _create_test_builder(self, 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', 'U']
builder.color_identity_key = 'R, G, U'
builder._combined_cards_df = self.test_cards_df.copy()
builder._full_cards_df = self.test_cards_df.copy()
# Set exclude cards
builder.exclude_cards = exclude_cards or []
return builder
def test_exclude_prevents_direct_add_card(self):
"""Test that excluded cards are prevented from being added directly."""
builder = self._create_test_builder(exclude_cards=['Lightning Bolt', 'Sol Ring'])
# Try to add excluded cards directly
builder.add_card('Lightning Bolt', card_type='Instant')
builder.add_card('Sol Ring', card_type='Artifact')
# Verify excluded cards were not added
self.assertNotIn('Lightning Bolt', builder.card_library)
self.assertNotIn('Sol Ring', builder.card_library)
def test_exclude_allows_non_excluded_cards(self):
"""Test that non-excluded cards can still be added normally."""
builder = self._create_test_builder(exclude_cards=['Lightning Bolt'])
# Add a non-excluded card
builder.add_card('Sol Ring', card_type='Artifact')
builder.add_card('Counterspell', card_type='Instant')
# Verify non-excluded cards were added
self.assertIn('Sol Ring', builder.card_library)
self.assertIn('Counterspell', builder.card_library)
def test_exclude_prevention_with_fuzzy_matching(self):
"""Test that exclude prevention works with normalized card names."""
# Test variations in card name formatting
builder = self._create_test_builder(exclude_cards=['lightning bolt']) # lowercase
# Try to add with different casing/formatting
builder.add_card('Lightning Bolt', card_type='Instant') # proper case
builder.add_card('LIGHTNING BOLT', card_type='Instant') # uppercase
# All should be prevented
self.assertNotIn('Lightning Bolt', builder.card_library)
self.assertNotIn('LIGHTNING BOLT', builder.card_library)
def test_exclude_prevention_with_punctuation_variations(self):
"""Test exclude prevention with punctuation variations."""
# Create test data with punctuation
test_df = pd.DataFrame([
{
'name': 'Krenko, Mob Boss',
'type': 'Legendary Creature — Goblin Warrior',
'mana_cost': '{2}{R}{R}',
'manaValue': 4,
'themeTags': ['goblins'],
'colorIdentity': ['R']
}
])
builder = self._create_test_builder(exclude_cards=['Krenko Mob Boss']) # no comma
builder._combined_cards_df = test_df
builder._full_cards_df = test_df
# Try to add with comma (should be prevented due to normalization)
builder.add_card('Krenko, Mob Boss', card_type='Legendary Creature — Goblin Warrior')
# Should be prevented
self.assertNotIn('Krenko, Mob Boss', builder.card_library)
def test_commander_exemption_from_exclude_prevention(self):
"""Test that commanders are exempted from exclude prevention."""
builder = self._create_test_builder(exclude_cards=['Lightning Bolt'])
# Add Lightning Bolt as commander (should be allowed)
builder.add_card('Lightning Bolt', card_type='Instant', is_commander=True)
# Should be added despite being in exclude list
self.assertIn('Lightning Bolt', builder.card_library)
self.assertTrue(builder.card_library['Lightning Bolt']['Commander'])
def test_exclude_reentry_prevention_during_phases(self):
"""Test that excluded cards cannot re-enter during creature/spell phases."""
builder = self._create_test_builder(exclude_cards=['Llanowar Elves'])
# Simulate a creature addition phase trying to add excluded creature
# This would typically happen through automated heuristics
builder.add_card('Llanowar Elves', card_type='Creature — Elf Druid', added_by='creature_phase')
# Should be prevented
self.assertNotIn('Llanowar Elves', builder.card_library)
def test_exclude_prevention_with_empty_exclude_list(self):
"""Test that exclude prevention handles empty exclude lists gracefully."""
builder = self._create_test_builder(exclude_cards=[])
# Should allow normal addition
builder.add_card('Lightning Bolt', card_type='Instant')
# Should be added normally
self.assertIn('Lightning Bolt', builder.card_library)
def test_exclude_prevention_with_none_exclude_list(self):
"""Test that exclude prevention handles None exclude lists gracefully."""
builder = self._create_test_builder()
builder.exclude_cards = None # Explicitly set to None
# Should allow normal addition
builder.add_card('Lightning Bolt', card_type='Instant')
# Should be added normally
self.assertIn('Lightning Bolt', builder.card_library)
def test_multiple_exclude_attempts_logged(self):
"""Test that multiple attempts to add excluded cards are properly logged."""
builder = self._create_test_builder(exclude_cards=['Sol Ring'])
# Track log calls by mocking the logger
with self.assertLogs('deck_builder.builder', level='INFO') as log_context:
# Try to add excluded card multiple times
builder.add_card('Sol Ring', card_type='Artifact', added_by='test1')
builder.add_card('Sol Ring', card_type='Artifact', added_by='test2')
builder.add_card('Sol Ring', card_type='Artifact', added_by='test3')
# Verify card was not added
self.assertNotIn('Sol Ring', builder.card_library)
# Verify logging occurred
log_messages = [record.message for record in log_context.records]
prevent_logs = [msg for msg in log_messages if 'EXCLUDE_REENTRY_PREVENTED' in msg]
self.assertEqual(len(prevent_logs), 3) # Should log each prevention
def test_exclude_prevention_maintains_deck_integrity(self):
"""Test that exclude prevention doesn't interfere with normal deck building."""
builder = self._create_test_builder(exclude_cards=['Lightning Bolt'])
# Add a mix of cards, some excluded, some not
cards_to_add = [
('Lightning Bolt', 'Instant'), # excluded
('Sol Ring', 'Artifact'), # allowed
('Counterspell', 'Instant'), # allowed
('Lightning Bolt', 'Instant'), # excluded (retry)
('Llanowar Elves', 'Creature — Elf Druid') # allowed
]
for name, card_type in cards_to_add:
builder.add_card(name, card_type=card_type)
# Verify only non-excluded cards were added
expected_cards = {'Sol Ring', 'Counterspell', 'Llanowar Elves'}
actual_cards = set(builder.card_library.keys())
self.assertEqual(actual_cards, expected_cards)
self.assertNotIn('Lightning Bolt', actual_cards)
def test_exclude_prevention_works_after_pool_filtering(self):
"""Test that exclude prevention works even after pool filtering removes cards."""
builder = self._create_test_builder(exclude_cards=['Lightning Bolt'])
# Simulate setup_dataframes filtering (M0.5 implementation)
# The card should already be filtered from the pool, but prevention should still work
original_df = builder._combined_cards_df.copy()
# Remove Lightning Bolt from pool (simulating M0.5 filtering)
builder._combined_cards_df = original_df[original_df['name'] != 'Lightning Bolt']
# Try to add it anyway (simulating downstream heuristic attempting to add)
builder.add_card('Lightning Bolt', card_type='Instant')
# Should still be prevented
self.assertNotIn('Lightning Bolt', builder.card_library)
if __name__ == "__main__":
pytest.main([__file__])