Skip to content

Commit e6f3901

Browse files
authored
add comprehensive unit test suite for all arenas (#84)
1 parent ea6cf0e commit e6f3901

File tree

10 files changed

+1953
-0
lines changed

10 files changed

+1953
-0
lines changed

tests/arenas/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Arena unit tests

tests/arenas/conftest.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Shared fixtures and mocks for arena unit tests.
3+
4+
These fixtures allow testing arena logic (validation, result parsing)
5+
without requiring Docker containers or actual game execution.
6+
"""
7+
8+
from pathlib import Path
9+
from typing import Any
10+
from unittest.mock import MagicMock
11+
12+
import pytest
13+
14+
15+
class MockEnvironment:
16+
"""Mock environment that simulates container file system and command execution."""
17+
18+
def __init__(self, files: dict[str, str] | None = None, command_outputs: dict[str, dict] | None = None):
19+
"""
20+
Args:
21+
files: Dict mapping file paths to their contents
22+
command_outputs: Dict mapping command prefixes to their outputs
23+
Format: {"ls": {"output": "file1.py\nfile2.py", "returncode": 0}}
24+
"""
25+
self.files = files or {}
26+
self.command_outputs = command_outputs or {}
27+
self.config = MagicMock()
28+
self.config.cwd = "/workspace"
29+
self._executed_commands: list[str] = []
30+
31+
def execute(self, cmd: str, cwd: str | None = None, timeout: int | None = None) -> dict[str, Any]:
32+
"""Simulate command execution based on configured outputs."""
33+
self._executed_commands.append(cmd)
34+
35+
# Check for exact matches first
36+
if cmd in self.command_outputs:
37+
return self.command_outputs[cmd]
38+
39+
# Check for prefix matches
40+
for prefix, output in self.command_outputs.items():
41+
if cmd.startswith(prefix):
42+
return output
43+
44+
# Default behavior for common commands
45+
if cmd.startswith("ls"):
46+
# Extract path from command
47+
parts = cmd.split()
48+
path = parts[1] if len(parts) > 1 else "."
49+
matching_files = [Path(f).name for f in self.files.keys() if f.startswith(path) or path == "."]
50+
return {"output": "\n".join(matching_files), "returncode": 0}
51+
52+
if cmd.startswith("cat "):
53+
file_path = cmd.split("cat ", 1)[1].strip()
54+
if file_path in self.files:
55+
return {"output": self.files[file_path], "returncode": 0}
56+
return {"output": f"cat: {file_path}: No such file or directory", "returncode": 1}
57+
58+
if cmd.startswith("test -f ") and "echo" in cmd:
59+
file_path = cmd.split("test -f ")[1].split(" &&")[0].strip()
60+
exists = file_path in self.files
61+
return {"output": "exists" if exists else "", "returncode": 0 if exists else 1}
62+
63+
if cmd.startswith("test -d ") and "echo" in cmd:
64+
dir_path = cmd.split("test -d ")[1].split(" &&")[0].strip()
65+
# Check if any file path starts with this directory
66+
exists = any(f.startswith(dir_path + "/") or f == dir_path for f in self.files.keys())
67+
return {"output": "exists" if exists else "", "returncode": 0 if exists else 1}
68+
69+
# Default: command succeeded with no output
70+
return {"output": "", "returncode": 0}
71+
72+
73+
class MockPlayer:
74+
"""Mock player for testing arena validation and result parsing."""
75+
76+
def __init__(self, name: str, environment: MockEnvironment | None = None):
77+
self.name = name
78+
self.environment = environment or MockEnvironment()
79+
80+
81+
def create_mock_player(name: str, files: dict[str, str] | None = None, **kwargs) -> MockPlayer:
82+
"""Create a mock player with specified file system contents."""
83+
env = MockEnvironment(files=files, **kwargs)
84+
return MockPlayer(name=name, environment=env)
85+
86+
87+
@pytest.fixture
88+
def mock_player_factory():
89+
"""Factory fixture for creating mock players."""
90+
return create_mock_player
91+
92+
93+
@pytest.fixture
94+
def minimal_config():
95+
"""Minimal config dict for arena initialization."""
96+
return {
97+
"game": {
98+
"name": "Test",
99+
"sims_per_round": 10,
100+
},
101+
"tournament": {
102+
"rounds": 3,
103+
},
104+
"players": [
105+
{"name": "p1", "agent": "dummy"},
106+
{"name": "p2", "agent": "dummy"},
107+
],
108+
}
109+
110+
111+
@pytest.fixture
112+
def tmp_log_dir(tmp_path):
113+
"""Create a temporary log directory structure."""
114+
log_dir = tmp_path / "logs"
115+
log_dir.mkdir()
116+
rounds_dir = log_dir / "rounds"
117+
rounds_dir.mkdir()
118+
return log_dir

tests/arenas/test_battlecode.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""
2+
Unit tests for BattleCodeArena.
3+
4+
Tests validate_code() and get_results() methods without requiring Docker.
5+
"""
6+
7+
import pytest
8+
9+
from codeclash.arenas.arena import RoundStats
10+
from codeclash.arenas.battlecode.battlecode import BC_FOLDER, BC_LOG, BC_TIE, BattleCodeArena
11+
12+
from .conftest import MockPlayer
13+
14+
VALID_BOT_PY = """
15+
from battlecode25.stubs import *
16+
17+
def turn():
18+
# Simple bot that does nothing
19+
pass
20+
"""
21+
22+
23+
class TestBattleCodeValidation:
24+
"""Tests for BattleCodeArena.validate_code()"""
25+
26+
@pytest.fixture
27+
def arena(self, tmp_log_dir, minimal_config):
28+
"""Create BattleCodeArena instance with mocked environment."""
29+
arena = BattleCodeArena.__new__(BattleCodeArena)
30+
arena.submission = f"src/{BC_FOLDER}"
31+
arena.log_local = tmp_log_dir
32+
arena.run_cmd_round = "python run.py run"
33+
return arena
34+
35+
def test_valid_submission(self, arena, mock_player_factory):
36+
"""Test that a valid bot.py passes validation."""
37+
player = mock_player_factory(
38+
name="test_player",
39+
files={
40+
f"src/{BC_FOLDER}/bot.py": VALID_BOT_PY,
41+
},
42+
command_outputs={
43+
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
44+
f"ls src/{BC_FOLDER}": {"output": "bot.py\n__init__.py\n", "returncode": 0},
45+
f"cat src/{BC_FOLDER}/bot.py": {"output": VALID_BOT_PY, "returncode": 0},
46+
},
47+
)
48+
is_valid, error = arena.validate_code(player)
49+
assert is_valid is True
50+
assert error is None
51+
52+
def test_missing_mysubmission_directory(self, arena, mock_player_factory):
53+
"""Test that missing src/mysubmission/ fails validation."""
54+
player = mock_player_factory(
55+
name="test_player",
56+
files={},
57+
command_outputs={
58+
"ls src": {"output": "otherpackage\n", "returncode": 0},
59+
},
60+
)
61+
is_valid, error = arena.validate_code(player)
62+
assert is_valid is False
63+
assert BC_FOLDER in error
64+
65+
def test_missing_bot_file(self, arena, mock_player_factory):
66+
"""Test that missing bot.py fails validation."""
67+
player = mock_player_factory(
68+
name="test_player",
69+
files={
70+
f"src/{BC_FOLDER}/__init__.py": "",
71+
},
72+
command_outputs={
73+
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
74+
f"ls src/{BC_FOLDER}": {"output": "__init__.py\n", "returncode": 0},
75+
},
76+
)
77+
is_valid, error = arena.validate_code(player)
78+
assert is_valid is False
79+
assert "bot.py" in error
80+
81+
def test_missing_turn_function(self, arena, mock_player_factory):
82+
"""Test that bot.py without turn() function fails validation."""
83+
invalid_bot = """
84+
from battlecode25.stubs import *
85+
86+
def setup():
87+
pass
88+
89+
def run():
90+
pass
91+
"""
92+
player = mock_player_factory(
93+
name="test_player",
94+
files={
95+
f"src/{BC_FOLDER}/bot.py": invalid_bot,
96+
},
97+
command_outputs={
98+
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
99+
f"ls src/{BC_FOLDER}": {"output": "bot.py\n", "returncode": 0},
100+
f"cat src/{BC_FOLDER}/bot.py": {"output": invalid_bot, "returncode": 0},
101+
},
102+
)
103+
is_valid, error = arena.validate_code(player)
104+
assert is_valid is False
105+
assert "turn()" in error
106+
107+
108+
class TestBattleCodeResults:
109+
"""Tests for BattleCodeArena.get_results()"""
110+
111+
@pytest.fixture
112+
def arena(self, tmp_log_dir, minimal_config):
113+
"""Create BattleCodeArena instance."""
114+
config = minimal_config.copy()
115+
config["game"]["name"] = "BattleCode"
116+
config["game"]["sims_per_round"] = 3
117+
arena = BattleCodeArena.__new__(BattleCodeArena)
118+
arena.submission = f"src/{BC_FOLDER}"
119+
arena.log_local = tmp_log_dir
120+
arena.config = config
121+
arena.logger = type("Logger", (), {"debug": lambda self, msg: None, "info": lambda self, msg: None})()
122+
return arena
123+
124+
def _create_sim_log(self, round_dir, idx: int, winner_key: str, is_coin_flip: bool = False):
125+
"""
126+
Create a simulation log file.
127+
128+
Args:
129+
winner_key: "A" or "B" to indicate which player won
130+
is_coin_flip: If True, sets reason to coin flip (arbitrary win)
131+
"""
132+
log_file = round_dir / BC_LOG.format(idx=idx)
133+
reason = BC_TIE if is_coin_flip else "Reason: Team won by controlling more territory."
134+
# The log format has winner info in third-to-last line
135+
log_file.write_text(
136+
f"""Round starting...
137+
Turn 100...
138+
Turn 200...
139+
Winner: Team ({winner_key}) wins (game over)
140+
{reason}
141+
Final stats
142+
"""
143+
)
144+
145+
def test_parse_results_player_a_wins(self, arena, tmp_log_dir):
146+
"""Test parsing results when player A (first player) wins."""
147+
round_dir = tmp_log_dir / "rounds" / "1"
148+
round_dir.mkdir(parents=True)
149+
150+
# A wins 2 games, B wins 1
151+
self._create_sim_log(round_dir, 0, "A")
152+
self._create_sim_log(round_dir, 1, "A")
153+
self._create_sim_log(round_dir, 2, "B")
154+
155+
agents = [MockPlayer("Alice"), MockPlayer("Bob")]
156+
stats = RoundStats(round_num=1, agents=agents)
157+
158+
arena.get_results(agents, round_num=1, stats=stats)
159+
160+
assert stats.winner == "Alice"
161+
assert stats.scores["Alice"] == 2
162+
assert stats.scores["Bob"] == 1
163+
164+
def test_parse_results_player_b_wins(self, arena, tmp_log_dir):
165+
"""Test parsing results when player B (second player) wins."""
166+
round_dir = tmp_log_dir / "rounds" / "1"
167+
round_dir.mkdir(parents=True)
168+
169+
# A wins 1 game, B wins 2
170+
self._create_sim_log(round_dir, 0, "B")
171+
self._create_sim_log(round_dir, 1, "B")
172+
self._create_sim_log(round_dir, 2, "A")
173+
174+
agents = [MockPlayer("Alice"), MockPlayer("Bob")]
175+
stats = RoundStats(round_num=1, agents=agents)
176+
177+
arena.get_results(agents, round_num=1, stats=stats)
178+
179+
assert stats.winner == "Bob"
180+
assert stats.scores["Alice"] == 1
181+
assert stats.scores["Bob"] == 2
182+
183+
def test_parse_results_with_coin_flips(self, arena, tmp_log_dir):
184+
"""Test parsing results where some wins are coin flips (don't count)."""
185+
round_dir = tmp_log_dir / "rounds" / "1"
186+
round_dir.mkdir(parents=True)
187+
188+
# Coin flip wins should be treated as ties
189+
self._create_sim_log(round_dir, 0, "A")
190+
self._create_sim_log(round_dir, 1, "A", is_coin_flip=True) # Doesn't count
191+
self._create_sim_log(round_dir, 2, "B")
192+
193+
agents = [MockPlayer("Alice"), MockPlayer("Bob")]
194+
stats = RoundStats(round_num=1, agents=agents)
195+
196+
arena.get_results(agents, round_num=1, stats=stats)
197+
198+
# Only non-coin-flip wins count
199+
assert stats.scores["Alice"] == 1
200+
assert stats.scores["Bob"] == 1
201+
202+
203+
class TestBattleCodeConfig:
204+
"""Tests for BattleCodeArena configuration and properties."""
205+
206+
def test_arena_name(self):
207+
"""Test that arena has correct name."""
208+
assert BattleCodeArena.name == "BattleCode"
209+
210+
def test_submission_path(self):
211+
"""Test that submission path is correct."""
212+
assert BattleCodeArena.submission == f"src/{BC_FOLDER}"
213+
214+
def test_bc_folder_name(self):
215+
"""Test that BC folder name is mysubmission."""
216+
assert BC_FOLDER == "mysubmission"
217+
218+
def test_default_args(self):
219+
"""Test default arguments."""
220+
assert BattleCodeArena.default_args.get("maps") == "quack"
221+
222+
def test_description_mentions_python(self):
223+
"""Test that description mentions Python as the language."""
224+
assert "python" in BattleCodeArena.description.lower()

0 commit comments

Comments
 (0)