"""TDD for spaceutil.deck.build_deck. Hard invariants the builder must always satisfy: - Total quantity is exactly 50. - Every card shares at least one color with the leader. - No card has more than `max_copies` (default 4). - The leader itself is never in the main deck. - No other Leader cards are in the main deck. Soft invariants (style behaviour): - Aggro decks have lower average cost than midrange. - Control decks have higher average cost than midrange. """ from __future__ import annotations import numpy as np import pytest def _matrix(cards): return np.stack( [np.asarray(c["embedding"], dtype=np.float32) for c in cards], axis=0 ) def _strip_emb(cards): return [{k: v for k, v in c.items() if k != "embedding"} for c in cards] def _first_leader_idx(cards): return next(i for i, c in enumerate(cards) if c["card_type"] == "Leader") class TestDeckSize: def test_total_quantity_is_50(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") assert deck.total_quantity == 50 def test_total_quantity_is_50_for_each_style(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) for style in ("aggro", "midrange", "control"): deck = build_deck(idx, cards, matrix, style=style) assert deck.total_quantity == 50, f"{style} produced {deck.total_quantity}" class TestColorLegality: def test_all_cards_share_a_color_with_leader(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) leader_colors = set(synthetic_cards[idx]["colors"]) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") for dc in deck.cards: assert set(dc.colors) & leader_colors, ( f"{dc.card_id} has {dc.colors}, leader has {leader_colors}" ) class TestCopyLimit: def test_no_card_exceeds_max_copies_default(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") for dc in deck.cards: assert 1 <= dc.quantity <= 4 def test_no_card_exceeds_max_copies_explicit(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange", max_copies=2) assert deck.total_quantity == 50 for dc in deck.cards: assert 1 <= dc.quantity <= 2 class TestLeaderExclusion: def test_other_leaders_not_in_deck(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") for dc in deck.cards: assert dc.card_type != "Leader" def test_chosen_leader_not_in_main_deck(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) leader_id = synthetic_cards[idx]["id"] matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") for dc in deck.cards: assert dc.card_id != leader_id def test_raises_when_index_is_not_leader(self, synthetic_cards): from spaceutil.deck import build_deck non_leader_idx = next( i for i, c in enumerate(synthetic_cards) if c["card_type"] != "Leader" ) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) with pytest.raises(ValueError, match="not a Leader"): build_deck(non_leader_idx, cards, matrix) class TestDeckMetadata: def test_deck_carries_leader_reference(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) leader_id = synthetic_cards[idx]["id"] matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") assert deck.leader["id"] == leader_id assert deck.style == "midrange" def test_avg_cost_computed(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") assert deck.avg_cost > 0 # 1-9 cost spread, midrange should land somewhere reasonable assert 1.5 < deck.avg_cost < 8.0 class TestStyleSensitivity: def test_aggro_cheaper_than_midrange(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) aggro = build_deck(idx, cards, matrix, style="aggro") midrange = build_deck(idx, cards, matrix, style="midrange") assert aggro.avg_cost < midrange.avg_cost, ( f"aggro avg {aggro.avg_cost:.2f} not < midrange {midrange.avg_cost:.2f}" ) def test_control_pricier_than_midrange(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) midrange = build_deck(idx, cards, matrix, style="midrange") control = build_deck(idx, cards, matrix, style="control") assert control.avg_cost > midrange.avg_cost, ( f"control avg {control.avg_cost:.2f} not > midrange {midrange.avg_cost:.2f}" ) def test_unknown_style_raises(self, synthetic_cards): from spaceutil.deck import build_deck idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) with pytest.raises(ValueError, match="style"): build_deck(idx, cards, matrix, style="bogus") class TestExport: def test_to_text_format(self, synthetic_cards): from spaceutil.deck import build_deck, deck_to_text idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") text = deck_to_text(deck) assert deck.leader["id"] in text assert deck.leader["name"] in text for dc in deck.cards: assert f"{dc.quantity}x {dc.card_id}" in text # Sanity: at least one section header so it's human-readable assert "Leader" in text and "Main deck" in text def test_to_text_total_quantity_in_summary(self, synthetic_cards): from spaceutil.deck import build_deck, deck_to_text idx = _first_leader_idx(synthetic_cards) matrix = _matrix(synthetic_cards) cards = _strip_emb(synthetic_cards) deck = build_deck(idx, cards, matrix, style="midrange") text = deck_to_text(deck) assert "50" in text