mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-07 22:22:34 +01:00
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.
906 lines
36 KiB
Python
906 lines
36 KiB
Python
"""
|
|
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__])
|