Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tests/arenas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Arena unit tests
118 changes: 118 additions & 0 deletions tests/arenas/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Shared fixtures and mocks for arena unit tests.

These fixtures allow testing arena logic (validation, result parsing)
without requiring Docker containers or actual game execution.
"""

from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest


class MockEnvironment:
"""Mock environment that simulates container file system and command execution."""

def __init__(self, files: dict[str, str] | None = None, command_outputs: dict[str, dict] | None = None):
"""
Args:
files: Dict mapping file paths to their contents
command_outputs: Dict mapping command prefixes to their outputs
Format: {"ls": {"output": "file1.py\nfile2.py", "returncode": 0}}
"""
self.files = files or {}
self.command_outputs = command_outputs or {}
self.config = MagicMock()
self.config.cwd = "/workspace"
self._executed_commands: list[str] = []

def execute(self, cmd: str, cwd: str | None = None, timeout: int | None = None) -> dict[str, Any]:
"""Simulate command execution based on configured outputs."""
self._executed_commands.append(cmd)

# Check for exact matches first
if cmd in self.command_outputs:
return self.command_outputs[cmd]

# Check for prefix matches
for prefix, output in self.command_outputs.items():
if cmd.startswith(prefix):
return output

# Default behavior for common commands
if cmd.startswith("ls"):
# Extract path from command
parts = cmd.split()
path = parts[1] if len(parts) > 1 else "."
matching_files = [Path(f).name for f in self.files.keys() if f.startswith(path) or path == "."]
return {"output": "\n".join(matching_files), "returncode": 0}

if cmd.startswith("cat "):
file_path = cmd.split("cat ", 1)[1].strip()
if file_path in self.files:
return {"output": self.files[file_path], "returncode": 0}
return {"output": f"cat: {file_path}: No such file or directory", "returncode": 1}

if cmd.startswith("test -f ") and "echo" in cmd:
file_path = cmd.split("test -f ")[1].split(" &&")[0].strip()
exists = file_path in self.files
return {"output": "exists" if exists else "", "returncode": 0 if exists else 1}

if cmd.startswith("test -d ") and "echo" in cmd:
dir_path = cmd.split("test -d ")[1].split(" &&")[0].strip()
# Check if any file path starts with this directory
exists = any(f.startswith(dir_path + "/") or f == dir_path for f in self.files.keys())
return {"output": "exists" if exists else "", "returncode": 0 if exists else 1}

# Default: command succeeded with no output
return {"output": "", "returncode": 0}


class MockPlayer:
"""Mock player for testing arena validation and result parsing."""

def __init__(self, name: str, environment: MockEnvironment | None = None):
self.name = name
self.environment = environment or MockEnvironment()


def create_mock_player(name: str, files: dict[str, str] | None = None, **kwargs) -> MockPlayer:
"""Create a mock player with specified file system contents."""
env = MockEnvironment(files=files, **kwargs)
return MockPlayer(name=name, environment=env)


@pytest.fixture
def mock_player_factory():
"""Factory fixture for creating mock players."""
return create_mock_player


@pytest.fixture
def minimal_config():
"""Minimal config dict for arena initialization."""
return {
"game": {
"name": "Test",
"sims_per_round": 10,
},
"tournament": {
"rounds": 3,
},
"players": [
{"name": "p1", "agent": "dummy"},
{"name": "p2", "agent": "dummy"},
],
}


@pytest.fixture
def tmp_log_dir(tmp_path):
"""Create a temporary log directory structure."""
log_dir = tmp_path / "logs"
log_dir.mkdir()
rounds_dir = log_dir / "rounds"
rounds_dir.mkdir()
return log_dir
224 changes: 224 additions & 0 deletions tests/arenas/test_battlecode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""
Unit tests for BattleCodeArena.

Tests validate_code() and get_results() methods without requiring Docker.
"""

import pytest

from codeclash.arenas.arena import RoundStats
from codeclash.arenas.battlecode.battlecode import BC_FOLDER, BC_LOG, BC_TIE, BattleCodeArena

from .conftest import MockPlayer

VALID_BOT_PY = """
from battlecode25.stubs import *

def turn():
# Simple bot that does nothing
pass
"""


class TestBattleCodeValidation:
"""Tests for BattleCodeArena.validate_code()"""

@pytest.fixture
def arena(self, tmp_log_dir, minimal_config):
"""Create BattleCodeArena instance with mocked environment."""
arena = BattleCodeArena.__new__(BattleCodeArena)
arena.submission = f"src/{BC_FOLDER}"
arena.log_local = tmp_log_dir
arena.run_cmd_round = "python run.py run"
return arena

def test_valid_submission(self, arena, mock_player_factory):
"""Test that a valid bot.py passes validation."""
player = mock_player_factory(
name="test_player",
files={
f"src/{BC_FOLDER}/bot.py": VALID_BOT_PY,
},
command_outputs={
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
f"ls src/{BC_FOLDER}": {"output": "bot.py\n__init__.py\n", "returncode": 0},
f"cat src/{BC_FOLDER}/bot.py": {"output": VALID_BOT_PY, "returncode": 0},
},
)
is_valid, error = arena.validate_code(player)
assert is_valid is True
assert error is None

def test_missing_mysubmission_directory(self, arena, mock_player_factory):
"""Test that missing src/mysubmission/ fails validation."""
player = mock_player_factory(
name="test_player",
files={},
command_outputs={
"ls src": {"output": "otherpackage\n", "returncode": 0},
},
)
is_valid, error = arena.validate_code(player)
assert is_valid is False
assert BC_FOLDER in error

def test_missing_bot_file(self, arena, mock_player_factory):
"""Test that missing bot.py fails validation."""
player = mock_player_factory(
name="test_player",
files={
f"src/{BC_FOLDER}/__init__.py": "",
},
command_outputs={
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
f"ls src/{BC_FOLDER}": {"output": "__init__.py\n", "returncode": 0},
},
)
is_valid, error = arena.validate_code(player)
assert is_valid is False
assert "bot.py" in error

def test_missing_turn_function(self, arena, mock_player_factory):
"""Test that bot.py without turn() function fails validation."""
invalid_bot = """
from battlecode25.stubs import *

def setup():
pass

def run():
pass
"""
player = mock_player_factory(
name="test_player",
files={
f"src/{BC_FOLDER}/bot.py": invalid_bot,
},
command_outputs={
"ls src": {"output": f"{BC_FOLDER}\n", "returncode": 0},
f"ls src/{BC_FOLDER}": {"output": "bot.py\n", "returncode": 0},
f"cat src/{BC_FOLDER}/bot.py": {"output": invalid_bot, "returncode": 0},
},
)
is_valid, error = arena.validate_code(player)
assert is_valid is False
assert "turn()" in error


class TestBattleCodeResults:
"""Tests for BattleCodeArena.get_results()"""

@pytest.fixture
def arena(self, tmp_log_dir, minimal_config):
"""Create BattleCodeArena instance."""
config = minimal_config.copy()
config["game"]["name"] = "BattleCode"
config["game"]["sims_per_round"] = 3
arena = BattleCodeArena.__new__(BattleCodeArena)
arena.submission = f"src/{BC_FOLDER}"
arena.log_local = tmp_log_dir
arena.config = config
arena.logger = type("Logger", (), {"debug": lambda self, msg: None, "info": lambda self, msg: None})()
return arena

def _create_sim_log(self, round_dir, idx: int, winner_key: str, is_coin_flip: bool = False):
"""
Create a simulation log file.

Args:
winner_key: "A" or "B" to indicate which player won
is_coin_flip: If True, sets reason to coin flip (arbitrary win)
"""
log_file = round_dir / BC_LOG.format(idx=idx)
reason = BC_TIE if is_coin_flip else "Reason: Team won by controlling more territory."
# The log format has winner info in third-to-last line
log_file.write_text(
f"""Round starting...
Turn 100...
Turn 200...
Winner: Team ({winner_key}) wins (game over)
{reason}
Final stats
"""
)

def test_parse_results_player_a_wins(self, arena, tmp_log_dir):
"""Test parsing results when player A (first player) wins."""
round_dir = tmp_log_dir / "rounds" / "1"
round_dir.mkdir(parents=True)

# A wins 2 games, B wins 1
self._create_sim_log(round_dir, 0, "A")
self._create_sim_log(round_dir, 1, "A")
self._create_sim_log(round_dir, 2, "B")

agents = [MockPlayer("Alice"), MockPlayer("Bob")]
stats = RoundStats(round_num=1, agents=agents)

arena.get_results(agents, round_num=1, stats=stats)

assert stats.winner == "Alice"
assert stats.scores["Alice"] == 2
assert stats.scores["Bob"] == 1

def test_parse_results_player_b_wins(self, arena, tmp_log_dir):
"""Test parsing results when player B (second player) wins."""
round_dir = tmp_log_dir / "rounds" / "1"
round_dir.mkdir(parents=True)

# A wins 1 game, B wins 2
self._create_sim_log(round_dir, 0, "B")
self._create_sim_log(round_dir, 1, "B")
self._create_sim_log(round_dir, 2, "A")

agents = [MockPlayer("Alice"), MockPlayer("Bob")]
stats = RoundStats(round_num=1, agents=agents)

arena.get_results(agents, round_num=1, stats=stats)

assert stats.winner == "Bob"
assert stats.scores["Alice"] == 1
assert stats.scores["Bob"] == 2

def test_parse_results_with_coin_flips(self, arena, tmp_log_dir):
"""Test parsing results where some wins are coin flips (don't count)."""
round_dir = tmp_log_dir / "rounds" / "1"
round_dir.mkdir(parents=True)

# Coin flip wins should be treated as ties
self._create_sim_log(round_dir, 0, "A")
self._create_sim_log(round_dir, 1, "A", is_coin_flip=True) # Doesn't count
self._create_sim_log(round_dir, 2, "B")

agents = [MockPlayer("Alice"), MockPlayer("Bob")]
stats = RoundStats(round_num=1, agents=agents)

arena.get_results(agents, round_num=1, stats=stats)

# Only non-coin-flip wins count
assert stats.scores["Alice"] == 1
assert stats.scores["Bob"] == 1


class TestBattleCodeConfig:
"""Tests for BattleCodeArena configuration and properties."""

def test_arena_name(self):
"""Test that arena has correct name."""
assert BattleCodeArena.name == "BattleCode"

def test_submission_path(self):
"""Test that submission path is correct."""
assert BattleCodeArena.submission == f"src/{BC_FOLDER}"

def test_bc_folder_name(self):
"""Test that BC folder name is mysubmission."""
assert BC_FOLDER == "mysubmission"

def test_default_args(self):
"""Test default arguments."""
assert BattleCodeArena.default_args.get("maps") == "quack"

def test_description_mentions_python(self):
"""Test that description mentions Python as the language."""
assert "python" in BattleCodeArena.description.lower()
Loading