feat(cli): add type indicators, ideal count args, and theme name support

Enhanced CLI with type-safe help text, 8 ideal count flags (--land-count, etc), and theme selection by name (--primary-tag)
This commit is contained in:
matt 2025-09-09 18:52:47 -07:00
parent cfcc01db85
commit abea242c16
6 changed files with 588 additions and 79 deletions

View file

@ -29,6 +29,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Engine integration with include injection after lands, before creatures/spells with ordering tests - Engine integration with include injection after lands, before creatures/spells with ordering tests
- Exclude re-entry prevention ensuring blocked cards cannot re-enter via downstream heuristics - Exclude re-entry prevention ensuring blocked cards cannot re-enter via downstream heuristics
- Web UI enhancement with two-column layout, chips/tag UI, and real-time validation - Web UI enhancement with two-column layout, chips/tag UI, and real-time validation
- **CLI enhancement: Enhanced help text with type indicators** - All CLI arguments now show expected value types (PATH, NAME, INT, BOOL) and organized into logical groups
- **CLI enhancement: Ideal count arguments** - New CLI flags for deck composition: `--ramp-count`, `--land-count`, `--basic-land-count`, `--creature-count`, `--removal-count`, `--wipe-count`, `--card-advantage-count`, `--protection-count`
- **CLI enhancement: Theme tag name support** - Theme selection by name instead of index: `--primary-tag`, `--secondary-tag`, `--tertiary-tag` as alternatives to numeric choices
- **CLI enhancement: Include/exclude CLI support** - Full CLI parity for include/exclude with `--include-cards`, `--exclude-cards`, `--enforcement-mode`, `--allow-illegal`, `--fuzzy-matching`
- **CLI enhancement: Console summary printing** - Detailed include/exclude summary output for headless builds with diagnostics and validation results
- Enhanced fuzzy matching with 300+ Commander-legal card knowledge base and popular/iconic card prioritization - Enhanced fuzzy matching with 300+ Commander-legal card knowledge base and popular/iconic card prioritization
- Card constants refactored to dedicated `builder_constants.py` with functional organization - Card constants refactored to dedicated `builder_constants.py` with functional organization
- Fuzzy match confirmation modal with dark theme support and card preview functionality - Fuzzy match confirmation modal with dark theme support and card preview functionality

BIN
README.md

Binary file not shown.

View file

@ -2,6 +2,8 @@
## Highlights ## Highlights
- **Include/Exclude Cards Feature Complete**: Full implementation with enhanced web UI, intelligent fuzzy matching, and performance optimization. Users can now specify must-include and must-exclude cards with comprehensive card knowledge base and excellent performance. - **Include/Exclude Cards Feature Complete**: Full implementation with enhanced web UI, intelligent fuzzy matching, and performance optimization. Users can now specify must-include and must-exclude cards with comprehensive card knowledge base and excellent performance.
- **Enhanced CLI with Type Safety**: Comprehensive CLI enhancement with type indicators, ideal count arguments, and theme tag name support making headless operation more user-friendly and discoverable.
- **Theme Tag Name Selection**: Intelligent theme selection by name instead of index numbers, automatically resolving to correct choices accounting for selection ordering.
- **Enhanced Fuzzy Matching**: Advanced algorithm with 300+ Commander-legal card knowledge base, popular/iconic card prioritization, and dark theme confirmation modal for optimal user experience. - **Enhanced Fuzzy Matching**: Advanced algorithm with 300+ Commander-legal card knowledge base, popular/iconic card prioritization, and dark theme confirmation modal for optimal user experience.
- **Mobile Responsive Design**: Optimized mobile experience with bottom-floating build controls, two-column grid layout, and horizontal scrolling prevention for improved thumb navigation. - **Mobile Responsive Design**: Optimized mobile experience with bottom-floating build controls, two-column grid layout, and horizontal scrolling prevention for improved thumb navigation.
- **Enhanced Visual Validation**: List size validation UI with warning icons (⚠️ over-limit, ⚡ approaching limit) and color coding providing clear feedback on usage limits. - **Enhanced Visual Validation**: List size validation UI with warning icons (⚠️ over-limit, ⚡ approaching limit) and color coding providing clear feedback on usage limits.
@ -9,6 +11,13 @@
- **Dual Architecture Support**: Seamless functionality across both web interface (staging system) and CLI (direct build) with proper include injection timing. - **Dual Architecture Support**: Seamless functionality across both web interface (staging system) and CLI (direct build) with proper include injection timing.
## What's new ## What's new
- **Enhanced CLI Experience**
- Type-safe help text with value indicators (PATH, NAME, INT, BOOL) and organized argument groups
- Ideal count CLI arguments: `--ramp-count`, `--land-count`, `--creature-count`, etc. for deck composition control
- Theme tag name support: `--primary-tag "Airbending"` instead of `--primary-choice 1` with intelligent resolution
- Include/exclude CLI parity: `--include-cards`, `--exclude-cards` with semicolon support for comma-containing card names
- Console summary output with detailed diagnostics and validation results for headless builds
- Priority system: CLI > JSON Config > Environment Variables > Defaults
- **Enhanced Visual Validation** - **Enhanced Visual Validation**
- List size validation UI with visual warning system using icons and color coding - List size validation UI with visual warning system using icons and color coding
- Live validation badges showing count/limit status with clear visual indicators - Live validation badges showing count/limit status with clear visual indicators

View file

@ -4,11 +4,7 @@ import argparse
import json import json
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import time
import sys
import os
from deck_builder.builder import DeckBuilder from deck_builder.builder import DeckBuilder
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from file_setup.setup import initial_setup from file_setup.setup import initial_setup
@ -207,7 +203,97 @@ def run(
def _should_export_json_headless() -> bool: def _should_export_json_headless() -> bool:
return os.getenv('HEADLESS_EXPORT_JSON', '').strip().lower() in {'1','true','yes','on'} return os.getenv('HEADLESS_EXPORT_JSON', '').strip().lower() in {'1','true','yes','on'}
def _print_include_exclude_summary(builder: DeckBuilder) -> None:
"""Print include/exclude summary to console (M4: Extended summary printing)."""
if not hasattr(builder, 'include_exclude_diagnostics') or not builder.include_exclude_diagnostics:
return
diagnostics = builder.include_exclude_diagnostics
# Skip if no include/exclude activity
if not any([
diagnostics.get('include_cards'),
diagnostics.get('exclude_cards'),
diagnostics.get('include_added'),
diagnostics.get('excluded_removed')
]):
return
print("\n" + "=" * 50)
print("INCLUDE/EXCLUDE SUMMARY")
print("=" * 50)
# Include cards impact
include_cards = diagnostics.get('include_cards', [])
if include_cards:
print(f"\n✓ Must Include Cards ({len(include_cards)}):")
include_added = diagnostics.get('include_added', [])
if include_added:
print(f" ✓ Successfully Added ({len(include_added)}):")
for card in include_added:
print(f"{card}")
missing_includes = diagnostics.get('missing_includes', [])
if missing_includes:
print(f" ⚠ Could Not Include ({len(missing_includes)}):")
for card in missing_includes:
print(f"{card}")
# Exclude cards impact
exclude_cards = diagnostics.get('exclude_cards', [])
if exclude_cards:
print(f"\n✗ Must Exclude Cards ({len(exclude_cards)}):")
excluded_removed = diagnostics.get('excluded_removed', [])
if excluded_removed:
print(f" ✓ Successfully Excluded ({len(excluded_removed)}):")
for card in excluded_removed:
print(f"{card}")
print(" Patterns:")
for pattern in exclude_cards:
print(f"{pattern}")
# Validation issues
issues = []
fuzzy_corrections = diagnostics.get('fuzzy_corrections', {})
if fuzzy_corrections:
issues.append(f"Fuzzy Matched ({len(fuzzy_corrections)})")
duplicates = diagnostics.get('duplicates_collapsed', {})
if duplicates:
issues.append(f"Duplicates Collapsed ({len(duplicates)})")
illegal_dropped = diagnostics.get('illegal_dropped', [])
if illegal_dropped:
issues.append(f"Illegal Cards Dropped ({len(illegal_dropped)})")
if issues:
print("\n⚠ Validation Issues:")
if fuzzy_corrections:
print(" ⚡ Fuzzy Matched:")
for original, corrected in fuzzy_corrections.items():
print(f"{original}{corrected}")
if duplicates:
print(" Duplicates Collapsed:")
for card, count in duplicates.items():
print(f"{card} ({count}x)")
if illegal_dropped:
print(" Illegal Cards Dropped:")
for card in illegal_dropped:
print(f"{card}")
print("=" * 50)
def _export_outputs(builder: DeckBuilder) -> None: def _export_outputs(builder: DeckBuilder) -> None:
# M4: Print include/exclude summary to console
_print_include_exclude_summary(builder)
csv_path: Optional[str] = None csv_path: Optional[str] = None
try: try:
csv_path = builder.export_decklist_csv() if hasattr(builder, "export_decklist_csv") else None csv_path = builder.export_decklist_csv() if hasattr(builder, "export_decklist_csv") else None
@ -252,6 +338,24 @@ def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
return None return None
def _parse_card_list(val: Optional[str]) -> List[str]:
"""Parse comma or semicolon-separated card list from CLI argument."""
if not val:
return []
# Support semicolon separation for card names with commas
if ';' in val:
return [card.strip() for card in val.split(';') if card.strip()]
# Use the intelligent parsing for comma-separated (handles card names with commas)
try:
from deck_builder.include_exclude_utils import parse_card_list_input
return parse_card_list_input(val)
except ImportError:
# Fallback to simple comma split if import fails
return [card.strip() for card in val.split(',') if card.strip()]
def _parse_opt_int(val: Optional[str | int]) -> Optional[int]: def _parse_opt_int(val: Optional[str | int]) -> Optional[int]:
if val is None: if val is None:
return None return None
@ -278,27 +382,94 @@ def _load_json_config(path: Optional[str]) -> Dict[str, Any]:
def _build_arg_parser() -> argparse.ArgumentParser: def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Headless deck builder runner") p = argparse.ArgumentParser(description="Headless deck builder runner")
p.add_argument("--config", default=os.getenv("DECK_CONFIG"), help="Path to JSON config file") p.add_argument("--config", metavar="PATH", default=os.getenv("DECK_CONFIG"),
p.add_argument("--commander", default=None) help="Path to JSON config file (string)")
p.add_argument("--primary-choice", type=int, default=None) p.add_argument("--commander", metavar="NAME", default=None,
p.add_argument("--secondary-choice", type=_parse_opt_int, default=None) help="Commander name to search for (string)")
p.add_argument("--tertiary-choice", type=_parse_opt_int, default=None) p.add_argument("--primary-choice", metavar="INT", type=int, default=None,
p.add_argument("--bracket-level", type=int, default=None) help="Primary theme tag choice number (integer)")
p.add_argument("--add-lands", type=_parse_bool, default=None) p.add_argument("--secondary-choice", metavar="INT", type=_parse_opt_int, default=None,
p.add_argument("--fetch-count", type=_parse_opt_int, default=None) help="Secondary theme tag choice number (integer, optional)")
p.add_argument("--dual-count", type=_parse_opt_int, default=None) p.add_argument("--tertiary-choice", metavar="INT", type=_parse_opt_int, default=None,
p.add_argument("--triple-count", type=_parse_opt_int, default=None) help="Tertiary theme tag choice number (integer, optional)")
p.add_argument("--utility-count", type=_parse_opt_int, default=None) p.add_argument("--primary-tag", metavar="NAME", default=None,
# no seed support help="Primary theme tag name (string, alternative to --primary-choice)")
# Booleans p.add_argument("--secondary-tag", metavar="NAME", default=None,
p.add_argument("--add-creatures", type=_parse_bool, default=None) help="Secondary theme tag name (string, alternative to --secondary-choice)")
p.add_argument("--add-non-creature-spells", type=_parse_bool, default=None) p.add_argument("--tertiary-tag", metavar="NAME", default=None,
p.add_argument("--add-ramp", type=_parse_bool, default=None) help="Tertiary theme tag name (string, alternative to --tertiary-choice)")
p.add_argument("--add-removal", type=_parse_bool, default=None) p.add_argument("--bracket-level", metavar="1-5", type=int, default=None,
p.add_argument("--add-wipes", type=_parse_bool, default=None) help="Power bracket level 1-5 (integer)")
p.add_argument("--add-card-advantage", type=_parse_bool, default=None)
p.add_argument("--add-protection", type=_parse_bool, default=None) # Ideal count arguments - new feature!
p.add_argument("--dry-run", action="store_true", help="Print resolved config and exit") ideal_group = p.add_argument_group("Ideal Deck Composition",
"Override default target counts for deck categories")
ideal_group.add_argument("--ramp-count", metavar="INT", type=int, default=None,
help="Target number of ramp spells (integer, default: 8)")
ideal_group.add_argument("--land-count", metavar="INT", type=int, default=None,
help="Target total number of lands (integer, default: 35)")
ideal_group.add_argument("--basic-land-count", metavar="INT", type=int, default=None,
help="Minimum number of basic lands (integer, default: 15)")
ideal_group.add_argument("--creature-count", metavar="INT", type=int, default=None,
help="Target number of creatures (integer, default: 25)")
ideal_group.add_argument("--removal-count", metavar="INT", type=int, default=None,
help="Target number of spot removal spells (integer, default: 10)")
ideal_group.add_argument("--wipe-count", metavar="INT", type=int, default=None,
help="Target number of board wipes (integer, default: 2)")
ideal_group.add_argument("--card-advantage-count", metavar="INT", type=int, default=None,
help="Target number of card advantage pieces (integer, default: 10)")
ideal_group.add_argument("--protection-count", metavar="INT", type=int, default=None,
help="Target number of protection spells (integer, default: 8)")
# Land-specific counts
land_group = p.add_argument_group("Land Configuration",
"Control specific land type counts and options")
land_group.add_argument("--add-lands", metavar="BOOL", type=_parse_bool, default=None,
help="Whether to add lands (bool: true/false/1/0)")
land_group.add_argument("--fetch-count", metavar="INT", type=_parse_opt_int, default=None,
help="Number of fetch lands to include (integer, optional)")
land_group.add_argument("--dual-count", metavar="INT", type=_parse_opt_int, default=None,
help="Number of dual lands to include (integer, optional)")
land_group.add_argument("--triple-count", metavar="INT", type=_parse_opt_int, default=None,
help="Number of triple lands to include (integer, optional)")
land_group.add_argument("--utility-count", metavar="INT", type=_parse_opt_int, default=None,
help="Number of utility lands to include (integer, optional)")
# Card type toggles
toggle_group = p.add_argument_group("Card Type Toggles",
"Enable/disable adding specific card types")
toggle_group.add_argument("--add-creatures", metavar="BOOL", type=_parse_bool, default=None,
help="Add creatures to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-non-creature-spells", metavar="BOOL", type=_parse_bool, default=None,
help="Add non-creature spells to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-ramp", metavar="BOOL", type=_parse_bool, default=None,
help="Add ramp spells to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-removal", metavar="BOOL", type=_parse_bool, default=None,
help="Add removal spells to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-wipes", metavar="BOOL", type=_parse_bool, default=None,
help="Add board wipes to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-card-advantage", metavar="BOOL", type=_parse_bool, default=None,
help="Add card advantage pieces to deck (bool: true/false/1/0)")
toggle_group.add_argument("--add-protection", metavar="BOOL", type=_parse_bool, default=None,
help="Add protection spells to deck (bool: true/false/1/0)")
# Include/Exclude configuration
include_group = p.add_argument_group("Include/Exclude Cards",
"Force include or exclude specific cards")
include_group.add_argument("--include-cards", metavar="CARDS",
help='Cards to force include (string: comma-separated, max 10). For cards with commas in names like "Krenko, Mob Boss", use semicolons or JSON config.')
include_group.add_argument("--exclude-cards", metavar="CARDS",
help='Cards to exclude from deck (string: comma-separated, max 15). For cards with commas in names like "Krenko, Mob Boss", use semicolons or JSON config.')
include_group.add_argument("--enforcement-mode", metavar="MODE", choices=["warn", "strict"], default=None,
help="How to handle missing includes (string: warn=continue, strict=abort)")
include_group.add_argument("--allow-illegal", metavar="BOOL", type=_parse_bool, default=None,
help="Allow illegal cards in includes/excludes (bool: true/false/1/0)")
include_group.add_argument("--fuzzy-matching", metavar="BOOL", type=_parse_bool, default=None,
help="Enable fuzzy card name matching (bool: true/false/1/0)")
# Utility
p.add_argument("--dry-run", action="store_true",
help="Print resolved configuration and exit without building")
return p return p
@ -375,6 +546,27 @@ def _main() -> int:
except Exception: except Exception:
ideal_counts_json = {} ideal_counts_json = {}
# Build ideal_counts dict from CLI args, JSON, or defaults
ideal_counts_resolved = {}
ideal_mappings = [
("ramp_count", "ramp", 8),
("land_count", "lands", 35),
("basic_land_count", "basic_lands", 15),
("creature_count", "creatures", 25),
("removal_count", "removal", 10),
("wipe_count", "wipes", 2),
("card_advantage_count", "card_advantage", 10),
("protection_count", "protection", 8),
]
for cli_key, json_key, default_val in ideal_mappings:
cli_val = getattr(args, cli_key, None)
if cli_val is not None:
ideal_counts_resolved[json_key] = cli_val
elif json_key in ideal_counts_json:
ideal_counts_resolved[json_key] = ideal_counts_json[json_key]
# Don't set defaults here - let the builder use its own defaults
# Pull include/exclude configuration from JSON (M1: Config + Validation + Persistence) # Pull include/exclude configuration from JSON (M1: Config + Validation + Persistence)
include_cards_json = [] include_cards_json = []
exclude_cards_json = [] exclude_cards_json = []
@ -386,6 +578,97 @@ def _main() -> int:
except Exception: except Exception:
pass pass
# M4: Parse CLI include/exclude card lists
cli_include_cards = _parse_card_list(args.include_cards) if hasattr(args, 'include_cards') else []
cli_exclude_cards = _parse_card_list(args.exclude_cards) if hasattr(args, 'exclude_cards') else []
# Resolve tag names to indices BEFORE building resolved dict (so they can override defaults)
resolved_primary_choice = args.primary_choice
resolved_secondary_choice = args.secondary_choice
resolved_tertiary_choice = args.tertiary_choice
try:
# Collect tag names from CLI, JSON, and environment (CLI takes precedence)
primary_tag_name = (
args.primary_tag or
(str(os.getenv("DECK_PRIMARY_TAG") or "").strip()) or
str(json_cfg.get("primary_tag", "")).strip()
)
secondary_tag_name = (
args.secondary_tag or
(str(os.getenv("DECK_SECONDARY_TAG") or "").strip()) or
str(json_cfg.get("secondary_tag", "")).strip()
)
tertiary_tag_name = (
args.tertiary_tag or
(str(os.getenv("DECK_TERTIARY_TAG") or "").strip()) or
str(json_cfg.get("tertiary_tag", "")).strip()
)
tag_names = [t for t in [primary_tag_name, secondary_tag_name, tertiary_tag_name] if t]
if tag_names:
# Load commander name to resolve tags
commander_name = _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", "")
if commander_name:
try:
# Load commander tags to compute indices
tmp = DeckBuilder()
df = tmp.load_commander_data()
row = df[df["name"] == commander_name]
if not row.empty:
original = list(dict.fromkeys(row.iloc[0].get("themeTags", []) or []))
# Step 1: primary from original
if primary_tag_name:
for i, t in enumerate(original, start=1):
if str(t).strip().lower() == primary_tag_name.strip().lower():
resolved_primary_choice = i
break
# Step 2: secondary from remaining after primary
if secondary_tag_name:
if resolved_primary_choice is not None:
# Create remaining list after removing primary choice
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
for i2, t in enumerate(remaining_1, start=1):
if str(t).strip().lower() == secondary_tag_name.strip().lower():
resolved_secondary_choice = i2
break
else:
# If no primary set, secondary maps directly to original list
for i, t in enumerate(original, start=1):
if str(t).strip().lower() == secondary_tag_name.strip().lower():
resolved_secondary_choice = i
break
# Step 3: tertiary from remaining after primary+secondary
if tertiary_tag_name:
if resolved_primary_choice is not None and resolved_secondary_choice is not None:
# reconstruct remaining after removing primary then secondary as displayed
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
remaining_2 = [t for j, t in enumerate(remaining_1, start=1) if j != resolved_secondary_choice]
for i3, t in enumerate(remaining_2, start=1):
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
resolved_tertiary_choice = i3
break
elif resolved_primary_choice is not None:
# Only primary set, tertiary from remaining after primary
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
for i, t in enumerate(remaining_1, start=1):
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
resolved_tertiary_choice = i
break
else:
# No primary or secondary set, tertiary maps directly to original list
for i, t in enumerate(original, start=1):
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
resolved_tertiary_choice = i
break
except Exception:
pass
except Exception:
pass
resolved = { resolved = {
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]), "command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]), "add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
@ -395,72 +678,28 @@ def _main() -> int:
"add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]), "add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]),
"add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]), "add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]),
"add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]), "add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]),
"primary_choice": _resolve_value(args.primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]), "primary_choice": _resolve_value(resolved_primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]),
"secondary_choice": _resolve_value(args.secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]), "secondary_choice": _resolve_value(resolved_secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]),
"tertiary_choice": _resolve_value(args.tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]), "tertiary_choice": _resolve_value(resolved_tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]),
"bracket_level": _resolve_value(args.bracket_level, "DECK_BRACKET_LEVEL", json_cfg, "bracket_level", None), "bracket_level": _resolve_value(args.bracket_level, "DECK_BRACKET_LEVEL", json_cfg, "bracket_level", None),
"add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]), "add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]),
"fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]), "fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]),
"dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]), "dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]),
"triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]), "triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]),
"utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]), "utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]),
"ideal_counts": ideal_counts_json, "ideal_counts": ideal_counts_resolved,
# Include/Exclude configuration (M1: Config + Validation + Persistence) # M4: Include/Exclude configuration (CLI + JSON + Env priority)
"include_cards": include_cards_json, "include_cards": cli_include_cards or include_cards_json,
"exclude_cards": exclude_cards_json, "exclude_cards": cli_exclude_cards or exclude_cards_json,
"enforcement_mode": json_cfg.get("enforcement_mode", "warn"), "enforcement_mode": args.enforcement_mode or json_cfg.get("enforcement_mode", "warn"),
"allow_illegal": bool(json_cfg.get("allow_illegal", False)), "allow_illegal": args.allow_illegal if args.allow_illegal is not None else bool(json_cfg.get("allow_illegal", False)),
"fuzzy_matching": bool(json_cfg.get("fuzzy_matching", True)), "fuzzy_matching": args.fuzzy_matching if args.fuzzy_matching is not None else bool(json_cfg.get("fuzzy_matching", True)),
} }
if args.dry_run: if args.dry_run:
print(json.dumps(resolved, indent=2)) print(json.dumps(resolved, indent=2))
return 0 return 0
# Optional: map tag names from JSON/env to numeric indices for this commander
try:
primary_tag_name = (str(os.getenv("DECK_PRIMARY_TAG") or "").strip()) or str(json_cfg.get("primary_tag", "")).strip()
secondary_tag_name = (str(os.getenv("DECK_SECONDARY_TAG") or "").strip()) or str(json_cfg.get("secondary_tag", "")).strip()
tertiary_tag_name = (str(os.getenv("DECK_TERTIARY_TAG") or "").strip()) or str(json_cfg.get("tertiary_tag", "")).strip()
tag_names = [t for t in [primary_tag_name, secondary_tag_name, tertiary_tag_name] if t]
if tag_names:
try:
# Load commander tags to compute indices
tmp = DeckBuilder()
df = tmp.load_commander_data()
row = df[df["name"] == resolved["command_name"]]
if not row.empty:
original = list(dict.fromkeys(row.iloc[0].get("themeTags", []) or []))
# Step 1: primary from original
if primary_tag_name:
for i, t in enumerate(original, start=1):
if str(t).strip().lower() == primary_tag_name.strip().lower():
resolved["primary_choice"] = i
break
# Step 2: secondary from remaining after primary
if secondary_tag_name:
primary_idx = resolved.get("primary_choice")
remaining_1 = [t for j, t in enumerate(original, start=1) if j != primary_idx]
for i2, t in enumerate(remaining_1, start=1):
if str(t).strip().lower() == secondary_tag_name.strip().lower():
resolved["secondary_choice"] = i2
break
# Step 3: tertiary from remaining after primary+secondary
if tertiary_tag_name and resolved.get("secondary_choice") is not None:
primary_idx = resolved.get("primary_choice")
secondary_idx = resolved.get("secondary_choice")
# reconstruct remaining after removing primary then secondary as displayed
remaining_1 = [t for j, t in enumerate(original, start=1) if j != primary_idx]
remaining_2 = [t for j, t in enumerate(remaining_1, start=1) if j != secondary_idx]
for i3, t in enumerate(remaining_2, start=1):
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
resolved["tertiary_choice"] = i3
break
except Exception:
pass
except Exception:
pass
if not str(resolved.get("command_name", "")).strip(): if not str(resolved.get("command_name", "")).strip():
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.") print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
return 2 return 2

View file

@ -0,0 +1,137 @@
"""
Test CLI include/exclude functionality (M4: CLI Parity).
"""
import pytest
import subprocess
import json
import os
import tempfile
from pathlib import Path
class TestCLIIncludeExclude:
"""Test CLI include/exclude argument parsing and functionality."""
def test_cli_argument_parsing(self):
"""Test that CLI arguments are properly parsed."""
# Test help output includes new arguments
result = subprocess.run(
['python', 'code/headless_runner.py', '--help'],
capture_output=True,
text=True,
cwd=Path(__file__).parent.parent.parent
)
assert result.returncode == 0
help_text = result.stdout
assert '--include-cards' in help_text
assert '--exclude-cards' in help_text
assert '--enforcement-mode' in help_text
assert '--allow-illegal' in help_text
assert '--fuzzy-matching' in help_text
assert 'semicolons' in help_text # Check for comma warning
def test_cli_dry_run_with_include_exclude(self):
"""Test dry run output includes include/exclude configuration."""
result = subprocess.run([
'python', 'code/headless_runner.py',
'--commander', 'Krenko, Mob Boss',
'--include-cards', 'Sol Ring;Lightning Bolt',
'--exclude-cards', 'Chaos Orb',
'--enforcement-mode', 'strict',
'--dry-run'
], capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent)
assert result.returncode == 0
# Parse the JSON output
config = json.loads(result.stdout)
assert config['command_name'] == 'Krenko, Mob Boss'
assert config['include_cards'] == ['Sol Ring', 'Lightning Bolt']
assert config['exclude_cards'] == ['Chaos Orb']
assert config['enforcement_mode'] == 'strict'
def test_cli_semicolon_parsing(self):
"""Test semicolon separation for card names with commas."""
result = subprocess.run([
'python', 'code/headless_runner.py',
'--include-cards', 'Krenko, Mob Boss;Jace, the Mind Sculptor',
'--exclude-cards', 'Teferi, Hero of Dominaria',
'--dry-run'
], capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent)
assert result.returncode == 0
config = json.loads(result.stdout)
assert config['include_cards'] == ['Krenko, Mob Boss', 'Jace, the Mind Sculptor']
assert config['exclude_cards'] == ['Teferi, Hero of Dominaria']
def test_cli_comma_parsing_simple_names(self):
"""Test comma separation for simple card names without commas."""
result = subprocess.run([
'python', 'code/headless_runner.py',
'--include-cards', 'Sol Ring,Lightning Bolt,Counterspell',
'--exclude-cards', 'Island,Mountain',
'--dry-run'
], capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent)
assert result.returncode == 0
config = json.loads(result.stdout)
assert config['include_cards'] == ['Sol Ring', 'Lightning Bolt', 'Counterspell']
assert config['exclude_cards'] == ['Island', 'Mountain']
def test_cli_json_priority(self):
"""Test that CLI arguments override JSON config values."""
# Create a temporary JSON config
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump({
'commander': 'Atraxa, Praetors\' Voice',
'include_cards': ['Doubling Season'],
'exclude_cards': ['Winter Orb'],
'enforcement_mode': 'warn'
}, f, indent=2)
temp_config = f.name
try:
result = subprocess.run([
'python', 'code/headless_runner.py',
'--config', temp_config,
'--include-cards', 'Sol Ring', # Override JSON
'--enforcement-mode', 'strict', # Override JSON
'--dry-run'
], capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent)
assert result.returncode == 0
config = json.loads(result.stdout)
# CLI should override JSON
assert config['include_cards'] == ['Sol Ring'] # CLI override
assert config['exclude_cards'] == ['Winter Orb'] # From JSON (no CLI override)
assert config['enforcement_mode'] == 'strict' # CLI override
finally:
os.unlink(temp_config)
def test_cli_empty_values(self):
"""Test handling of empty/missing include/exclude values."""
result = subprocess.run([
'python', 'code/headless_runner.py',
'--commander', 'Krenko, Mob Boss',
'--dry-run'
], capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent)
assert result.returncode == 0
config = json.loads(result.stdout)
assert config['include_cards'] == []
assert config['exclude_cards'] == []
assert config['enforcement_mode'] == 'warn' # Default
assert config['allow_illegal'] is False # Default
assert config['fuzzy_matching'] is True # Default
if __name__ == '__main__':
pytest.main([__file__])

119
test_cli_ideal_counts.py Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Quick test script to verify CLI ideal count functionality works correctly.
"""
import subprocess
import json
import os
def test_cli_ideal_counts():
"""Test that CLI ideal count arguments work correctly."""
print("Testing CLI ideal count arguments...")
# Test dry-run with various ideal count CLI args
cmd = [
"python", "code/headless_runner.py",
"--commander", "Aang, Airbending Master",
"--creature-count", "30",
"--land-count", "37",
"--ramp-count", "10",
"--removal-count", "12",
"--basic-land-count", "18",
"--dry-run"
]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=".")
if result.returncode != 0:
print(f"❌ Command failed: {result.stderr}")
return False
try:
config = json.loads(result.stdout)
ideal_counts = config.get("ideal_counts", {})
# Verify CLI args took effect
expected = {
"creatures": 30,
"lands": 37,
"ramp": 10,
"removal": 12,
"basic_lands": 18
}
for key, expected_val in expected.items():
actual_val = ideal_counts.get(key)
if actual_val != expected_val:
print(f"{key}: expected {expected_val}, got {actual_val}")
return False
print(f"{key}: {actual_val}")
print("✅ All CLI ideal count arguments working correctly!")
return True
except json.JSONDecodeError as e:
print(f"❌ Failed to parse JSON output: {e}")
print(f"Output was: {result.stdout}")
return False
def test_help_contains_types():
"""Test that help text shows value types."""
print("\nTesting help text contains type information...")
cmd = ["python", "code/headless_runner.py", "--help"]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=".")
if result.returncode != 0:
print(f"❌ Help command failed: {result.stderr}")
return False
help_text = result.stdout
# Check for type indicators
type_indicators = [
"PATH", "NAME", "INT", "BOOL", "CARDS", "MODE", "1-5"
]
missing = []
for indicator in type_indicators:
if indicator not in help_text:
missing.append(indicator)
if missing:
print(f"❌ Missing type indicators: {missing}")
return False
# Check for organized sections
sections = [
"Ideal Deck Composition:",
"Land Configuration:",
"Card Type Toggles:",
"Include/Exclude Cards:"
]
missing_sections = []
for section in sections:
if section not in help_text:
missing_sections.append(section)
if missing_sections:
print(f"❌ Missing help sections: {missing_sections}")
return False
print("✅ Help text contains proper type information and sections!")
return True
if __name__ == "__main__":
os.chdir(os.path.dirname(os.path.abspath(__file__)))
success = True
success &= test_cli_ideal_counts()
success &= test_help_contains_types()
if success:
print("\n🎉 All tests passed! CLI ideal count functionality working correctly.")
else:
print("\n❌ Some tests failed.")
exit(0 if success else 1)