From f1c0b2804db716fa71403c229c04255ad140ef1d Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 12:00:47 -0400 Subject: [PATCH 1/6] fix: `info` command now also shows mission info --- README.md | 2 +- src/tcgp_deck_genie/cli.py | 51 ++++++++++++++++-------- tests/test_cli.py | 79 ++++++++++++++++++++++++++++++++++++++ tests/test_missions.py | 15 ++++++++ 4 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index fc40885..19b2584 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ TCGdex SDK ──[ sync, once ]──▶ local JSON cache ```text tcgp-deck-genie sync # download the TCGP corpus (one-time, ~30-90 s) tcgp-deck-genie sync-missions # download solo-battle opponent decks (one-time) -tcgp-deck-genie info # print cache summary +tcgp-deck-genie info # print card corpus and mission cache summaries tcgp-deck-genie search # filter the local corpus, no API cost tcgp-deck-genie missions # list/search/show solo-battle decks, no API cost tcgp-deck-genie build-deck # produce a 20-card deck via Gemini diff --git a/src/tcgp_deck_genie/cli.py b/src/tcgp_deck_genie/cli.py index 3f9b776..8a757a7 100644 --- a/src/tcgp_deck_genie/cli.py +++ b/src/tcgp_deck_genie/cli.py @@ -22,7 +22,7 @@ from rich.table import Table from . import __version__ -from .cache import Corpus, corpus_info, default_cache_dir, load_corpus, save_corpus +from .cache import Corpus, corpus_info, corpus_path, default_cache_dir, load_corpus, save_corpus from .deck_builder import ( BuildOptions, BuildResult, @@ -41,6 +41,8 @@ MissionLookupError, find_mission, load_missions, + missions_info, + missions_path, save_missions, ) from .models import ENERGY_TYPES, Card, DeckEntry, DeckPlan, OpponentDeckSpec @@ -168,21 +170,10 @@ def cb(p: FetchProgress, _task=task_id) -> None: # --------------------------------------------------------------------------- -@main.command() -@click.pass_context -def info(ctx: click.Context) -> None: - """Show what's in the local cache.""" - cache_dir: Path = ctx.obj["cache_dir"] - summary = corpus_info(cache_dir) - if summary is None: - console.print( - f"[yellow]No cache found at {cache_dir / 'cards_tcgp.json'}[/].\n" - "Run [bold]tcgp-deck-genie sync[/] first." - ) - raise SystemExit(1) - table = Table(title="Cache summary", show_header=False) +def _print_cache_section(title: str, summary: dict, count_label: str, count_key: str) -> None: + table = Table(title=title, show_header=False) table.add_row("Path", str(summary["path"])) - table.add_row("Cards", str(summary["card_count"])) + table.add_row(count_label, str(summary[count_key])) table.add_row("Sets", ", ".join(summary["sets_included"])) table.add_row( "Fetched at", @@ -192,6 +183,36 @@ def info(ctx: click.Context) -> None: console.print(table) +@main.command() +@click.pass_context +def info(ctx: click.Context) -> None: + """Show what's in the local card and mission caches.""" + cache_dir: Path = ctx.obj["cache_dir"] + card_summary = corpus_info(cache_dir) + mission_summary = missions_info(cache_dir) + + if card_summary: + _print_cache_section("Card corpus", card_summary, "Cards", "card_count") + else: + console.print( + f"[yellow]No card cache found at {corpus_path(cache_dir)}[/].\n" + "Run [bold]tcgp-deck-genie sync[/] first." + ) + + console.print() + + if mission_summary: + _print_cache_section("Mission decks", mission_summary, "Decks", "deck_count") + else: + console.print( + f"[yellow]No mission cache found at {missions_path(cache_dir)}[/].\n" + "Run [bold]tcgp-deck-genie sync-missions[/] first." + ) + + if card_summary is None: + raise SystemExit(1) + + # --------------------------------------------------------------------------- # sync-missions # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..56b5027 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from click.testing import CliRunner + +from tcgp_deck_genie.cache import Corpus, save_corpus +from tcgp_deck_genie.cli import main +from tcgp_deck_genie.missions import MissionCorpus, MissionDeck, save_missions +from tcgp_deck_genie.models import Card + + +def _minimal_card() -> Card: + return Card( + id="A1-079", + name="Lapras", + set_id="A1", + category="Pokemon", + hp=140, + types=["Water"], + stage="Basic", + ) + + +def _write_card_cache(cache_dir) -> None: + save_corpus( + Corpus(cards=[_minimal_card()], sets_included=["A1"], fetched_at=100.0), + cache_dir=cache_dir, + ) + + +def _write_mission_cache(cache_dir) -> None: + save_missions( + MissionCorpus( + decks=[ + MissionDeck( + name="Test Deck", + set_name="Genetic Apex", + set_id="A1", + difficulty="Expert solo battles", + ) + ], + sets_included=["A1"], + fetched_at=200.0, + ), + cache_dir=cache_dir, + ) + + +def test_info_shows_both_caches(tmp_path): + _write_card_cache(tmp_path) + _write_mission_cache(tmp_path) + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "info"]) + assert result.exit_code == 0 + assert "Card corpus" in result.output + assert "Mission decks" in result.output + assert "Cards" in result.output + assert "Decks" in result.output + + +def test_info_card_only_shows_mission_hint(tmp_path): + _write_card_cache(tmp_path) + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "info"]) + assert result.exit_code == 0 + assert "Card corpus" in result.output + assert "sync-missions" in result.output + + +def test_info_mission_only_exits_error(tmp_path): + _write_mission_cache(tmp_path) + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "info"]) + assert result.exit_code == 1 + assert "sync" in result.output + assert "Mission decks" in result.output + + +def test_info_neither_cache_exits_error(tmp_path): + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "info"]) + assert result.exit_code == 1 + assert "sync" in result.output + assert "sync-missions" in result.output diff --git a/tests/test_missions.py b/tests/test_missions.py index 6c0a6ea..fb81279 100644 --- a/tests/test_missions.py +++ b/tests/test_missions.py @@ -11,6 +11,7 @@ _resolve_card_id, find_mission, load_missions, + missions_info, parse_mission_page, save_missions, ) @@ -189,6 +190,20 @@ def test_load_missing_returns_none(tmp_path): assert load_missions(cache_dir=tmp_path) is None +def test_missions_info_summary(tmp_path): + decks = parse_mission_page( + WIKITEXT, set_name="Genetic Apex", set_id="A1", + name_to_id=NAME_TO_ID, valid_ids=VALID_IDS, + ) + corpus = MissionCorpus(decks=decks, sets_included=["A1"], fetched_at=123.0) + save_missions(corpus, cache_dir=tmp_path) + summary = missions_info(cache_dir=tmp_path) + assert summary is not None + assert summary["deck_count"] == len(decks) + assert summary["sets_included"] == ["A1"] + assert summary["fetched_at"] == 123.0 + + def test_mission_card_model_defaults(): c = MissionCard(count=2, name="Poké Ball") assert c.card_id is None From 53ade00366ffbb9e951a71a6c285202c85a507e0 Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 12:06:20 -0400 Subject: [PATCH 2/6] fix: DRYing `cli.py` by using existing `Corpus.by_id` helper --- src/tcgp_deck_genie/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tcgp_deck_genie/cli.py b/src/tcgp_deck_genie/cli.py index 8a757a7..e7d660d 100644 --- a/src/tcgp_deck_genie/cli.py +++ b/src/tcgp_deck_genie/cli.py @@ -313,7 +313,7 @@ def missions( """ corpus = _load_or_fail(ctx) mission_corpus = _load_missions_or_fail(ctx) - by_id = {c.id: c for c in corpus.cards} + by_id = corpus.by_id() if query: name = " ".join(query) @@ -532,7 +532,7 @@ def build_deck_cmd( from the opponent's weaknesses when omitted. """ corpus = _load_or_fail(ctx) - by_id = {c.id: c for c in corpus.cards} + by_id = corpus.by_id() if counter_mission and counter_file: console.print("[red]Use only one of --counter-mission / --counter-file.[/]") @@ -670,7 +670,7 @@ def show_deck_cmd(ctx: click.Context, path: Path) -> None: corpus = _load_or_fail(ctx) payload = json.loads(path.read_text()) deck = DeckPlan.model_validate(payload["deck"]) - by_id = {c.id: c for c in corpus.cards} + by_id = corpus.by_id() used = [by_id[e.card_id] for e in deck.cards if e.card_id in by_id] warnings = payload.get("validation_warnings", []) result = BuildResult( From 7497c22f75bf16910de4cc3d0b7b1b24b26ee1e3 Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 12:23:20 -0400 Subject: [PATCH 3/6] fix: DRYing uses of `parse_damage` helpers --- src/tcgp_deck_genie/deck_builder.py | 10 +--------- src/tcgp_deck_genie/models.py | 9 +++++++++ src/tcgp_deck_genie/search.py | 9 +-------- tests/test_models.py | 8 ++++++++ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/tcgp_deck_genie/deck_builder.py b/src/tcgp_deck_genie/deck_builder.py index 805e334..50417aa 100644 --- a/src/tcgp_deck_genie/deck_builder.py +++ b/src/tcgp_deck_genie/deck_builder.py @@ -24,7 +24,6 @@ from __future__ import annotations import logging -import re from dataclasses import dataclass, field from .gemini_client import GeminiClient @@ -278,13 +277,6 @@ def _index_pokemon_by_name(corpus: list[Card]) -> dict[str, Card]: # --------------------------------------------------------------------------- -def _parse_damage(damage: str | None) -> int: - if not damage: - return 0 - m = re.search(r"\d+", damage) - return int(m.group()) if m else 0 - - def summarise_opponent(cards: list[Card], energy_types: list[str] | None = None) -> dict: """Pre-digest an opponent deck into the tactical reads a counter needs. @@ -323,7 +315,7 @@ def summarise_opponent(cards: list[Card], energy_types: list[str] | None = None) "attacks": [ { "cost": "".join(e[0] for e in a.cost) if a.cost else "", - "dmg": _parse_damage(a.damage) or None, + "dmg": a.parsed_damage or None, } for a in c.attacks ], diff --git a/src/tcgp_deck_genie/models.py b/src/tcgp_deck_genie/models.py index 5277331..90ccf5c 100644 --- a/src/tcgp_deck_genie/models.py +++ b/src/tcgp_deck_genie/models.py @@ -12,6 +12,7 @@ """ from __future__ import annotations +import re from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -48,6 +49,14 @@ class Attack(BaseModel): def energy_cost_total(self) -> int: return len(self.cost) + @property + def parsed_damage(self) -> int: + """First integer in the damage string (e.g. ``'80+'`` → 80), or 0.""" + if not self.damage: + return 0 + m = re.search(r"\d+", self.damage) + return int(m.group()) if m else 0 + class Ability(BaseModel): """A passive ability on a Pokémon card.""" diff --git a/src/tcgp_deck_genie/search.py b/src/tcgp_deck_genie/search.py index cd299c9..9e3fc6f 100644 --- a/src/tcgp_deck_genie/search.py +++ b/src/tcgp_deck_genie/search.py @@ -156,18 +156,11 @@ def candidate_score(card: Card) -> float: if card.abilities: score += 1.5 * len(card.abilities) if card.attacks: - best_dmg = max((_parse_damage(a.damage) for a in card.attacks), default=0) + best_dmg = max((a.parsed_damage for a in card.attacks), default=0) score += best_dmg / 50.0 return score -def _parse_damage(d: str | None) -> int: - if not d: - return 0 - m = re.search(r"\d+", d) - return int(m.group()) if m else 0 - - def top_candidates(cards: list[Card], limit: int) -> list[Card]: """Return the highest-scoring ``limit`` cards, ordered for stable prompts.""" if limit <= 0 or len(cards) <= limit: diff --git a/tests/test_models.py b/tests/test_models.py index 713a865..ff8bb82 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -226,3 +226,11 @@ def test_parse_counter_deck_reads_example_file(): assert name == "Misty's Tide" assert energy == ["Water"] assert sum(e.count for e in cards) == 20 + + +@pytest.mark.parametrize("damage,expected", [ + (None, 0), ("", 0), ("80", 80), ("80+", 80), ("no digits", 0), +]) +def test_attack_parsed_damage(damage, expected): + attack = Attack(name="Tackle", cost=["Colorless"], damage=damage) + assert attack.parsed_damage == expected From 244fecb25a506f1c89f5b572689465a6ad1f71e6 Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 12:48:30 -0400 Subject: [PATCH 4/6] chore: Remove untested Python versions from classifiers list --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7d65f08..2990ce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,6 @@ authors = [{ name = "Michael Murphy" }] keywords = ["pokemon", "tcg", "tcgp", "gemini", "deck-builder", "llm"] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Games/Entertainment", ] From 8b8c604ab84f2c78610579d66c1ea6654a981cc5 Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 13:04:51 -0400 Subject: [PATCH 5/6] fix: proper error handling on `show-deck` command --- src/tcgp_deck_genie/cli.py | 24 +++++++++++++-- tests/test_cli.py | 62 +++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/tcgp_deck_genie/cli.py b/src/tcgp_deck_genie/cli.py index e7d660d..81407cb 100644 --- a/src/tcgp_deck_genie/cli.py +++ b/src/tcgp_deck_genie/cli.py @@ -668,8 +668,28 @@ def _resolve_opponent( def show_deck_cmd(ctx: click.Context, path: Path) -> None: """Pretty-print a deck previously saved with --out.""" corpus = _load_or_fail(ctx) - payload = json.loads(path.read_text()) - deck = DeckPlan.model_validate(payload["deck"]) + + try: + payload = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + console.print(f"[red]Invalid deck file (not valid JSON):[/] {exc}") + raise SystemExit(2) from exc + if not isinstance(payload, dict): + console.print("[red]Invalid deck file:[/] expected a JSON object at the top level.") + raise SystemExit(2) + if "deck" not in payload: + console.print( + "[red]Invalid deck file:[/] missing [bold]deck[/] key. " + "Use a file saved with [bold]build-deck --out[/]." + ) + raise SystemExit(2) + + try: + deck = DeckPlan.model_validate(payload["deck"]) + except ValidationError as exc: + console.print(f"[red]Invalid deck file:[/] {exc}") + raise SystemExit(2) from exc + by_id = corpus.by_id() used = [by_id[e.card_id] for e in deck.cards if e.card_id in by_id] warnings = payload.get("validation_warnings", []) diff --git a/tests/test_cli.py b/tests/test_cli.py index 56b5027..8271267 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json + from click.testing import CliRunner from tcgp_deck_genie.cache import Corpus, save_corpus from tcgp_deck_genie.cli import main from tcgp_deck_genie.missions import MissionCorpus, MissionDeck, save_missions -from tcgp_deck_genie.models import Card +from tcgp_deck_genie.models import Card, DeckEntry, DeckPlan def _minimal_card() -> Card: @@ -77,3 +79,61 @@ def test_info_neither_cache_exits_error(tmp_path): assert result.exit_code == 1 assert "sync" in result.output assert "sync-missions" in result.output + +def _saved_deck_payload() -> dict: + return { + "deck": DeckPlan( + name="Misty's Tide", + energy_types=["Water"], + cards=[DeckEntry(card_id="A1-079", count=2, role="main attacker")], + strategy="Open with Lapras.", + ).model_dump(mode="json"), + "candidate_pool_size": 42, + "validation_warnings": [], + } + + +def test_show_deck_renders_saved_file(tmp_path): + _write_card_cache(tmp_path) + deck_path = tmp_path / "water.deck.json" + deck_path.write_text(json.dumps(_saved_deck_payload())) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "show-deck", str(deck_path)] + ) + assert result.exit_code == 0 + assert "Misty's Tide" in result.output + assert "Lapras" in result.output + + +def test_show_deck_invalid_json(tmp_path): + _write_card_cache(tmp_path) + deck_path = tmp_path / "bad.deck.json" + deck_path.write_text("{not json") + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "show-deck", str(deck_path)] + ) + assert result.exit_code == 2 + assert "not valid JSON" in result.output + + +def test_show_deck_missing_deck_key(tmp_path): + _write_card_cache(tmp_path) + deck_path = tmp_path / "bad.deck.json" + deck_path.write_text(json.dumps({"candidate_pool_size": 1})) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "show-deck", str(deck_path)] + ) + assert result.exit_code == 2 + assert "missing" in result.output + assert "deck" in result.output + + +def test_show_deck_invalid_deck_shape(tmp_path): + _write_card_cache(tmp_path) + deck_path = tmp_path / "bad.deck.json" + deck_path.write_text(json.dumps({"deck": {"name": "x"}})) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "show-deck", str(deck_path)] + ) + assert result.exit_code == 2 + assert "Invalid deck file" in result.output From e484eef278de495e05532fc18e10b131467b8216 Mon Sep 17 00:00:00 2001 From: murchu27 Date: Sun, 14 Jun 2026 13:29:17 -0400 Subject: [PATCH 6/6] tests: more CLI coverage --- tests/test_cli.py | 218 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8271267..49c062f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,8 @@ from tcgp_deck_genie.cache import Corpus, save_corpus from tcgp_deck_genie.cli import main -from tcgp_deck_genie.missions import MissionCorpus, MissionDeck, save_missions +from tcgp_deck_genie.deck_builder import BuildOptions, BuildResult +from tcgp_deck_genie.missions import MissionCard, MissionCorpus, MissionDeck, save_missions from tcgp_deck_genie.models import Card, DeckEntry, DeckPlan @@ -47,6 +48,63 @@ def _write_mission_cache(cache_dir) -> None: ) +def _write_fake_corpus(cache_dir, fake_corpus) -> None: + save_corpus( + Corpus( + cards=fake_corpus, + sets_included=sorted({c.set_id for c in fake_corpus}), + fetched_at=100.0, + ), + cache_dir=cache_dir, + ) + + +def _write_mission_with_cards(cache_dir) -> None: + save_missions( + MissionCorpus( + decks=[ + MissionDeck( + name="Test Deck", + set_name="Genetic Apex", + set_id="A1", + difficulty="Expert solo battles", + energy_types=["Water"], + cards=[MissionCard(count=2, name="Lapras", card_id="A1-079")], + ) + ], + sets_included=["A1"], + fetched_at=200.0, + ), + cache_dir=cache_dir, + ) + + +def _fake_build_result() -> BuildResult: + return BuildResult( + deck=DeckPlan( + name="Lapras splash", + energy_types=["Water"], + cards=[DeckEntry(card_id="A1-079", count=2, role="attacker")], + strategy="Use Misty to power Lapras early.", + ), + cards_used=[], + candidate_pool_size=12, + shortlist_size=None, + validation_warnings=[], + ) + + +class _FakeBuilder: + """Records CLI wiring without calling Gemini.""" + + def __init__(self, cards, gemini) -> None: + self.last_options: BuildOptions | None = None + + def build(self, options: BuildOptions) -> BuildResult: + self.last_options = options + return _fake_build_result() + + def test_info_shows_both_caches(tmp_path): _write_card_cache(tmp_path) _write_mission_cache(tmp_path) @@ -137,3 +195,161 @@ def test_show_deck_invalid_deck_shape(tmp_path): ) assert result.exit_code == 2 assert "Invalid deck file" in result.output + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + + +def test_search_filters_by_energy(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "search", "--energy", "Water"] + ) + assert result.exit_code == 0 + assert "match(es)" in result.output + assert "Lapras" in result.output + assert "Pikachu" not in result.output + + +def test_search_filters_by_keyword(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "search", "--keyword", "flip a coin"] + ) + assert result.exit_code == 0 + assert "Misty" in result.output + + +def test_search_no_cache_exits_error(tmp_path): + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "search", "--energy", "Water"] + ) + assert result.exit_code == 1 + assert "sync" in result.output + + +# --------------------------------------------------------------------------- +# missions +# --------------------------------------------------------------------------- + + +def test_missions_lists_cached_decks(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + _write_mission_cache(tmp_path) + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "missions"]) + assert result.exit_code == 0 + assert "1 mission(s)" in result.output + assert "Test Deck" in result.output + + +def test_missions_shows_detail_by_name(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + _write_mission_with_cards(tmp_path) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "missions", "Test Deck"] + ) + assert result.exit_code == 0 + assert "Lapras" in result.output + assert "A1-079" in result.output + + +def test_missions_lookup_error(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + _write_mission_cache(tmp_path) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "missions", "No Such Deck"] + ) + assert result.exit_code == 2 + + +def test_missions_no_mission_cache_exits_error(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + result = CliRunner().invoke(main, ["--cache-dir", str(tmp_path), "missions"]) + assert result.exit_code == 1 + assert "sync-missions" in result.output + + +# --------------------------------------------------------------------------- +# build-deck (mocked — no Gemini / network) +# --------------------------------------------------------------------------- + + +def test_build_deck_requires_energy(tmp_path, fake_corpus): + _write_fake_corpus(tmp_path, fake_corpus) + result = CliRunner().invoke( + main, ["--cache-dir", str(tmp_path), "build-deck"] + ) + assert result.exit_code == 2 + assert "--energy is required" in result.output + + +def test_build_deck_rejects_both_counter_modes(tmp_path, fake_corpus, tmp_path_factory): + _write_fake_corpus(tmp_path, fake_corpus) + counter = tmp_path / "opp.json" + counter.write_text(json.dumps({"cards": [{"card_id": "A1-079", "count": 2}]})) + result = CliRunner().invoke( + main, + [ + "--cache-dir", + str(tmp_path), + "build-deck", + "--counter-mission", + "Test Deck", + "--counter-file", + str(counter), + ], + ) + assert result.exit_code == 2 + assert "only one of" in result.output + + +def test_build_deck_success_with_mocked_builder(tmp_path, fake_corpus, monkeypatch): + _write_fake_corpus(tmp_path, fake_corpus) + fake_builder = _FakeBuilder([], object()) + monkeypatch.setattr("tcgp_deck_genie.cli.GeminiClient", lambda: object()) + monkeypatch.setattr("tcgp_deck_genie.cli.DeckBuilder", lambda cards, gemini: fake_builder) + + result = CliRunner().invoke( + main, + [ + "--cache-dir", + str(tmp_path), + "build-deck", + "--energy", + "Water", + "--no-shortlist", + "--brief", + "Aggro water", + ], + ) + assert result.exit_code == 0 + assert "Lapras splash" in result.output + assert fake_builder.last_options is not None + assert fake_builder.last_options.energy_type == "Water" + assert fake_builder.last_options.user_brief == "Aggro water" + + +def test_build_deck_writes_out_file(tmp_path, fake_corpus, monkeypatch): + _write_fake_corpus(tmp_path, fake_corpus) + monkeypatch.setattr("tcgp_deck_genie.cli.GeminiClient", lambda: object()) + monkeypatch.setattr("tcgp_deck_genie.cli.DeckBuilder", _FakeBuilder) + + out = tmp_path / "water.deck.json" + result = CliRunner().invoke( + main, + [ + "--cache-dir", + str(tmp_path), + "build-deck", + "--energy", + "Water", + "--no-shortlist", + "--out", + str(out), + ], + ) + assert result.exit_code == 0 + assert out.exists() + payload = json.loads(out.read_text()) + assert payload["deck"]["name"] == "Lapras splash"