File size: 7,879 Bytes
16eaadc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""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