Spaces:
Sleeping
Sleeping
| """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 | |