mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Compare commits
6 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
03e839fb87 | ||
![]() |
5904ff1a3d | ||
![]() |
947adacfe2 | ||
![]() |
f07daaeb4a | ||
![]() |
ed780d91e9 | ||
![]() |
f28f8e6b4f |
29 changed files with 566 additions and 449 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
@ -13,19 +13,27 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Misc land step: dynamic EDHREC keep percentage range (roll between 75%-100%) via `MISC_LAND_EDHREC_KEEP_PERCENT_MIN/MAX` for more variety in utility land pools
|
- CI: additional checks to improve stability and reproducibility.
|
||||||
- Alternatives: initial land support – when requesting alternatives for a land, endpoint now returns land-only suggestions (basics → other basics; non-basics → other non-basics) with heuristic sub-category narrowing on large pools.
|
- Tests: broader coverage for validation and web flows.
|
||||||
- Land alternatives now randomize: 12 suggestions sampled each request from a randomly sized window within the top 60–100 ranked land candidates (per-card, no caching) for higher variety.
|
|
||||||
- Misc land debug CSV exports gated behind `MISC_LAND_DEBUG` or diagnostics flag; not produced in normal runs.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Misc land step now excludes all fetch lands outright (they're handled earlier); reason recorded as `fetch-skip-misc` in diagnostics CSV
|
- Tests: refactored to use pytest assertions and cleaned up fixtures/utilities to reduce noise and deprecations.
|
||||||
- Legacy single-value `MISC_LAND_EDHREC_KEEP_PERCENT` retained as fallback if min/max not defined
|
- Tests: HTTP-dependent tests now skip gracefully when the local web server is unavailable.
|
||||||
- Documentation: README and compose files updated with misc land tuning env vars (`MISC_LAND_DEBUG`, dynamic EDHREC keep range, theme weighting multipliers)
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- (placeholder) – no current unreleased land alternatives bugs logged
|
- Tests: reduced deprecation warnings and incidental failures; improved consistency and reliability across runs.
|
||||||
- Step 5 card grid scroll flicker at bottom: added overscroll containment and skip virtualization for small (<80 items) grids to prevent upward jump when reaching end
|
|
||||||
|
## [2.2.10] - 2025-09-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Web UI: Test Hand uses a default fanned layout on desktop with tightened arc and 40% overlap; outer cards sit lower for a full-arc look
|
||||||
|
- Desktop Test Hand card size set to 280×392; responsive sizes refined at common breakpoints
|
||||||
|
- Theme controls moved from the top banner to the bottom of the left sidebar; sidebar made a flex column with the theme block anchored at the bottom
|
||||||
|
- Mobile banner simplified to show only Menu, title; spacing and gaps tuned to prevent overflow and wrapping
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevented mobile banner overflow by hiding non-essential items and relocating theme controls
|
||||||
|
- Ensured desktop sizing wins over previous inline styles by using global CSS overrides; cards no longer shrink due to flex
|
||||||
|
|
||||||
## [2.2.9] - 2025-09-10
|
## [2.2.9] - 2025-09-10
|
||||||
|
|
||||||
|
@ -42,15 +50,6 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [2.2.8] - 2025-09-10
|
## [2.2.8] - 2025-09-10
|
||||||
|
|
||||||
### Added
|
|
||||||
- (placeholder)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- (placeholder)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- (placeholder)
|
|
||||||
|
|
||||||
## [2.2.7] - 2025-09-10
|
## [2.2.7] - 2025-09-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -1,57 +1,14 @@
|
||||||
# MTG Python Deckbuilder ${VERSION}
|
# MTG Python Deckbuilder ${VERSION}
|
||||||
|
|
||||||
## Highlights
|
### Added
|
||||||
- Dynamic misc utility land variety: EDHREC keep percentage now randomly rolls between configurable min/max each build (defaults 75%–100%).
|
- CI improvements to increase stability and reproducibility of builds/tests.
|
||||||
- Land alternatives overhaul: land-aware suggestions (basics→basics, non-basics→non-basics) plus randomized 12-card window (random slice of top 60–100) for per-request variety.
|
- Expanded test coverage for validation and web flows.
|
||||||
- Cleaner mono-color utility land pools: rainbow/any-color filler and fetch lands excluded after their dedicated phases; explicit allow-list preserves strategic exceptions.
|
|
||||||
- Theme-aware misc land weighting with configurable multipliers (base + per-extra + cap) via new environment overrides.
|
|
||||||
- Production-friendly diagnostics: misc land debug CSVs gated behind `MISC_LAND_DEBUG` or diagnostics flag (off by default).
|
|
||||||
- UI polish & stability: eliminated Step 5 bottom-of-grid scroll flicker (overscroll containment + skip virtualization for small grids <80 items).
|
|
||||||
- Documentation & compose updates: all new tuning variables surfaced in README, compose files, and sample env.
|
|
||||||
|
|
||||||
## Added
|
### Changed
|
||||||
- Land alternatives: land-only mode with parity filtering (mono-color exclusions, rainbow text heuristics, fetch exclusion, World Tree legality check).
|
- Tests refactored to use pytest assertions and streamlined fixtures/utilities to reduce noise and deprecations.
|
||||||
- Randomized land alternative selection: 12 suggestions from a random window size inside the top 60–100 ranked candidates (uncached for variety).
|
- HTTP-dependent tests skip gracefully when the local web server is unavailable.
|
||||||
- Dynamic EDHREC keep range: `MISC_LAND_EDHREC_KEEP_PERCENT_MIN/MAX` (falls back to legacy single `MISC_LAND_EDHREC_KEEP_PERCENT` if min/max unset).
|
|
||||||
- Misc land theme weighting overrides: `MISC_LAND_THEME_MATCH_BASE`, `MISC_LAND_THEME_MATCH_PER_EXTRA`, `MISC_LAND_THEME_MATCH_CAP`.
|
|
||||||
- Debug gating: `MISC_LAND_DEBUG=1` to emit misc land candidate/post-filter CSVs (otherwise only when diagnostics enabled).
|
|
||||||
|
|
||||||
## Changed
|
### Fixed
|
||||||
- Fetch lands fully excluded from misc land (utility) step; they are handled earlier and no longer appear as filler.
|
- Reduced deprecation warnings and incidental test failures; improved consistency across runs.
|
||||||
- Mono-color pass prunes broad rainbow/any-color lands (except allow-list) using expanded text phrase heuristics.
|
|
||||||
- Alternatives endpoint skips caching for land role to preserve per-request randomness; non-land roles retain cache.
|
|
||||||
- Compose / README / .env example updated with new land tuning variables.
|
|
||||||
- Virtualization system now skips small grids (<80 items) to reduce overhead and prevent layout-induced scroll snapping.
|
|
||||||
|
|
||||||
## Fixed
|
---
|
||||||
- Step 5 scroll flicker / bounce when reaching bottom of short grids (overscroll containment + virtualization threshold).
|
|
||||||
- Random land alternatives previously surfacing excluded or fetch lands—now aligned with misc step filters.
|
|
||||||
|
|
||||||
## Environment Variables (new / updated)
|
|
||||||
| Variable | Purpose | Default |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| MISC_LAND_EDHREC_KEEP_PERCENT_MIN | Lower bound for dynamic EDHREC keep % (0–1) | 0.75 |
|
|
||||||
| MISC_LAND_EDHREC_KEEP_PERCENT_MAX | Upper bound for dynamic EDHREC keep % (0–1) | 1.0 |
|
|
||||||
| MISC_LAND_EDHREC_KEEP_PERCENT | Legacy single fixed keep % (fallback) | 0.80 |
|
|
||||||
| MISC_LAND_DEBUG | Emit misc land debug CSVs | Off |
|
|
||||||
| MISC_LAND_THEME_MATCH_BASE | Base multiplier for first theme match | 1.4 |
|
|
||||||
| MISC_LAND_THEME_MATCH_PER_EXTRA | Increment per additional matching theme | 0.15 |
|
|
||||||
| MISC_LAND_THEME_MATCH_CAP | Cap on total theme multiplier | 2.0 |
|
|
||||||
|
|
||||||
## Upgrade Notes
|
|
||||||
1. No migration steps required; defaults mirror prior behavior but introduce controlled randomness for utility land variety.
|
|
||||||
2. To restore pre-random behavior, set MIN=MAX=1.0 (or rely on legacy `MISC_LAND_EDHREC_KEEP_PERCENT`).
|
|
||||||
3. If deterministic land alternatives are needed for testing, consider temporarily disabling randomness (future flag can be added).
|
|
||||||
4. To analyze utility land selection, enable diagnostics or set `MISC_LAND_DEBUG=1` before running a build; CSVs appear under `logs/` (or diagnostic export path) only when enabled.
|
|
||||||
|
|
||||||
## Testing & Quality
|
|
||||||
- Existing fast test suite passes (include/exclude + summary utilities). Additional targeted tests for randomized window selection can be added in a follow-up if deterministic mode is introduced.
|
|
||||||
- Manual validation: multiple builds confirm varied utility land pools and land alternatives without fetch/rainbow leakage.
|
|
||||||
|
|
||||||
## Future Follow-ups (Optional)
|
|
||||||
- Deterministic toggle for land alternative randomization (e.g., `LAND_ALTS_DETERMINISTIC=1`).
|
|
||||||
- Unit tests focusing on edge-case mono-color filtering and theme weighting bounds.
|
|
||||||
- Potential adaptive virtualization row-height measurement per column for further smoothness (currently fixed estimate works acceptably).
|
|
||||||
|
|
||||||
---
|
|
||||||
Generated template ready for tagging release `${VERSION}` (update actual version number in CI/CD pipeline or tagging script).
|
|
|
@ -173,45 +173,49 @@ def fuzzy_match_card_name(
|
||||||
|
|
||||||
# Collect candidates with different scoring strategies
|
# Collect candidates with different scoring strategies
|
||||||
candidates = []
|
candidates = []
|
||||||
|
best_raw_similarity = 0.0
|
||||||
|
|
||||||
for name in normalized_names:
|
for name in normalized_names:
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
base_score = difflib.SequenceMatcher(None, input_lower, name_lower).ratio()
|
base_score = difflib.SequenceMatcher(None, input_lower, name_lower).ratio()
|
||||||
|
|
||||||
# Skip very low similarity matches early
|
# Skip very low similarity matches early
|
||||||
if base_score < 0.3:
|
if base_score < 0.3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
final_score = base_score
|
final_score = base_score
|
||||||
|
# Track best raw similarity to decide on true no-match vs. weak suggestions
|
||||||
|
if base_score > best_raw_similarity:
|
||||||
|
best_raw_similarity = base_score
|
||||||
|
|
||||||
# Strong boost for exact prefix matches (input is start of card name)
|
# Strong boost for exact prefix matches (input is start of card name)
|
||||||
if name_lower.startswith(input_lower):
|
if name_lower.startswith(input_lower):
|
||||||
final_score = min(1.0, base_score + 0.5)
|
final_score = min(1.0, base_score + 0.5)
|
||||||
|
|
||||||
# Moderate boost for word-level prefix matches
|
# Moderate boost for word-level prefix matches
|
||||||
elif any(word.startswith(input_lower) for word in name_lower.split()):
|
elif any(word.startswith(input_lower) for word in name_lower.split()):
|
||||||
final_score = min(1.0, base_score + 0.3)
|
final_score = min(1.0, base_score + 0.3)
|
||||||
|
|
||||||
# Special case: if input could be abbreviation of first word, boost heavily
|
# Special case: if input could be abbreviation of first word, boost heavily
|
||||||
elif len(input_lower) <= 6:
|
elif len(input_lower) <= 6:
|
||||||
first_word = name_lower.split()[0] if name_lower.split() else ""
|
first_word = name_lower.split()[0] if name_lower.split() else ""
|
||||||
if first_word and first_word.startswith(input_lower):
|
if first_word and first_word.startswith(input_lower):
|
||||||
final_score = min(1.0, base_score + 0.4)
|
final_score = min(1.0, base_score + 0.4)
|
||||||
|
|
||||||
# Boost for cards where input is contained as substring
|
# Boost for cards where input is contained as substring
|
||||||
elif input_lower in name_lower:
|
elif input_lower in name_lower:
|
||||||
final_score = min(1.0, base_score + 0.2)
|
final_score = min(1.0, base_score + 0.2)
|
||||||
|
|
||||||
# Special boost for very short inputs that are obvious abbreviations
|
# Special boost for very short inputs that are obvious abbreviations
|
||||||
if len(input_lower) <= 4:
|
if len(input_lower) <= 4:
|
||||||
# For short inputs, heavily favor cards that start with the input
|
# For short inputs, heavily favor cards that start with the input
|
||||||
if name_lower.startswith(input_lower):
|
if name_lower.startswith(input_lower):
|
||||||
final_score = min(1.0, final_score + 0.3)
|
final_score = min(1.0, final_score + 0.3)
|
||||||
|
|
||||||
# Popularity boost for well-known cards
|
# Popularity boost for well-known cards
|
||||||
if name_lower in popular_cards_lower:
|
if name_lower in popular_cards_lower:
|
||||||
final_score = min(1.0, final_score + 0.25)
|
final_score = min(1.0, final_score + 0.25)
|
||||||
|
|
||||||
# Extra boost for super iconic cards like Lightning Bolt (only when relevant)
|
# Extra boost for super iconic cards like Lightning Bolt (only when relevant)
|
||||||
if name_lower in iconic_cards_lower:
|
if name_lower in iconic_cards_lower:
|
||||||
# Only boost if there's some relevance to the input
|
# Only boost if there's some relevance to the input
|
||||||
|
@ -220,18 +224,23 @@ def fuzzy_match_card_name(
|
||||||
# Extra boost for Lightning Bolt when input is 'lightning' or similar
|
# Extra boost for Lightning Bolt when input is 'lightning' or similar
|
||||||
if name_lower == 'lightning bolt' and input_lower in ['lightning', 'lightn', 'light']:
|
if name_lower == 'lightning bolt' and input_lower in ['lightning', 'lightn', 'light']:
|
||||||
final_score = min(1.0, final_score + 0.2)
|
final_score = min(1.0, final_score + 0.2)
|
||||||
|
|
||||||
# Special handling for Lightning Bolt variants
|
# Special handling for Lightning Bolt variants
|
||||||
if 'lightning' in name_lower and 'bolt' in name_lower:
|
if 'lightning' in name_lower and 'bolt' in name_lower:
|
||||||
if input_lower in ['bolt', 'lightn', 'lightning']:
|
if input_lower in ['bolt', 'lightn', 'lightning']:
|
||||||
final_score = min(1.0, final_score + 0.4)
|
final_score = min(1.0, final_score + 0.4)
|
||||||
|
|
||||||
# Simplicity boost: prefer shorter, simpler card names for short inputs
|
# Simplicity boost: prefer shorter, simpler card names for short inputs
|
||||||
if len(input_lower) <= 6:
|
if len(input_lower) <= 6:
|
||||||
# Boost shorter card names slightly
|
# Boost shorter card names slightly
|
||||||
if len(name_lower) <= len(input_lower) * 2:
|
if len(name_lower) <= len(input_lower) * 2:
|
||||||
final_score = min(1.0, final_score + 0.05)
|
final_score = min(1.0, final_score + 0.05)
|
||||||
|
|
||||||
|
# Cap total boost to avoid over-accepting near-misses; allow only small boost
|
||||||
|
if final_score > base_score:
|
||||||
|
max_total_boost = 0.06
|
||||||
|
final_score = min(1.0, base_score + min(final_score - base_score, max_total_boost))
|
||||||
|
|
||||||
candidates.append((final_score, name))
|
candidates.append((final_score, name))
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
|
@ -249,6 +258,16 @@ def fuzzy_match_card_name(
|
||||||
# Get best match and confidence
|
# Get best match and confidence
|
||||||
best_score, best_match = candidates[0]
|
best_score, best_match = candidates[0]
|
||||||
confidence = best_score
|
confidence = best_score
|
||||||
|
# If raw similarity never cleared a minimal bar, treat as no reasonable match
|
||||||
|
# even if boosted scores exist; return confidence 0.0 and no suggestions.
|
||||||
|
if best_raw_similarity < 0.35:
|
||||||
|
return FuzzyMatchResult(
|
||||||
|
input_name=input_name,
|
||||||
|
matched_name=None,
|
||||||
|
confidence=0.0,
|
||||||
|
suggestions=[],
|
||||||
|
auto_accepted=False
|
||||||
|
)
|
||||||
|
|
||||||
# Convert back to original names, preserving score-based order
|
# Convert back to original names, preserving score-based order
|
||||||
suggestions = [normalized_to_original[match] for _, match in candidates[:MAX_SUGGESTIONS]]
|
suggestions = [normalized_to_original[match] for _, match in candidates[:MAX_SUGGESTIONS]]
|
||||||
|
|
|
@ -26,8 +26,8 @@ def test_apply_bracket_policy_tags(tmp_path: Path, monkeypatch):
|
||||||
{ 'name': "Forest", 'faceName': '', 'text': '', 'type': 'Basic Land — Forest', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
|
{ 'name': "Forest", 'faceName': '', 'text': '', 'type': 'Basic Land — Forest', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
|
||||||
])
|
])
|
||||||
|
|
||||||
# Ensure the JSON lists exist with expected names
|
# Ensure the JSON lists exist with expected names IN A TEMP DIR (avoid clobbering repo files)
|
||||||
lists_dir = Path('config/card_lists')
|
lists_dir = tmp_path / 'card_lists'
|
||||||
lists_dir.mkdir(parents=True, exist_ok=True)
|
lists_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(lists_dir / 'extra_turns.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Time Warp'] }), encoding='utf-8')
|
(lists_dir / 'extra_turns.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Time Warp'] }), encoding='utf-8')
|
||||||
(lists_dir / 'mass_land_denial.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Armageddon'] }), encoding='utf-8')
|
(lists_dir / 'mass_land_denial.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Armageddon'] }), encoding='utf-8')
|
||||||
|
@ -35,6 +35,13 @@ def test_apply_bracket_policy_tags(tmp_path: Path, monkeypatch):
|
||||||
(lists_dir / 'game_changers.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': [] }), encoding='utf-8')
|
(lists_dir / 'game_changers.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': [] }), encoding='utf-8')
|
||||||
|
|
||||||
mod = _load_applier()
|
mod = _load_applier()
|
||||||
|
# Redirect policy file paths to the temp directory
|
||||||
|
monkeypatch.setattr(mod, 'POLICY_FILES', {
|
||||||
|
'Bracket:GameChanger': str(lists_dir / 'game_changers.json'),
|
||||||
|
'Bracket:ExtraTurn': str(lists_dir / 'extra_turns.json'),
|
||||||
|
'Bracket:MassLandDenial': str(lists_dir / 'mass_land_denial.json'),
|
||||||
|
'Bracket:TutorNonland': str(lists_dir / 'tutors_nonland.json'),
|
||||||
|
}, raising=False)
|
||||||
mod.apply_bracket_policy_tags(df)
|
mod.apply_bracket_policy_tags(df)
|
||||||
|
|
||||||
row = df.set_index('name')
|
row = df.set_index('name')
|
||||||
|
|
|
@ -27,7 +27,7 @@ def test_cli_ideal_counts():
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"❌ Command failed: {result.stderr}")
|
print(f"❌ Command failed: {result.stderr}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = json.loads(result.stdout)
|
config = json.loads(result.stdout)
|
||||||
|
@ -46,16 +46,14 @@ def test_cli_ideal_counts():
|
||||||
actual_val = ideal_counts.get(key)
|
actual_val = ideal_counts.get(key)
|
||||||
if actual_val != expected_val:
|
if actual_val != expected_val:
|
||||||
print(f"❌ {key}: expected {expected_val}, got {actual_val}")
|
print(f"❌ {key}: expected {expected_val}, got {actual_val}")
|
||||||
return False
|
assert False
|
||||||
print(f"✅ {key}: {actual_val}")
|
print(f"✅ {key}: {actual_val}")
|
||||||
|
|
||||||
print("✅ All CLI ideal count arguments working correctly!")
|
print("✅ All CLI ideal count arguments working correctly!")
|
||||||
return True
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"❌ Failed to parse JSON output: {e}")
|
print(f"❌ Failed to parse JSON output: {e}")
|
||||||
print(f"Output was: {result.stdout}")
|
print(f"Output was: {result.stdout}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
def test_help_contains_types():
|
def test_help_contains_types():
|
||||||
"""Test that help text shows value types."""
|
"""Test that help text shows value types."""
|
||||||
|
@ -66,7 +64,7 @@ def test_help_contains_types():
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"❌ Help command failed: {result.stderr}")
|
print(f"❌ Help command failed: {result.stderr}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
help_text = result.stdout
|
help_text = result.stdout
|
||||||
|
|
||||||
|
@ -82,7 +80,7 @@ def test_help_contains_types():
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
print(f"❌ Missing type indicators: {missing}")
|
print(f"❌ Missing type indicators: {missing}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
# Check for organized sections
|
# Check for organized sections
|
||||||
sections = [
|
sections = [
|
||||||
|
@ -99,10 +97,9 @@ def test_help_contains_types():
|
||||||
|
|
||||||
if missing_sections:
|
if missing_sections:
|
||||||
print(f"❌ Missing help sections: {missing_sections}")
|
print(f"❌ Missing help sections: {missing_sections}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
print("✅ Help text contains proper type information and sections!")
|
print("✅ Help text contains proper type information and sections!")
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
|
@ -4,10 +4,6 @@ Advanced integration test for exclude functionality.
|
||||||
Tests that excluded cards are completely removed from all dataframe sources.
|
Tests that excluded cards are completely removed from all dataframe sources.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'code'))
|
|
||||||
|
|
||||||
from code.deck_builder.builder import DeckBuilder
|
from code.deck_builder.builder import DeckBuilder
|
||||||
|
|
||||||
def test_comprehensive_exclude_filtering():
|
def test_comprehensive_exclude_filtering():
|
||||||
|
@ -74,18 +70,10 @@ def test_comprehensive_exclude_filtering():
|
||||||
print(f" ✗ '{exclude_card}' incorrectly found in lookup: {lookup_result['name'].tolist()}")
|
print(f" ✗ '{exclude_card}' incorrectly found in lookup: {lookup_result['name'].tolist()}")
|
||||||
|
|
||||||
print("\n=== Test Complete ===")
|
print("\n=== Test Complete ===")
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Test failed with error: {e}")
|
print(f"Test failed with error: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = test_comprehensive_exclude_filtering()
|
|
||||||
if success:
|
|
||||||
print("✅ Comprehensive exclude filtering test passed!")
|
|
||||||
else:
|
|
||||||
print("❌ Comprehensive exclude filtering test failed!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
|
@ -143,10 +143,9 @@ def test_direct_exclude_filtering():
|
||||||
|
|
||||||
if failed_exclusions:
|
if failed_exclusions:
|
||||||
print(f"\n❌ FAILED: {len(failed_exclusions)} cards were not excluded: {failed_exclusions}")
|
print(f"\n❌ FAILED: {len(failed_exclusions)} cards were not excluded: {failed_exclusions}")
|
||||||
return False
|
assert False
|
||||||
else:
|
else:
|
||||||
print(f"\n✅ SUCCESS: All {len(exclude_list)} cards were properly excluded")
|
print(f"\n✅ SUCCESS: All {len(exclude_list)} cards were properly excluded")
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
success = test_direct_exclude_filtering()
|
success = test_direct_exclude_filtering()
|
||||||
|
|
|
@ -106,7 +106,9 @@ def test_exclude_cards_json_roundtrip(client):
|
||||||
assert session_cookie is not None, "Session cookie not found"
|
assert session_cookie is not None, "Session cookie not found"
|
||||||
|
|
||||||
# Export permalink with exclude_cards
|
# Export permalink with exclude_cards
|
||||||
r3 = client.get('/build/permalink', cookies={'sid': session_cookie})
|
if session_cookie:
|
||||||
|
client.cookies.set('sid', session_cookie)
|
||||||
|
r3 = client.get('/build/permalink')
|
||||||
assert r3.status_code == 200
|
assert r3.status_code == 200
|
||||||
|
|
||||||
permalink_data = r3.json()
|
permalink_data = r3.json()
|
||||||
|
@ -128,7 +130,9 @@ def test_exclude_cards_json_roundtrip(client):
|
||||||
import_cookie = r4.cookies.get('sid')
|
import_cookie = r4.cookies.get('sid')
|
||||||
assert import_cookie is not None, "Import session cookie not found"
|
assert import_cookie is not None, "Import session cookie not found"
|
||||||
|
|
||||||
r5 = client.get('/build/permalink', cookies={'sid': import_cookie})
|
if import_cookie:
|
||||||
|
client.cookies.set('sid', import_cookie)
|
||||||
|
r5 = client.get('/build/permalink')
|
||||||
assert r5.status_code == 200
|
assert r5.status_code == 200
|
||||||
|
|
||||||
reimported_data = r5.json()
|
reimported_data = r5.json()
|
||||||
|
|
|
@ -96,7 +96,10 @@ Counterspell"""
|
||||||
|
|
||||||
# Get session cookie and export permalink
|
# Get session cookie and export permalink
|
||||||
session_cookie = r2.cookies.get('sid')
|
session_cookie = r2.cookies.get('sid')
|
||||||
r3 = client.get('/build/permalink', cookies={'sid': session_cookie})
|
# 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
|
assert r3.status_code == 200
|
||||||
|
|
||||||
export_data = r3.json()
|
export_data = r3.json()
|
||||||
|
|
|
@ -57,15 +57,14 @@ def test_exclude_filtering():
|
||||||
for exclude_card in exclude_list:
|
for exclude_card in exclude_list:
|
||||||
if exclude_card in remaining_cards:
|
if exclude_card in remaining_cards:
|
||||||
print(f"ERROR: {exclude_card} was NOT excluded!")
|
print(f"ERROR: {exclude_card} was NOT excluded!")
|
||||||
return False
|
assert False
|
||||||
else:
|
else:
|
||||||
print(f"✓ {exclude_card} was properly excluded")
|
print(f"✓ {exclude_card} was properly excluded")
|
||||||
|
|
||||||
print(f"\n✓ SUCCESS: All {len(exclude_list)} cards were 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)}")
|
print(f"✓ Remaining cards: {len(remaining_cards)} out of {len(test_cards_df)}")
|
||||||
return True
|
else:
|
||||||
|
assert False
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_exclude_filtering()
|
test_exclude_filtering()
|
||||||
|
|
|
@ -2,66 +2,43 @@
|
||||||
"""Test the improved fuzzy matching and modal styling"""
|
"""Test the improved fuzzy matching and modal styling"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import pytest
|
||||||
|
|
||||||
test_cases = [
|
|
||||||
("lightn", "Should find Lightning cards"),
|
|
||||||
("lightni", "Should find Lightning with slight typo"),
|
|
||||||
("bolt", "Should find Bolt cards"),
|
|
||||||
("bligh", "Should find Blightning"),
|
|
||||||
("unknowncard", "Should trigger confirmation modal"),
|
|
||||||
("ligth", "Should find Light cards"),
|
|
||||||
("boltt", "Should find Bolt with typo")
|
|
||||||
]
|
|
||||||
|
|
||||||
for input_text, description in test_cases:
|
@pytest.mark.parametrize(
|
||||||
|
"input_text,description",
|
||||||
|
[
|
||||||
|
("lightn", "Should find Lightning cards"),
|
||||||
|
("lightni", "Should find Lightning with slight typo"),
|
||||||
|
("bolt", "Should find Bolt cards"),
|
||||||
|
("bligh", "Should find Blightning"),
|
||||||
|
("unknowncard", "Should trigger confirmation modal"),
|
||||||
|
("ligth", "Should find Light cards"),
|
||||||
|
("boltt", "Should find Bolt with typo"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_final_fuzzy(input_text: str, description: str):
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
test_data = {
|
test_data = {
|
||||||
"include_cards": input_text,
|
"include_cards": input_text,
|
||||||
"exclude_cards": "",
|
"exclude_cards": "",
|
||||||
"commander": "",
|
"commander": "",
|
||||||
"enforcement_mode": "warn",
|
"enforcement_mode": "warn",
|
||||||
"allow_illegal": "false",
|
"allow_illegal": "false",
|
||||||
"fuzzy_matching": "true"
|
"fuzzy_matching": "true",
|
||||||
}
|
}
|
||||||
|
response = requests.post(
|
||||||
try:
|
"http://localhost:8080/build/validate/include_exclude",
|
||||||
response = requests.post(
|
data=test_data,
|
||||||
"http://localhost:8080/build/validate/include_exclude",
|
timeout=10,
|
||||||
data=test_data,
|
)
|
||||||
timeout=10
|
assert response.status_code == 200
|
||||||
)
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
if response.status_code == 200:
|
assert 'includes' in data or 'confirmation_needed' in data or 'invalid' in data
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check results
|
|
||||||
if data.get("confirmation_needed"):
|
|
||||||
print(f"🔄 Confirmation modal would show:")
|
|
||||||
for item in data["confirmation_needed"]:
|
|
||||||
print(f" Input: '{item['input']}'")
|
|
||||||
print(f" Confidence: {item['confidence']:.1%}")
|
|
||||||
print(f" Suggestions: {item['suggestions'][:3]}")
|
|
||||||
elif data.get("includes", {}).get("legal"):
|
|
||||||
legal = data["includes"]["legal"]
|
|
||||||
fuzzy = data["includes"].get("fuzzy_matches", {})
|
|
||||||
if input_text in fuzzy:
|
|
||||||
print(f"✅ Auto-accepted fuzzy match: '{input_text}' → '{fuzzy[input_text]}'")
|
|
||||||
else:
|
|
||||||
print(f"✅ Exact match: {legal}")
|
|
||||||
elif data.get("includes", {}).get("illegal"):
|
|
||||||
print(f"❌ No matches found")
|
|
||||||
else:
|
|
||||||
print(f"❓ Unclear result")
|
|
||||||
else:
|
|
||||||
print(f"❌ HTTP {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ EXCEPTION: {e}")
|
|
||||||
|
|
||||||
print(f"\n🎯 Summary:")
|
|
||||||
print("✅ Enhanced prefix matching prioritizes Lightning cards for 'lightn'")
|
|
||||||
print("✅ Dark theme modal styling implemented")
|
|
||||||
print("✅ Confidence threshold set to 95% for more confirmations")
|
|
||||||
print("💡 Ready for user testing in web UI!")
|
|
||||||
|
|
|
@ -34,10 +34,9 @@ def test_fuzzy_matching_direct():
|
||||||
|
|
||||||
if result.matched_name is None and not result.auto_accepted and result.suggestions:
|
if result.matched_name is None and not result.auto_accepted and result.suggestions:
|
||||||
print("✅ Fuzzy matching correctly triggered confirmation!")
|
print("✅ Fuzzy matching correctly triggered confirmation!")
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
print("❌ Fuzzy matching should have triggered confirmation")
|
print("❌ Fuzzy matching should have triggered confirmation")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
def test_exact_match_direct():
|
def test_exact_match_direct():
|
||||||
"""Test exact matching directly."""
|
"""Test exact matching directly."""
|
||||||
|
@ -52,17 +51,16 @@ def test_exact_match_direct():
|
||||||
|
|
||||||
result = fuzzy_match_card_name('Lightning Bolt', available_cards)
|
result = fuzzy_match_card_name('Lightning Bolt', available_cards)
|
||||||
|
|
||||||
print(f"Input: 'Lightning Bolt'")
|
print("Input: 'Lightning Bolt'")
|
||||||
print(f"Matched name: {result.matched_name}")
|
print(f"Matched name: {result.matched_name}")
|
||||||
print(f"Auto accepted: {result.auto_accepted}")
|
print(f"Auto accepted: {result.auto_accepted}")
|
||||||
print(f"Confidence: {result.confidence:.2%}")
|
print(f"Confidence: {result.confidence:.2%}")
|
||||||
|
|
||||||
if result.matched_name and result.auto_accepted:
|
if result.matched_name and result.auto_accepted:
|
||||||
print("✅ Exact match correctly auto-accepted!")
|
print("✅ Exact match correctly auto-accepted!")
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
print("❌ Exact match should have been auto-accepted")
|
print("❌ Exact match should have been auto-accepted")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("🧪 Testing Fuzzy Matching Logic")
|
print("🧪 Testing Fuzzy Matching Logic")
|
||||||
|
|
|
@ -8,11 +8,17 @@ import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'code'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'code'))
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import pytest
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def test_fuzzy_match_confirmation():
|
def test_fuzzy_match_confirmation():
|
||||||
"""Test that fuzzy matching returns confirmation_needed items for low confidence matches."""
|
"""Test that fuzzy matching returns confirmation_needed items for low confidence matches."""
|
||||||
print("🔍 Testing fuzzy match confirmation modal backend...")
|
print("🔍 Testing fuzzy match confirmation modal backend...")
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
# Test with a typo that should trigger confirmation
|
# Test with a typo that should trigger confirmation
|
||||||
test_data = {
|
test_data = {
|
||||||
|
@ -29,19 +35,19 @@ def test_fuzzy_match_confirmation():
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
print(f"❌ Request failed with status {response.status_code}")
|
print(f"❌ Request failed with status {response.status_code}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Check if confirmation_needed is populated
|
# Check if confirmation_needed is populated
|
||||||
if 'confirmation_needed' not in data:
|
if 'confirmation_needed' not in data:
|
||||||
print("❌ No confirmation_needed field in response")
|
print("❌ No confirmation_needed field in response")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
if not data['confirmation_needed']:
|
if not data['confirmation_needed']:
|
||||||
print("❌ confirmation_needed is empty")
|
print("❌ confirmation_needed is empty")
|
||||||
print(f"Response: {json.dumps(data, indent=2)}")
|
print(f"Response: {json.dumps(data, indent=2)}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
confirmation = data['confirmation_needed'][0]
|
confirmation = data['confirmation_needed'][0]
|
||||||
expected_fields = ['input', 'suggestions', 'confidence', 'type']
|
expected_fields = ['input', 'suggestions', 'confidence', 'type']
|
||||||
|
@ -49,23 +55,25 @@ def test_fuzzy_match_confirmation():
|
||||||
for field in expected_fields:
|
for field in expected_fields:
|
||||||
if field not in confirmation:
|
if field not in confirmation:
|
||||||
print(f"❌ Missing field '{field}' in confirmation")
|
print(f"❌ Missing field '{field}' in confirmation")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
print(f"✅ Fuzzy match confirmation working!")
|
print("✅ Fuzzy match confirmation working!")
|
||||||
print(f" Input: {confirmation['input']}")
|
print(f" Input: {confirmation['input']}")
|
||||||
print(f" Suggestions: {confirmation['suggestions']}")
|
print(f" Suggestions: {confirmation['suggestions']}")
|
||||||
print(f" Confidence: {confirmation['confidence']:.2%}")
|
print(f" Confidence: {confirmation['confidence']:.2%}")
|
||||||
print(f" Type: {confirmation['type']}")
|
print(f" Type: {confirmation['type']}")
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
def test_exact_match_no_confirmation():
|
def test_exact_match_no_confirmation():
|
||||||
"""Test that exact matches don't trigger confirmation."""
|
"""Test that exact matches don't trigger confirmation."""
|
||||||
print("\n🎯 Testing exact match (no confirmation)...")
|
print("\n🎯 Testing exact match (no confirmation)...")
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
test_data = {
|
test_data = {
|
||||||
'include_cards': 'Lightning Bolt', # Exact match
|
'include_cards': 'Lightning Bolt', # Exact match
|
||||||
|
@ -81,27 +89,25 @@ def test_exact_match_no_confirmation():
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
print(f"❌ Request failed with status {response.status_code}")
|
print(f"❌ Request failed with status {response.status_code}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Should not have confirmation_needed for exact match
|
# Should not have confirmation_needed for exact match
|
||||||
if data.get('confirmation_needed'):
|
if data.get('confirmation_needed'):
|
||||||
print(f"❌ Exact match should not trigger confirmation: {data['confirmation_needed']}")
|
print(f"❌ Exact match should not trigger confirmation: {data['confirmation_needed']}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
# Should have legal includes
|
# Should have legal includes
|
||||||
if not data.get('includes', {}).get('legal'):
|
if not data.get('includes', {}).get('legal'):
|
||||||
print("❌ Exact match should be in legal includes")
|
print("❌ Exact match should be in legal includes")
|
||||||
print(f"Response: {json.dumps(data, indent=2)}")
|
print(f"Response: {json.dumps(data, indent=2)}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
print("✅ Exact match correctly bypasses confirmation!")
|
print("✅ Exact match correctly bypasses confirmation!")
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("🧪 Testing Fuzzy Match Confirmation Modal")
|
print("🧪 Testing Fuzzy Match Confirmation Modal")
|
||||||
|
|
|
@ -2,69 +2,43 @@
|
||||||
"""Test improved fuzzy matching algorithm with the new endpoint"""
|
"""Test improved fuzzy matching algorithm with the new endpoint"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import pytest
|
||||||
|
|
||||||
def test_improved_fuzzy():
|
|
||||||
"""Test improved fuzzy matching with various inputs"""
|
@pytest.mark.parametrize(
|
||||||
|
"input_text,description",
|
||||||
test_cases = [
|
[
|
||||||
("lightn", "Should find Lightning cards"),
|
("lightn", "Should find Lightning cards"),
|
||||||
("light", "Should find Light cards"),
|
("light", "Should find Light cards"),
|
||||||
("bolt", "Should find Bolt cards"),
|
("bolt", "Should find Bolt cards"),
|
||||||
("blightni", "Should find Blightning"),
|
("blightni", "Should find Blightning"),
|
||||||
("lightn bo", "Should be unclear match")
|
("lightn bo", "Should be unclear match"),
|
||||||
]
|
],
|
||||||
|
)
|
||||||
for input_text, description in test_cases:
|
def test_improved_fuzzy(input_text: str, description: str):
|
||||||
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
# Skip if local server isn't running
|
||||||
print("=" * 60)
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
test_data = {
|
except Exception:
|
||||||
"include_cards": input_text,
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
"exclude_cards": "",
|
|
||||||
"commander": "",
|
|
||||||
"enforcement_mode": "warn",
|
|
||||||
"allow_illegal": "false",
|
|
||||||
"fuzzy_matching": "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
"http://localhost:8080/build/validate/include_exclude",
|
|
||||||
data=test_data,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check results
|
|
||||||
if data.get("confirmation_needed"):
|
|
||||||
print(f"🔄 Fuzzy confirmation needed for '{input_text}'")
|
|
||||||
for item in data["confirmation_needed"]:
|
|
||||||
print(f" Best: '{item['best_match']}' ({item['confidence']:.1%})")
|
|
||||||
if item.get('suggestions'):
|
|
||||||
print(f" Top 3:")
|
|
||||||
for i, suggestion in enumerate(item['suggestions'][:3], 1):
|
|
||||||
print(f" {i}. {suggestion}")
|
|
||||||
elif data.get("valid"):
|
|
||||||
print(f"✅ Auto-accepted: {[card['name'] for card in data['valid']]}")
|
|
||||||
# Show best match info if available
|
|
||||||
for card in data['valid']:
|
|
||||||
if card.get('fuzzy_match_info'):
|
|
||||||
print(f" Fuzzy matched '{input_text}' → '{card['name']}' ({card['fuzzy_match_info'].get('confidence', 0):.1%})")
|
|
||||||
elif data.get("invalid"):
|
|
||||||
print(f"❌ Invalid: {[card['input'] for card in data['invalid']]}")
|
|
||||||
else:
|
|
||||||
print(f"❓ No clear result for '{input_text}'")
|
|
||||||
print(f"Response keys: {list(data.keys())}")
|
|
||||||
else:
|
|
||||||
print(f"❌ HTTP {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ EXCEPTION: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
||||||
print("🧪 Testing Improved Fuzzy Match Algorithm")
|
test_data = {
|
||||||
print("==========================================")
|
"include_cards": input_text,
|
||||||
test_improved_fuzzy()
|
"exclude_cards": "",
|
||||||
|
"commander": "",
|
||||||
|
"enforcement_mode": "warn",
|
||||||
|
"allow_illegal": "false",
|
||||||
|
"fuzzy_matching": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"http://localhost:8080/build/validate/include_exclude",
|
||||||
|
data=test_data,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Ensure we got some structured response
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert 'includes' in data or 'confirmation_needed' in data or 'invalid' in data
|
||||||
|
|
|
@ -73,7 +73,7 @@ def test_m5_structured_logging():
|
||||||
print(f"❌ Missing event: {event}")
|
print(f"❌ Missing event: {event}")
|
||||||
|
|
||||||
print(f"\n📋 Results: {len(found_events)}/{len(expected_events)} expected events found")
|
print(f"\n📋 Results: {len(found_events)}/{len(expected_events)} expected events found")
|
||||||
|
|
||||||
# Test strict mode logging
|
# Test strict mode logging
|
||||||
print("\n🔒 Testing strict mode logging...")
|
print("\n🔒 Testing strict mode logging...")
|
||||||
builder_obj.enforcement_mode = "strict"
|
builder_obj.enforcement_mode = "strict"
|
||||||
|
@ -82,14 +82,13 @@ def test_m5_structured_logging():
|
||||||
print("✅ Strict mode passed (no missing includes)")
|
print("✅ Strict mode passed (no missing includes)")
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
print(f"❌ Strict mode failed: {e}")
|
print(f"❌ Strict mode failed: {e}")
|
||||||
|
|
||||||
return len(found_events) == len(expected_events)
|
assert len(found_events) == len(expected_events)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
|
||||||
finally:
|
finally:
|
||||||
logger.removeHandler(handler)
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
@ -128,7 +127,7 @@ def test_m5_performance_metrics():
|
||||||
else:
|
else:
|
||||||
print("❌ Performance metrics too slow")
|
print("❌ Performance metrics too slow")
|
||||||
|
|
||||||
return performance_acceptable
|
assert performance_acceptable
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -2,59 +2,46 @@
|
||||||
"""Test improved matching for specific cases that were problematic"""
|
"""Test improved matching for specific cases that were problematic"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import pytest
|
||||||
|
|
||||||
# Test the specific cases from the screenshots
|
|
||||||
test_cases = [
|
|
||||||
("lightn", "Should prioritize Lightning Bolt over Blightning/Flight"),
|
|
||||||
("cahso warp", "Should clearly find Chaos Warp first"),
|
|
||||||
("bolt", "Should find Lightning Bolt"),
|
|
||||||
("warp", "Should find Chaos Warp")
|
|
||||||
]
|
|
||||||
|
|
||||||
for input_text, description in test_cases:
|
@pytest.mark.parametrize(
|
||||||
|
"input_text,description",
|
||||||
|
[
|
||||||
|
("lightn", "Should prioritize Lightning Bolt over Blightning/Flight"),
|
||||||
|
("cahso warp", "Should clearly find Chaos Warp first"),
|
||||||
|
("bolt", "Should find Lightning Bolt"),
|
||||||
|
("warp", "Should find Chaos Warp"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_specific_matches(input_text: str, description: str):
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
print(f"\n🔍 Testing: '{input_text}' ({description})")
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
test_data = {
|
test_data = {
|
||||||
"include_cards": input_text,
|
"include_cards": input_text,
|
||||||
"exclude_cards": "",
|
"exclude_cards": "",
|
||||||
"commander": "",
|
"commander": "",
|
||||||
"enforcement_mode": "warn",
|
"enforcement_mode": "warn",
|
||||||
"allow_illegal": "false",
|
"allow_illegal": "false",
|
||||||
"fuzzy_matching": "true"
|
"fuzzy_matching": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
"http://localhost:8080/build/validate/include_exclude",
|
|
||||||
data=test_data,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check results
|
|
||||||
if data.get("confirmation_needed"):
|
|
||||||
print("🔄 Confirmation modal would show:")
|
|
||||||
for item in data["confirmation_needed"]:
|
|
||||||
print(f" Input: '{item['input']}'")
|
|
||||||
print(f" Confidence: {item['confidence']:.1%}")
|
|
||||||
print(f" Top suggestions:")
|
|
||||||
for i, suggestion in enumerate(item['suggestions'][:5], 1):
|
|
||||||
print(f" {i}. {suggestion}")
|
|
||||||
elif data.get("includes", {}).get("legal"):
|
|
||||||
fuzzy = data["includes"].get("fuzzy_matches", {})
|
|
||||||
if input_text in fuzzy:
|
|
||||||
print(f"✅ Auto-accepted: '{input_text}' → '{fuzzy[input_text]}'")
|
|
||||||
else:
|
|
||||||
print(f"✅ Exact match: {data['includes']['legal']}")
|
|
||||||
else:
|
|
||||||
print("❌ No matches found")
|
|
||||||
else:
|
|
||||||
print(f"❌ HTTP {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ EXCEPTION: {e}")
|
|
||||||
|
|
||||||
print(f"\n💡 Testing complete! Check if Lightning/Chaos suggestions are now prioritized.")
|
response = requests.post(
|
||||||
|
"http://localhost:8080/build/validate/include_exclude",
|
||||||
|
data=test_data,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
# At least one of the expected result containers should exist
|
||||||
|
assert (
|
||||||
|
data.get("confirmation_needed") is not None
|
||||||
|
or data.get("includes") is not None
|
||||||
|
or data.get("invalid") is not None
|
||||||
|
)
|
||||||
|
|
|
@ -71,9 +71,9 @@ def test_m5_structured_logging():
|
||||||
print(f"✅ Found event: {event}")
|
print(f"✅ Found event: {event}")
|
||||||
else:
|
else:
|
||||||
print(f"❌ Missing event: {event}")
|
print(f"❌ Missing event: {event}")
|
||||||
|
|
||||||
print(f"\n📋 Results: {len(found_events)}/{len(expected_events)} expected events found")
|
print(f"\n📋 Results: {len(found_events)}/{len(expected_events)} expected events found")
|
||||||
|
|
||||||
# Test strict mode logging
|
# Test strict mode logging
|
||||||
print("\n🔒 Testing strict mode logging...")
|
print("\n🔒 Testing strict mode logging...")
|
||||||
builder_obj.enforcement_mode = "strict"
|
builder_obj.enforcement_mode = "strict"
|
||||||
|
@ -82,14 +82,14 @@ def test_m5_structured_logging():
|
||||||
print("✅ Strict mode passed (no missing includes)")
|
print("✅ Strict mode passed (no missing includes)")
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
print(f"❌ Strict mode failed: {e}")
|
print(f"❌ Strict mode failed: {e}")
|
||||||
|
|
||||||
return len(found_events) == len(expected_events)
|
# Final assertion inside try so except/finally remain valid
|
||||||
|
assert len(found_events) == len(expected_events)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
|
||||||
finally:
|
finally:
|
||||||
logger.removeHandler(handler)
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ def test_m5_performance_metrics():
|
||||||
else:
|
else:
|
||||||
print("❌ Performance metrics too slow")
|
print("❌ Performance metrics too slow")
|
||||||
|
|
||||||
return performance_acceptable
|
assert performance_acceptable
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -1,18 +1,21 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Test the web validation endpoint to confirm fuzzy matching works.
|
Test the web validation endpoint to confirm fuzzy matching works.
|
||||||
|
Skips if the local web server is not running.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'code'))
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
def test_validation_with_empty_commander():
|
def test_validation_with_empty_commander():
|
||||||
"""Test validation without commander to see basic fuzzy logic."""
|
"""Test validation without commander to see basic fuzzy logic."""
|
||||||
print("🔍 Testing validation endpoint with empty commander...")
|
print("🔍 Testing validation endpoint with empty commander...")
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
test_data = {
|
test_data = {
|
||||||
'include_cards': 'Lighning', # Should trigger suggestions
|
'include_cards': 'Lighning', # Should trigger suggestions
|
||||||
|
@ -25,20 +28,25 @@ def test_validation_with_empty_commander():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post('http://localhost:8080/build/validate/include_exclude', data=test_data)
|
response = requests.post('http://localhost:8080/build/validate/include_exclude', data=test_data)
|
||||||
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
# Check expected structure keys exist
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert 'includes' in data or 'confirmation_needed' in data or 'invalid' in data
|
||||||
print("Response:")
|
print("Response:")
|
||||||
print(json.dumps(data, indent=2))
|
print(json.dumps(data, indent=2))
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
return None
|
assert False
|
||||||
|
|
||||||
def test_validation_with_false_fuzzy():
|
def test_validation_with_false_fuzzy():
|
||||||
"""Test with fuzzy matching disabled."""
|
"""Test with fuzzy matching disabled."""
|
||||||
print("\n🎯 Testing with fuzzy matching disabled...")
|
print("\n🎯 Testing with fuzzy matching disabled...")
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
test_data = {
|
test_data = {
|
||||||
'include_cards': 'Lighning',
|
'include_cards': 'Lighning',
|
||||||
|
@ -51,29 +59,14 @@ def test_validation_with_false_fuzzy():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post('http://localhost:8080/build/validate/include_exclude', data=test_data)
|
response = requests.post('http://localhost:8080/build/validate/include_exclude', data=test_data)
|
||||||
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
print("Response:")
|
print("Response:")
|
||||||
print(json.dumps(data, indent=2))
|
print(json.dumps(data, indent=2))
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Test failed with error: {e}")
|
print(f"❌ Test failed with error: {e}")
|
||||||
return None
|
assert False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("🧪 Testing Web Validation Endpoint")
|
print("🧪 Run this test with pytest for proper reporting")
|
||||||
print("=" * 45)
|
|
||||||
|
|
||||||
data1 = test_validation_with_empty_commander()
|
|
||||||
data2 = test_validation_with_false_fuzzy()
|
|
||||||
|
|
||||||
print("\n📋 Analysis:")
|
|
||||||
if data1:
|
|
||||||
has_confirmation = data1.get('confirmation_needed', [])
|
|
||||||
print(f" With fuzzy enabled: {len(has_confirmation)} confirmations needed")
|
|
||||||
|
|
||||||
if data2:
|
|
||||||
has_confirmation2 = data2.get('confirmation_needed', [])
|
|
||||||
print(f" With fuzzy disabled: {len(has_confirmation2)} confirmations needed")
|
|
||||||
|
|
|
@ -67,15 +67,15 @@ Hare Apparent"""
|
||||||
combo_balance=mock_session.get("combo_balance", "mix"),
|
combo_balance=mock_session.get("combo_balance", "mix"),
|
||||||
exclude_cards=mock_session.get("exclude_cards"),
|
exclude_cards=mock_session.get("exclude_cards"),
|
||||||
)
|
)
|
||||||
print(f" ✓ Build context created successfully")
|
print(" ✓ Build context created successfully")
|
||||||
print(f" Context exclude_cards: {ctx.get('exclude_cards')}")
|
print(f" Context exclude_cards: {ctx.get('exclude_cards')}")
|
||||||
|
|
||||||
# Test running the first stage
|
# Test running the first stage
|
||||||
print("4. Running first build stage...")
|
print("4. Running first build stage...")
|
||||||
result = orch.run_stage(ctx, rerun=False, show_skipped=False)
|
result = orch.run_stage(ctx, rerun=False, show_skipped=False)
|
||||||
print(f" ✓ Stage completed: {result.get('label', 'Unknown')}")
|
print(f" ✓ Stage completed: {result.get('label', 'Unknown')}")
|
||||||
print(f" Stage done: {result.get('done', False)}")
|
print(f" Stage done: {result.get('done', False)}")
|
||||||
|
|
||||||
# Check if there were any exclude-related messages in output
|
# Check if there were any exclude-related messages in output
|
||||||
output = result.get('output', [])
|
output = result.get('output', [])
|
||||||
exclude_messages = [msg for msg in output if 'exclude' in msg.lower() or 'excluded' in msg.lower()]
|
exclude_messages = [msg for msg in output if 'exclude' in msg.lower() or 'excluded' in msg.lower()]
|
||||||
|
@ -86,14 +86,12 @@ Hare Apparent"""
|
||||||
else:
|
else:
|
||||||
print("5. ⚠️ No exclude-related output found in stage result")
|
print("5. ⚠️ No exclude-related output found in stage result")
|
||||||
print(" This might indicate the filtering isn't working")
|
print(" This might indicate the filtering isn't working")
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error during build: {e}")
|
print(f"❌ Error during build: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
success = test_web_exclude_flow()
|
success = test_web_exclude_flow()
|
||||||
|
|
|
@ -4,7 +4,8 @@ Test to check if the web form is properly sending exclude_cards
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import re
|
import pytest
|
||||||
|
# removed unused import re
|
||||||
|
|
||||||
def test_web_form_exclude():
|
def test_web_form_exclude():
|
||||||
"""Test that the web form properly handles exclude cards"""
|
"""Test that the web form properly handles exclude cards"""
|
||||||
|
@ -14,6 +15,12 @@ def test_web_form_exclude():
|
||||||
# Test 1: Check if the exclude textarea is visible
|
# Test 1: Check if the exclude textarea is visible
|
||||||
print("1. Checking if exclude textarea is visible in new deck modal...")
|
print("1. Checking if exclude textarea is visible in new deck modal...")
|
||||||
|
|
||||||
|
# Skip if local server isn't running
|
||||||
|
try:
|
||||||
|
requests.get('http://localhost:8080/', timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip('Local web server is not running on http://localhost:8080; skipping HTTP-based test')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get("http://localhost:8080/build/new")
|
response = requests.get("http://localhost:8080/build/new")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
@ -27,7 +34,7 @@ def test_web_form_exclude():
|
||||||
print(" ✅ Advanced Options section found")
|
print(" ✅ Advanced Options section found")
|
||||||
else:
|
else:
|
||||||
print(" ❌ Advanced Options section NOT found")
|
print(" ❌ Advanced Options section NOT found")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
# Check if feature flag is working
|
# Check if feature flag is working
|
||||||
if 'allow_must_haves' in content or 'exclude_cards' in content:
|
if 'allow_must_haves' in content or 'exclude_cards' in content:
|
||||||
|
@ -37,11 +44,11 @@ def test_web_form_exclude():
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f" ❌ Failed to get modal: HTTP {response.status_code}")
|
print(f" ❌ Failed to get modal: HTTP {response.status_code}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Error checking modal: {e}")
|
print(f" ❌ Error checking modal: {e}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
# Test 2: Try to submit a form with exclude cards
|
# Test 2: Try to submit a form with exclude cards
|
||||||
print("2. Testing form submission with exclude cards...")
|
print("2. Testing form submission with exclude cards...")
|
||||||
|
@ -68,14 +75,14 @@ def test_web_form_exclude():
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f" ❌ Form submission failed: HTTP {response.status_code}")
|
print(f" ❌ Form submission failed: HTTP {response.status_code}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Error submitting form: {e}")
|
print(f" ❌ Error submitting form: {e}")
|
||||||
return False
|
assert False
|
||||||
|
|
||||||
print("3. ✅ Web form test completed")
|
print("3. ✅ Web form test completed")
|
||||||
return True
|
# If we reached here without assertions, the test passed
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_web_form_exclude()
|
test_web_form_exclude()
|
||||||
|
|
|
@ -39,6 +39,31 @@ if _STATIC_DIR.exists():
|
||||||
# Jinja templates
|
# Jinja templates
|
||||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||||
|
|
||||||
|
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
|
||||||
|
# and reorder to the new signature TemplateResponse(request, name, {...}).
|
||||||
|
# Prevents DeprecationWarning noise in tests without touching all call sites.
|
||||||
|
_orig_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
def _compat_template_response(*args, **kwargs): # type: ignore[override]
|
||||||
|
try:
|
||||||
|
if args and isinstance(args[0], str):
|
||||||
|
name = args[0]
|
||||||
|
ctx = args[1] if len(args) > 1 else {}
|
||||||
|
req = None
|
||||||
|
try:
|
||||||
|
if isinstance(ctx, dict):
|
||||||
|
req = ctx.get("request")
|
||||||
|
except Exception:
|
||||||
|
req = None
|
||||||
|
if req is not None:
|
||||||
|
return _orig_template_response(req, name, ctx, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
# Fall through to original behavior on any unexpected error
|
||||||
|
pass
|
||||||
|
return _orig_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
templates.TemplateResponse = _compat_template_response # type: ignore[assignment]
|
||||||
|
|
||||||
# Global template flags (env-driven)
|
# Global template flags (env-driven)
|
||||||
def _as_bool(val: str | None, default: bool = False) -> bool:
|
def _as_bool(val: str | None, default: bool = False) -> bool:
|
||||||
if val is None:
|
if val is None:
|
||||||
|
@ -239,6 +264,12 @@ app.include_router(decks_routes.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
app.include_router(owned_routes.router)
|
app.include_router(owned_routes.router)
|
||||||
|
|
||||||
|
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||||
|
try:
|
||||||
|
build_routes.warm_validation_name_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- Exception handling ---
|
# --- Exception handling ---
|
||||||
def _wants_html(request: Request) -> bool:
|
def _wants_html(request: Request) -> bool:
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -24,6 +24,86 @@ from deck_builder import builder_utils as bu
|
||||||
from ..services.combo_utils import detect_all as _detect_all
|
from ..services.combo_utils import detect_all as _detect_all
|
||||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||||
|
|
||||||
|
# Cache for available card names used by validation endpoints
|
||||||
|
_AVAILABLE_CARDS_CACHE: set[str] | None = None
|
||||||
|
_AVAILABLE_CARDS_NORM_SET: set[str] | None = None
|
||||||
|
_AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None
|
||||||
|
|
||||||
|
def _available_cards() -> set[str]:
|
||||||
|
"""Fast load of available card names using the csv module (no pandas).
|
||||||
|
|
||||||
|
Reads only once and caches results in memory.
|
||||||
|
"""
|
||||||
|
global _AVAILABLE_CARDS_CACHE
|
||||||
|
if _AVAILABLE_CARDS_CACHE is not None:
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
try:
|
||||||
|
import csv
|
||||||
|
path = 'csv_files/cards.csv'
|
||||||
|
with open(path, 'r', encoding='utf-8', newline='') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
fields = reader.fieldnames or []
|
||||||
|
name_col = None
|
||||||
|
for col in ['name', 'Name', 'card_name', 'CardName']:
|
||||||
|
if col in fields:
|
||||||
|
name_col = col
|
||||||
|
break
|
||||||
|
if name_col is None and fields:
|
||||||
|
# Heuristic: pick first field containing 'name'
|
||||||
|
for col in fields:
|
||||||
|
if 'name' in col.lower():
|
||||||
|
name_col = col
|
||||||
|
break
|
||||||
|
if name_col is None:
|
||||||
|
raise ValueError(f"No name-like column found in {path}: {fields}")
|
||||||
|
names: set[str] = set()
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
v = row.get(name_col)
|
||||||
|
if v:
|
||||||
|
names.add(str(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_AVAILABLE_CARDS_CACHE = names
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
except Exception:
|
||||||
|
_AVAILABLE_CARDS_CACHE = set()
|
||||||
|
return _AVAILABLE_CARDS_CACHE
|
||||||
|
|
||||||
|
def _available_cards_normalized() -> tuple[set[str], dict[str, str]]:
|
||||||
|
"""Return cached normalized card names and mapping to originals."""
|
||||||
|
global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None:
|
||||||
|
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
# Build from available cards set
|
||||||
|
names = _available_cards()
|
||||||
|
try:
|
||||||
|
from deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
except Exception:
|
||||||
|
# Fallback: identity normalization
|
||||||
|
def normalize_punctuation(x: str) -> str: # type: ignore
|
||||||
|
return str(x).strip().casefold()
|
||||||
|
norm_map: dict[str, str] = {}
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
n = normalize_punctuation(name)
|
||||||
|
if n not in norm_map:
|
||||||
|
norm_map[n] = name
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_AVAILABLE_CARDS_NORM_MAP = norm_map
|
||||||
|
_AVAILABLE_CARDS_NORM_SET = set(norm_map.keys())
|
||||||
|
return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP
|
||||||
|
|
||||||
|
def warm_validation_name_cache() -> None:
|
||||||
|
"""Pre-populate the available-cards caches to avoid first-call latency."""
|
||||||
|
try:
|
||||||
|
_ = _available_cards()
|
||||||
|
_ = _available_cards_normalized()
|
||||||
|
except Exception:
|
||||||
|
# Best-effort warmup; proceed silently on failure
|
||||||
|
pass
|
||||||
|
|
||||||
router = APIRouter(prefix="/build")
|
router = APIRouter(prefix="/build")
|
||||||
|
|
||||||
# Alternatives cache moved to services/alts_utils
|
# Alternatives cache moved to services/alts_utils
|
||||||
|
@ -120,9 +200,9 @@ async def build_index(request: Request) -> HTMLResponse:
|
||||||
else:
|
else:
|
||||||
last_step = 1
|
last_step = 1
|
||||||
resp = templates.TemplateResponse(
|
resp = templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"build/index.html",
|
"build/index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"sid": sid,
|
"sid": sid,
|
||||||
"commander": sess.get("commander"),
|
"commander": sess.get("commander"),
|
||||||
"tags": sess.get("tags", []),
|
"tags": sess.get("tags", []),
|
||||||
|
@ -2719,7 +2799,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
||||||
"compliance": compliance or rep,
|
"compliance": compliance or rep,
|
||||||
}
|
}
|
||||||
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
||||||
resp = templates.TemplateResponse("build/_step5.html", page_ctx)
|
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@ -2751,7 +2831,7 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ctx2 = {"request": request, "compliance": comp}
|
ctx2 = {"request": request, "compliance": comp}
|
||||||
resp = templates.TemplateResponse("build/enforcement.html", ctx2)
|
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@ -2832,8 +2912,7 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
||||||
locks_restored = len(sess.get("locks", []) or [])
|
locks_restored = len(sess.get("locks", []) or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
locks_restored = 0
|
locks_restored = 0
|
||||||
resp = templates.TemplateResponse("build/_step4.html", {
|
resp = templates.TemplateResponse(request, "build/_step4.html", {
|
||||||
"request": request,
|
|
||||||
"labels": orch.ideal_labels(),
|
"labels": orch.ideal_labels(),
|
||||||
"values": sess.get("ideals") or orch.ideal_defaults(),
|
"values": sess.get("ideals") or orch.ideal_defaults(),
|
||||||
"commander": sess.get("commander"),
|
"commander": sess.get("commander"),
|
||||||
|
@ -3052,24 +3131,19 @@ async def validate_include_exclude_cards(
|
||||||
# No commander provided, do basic fuzzy matching only
|
# No commander provided, do basic fuzzy matching only
|
||||||
if fuzzy_matching and (include_unique or exclude_unique):
|
if fuzzy_matching and (include_unique or exclude_unique):
|
||||||
try:
|
try:
|
||||||
# Get card names directly from CSV without requiring commander setup
|
# Use cached available cards set (1st call populates cache)
|
||||||
import pandas as pd
|
available_cards = _available_cards()
|
||||||
cards_df = pd.read_csv('csv_files/cards.csv')
|
|
||||||
|
|
||||||
# Try to find the name column
|
|
||||||
name_column = None
|
|
||||||
for col in ['Name', 'name', 'card_name', 'CardName']:
|
|
||||||
if col in cards_df.columns:
|
|
||||||
name_column = col
|
|
||||||
break
|
|
||||||
|
|
||||||
if name_column is None:
|
|
||||||
raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}")
|
|
||||||
|
|
||||||
available_cards = set(cards_df[name_column].tolist())
|
|
||||||
|
|
||||||
|
# Fast path: normalized exact matches via cached sets
|
||||||
|
norm_set, norm_map = _available_cards_normalized()
|
||||||
# Validate includes with fuzzy matching
|
# Validate includes with fuzzy matching
|
||||||
for card_name in include_unique:
|
for card_name in include_unique:
|
||||||
|
from deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
n = normalize_punctuation(card_name)
|
||||||
|
if n in norm_set:
|
||||||
|
result["includes"]["fuzzy_matches"][card_name] = norm_map[n]
|
||||||
|
result["includes"]["legal"].append(norm_map[n])
|
||||||
|
continue
|
||||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
|
|
||||||
if match_result.matched_name and match_result.auto_accepted:
|
if match_result.matched_name and match_result.auto_accepted:
|
||||||
|
@ -3087,9 +3161,14 @@ async def validate_include_exclude_cards(
|
||||||
else:
|
else:
|
||||||
# No match found at all, add to illegal
|
# No match found at all, add to illegal
|
||||||
result["includes"]["illegal"].append(card_name)
|
result["includes"]["illegal"].append(card_name)
|
||||||
|
|
||||||
# Validate excludes with fuzzy matching
|
# Validate excludes with fuzzy matching
|
||||||
for card_name in exclude_unique:
|
for card_name in exclude_unique:
|
||||||
|
from deck_builder.include_exclude_utils import normalize_punctuation
|
||||||
|
n = normalize_punctuation(card_name)
|
||||||
|
if n in norm_set:
|
||||||
|
result["excludes"]["fuzzy_matches"][card_name] = norm_map[n]
|
||||||
|
result["excludes"]["legal"].append(norm_map[n])
|
||||||
|
continue
|
||||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||||
if match_result.matched_name:
|
if match_result.matched_name:
|
||||||
if match_result.auto_accepted:
|
if match_result.auto_accepted:
|
||||||
|
|
|
@ -83,7 +83,12 @@ body {
|
||||||
/* Top banner */
|
/* Top banner */
|
||||||
.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
|
.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
|
||||||
.top-banner{ min-height: var(--banner-h); }
|
.top-banner{ min-height: var(--banner-h); }
|
||||||
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
|
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; }
|
||||||
|
.top-banner .top-inner > div{ min-width:0; }
|
||||||
|
@media (max-width: 1100px){
|
||||||
|
.top-banner .top-inner{ grid-auto-rows:auto; }
|
||||||
|
.top-banner .top-inner select{ max-width:140px; }
|
||||||
|
}
|
||||||
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
|
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
|
||||||
.banner-status.busy{ color:#fbbf24; }
|
.banner-status.busy{ color:#fbbf24; }
|
||||||
|
@ -105,6 +110,8 @@ body {
|
||||||
width: var(--sidebar-w);
|
width: var(--sidebar-w);
|
||||||
z-index: 9; /* below the banner (z=10) */
|
z-index: 9; /* below the banner (z=10) */
|
||||||
box-shadow: 2px 0 10px rgba(0,0,0,.18);
|
box-shadow: 2px 0 10px rgba(0,0,0,.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
|
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
|
||||||
|
|
||||||
|
@ -120,13 +127,22 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
|
||||||
/* Mobile tweaks */
|
/* Mobile tweaks */
|
||||||
@media (max-width: 900px){
|
@media (max-width: 900px){
|
||||||
:root{ --sidebar-w: 240px; }
|
:root{ --sidebar-w: 240px; }
|
||||||
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem .5rem; }
|
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; }
|
||||||
.banner-status{ padding-left: .5rem; }
|
.banner-status{ padding-left: .5rem; }
|
||||||
.layout{ grid-template-columns: 0 1fr; }
|
.layout{ grid-template-columns: 0 1fr; }
|
||||||
.sidebar{ transform: translateX(-100%); visibility: hidden; }
|
.sidebar{ transform: translateX(-100%); visibility: hidden; }
|
||||||
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
|
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
|
||||||
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
|
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
|
||||||
.content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
|
.content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
|
||||||
|
.top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); }
|
||||||
|
/* Spacing tweaks: tighter left, larger gaps between visible items */
|
||||||
|
.top-banner .top-inner > div{ gap: 25px !important; }
|
||||||
|
.top-banner .top-inner > div:first-child{ padding-left: 0 !important; }
|
||||||
|
/* Mobile: show only Menu, Title, and Theme selector */
|
||||||
|
#btn-open-permalink{ display:none !important; }
|
||||||
|
#banner-status{ display:none !important; }
|
||||||
|
#health-dot{ display:none !important; }
|
||||||
|
.top-banner #theme-reset{ display:none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional mobile spacing for bottom floating controls */
|
/* Additional mobile spacing for bottom floating controls */
|
||||||
|
@ -149,6 +165,14 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
|
||||||
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
||||||
.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
|
.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
|
||||||
|
|
||||||
|
/* Sidebar theme controls anchored at bottom */
|
||||||
|
.sidebar .nav { flex: 1 1 auto; }
|
||||||
|
.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
|
||||||
|
.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; }
|
||||||
|
.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; }
|
||||||
|
.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; }
|
||||||
|
.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); }
|
||||||
|
|
||||||
/* Simple two-column layout for inspect panel */
|
/* Simple two-column layout for inspect panel */
|
||||||
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
||||||
.two-col .grow { min-width: 0; }
|
.two-col .grow { min-width: 0; }
|
||||||
|
@ -392,63 +416,50 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100vw !important;
|
max-width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Test hand responsive adjustments */
|
||||||
|
#test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; }
|
||||||
|
|
||||||
|
/* Modal & form layout fixes (original block retained inside media query) */
|
||||||
/* Fix modal layout on mobile */
|
/* Fix modal layout on mobile */
|
||||||
.modal {
|
.modal {
|
||||||
padding: 10px !important;
|
padding: 10px !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: calc(100vw - 20px) !important;
|
max-width: calc(100vw - 20px) !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force single column for include/exclude grid */
|
/* Force single column for include/exclude grid */
|
||||||
.include-exclude-grid {
|
.include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; }
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
gap: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix basics grid */
|
/* Fix basics grid */
|
||||||
.basics-grid {
|
.basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; }
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
gap: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure all inputs and textareas fit properly */
|
/* Ensure all inputs and textareas fit properly */
|
||||||
.modal input,
|
.modal input,
|
||||||
.modal textarea,
|
.modal textarea,
|
||||||
.modal select {
|
.modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; }
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix chips containers */
|
/* Fix chips containers */
|
||||||
.modal [id$="_chips_container"] {
|
.modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; }
|
||||||
max-width: 100% !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
word-wrap: break-word !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure fieldsets don't overflow */
|
/* Ensure fieldsets don't overflow */
|
||||||
.modal fieldset {
|
.modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; }
|
||||||
max-width: 100% !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix any inline styles that might cause overflow */
|
/* Fix any inline styles that might cause overflow */
|
||||||
.modal fieldset > div,
|
.modal fieldset > div,
|
||||||
.modal fieldset > div > div {
|
.modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; }
|
||||||
max-width: 100% !important;
|
}
|
||||||
overflow-x: hidden !important;
|
|
||||||
}
|
@media (max-width: 640px){
|
||||||
|
#test-hand{ --card-w: 150px !important; --card-h: 210px !important; }
|
||||||
|
/* Generic stack shrink */
|
||||||
|
.stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px){
|
||||||
|
#test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; }
|
||||||
|
#test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; }
|
||||||
|
#test-hand .stack-card{ flex:0 0 auto; }
|
||||||
|
.stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
@ -508,3 +519,8 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
||||||
display: none !important; /* Hide separators on mobile */
|
display: none !important; /* Hide separators on mobile */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop sizing for Test Hand */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
|
||||||
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
}catch(_){ }
|
}catch(_){ }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
|
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
|
||||||
<!-- Performance hints -->
|
<!-- Performance hints -->
|
||||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||||
|
@ -54,23 +54,9 @@
|
||||||
<div style="display:flex; align-items:center; gap:.5rem">
|
<div style="display:flex; align-items:center; gap:.5rem">
|
||||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||||
<button type="button" class="btn" title="Open a saved permalink"
|
<button type="button" id="btn-open-permalink" class="btn" title="Open a saved permalink"
|
||||||
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||||||
{% if enable_themes %}
|
{# Theme controls moved to sidebar #}
|
||||||
<label style="margin:0 .5rem; align-items:flex-start; margin-left:auto">
|
|
||||||
<span class="muted" style="font-size:11px">Theme</span>
|
|
||||||
<select id="theme-select" aria-label="Theme selector">
|
|
||||||
<option value="system">System</option>
|
|
||||||
<option value="light">Light</option>
|
|
||||||
<option value="dark">Dark</option>
|
|
||||||
<option value="high-contrast">High contrast</option>
|
|
||||||
<option value="cb-friendly">Color-blind</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" id="theme-reset" class="btn" title="Reset theme preference" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -95,6 +81,21 @@
|
||||||
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
||||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
{% if enable_themes %}
|
||||||
|
<div class="sidebar-theme" role="group" aria-label="Theme">
|
||||||
|
<label class="sidebar-theme-label" for="theme-select">Theme</label>
|
||||||
|
<div class="sidebar-theme-row">
|
||||||
|
<select id="theme-select" aria-label="Theme selector">
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="high-contrast">High contrast</option>
|
||||||
|
<option value="cb-friendly">Color-blind</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="theme-reset" class="btn btn-ghost" title="Reset theme preference">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<main class="content" data-error-surface>
|
<main class="content" data-error-surface>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
|
@ -8,11 +8,13 @@
|
||||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
||||||
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
|
<div class="two-col two-col-left-rail" style="margin-top:.75rem;">
|
||||||
<div>
|
<aside class="card-preview">
|
||||||
{% if commander %}
|
{% if commander %}
|
||||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" width="320" />
|
||||||
|
</a>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
{% if csv_path %}
|
{% if csv_path %}
|
||||||
|
@ -27,13 +29,13 @@
|
||||||
<button type="submit">Download TXT</button>
|
<button type="submit">Download TXT</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||||
<button type="submit">Back to Finished Decks</button>
|
<button type="submit">Back to Finished Decks</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
<div>
|
<div class="grow">
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
{% if owned_set %}
|
{% if owned_set %}
|
||||||
{% set ns = namespace(owned=0, total=0) %}
|
{% set ns = namespace(owned=0, total=0) %}
|
||||||
|
|
|
@ -338,12 +338,13 @@
|
||||||
|
|
||||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||||
<section style="margin-top:1rem;">
|
<section style="margin-top:1rem;">
|
||||||
<h5>Test Hand</h5>
|
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
|
||||||
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||||
|
</h5>
|
||||||
|
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||||
<button type="button" id="btn-new-hand">New Hand</button>
|
<button type="button" id="btn-new-hand">New Hand</button>
|
||||||
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
|
<div class="stack-wrap hand-row-overlap" id="test-hand">
|
||||||
<div class="stack-grid" id="test-hand-grid"></div>
|
<div class="stack-grid" id="test-hand-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
@ -415,13 +416,30 @@
|
||||||
var grid = document.getElementById('test-hand-grid');
|
var grid = document.getElementById('test-hand-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
hand.forEach(function(name){
|
var host = document.getElementById('test-hand');
|
||||||
|
if (host){ host.style.setProperty('--mid', (hand.length ? (hand.length - 1)/2 : 0)); host.style.setProperty('--count', hand.length); }
|
||||||
|
hand.forEach(function(name, idx){
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = 'stack-card';
|
div.className = 'stack-card';
|
||||||
if (GC_SET && GC_SET.has(name)) {
|
if (GC_SET && GC_SET.has(name)) {
|
||||||
div.className += ' game-changer';
|
div.className += ' game-changer';
|
||||||
}
|
}
|
||||||
|
div.style.setProperty('--i', idx);
|
||||||
|
var mid = (hand.length - 1) / 2;
|
||||||
|
var diff = Math.abs(idx - mid);
|
||||||
|
var peakRaise = 22; // px raise at center (accentuate arc)
|
||||||
|
var dropPer = 5; // linear component per distance step
|
||||||
|
// Strengthen curve so the very outer cards sit lower
|
||||||
|
var outerExtra = 24; // quadratic downward px strongest at edges
|
||||||
|
var edgeBias = 8; // cubic bias for far edges
|
||||||
|
var norm = (mid > 0 ? diff / mid : 0); // 0..1
|
||||||
|
var curve = norm * norm * outerExtra; // quadratic easing
|
||||||
|
var curve3 = norm * norm * norm * edgeBias; // cubic accentuation
|
||||||
|
var y = (diff * dropPer) + curve + curve3 - peakRaise; // center negative (raised), edges positive (lower)
|
||||||
|
// Minor smoothing so second-from-edge isn't too low
|
||||||
|
if (mid >= 2 && Math.abs(diff - (mid - 1)) < 0.001) { y += 2; }
|
||||||
|
div.style.setProperty('--ty', y + 'px');
|
||||||
div.innerHTML = (
|
div.innerHTML = (
|
||||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
||||||
);
|
);
|
||||||
|
@ -431,9 +449,73 @@
|
||||||
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
|
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
|
||||||
var btn = document.getElementById('btn-new-hand');
|
var btn = document.getElementById('btn-new-hand');
|
||||||
if (btn) btn.addEventListener('click', newHand);
|
if (btn) btn.addEventListener('click', newHand);
|
||||||
|
// Fan effect — desktop default (>=900px, hover-capable pointer)
|
||||||
|
var handEl = document.getElementById('test-hand');
|
||||||
|
(function(){
|
||||||
|
if(!handEl) return;
|
||||||
|
var onEnter = function(){ handEl.classList.add('fan'); };
|
||||||
|
var onLeave = function(){ handEl.classList.remove('fan'); };
|
||||||
|
var mq = window.matchMedia('(any-hover: hover) and (pointer: fine) and (min-width: 900px)');
|
||||||
|
function attach(){ handEl.addEventListener('mouseenter', onEnter); handEl.addEventListener('mouseleave', onLeave); }
|
||||||
|
function detach(){ handEl.removeEventListener('mouseenter', onEnter); handEl.removeEventListener('mouseleave', onLeave); }
|
||||||
|
// Desktop: fan is default; Mobile/tablet: no fan
|
||||||
|
function apply(){
|
||||||
|
if (mq.matches) {
|
||||||
|
detach();
|
||||||
|
handEl.classList.add('fan');
|
||||||
|
} else {
|
||||||
|
detach();
|
||||||
|
handEl.classList.remove('fan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof mq.addEventListener === 'function') mq.addEventListener('change', apply);
|
||||||
|
else if (typeof mq.addListener === 'function') mq.addListener(apply);
|
||||||
|
} catch(_) {}
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
newHand();
|
newHand();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Base overlapping hand: 160px cards (same as deck thumbnails) */
|
||||||
|
#test-hand.hand-row-overlap{ padding-bottom:.9rem; --fan-gap:28px; --card-w:160px; --card-h:224px; }
|
||||||
|
#test-hand.hand-row-overlap .stack-grid{ display:flex !important; gap:0; overflow-x:auto; scrollbar-width:thin; }
|
||||||
|
#test-hand.hand-row-overlap .stack-card{ width:var(--card-w); height:var(--card-h); transition: transform .25s ease, margin-left .25s ease, width .25s ease, height .25s ease; flex: 0 0 auto; }
|
||||||
|
/* Dynamic overlap: show ~30% of next card */
|
||||||
|
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.7); }
|
||||||
|
#test-hand.hand-row-overlap .stack-card img{ width:var(--card-w); height:var(--card-h); display:block; }
|
||||||
|
#test-hand.hand-row-overlap .stack-card:hover{ z-index:999; transform:translateY(-4px); }
|
||||||
|
/* Desktop sizing for Test Hand */
|
||||||
|
@media (min-width:900px){
|
||||||
|
#test-hand.hand-row-overlap{ --card-w:280px; --card-h:392px; --fan-gap:40px; }
|
||||||
|
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.7); }
|
||||||
|
#test-hand.hand-row-overlap.fan{ --card-w:280px; --card-h:392px; }
|
||||||
|
}
|
||||||
|
/* Hover fan-out: spread cards horizontally, enlarge if not already large */
|
||||||
|
#test-hand.hand-row-overlap.fan{ --fan-overlap:0.40; --fan-gap:0px; }
|
||||||
|
#test-hand.hand-row-overlap.fan .stack-card + .stack-card{ margin-left:0; }
|
||||||
|
#test-hand.hand-row-overlap.fan .stack-grid{ justify-content:center; overflow:visible; padding-left:0; }
|
||||||
|
/* Fan transform now applies a 40% overlap (visible width ~60%) while keeping center aligned */
|
||||||
|
#test-hand.hand-row-overlap.fan .stack-card{ position:relative; transform: translateX(calc((var(--i) - var(--mid)) * (var(--fan-gap) - (var(--card-w) * var(--fan-overlap))))) translateY(var(--ty,0px)) rotate(calc((var(--i) - var(--mid)) * 4deg)); }
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width:900px){
|
||||||
|
#test-hand.hand-row-overlap.fan{ --card-w:240px; --card-h:336px; --fan-overlap:0.40; --fan-gap:0px; }
|
||||||
|
}
|
||||||
|
@media (max-width:640px){
|
||||||
|
#test-hand.hand-row-overlap{ --card-w:150px; --card-h:210px; }
|
||||||
|
#test-hand.hand-row-overlap.fan{ --card-w:200px; --card-h:280px; --fan-overlap:0.40; --fan-gap:0px; }
|
||||||
|
}
|
||||||
|
@media (min-width:640px) and (max-width:899px){
|
||||||
|
#test-hand.hand-row-overlap{ --card-w:160px; --card-h:224px; }
|
||||||
|
}
|
||||||
|
@media (max-width:480px){
|
||||||
|
#test-hand.hand-row-overlap{ --card-w:140px; --card-h:196px; }
|
||||||
|
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.65); }
|
||||||
|
#test-hand.hand-row-overlap.fan{ --card-w:180px; --card-h:252px; --fan-overlap:0.40; --fan-gap:0px; }
|
||||||
|
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</section>
|
</section>
|
||||||
<style>
|
<style>
|
||||||
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||||
|
|
|
@ -29,11 +29,6 @@ services:
|
||||||
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
|
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
|
||||||
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
|
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
|
||||||
|
|
||||||
# Compliance/exports
|
|
||||||
WEB_AUTO_ENFORCE: "0" # 1=auto-apply bracket enforcement and re-export
|
|
||||||
APP_VERSION: "v2.2.9" # Optional label shown in footer
|
|
||||||
# WEB_CUSTOM_EXPORT_BASE: "" # Optional custom export basename
|
|
||||||
|
|
||||||
# Misc land tuning (utility land selection – Step 7)
|
# Misc land tuning (utility land selection – Step 7)
|
||||||
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1
|
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1
|
||||||
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build
|
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build
|
||||||
|
|
|
@ -32,7 +32,7 @@ services:
|
||||||
|
|
||||||
# Compliance/exports
|
# Compliance/exports
|
||||||
WEB_AUTO_ENFORCE: "0"
|
WEB_AUTO_ENFORCE: "0"
|
||||||
APP_VERSION: "v2.2.9"
|
APP_VERSION: "v2.2.10"
|
||||||
# WEB_CUSTOM_EXPORT_BASE: ""
|
# WEB_CUSTOM_EXPORT_BASE: ""
|
||||||
|
|
||||||
# Misc land tuning (utility land selection – Step 7)
|
# Misc land tuning (utility land selection – Step 7)
|
||||||
|
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mtg-deckbuilder"
|
name = "mtg-deckbuilder"
|
||||||
version = "2.2.9"
|
version = "2.2.10"
|
||||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue