From 812e5b34ca10e666f8633f510fd20c459ee29a1f Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Fri, 13 Mar 2026 22:30:40 -0400 Subject: [PATCH] feat(tests): add pytest infrastructure and test coverage for models, rules, and sim engine (closes #28) --- tests/__init__.py | 0 tests/conftest.py | 75 ++++++++++++++ tests/sim/__init__.py | 0 tests/sim/test_engine.py | 97 ++++++++++++++++++ tests/sim/test_models.py | 214 +++++++++++++++++++++++++++++++++++++++ tests/sim/test_rules.py | 189 ++++++++++++++++++++++++++++++++++ 6 files changed, 575 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/sim/__init__.py create mode 100644 tests/sim/test_engine.py create mode 100644 tests/sim/test_models.py create mode 100644 tests/sim/test_rules.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8427d4b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +"""Shared pytest fixtures for Commander AI Lab tests.""" +import pytest +from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState + + +@pytest.fixture +def basic_creature(): + return Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2", power="2", toughness="2") + + +@pytest.fixture +def flying_creature(): + return Card( + name="Serra Angel", + type_line="Creature — Angel", + cmc=5, + pt="4/4", + power="4", + toughness="4", + oracle_text="Flying, vigilance", + keywords=["Flying", "Vigilance"], + ) + + +@pytest.fixture +def basic_land(): + return Card(name="Forest", type_line="Basic Land — Forest", cmc=0) + + +@pytest.fixture +def sol_ring(): + return Card(name="Sol Ring", type_line="Artifact", cmc=1, oracle_text="{T}: Add {C}{C}", is_ramp=True) + + +@pytest.fixture +def removal_spell(): + return Card(name="Murder", type_line="Instant", cmc=3, oracle_text="Destroy target creature.", is_removal=True) + + +@pytest.fixture +def board_wipe(): + return Card(name="Wrath of God", type_line="Sorcery", cmc=4, oracle_text="Destroy all creatures.", is_board_wipe=True) + + +@pytest.fixture +def commander_card(): + return Card( + name="Korvold, Fae-Cursed King", + type_line="Legendary Creature — Dragon Noble", + cmc=5, + pt="4/4", + power="4", + toughness="4", + is_commander=True, + oracle_text="Flying, haste\nWhenever Korvold, Fae-Cursed King enters the battlefield or attacks, sacrifice another permanent.", + keywords=["Flying", "Haste"], + ) + + +@pytest.fixture +def sample_player(basic_creature, basic_land, sol_ring): + p = Player(name="Alice", life=40, owner_id=0) + p.library = [basic_creature.clone(), basic_land.clone(), sol_ring.clone()] * 20 + return p + + +@pytest.fixture +def two_player_state(sample_player): + import copy + p2 = copy.deepcopy(sample_player) + p2.name = "Bob" + p2.owner_id = 1 + state = SimState(players=[sample_player, p2]) + state.init_battlefields(2) + return state diff --git a/tests/sim/__init__.py b/tests/sim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sim/test_engine.py b/tests/sim/test_engine.py new file mode 100644 index 0000000..fc18137 --- /dev/null +++ b/tests/sim/test_engine.py @@ -0,0 +1,97 @@ +"""Seeded simulation tests for the game engine.""" +import random +import pytest +from commander_ai_lab.sim.models import Card, Player, PlayerStats +from commander_ai_lab.sim.rules import enrich_card +from commander_ai_lab.sim.engine import GameEngine + + +def _make_deck(seed: int, size: int = 60) -> list[Card]: + """Build a deterministic test deck using a seeded RNG.""" + rng = random.Random(seed) + card_pool = [ + Card(name="Forest"), + Card(name="Sol Ring"), + Card(name="Cultivate"), + Card(name="Murder"), + Card(name="Wrath of God"), + Card(name="Grizzly Bears"), + Card(name="Serra Angel"), + ] + for c in card_pool: + enrich_card(c) + deck = [] + for i in range(size): + deck.append(card_pool[i % len(card_pool)].clone()) + rng.shuffle(deck) + return deck + + +class TestGameEngineTwoPlayer: + def test_game_completes(self): + """A 2-player seeded game should finish without error.""" + engine = GameEngine(seed=42) + deck_a = _make_deck(seed=1) + deck_b = _make_deck(seed=2) + result = engine.run_game( + deck_a=[c.clone() for c in deck_a], + deck_b=[c.clone() for c in deck_b], + player_a_name="Alice", + player_b_name="Bob", + ) + assert result is not None + assert result.turns > 0 + + def test_result_has_winner_or_draw(self): + """winner_seat must be 0, 1, or -1 (draw).""" + engine = GameEngine(seed=99) + deck = _make_deck(seed=5) + result = engine.run_game( + deck_a=[c.clone() for c in deck], + deck_b=[c.clone() for c in deck], + player_a_name="P1", + player_b_name="P2", + ) + assert result.winner_seat in (-1, 0, 1) + + def test_seeded_game_is_deterministic(self): + """Same seed must produce identical results.""" + deck_a = _make_deck(seed=7) + deck_b = _make_deck(seed=8) + + engine1 = GameEngine(seed=42) + result1 = engine1.run_game( + deck_a=[c.clone() for c in deck_a], + deck_b=[c.clone() for c in deck_b], + player_a_name="Alice", + player_b_name="Bob", + ) + + engine2 = GameEngine(seed=42) + result2 = engine2.run_game( + deck_a=[c.clone() for c in deck_a], + deck_b=[c.clone() for c in deck_b], + player_a_name="Alice", + player_b_name="Bob", + ) + + assert result1.winner_seat == result2.winner_seat + assert result1.turns == result2.turns + + def test_to_dict_serializable(self): + """Game result must serialize to a dict with expected keys.""" + import json + engine = GameEngine(seed=77) + deck = _make_deck(seed=3) + result = engine.run_game( + deck_a=[c.clone() for c in deck], + deck_b=[c.clone() for c in deck], + player_a_name="A", + player_b_name="B", + ) + d = result.to_dict() + assert "winner" in d + assert "turns" in d + assert "playerResults" in d + # Ensure it's JSON-serializable + json.dumps(d) diff --git a/tests/sim/test_models.py b/tests/sim/test_models.py new file mode 100644 index 0000000..d0de3bb --- /dev/null +++ b/tests/sim/test_models.py @@ -0,0 +1,214 @@ +"""Tests for commander_ai_lab.sim.models.""" +import pytest +from commander_ai_lab.sim.models import ( + Card, Player, PlayerStats, PlayerResult, GameResult, SimState +) + + +# ── Card ──────────────────────────────────────────────────── + +class TestCardGetPower: + def test_from_power_field(self): + c = Card(name="Test", power="3", toughness="2", pt="3/2") + assert c.get_power() == 3 + + def test_from_pt_field(self): + c = Card(name="Test", pt="4/5") + assert c.get_power() == 4 + + def test_no_pt_returns_zero(self): + c = Card(name="Test") + assert c.get_power() == 0 + + def test_non_numeric_returns_zero(self): + c = Card(name="Test", power="*", pt="*/2") + assert c.get_power() == 0 + + +class TestCardGetToughness: + def test_from_toughness_field(self): + c = Card(name="Test", power="2", toughness="5", pt="2/5") + assert c.get_toughness() == 5 + + def test_from_pt_field(self): + c = Card(name="Test", pt="2/7") + assert c.get_toughness() == 7 + + def test_no_pt_returns_zero(self): + c = Card(name="Test") + assert c.get_toughness() == 0 + + +class TestCardHasKeyword: + def test_in_keywords_list(self): + c = Card(name="Test", keywords=["Flying", "Haste"]) + assert c.has_keyword("flying") is True + assert c.has_keyword("haste") is True + + def test_in_oracle_text(self): + c = Card(name="Test", oracle_text="This creature has flying and deathtouch.") + assert c.has_keyword("flying") is True + assert c.has_keyword("deathtouch") is True + + def test_missing_keyword(self): + c = Card(name="Test", oracle_text="Draw a card.", keywords=[]) + assert c.has_keyword("flying") is False + + def test_case_insensitive(self): + c = Card(name="Test", keywords=["FLYING"]) + assert c.has_keyword("Flying") is True + + +class TestCardIsLand: + def test_basic_land(self): + c = Card(name="Forest", type_line="Basic Land — Forest") + assert c.is_land() is True + + def test_nonbasic_land(self): + c = Card(name="Command Tower", type_line="Land") + assert c.is_land() is True + + def test_creature_not_land(self): + c = Card(name="Grizzly Bears", type_line="Creature — Bear") + assert c.is_land() is False + + def test_no_type_line(self): + c = Card(name="Unknown") + assert c.is_land() is False + + +class TestCardIsCreature: + def test_creature(self): + c = Card(name="Grizzly Bears", type_line="Creature — Bear") + assert c.is_creature() is True + + def test_legendary_creature(self): + c = Card(name="Korvold", type_line="Legendary Creature — Dragon Noble") + assert c.is_creature() is True + + def test_instant_not_creature(self): + c = Card(name="Murder", type_line="Instant") + assert c.is_creature() is False + + def test_land_not_creature(self): + c = Card(name="Forest", type_line="Basic Land — Forest") + assert c.is_creature() is False + + +class TestCardClone: + def test_clone_is_equal(self, basic_creature): + clone = basic_creature.clone() + assert clone.name == basic_creature.name + assert clone.cmc == basic_creature.cmc + + def test_clone_is_independent(self, basic_creature): + clone = basic_creature.clone() + clone.name = "Changed" + assert basic_creature.name == "Grizzly Bears" + + +# ── PlayerStats ───────────────────────────────────────────── + +class TestPlayerStats: + def test_to_dict_keys(self): + stats = PlayerStats(cards_drawn=7, damage_dealt=10) + d = stats.to_dict() + assert "cardsDrawn" in d + assert "damageDealt" in d + assert d["cardsDrawn"] == 7 + assert d["damageDealt"] == 10 + + def test_default_values(self): + stats = PlayerStats() + assert stats.cards_drawn == 7 + assert stats.lands_played == 0 + + +# ── PlayerResult & GameResult ──────────────────────────────── + +class TestPlayerResult: + def test_to_dict_winner(self): + pr = PlayerResult(seat_index=0, name="Alice", life=40, finish_position=1) + d = pr.to_dict() + assert d["isWinner"] is True + assert d["seatIndex"] == 0 + assert d["name"] == "Alice" + + def test_to_dict_loser(self): + pr = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2) + d = pr.to_dict() + assert d["isWinner"] is False + assert d["eliminated"] is True + + +class TestGameResult: + def _make_result(self): + p0 = PlayerResult(seat_index=0, name="Alice", life=35, finish_position=1, stats=PlayerStats(damage_dealt=20)) + p1 = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2, stats=PlayerStats()) + return GameResult(winner_seat=0, turns=12, players=[p0, p1]) + + def test_winner_property(self): + gr = self._make_result() + assert gr.winner == 0 + + def test_player_a_name(self): + gr = self._make_result() + assert gr.player_a_name == "Alice" + + def test_player_b_name(self): + gr = self._make_result() + assert gr.player_b_name == "Bob" + + def test_player_a_eliminated(self): + gr = self._make_result() + assert gr.player_a_eliminated is False + + def test_player_b_eliminated(self): + gr = self._make_result() + assert gr.player_b_eliminated is True + + def test_to_dict_has_legacy_keys(self): + gr = self._make_result() + d = gr.to_dict() + assert "winner" in d + assert "turns" in d + assert "playerResults" in d + assert len(d["playerResults"]) == 2 + + +# ── SimState ───────────────────────────────────────────────── + +class TestSimState: + def test_init_battlefields(self): + state = SimState() + state.init_battlefields(4) + assert len(state.battlefields) == 4 + + def test_add_and_get_battlefield(self): + state = SimState() + c = Card(name="Test", id=1) + state.add_to_battlefield(0, c) + bf = state.get_battlefield(0) + assert len(bf) == 1 + assert bf[0].name == "Test" + + def test_remove_from_battlefield(self): + state = SimState() + c = Card(name="Test", id=42) + state.add_to_battlefield(0, c) + removed = state.remove_from_battlefield(42) + assert removed is not None + assert removed.name == "Test" + assert len(state.get_battlefield(0)) == 0 + + def test_remove_nonexistent_returns_none(self): + state = SimState() + assert state.remove_from_battlefield(999) is None + + def test_all_battlefield_cards(self): + state = SimState() + state.add_to_battlefield(0, Card(name="A", id=1)) + state.add_to_battlefield(1, Card(name="B", id=2)) + state.add_to_battlefield(1, Card(name="C", id=3)) + all_cards = state.all_battlefield_cards() + assert len(all_cards) == 3 diff --git a/tests/sim/test_rules.py b/tests/sim/test_rules.py new file mode 100644 index 0000000..ab15fd8 --- /dev/null +++ b/tests/sim/test_rules.py @@ -0,0 +1,189 @@ +"""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): + for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]: + c = Card(name=name) + enrich_card(c) + assert 2 <= c.cmc <= 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"