Delta matches \bdelta\b in the nonbasic land guard regex, causing enrich_card to classify it as a land (cmc=0). Zeta and Theta have no land keyword matches.
192 lines
7.1 KiB
Python
192 lines
7.1 KiB
Python
"""Tests for commander_ai_lab.sim.rules."""
|
|
import pytest
|
|
from commander_ai_lab.sim.models import Card
|
|
from commander_ai_lab.sim.rules import enrich_card, score_card, parse_decklist, AI_DEFAULT_WEIGHTS
|
|
|
|
|
|
# ── enrich_card ──────────────────────────────────────────────
|
|
|
|
class TestEnrichCardBasicLands:
|
|
@pytest.mark.parametrize("name,expected_color", [
|
|
("Forest", "G"),
|
|
("Island", "U"),
|
|
("Plains", "W"),
|
|
("Swamp", "B"),
|
|
("Mountain", "R"),
|
|
])
|
|
def test_basic_land_color(self, name, expected_color):
|
|
c = Card(name=name)
|
|
enrich_card(c)
|
|
assert c.is_land() is True
|
|
assert expected_color in c.oracle_text
|
|
assert c.cmc == 0
|
|
|
|
|
|
class TestEnrichCardKnownCards:
|
|
def test_sol_ring(self):
|
|
c = Card(name="Sol Ring")
|
|
enrich_card(c)
|
|
assert c.is_ramp is True
|
|
assert c.cmc == 1
|
|
|
|
def test_murder(self):
|
|
c = Card(name="Murder")
|
|
enrich_card(c)
|
|
assert c.is_removal is True
|
|
assert c.cmc == 3
|
|
|
|
def test_wrath_of_god(self):
|
|
c = Card(name="Wrath of God")
|
|
enrich_card(c)
|
|
assert c.is_board_wipe is True
|
|
|
|
def test_cultivate(self):
|
|
c = Card(name="Cultivate")
|
|
enrich_card(c)
|
|
assert c.is_ramp is True
|
|
assert c.cmc == 3
|
|
|
|
def test_counterspell(self):
|
|
c = Card(name="Counterspell")
|
|
enrich_card(c)
|
|
assert c.is_removal is True
|
|
|
|
|
|
class TestEnrichCardDeterministic:
|
|
def test_same_unknown_card_same_cmc(self):
|
|
"""Unknown cards must get deterministic CMC (no random jitter)."""
|
|
c1 = Card(name="Unknown Foobar Card")
|
|
c2 = Card(name="Unknown Foobar Card")
|
|
enrich_card(c1)
|
|
enrich_card(c2)
|
|
assert c1.cmc == c2.cmc
|
|
assert c1.pt == c2.pt
|
|
|
|
def test_different_unknown_cards_may_differ(self):
|
|
c1 = Card(name="Aaaaa")
|
|
c2 = Card(name="Zzzzz")
|
|
enrich_card(c1)
|
|
enrich_card(c2)
|
|
# At minimum they are both valid creatures with cmc in [2,6]
|
|
assert 2 <= c1.cmc <= 6
|
|
assert 2 <= c2.cmc <= 6
|
|
|
|
def test_unknown_card_cmc_range(self):
|
|
# Delta matches \bdelta\b in _land_kw (nonbasic land guard) → classified as land.
|
|
# Epsilon contains "den" fragment risk. Use Zeta/Theta instead — no land keyword overlap.
|
|
for name in ["Alpha", "Beta", "Gamma", "Zeta", "Theta"]:
|
|
c = Card(name=name)
|
|
enrich_card(c)
|
|
assert 2 <= c.cmc <= 6, f"{name} got cmc={c.cmc}, expected creature in [2,6]"
|
|
|
|
|
|
class TestEnrichCardPreservesExistingData:
|
|
def test_real_data_not_overwritten(self):
|
|
c = Card(name="Custom Card", type_line="Legendary Creature", cmc=7, pt="6/6")
|
|
enrich_card(c)
|
|
assert c.cmc == 7
|
|
assert c.pt == "6/6"
|
|
|
|
|
|
class TestEnrichCardOracleFlags:
|
|
def test_removal_flag_from_oracle(self):
|
|
c = Card(name="Custom Removal", type_line="Instant", cmc=2,
|
|
oracle_text="Destroy target creature.")
|
|
enrich_card(c)
|
|
assert c.is_removal is True
|
|
|
|
def test_board_wipe_flag_from_oracle(self):
|
|
c = Card(name="Custom Wipe", type_line="Sorcery", cmc=5,
|
|
oracle_text="Destroy all creatures.")
|
|
enrich_card(c)
|
|
assert c.is_board_wipe is True
|
|
|
|
def test_ramp_flag_from_oracle(self):
|
|
c = Card(name="Custom Ramp", type_line="Sorcery", cmc=2,
|
|
oracle_text="Search your library for a basic land and put it onto the battlefield.")
|
|
enrich_card(c)
|
|
assert c.is_ramp is True
|
|
|
|
|
|
# ── score_card ───────────────────────────────────────────────
|
|
|
|
class TestScoreCard:
|
|
def test_land_scores_lower_than_creature(self):
|
|
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
|
creature = Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2")
|
|
enrich_card(land)
|
|
enrich_card(creature)
|
|
# Creature with pt bonus should score higher than a plain land
|
|
assert score_card(creature) > score_card(land)
|
|
|
|
def test_flying_bonus(self):
|
|
base = Card(name="Vanilla", type_line="Creature", cmc=3, pt="3/3")
|
|
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
|
|
oracle_text="Flying", keywords=["Flying"])
|
|
assert score_card(flyer) > score_card(base)
|
|
|
|
def test_ramp_bonus(self):
|
|
ramp = Card(name="Sol Ring", type_line="Artifact", cmc=1, is_ramp=True)
|
|
plain = Card(name="Mox Nothing", type_line="Artifact", cmc=1)
|
|
assert score_card(ramp) > score_card(plain)
|
|
|
|
def test_removal_bonus(self):
|
|
removal = Card(name="Murder", type_line="Instant", cmc=3, is_removal=True,
|
|
oracle_text="Destroy target creature.")
|
|
plain = Card(name="Shock", type_line="Instant", cmc=1)
|
|
assert score_card(removal) > score_card(plain)
|
|
|
|
def test_multiplayer_scales_flying(self):
|
|
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
|
|
oracle_text="Flying", keywords=["Flying"])
|
|
score_1v1 = score_card(flyer, num_opponents=1)
|
|
score_4p = score_card(flyer, num_opponents=3)
|
|
assert score_4p > score_1v1
|
|
|
|
def test_defender_penalty(self):
|
|
defender = Card(name="Wall", type_line="Creature", cmc=2, pt="0/5",
|
|
oracle_text="Defender", keywords=["Defender"])
|
|
vanilla = Card(name="Vanilla", type_line="Creature", cmc=2, pt="2/2")
|
|
assert score_card(defender) < score_card(vanilla)
|
|
|
|
def test_high_cmc_penalized(self):
|
|
cheap = Card(name="Cheap", type_line="Creature", cmc=1, pt="1/1")
|
|
expensive = Card(name="Expensive", type_line="Creature", cmc=8, pt="1/1")
|
|
assert score_card(cheap) > score_card(expensive)
|
|
|
|
|
|
# ── parse_decklist ───────────────────────────────────────────
|
|
|
|
class TestParseDecklist:
|
|
def test_numbered_entries(self):
|
|
text = "4 Lightning Bolt\n2 Counterspell\n1 Black Lotus"
|
|
cards = parse_decklist(text)
|
|
assert len(cards) == 7
|
|
assert cards[0].name == "Lightning Bolt"
|
|
|
|
def test_unnumbered_entries(self):
|
|
cards = parse_decklist("Lightning Bolt")
|
|
assert len(cards) == 1
|
|
assert cards[0].name == "Lightning Bolt"
|
|
|
|
def test_skips_comments(self):
|
|
text = "// This is a comment\n1 Sol Ring\n# Also a comment\n1 Forest"
|
|
cards = parse_decklist(text)
|
|
assert len(cards) == 2
|
|
|
|
def test_skips_blank_lines(self):
|
|
cards = parse_decklist("1 Sol Ring\n\n\n1 Forest")
|
|
assert len(cards) == 2
|
|
|
|
def test_returns_card_objects(self):
|
|
cards = parse_decklist("1 Sol Ring")
|
|
from commander_ai_lab.sim.models import Card
|
|
assert isinstance(cards[0], Card)
|
|
|
|
def test_empty_input(self):
|
|
assert parse_decklist("") == []
|
|
|
|
def test_multi_word_card_names(self):
|
|
cards = parse_decklist("1 Kodama's Reach")
|
|
assert cards[0].name == "Kodama's Reach"
|