mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-07 22:22:34 +01:00
fix(tests): add missing comprehensive test files and fix gitignore
The comprehensive test files were not committed due to .gitignore pattern 'test_*.py' blocking all test files. Fixed gitignore to only exclude root-level test scripts.
This commit is contained in:
parent
c72f581ce7
commit
c75f37603f
13 changed files with 6048 additions and 2 deletions
906
code/tests/test_exclude_comprehensive.py
Normal file
906
code/tests/test_exclude_comprehensive.py
Normal file
|
|
@ -0,0 +1,906 @@
|
|||
"""
|
||||
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__])
|
||||
Loading…
Add table
Add a link
Reference in a new issue