diff --git a/CHANGELOG.md b/CHANGELOG.md index 2994d69..233722e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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 +- **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 - Card constants refactored to dedicated `builder_constants.py` with functional organization - Fuzzy match confirmation modal with dark theme support and card preview functionality diff --git a/README.md b/README.md index f32a8a4..4a4ce31 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 17b09e3..3fe70fb 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,6 +2,8 @@ ## 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. +- **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. - **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. @@ -9,6 +11,13 @@ - **Dual Architecture Support**: Seamless functionality across both web interface (staging system) and CLI (direct build) with proper include injection timing. ## 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** - List size validation UI with visual warning system using icons and color coding - Live validation badges showing count/limit status with clear visual indicators diff --git a/code/headless_runner.py b/code/headless_runner.py index 9220a85..9d97205 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -4,11 +4,7 @@ import argparse import json import os from typing import Any, Dict, List, Optional -import time - -import sys -import os from deck_builder.builder import DeckBuilder from deck_builder import builder_constants as bc from file_setup.setup import initial_setup @@ -207,7 +203,97 @@ def run( def _should_export_json_headless() -> bool: 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: + # M4: Print include/exclude summary to console + _print_include_exclude_summary(builder) + csv_path: Optional[str] = None try: 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 +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]: if val is None: return None @@ -278,27 +382,94 @@ def _load_json_config(path: Optional[str]) -> Dict[str, Any]: def _build_arg_parser() -> argparse.ArgumentParser: 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("--commander", default=None) - p.add_argument("--primary-choice", type=int, default=None) - p.add_argument("--secondary-choice", type=_parse_opt_int, default=None) - p.add_argument("--tertiary-choice", type=_parse_opt_int, default=None) - p.add_argument("--bracket-level", type=int, default=None) - p.add_argument("--add-lands", type=_parse_bool, default=None) - p.add_argument("--fetch-count", type=_parse_opt_int, default=None) - p.add_argument("--dual-count", type=_parse_opt_int, default=None) - p.add_argument("--triple-count", type=_parse_opt_int, default=None) - p.add_argument("--utility-count", type=_parse_opt_int, default=None) - # no seed support - # Booleans - p.add_argument("--add-creatures", type=_parse_bool, default=None) - p.add_argument("--add-non-creature-spells", type=_parse_bool, default=None) - p.add_argument("--add-ramp", type=_parse_bool, default=None) - p.add_argument("--add-removal", type=_parse_bool, default=None) - p.add_argument("--add-wipes", type=_parse_bool, default=None) - p.add_argument("--add-card-advantage", type=_parse_bool, default=None) - p.add_argument("--add-protection", type=_parse_bool, default=None) - p.add_argument("--dry-run", action="store_true", help="Print resolved config and exit") + p.add_argument("--config", metavar="PATH", default=os.getenv("DECK_CONFIG"), + help="Path to JSON config file (string)") + p.add_argument("--commander", metavar="NAME", default=None, + help="Commander name to search for (string)") + p.add_argument("--primary-choice", metavar="INT", type=int, default=None, + help="Primary theme tag choice number (integer)") + p.add_argument("--secondary-choice", metavar="INT", type=_parse_opt_int, default=None, + help="Secondary theme tag choice number (integer, optional)") + p.add_argument("--tertiary-choice", metavar="INT", type=_parse_opt_int, default=None, + help="Tertiary theme tag choice number (integer, optional)") + p.add_argument("--primary-tag", metavar="NAME", default=None, + help="Primary theme tag name (string, alternative to --primary-choice)") + p.add_argument("--secondary-tag", metavar="NAME", default=None, + help="Secondary theme tag name (string, alternative to --secondary-choice)") + p.add_argument("--tertiary-tag", metavar="NAME", default=None, + help="Tertiary theme tag name (string, alternative to --tertiary-choice)") + p.add_argument("--bracket-level", metavar="1-5", type=int, default=None, + help="Power bracket level 1-5 (integer)") + + # Ideal count arguments - new feature! + 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 @@ -375,6 +546,27 @@ def _main() -> int: except Exception: 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) include_cards_json = [] exclude_cards_json = [] @@ -386,6 +578,97 @@ def _main() -> int: except Exception: 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 = { "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"]), @@ -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_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"]), - "primary_choice": _resolve_value(args.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"]), - "tertiary_choice": _resolve_value(args.tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]), + "primary_choice": _resolve_value(resolved_primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]), + "secondary_choice": _resolve_value(resolved_secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_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), "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"]), "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"]), "utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]), - "ideal_counts": ideal_counts_json, - # Include/Exclude configuration (M1: Config + Validation + Persistence) - "include_cards": include_cards_json, - "exclude_cards": exclude_cards_json, - "enforcement_mode": json_cfg.get("enforcement_mode", "warn"), - "allow_illegal": bool(json_cfg.get("allow_illegal", False)), - "fuzzy_matching": bool(json_cfg.get("fuzzy_matching", True)), + "ideal_counts": ideal_counts_resolved, + # M4: Include/Exclude configuration (CLI + JSON + Env priority) + "include_cards": cli_include_cards or include_cards_json, + "exclude_cards": cli_exclude_cards or exclude_cards_json, + "enforcement_mode": args.enforcement_mode or json_cfg.get("enforcement_mode", "warn"), + "allow_illegal": args.allow_illegal if args.allow_illegal is not None else bool(json_cfg.get("allow_illegal", False)), + "fuzzy_matching": args.fuzzy_matching if args.fuzzy_matching is not None else bool(json_cfg.get("fuzzy_matching", True)), } if args.dry_run: print(json.dumps(resolved, indent=2)) 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(): print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.") return 2 diff --git a/code/tests/test_cli_include_exclude.py b/code/tests/test_cli_include_exclude.py new file mode 100644 index 0000000..633e3ce --- /dev/null +++ b/code/tests/test_cli_include_exclude.py @@ -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__]) diff --git a/test_cli_ideal_counts.py b/test_cli_ideal_counts.py new file mode 100644 index 0000000..b91e130 --- /dev/null +++ b/test_cli_ideal_counts.py @@ -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)