diff --git a/tests/arenas/__init__.py b/tests/arenas/__init__.py new file mode 100644 index 00000000..7299a5de --- /dev/null +++ b/tests/arenas/__init__.py @@ -0,0 +1 @@ +# Arena unit tests diff --git a/tests/arenas/conftest.py b/tests/arenas/conftest.py new file mode 100644 index 00000000..e8bc6b09 --- /dev/null +++ b/tests/arenas/conftest.py @@ -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 diff --git a/tests/arenas/test_battlecode.py b/tests/arenas/test_battlecode.py new file mode 100644 index 00000000..73187f02 --- /dev/null +++ b/tests/arenas/test_battlecode.py @@ -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() diff --git a/tests/arenas/test_battlesnake.py b/tests/arenas/test_battlesnake.py new file mode 100644 index 00000000..6306073d --- /dev/null +++ b/tests/arenas/test_battlesnake.py @@ -0,0 +1,259 @@ +""" +Unit tests for BattleSnakeArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import json + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.battlesnake.battlesnake import BattleSnakeArena +from codeclash.constants import RESULT_TIE + +from .conftest import MockPlayer + +VALID_BATTLESNAKE_BOT = """ +import bottle + +def info(): + return {"apiversion": "1", "author": "test", "color": "#888888", "head": "default", "tail": "default"} + +def start(game_state): + pass + +def end(game_state): + pass + +def move(game_state): + return {"move": "up"} + +if __name__ == "__main__": + bottle.run(host="0.0.0.0", port=8080) +""" + + +class TestBattleSnakeValidation: + """Tests for BattleSnakeArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create BattleSnakeArena instance with mocked environment.""" + config = minimal_config.copy() + config["game"]["name"] = "BattleSnake" + arena = BattleSnakeArena.__new__(BattleSnakeArena) + arena.submission = "main.py" + arena.log_local = tmp_log_dir + return arena + + def test_valid_submission(self, arena, mock_player_factory): + """Test that a valid BattleSnake bot passes validation.""" + player = mock_player_factory( + name="test_player", + files={"main.py": VALID_BATTLESNAKE_BOT}, + command_outputs={ + "ls": {"output": "main.py\n", "returncode": 0}, + "cat main.py": {"output": VALID_BATTLESNAKE_BOT, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_main_file(self, arena, mock_player_factory): + """Test that missing main.py fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "ls": {"output": "other.py\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "main.py" in error + + def test_missing_info_function(self, arena, mock_player_factory): + """Test that missing info() function fails validation.""" + bot_code = """ +def start(game_state): + pass + +def end(game_state): + pass + +def move(game_state): + return {"move": "up"} +""" + player = mock_player_factory( + name="test_player", + files={"main.py": bot_code}, + command_outputs={ + "ls": {"output": "main.py\n", "returncode": 0}, + "cat main.py": {"output": bot_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "def info(" in error + + def test_missing_move_function(self, arena, mock_player_factory): + """Test that missing move() function fails validation.""" + bot_code = """ +def info(): + return {} + +def start(game_state): + pass + +def end(game_state): + pass +""" + player = mock_player_factory( + name="test_player", + files={"main.py": bot_code}, + command_outputs={ + "ls": {"output": "main.py\n", "returncode": 0}, + "cat main.py": {"output": bot_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "def move(" in error + + def test_missing_multiple_functions(self, arena, mock_player_factory): + """Test that missing multiple functions reports all of them.""" + bot_code = """ +def info(): + return {} +""" + player = mock_player_factory( + name="test_player", + files={"main.py": bot_code}, + command_outputs={ + "ls": {"output": "main.py\n", "returncode": 0}, + "cat main.py": {"output": bot_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "def start(" in error + assert "def end(" in error + assert "def move(" in error + + +class TestBattleSnakeResults: + """Tests for BattleSnakeArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create BattleSnakeArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "BattleSnake" + config["game"]["sims_per_round"] = 3 + arena = BattleSnakeArena.__new__(BattleSnakeArena) + arena.submission = "main.py" + arena.log_local = tmp_log_dir + arena.config = config # game_config is a property that reads from self.config + arena._failed_to_start_player = [] + return arena + + def _create_sim_file(self, round_dir, idx: int, winner_name: str, is_draw: bool = False): + """Helper to create a simulation result file.""" + sim_file = round_dir / f"sim_{idx}.jsonl" + result = {"isDraw": is_draw, "winnerName": winner_name if not is_draw else None} + # Write multiple lines with result as last line + sim_file.write_text(f'{{"turn": 1}}\n{{"turn": 2}}\n{json.dumps(result)}\n') + + def test_parse_results_clear_winner(self, arena, tmp_log_dir): + """Test parsing results with a clear winner.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Alice wins 2 games, Bob wins 1 + self._create_sim_file(round_dir, 0, "Alice") + self._create_sim_file(round_dir, 1, "Alice") + self._create_sim_file(round_dir, 2, "Bob") + + 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_with_draws(self, arena, tmp_log_dir): + """Test parsing results that include draws.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_sim_file(round_dir, 0, "Alice") + self._create_sim_file(round_dir, 1, None, is_draw=True) + self._create_sim_file(round_dir, 2, "Bob") + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + # Both have 1 win, plus 1 draw + assert stats.scores["Alice"] == 1 + assert stats.scores["Bob"] == 1 + assert stats.scores[RESULT_TIE] == 1 + + def test_parse_results_all_draws(self, arena, tmp_log_dir): + """Test parsing results when all games are draws.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + for idx in range(3): + self._create_sim_file(round_dir, idx, None, is_draw=True) + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + assert stats.winner == RESULT_TIE + assert stats.scores[RESULT_TIE] == 3 + + def test_parse_results_tie_wins(self, arena, tmp_log_dir): + """Test parsing results when players have equal wins.""" + arena.game_config["sims_per_round"] = 4 + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_sim_file(round_dir, 0, "Alice") + self._create_sim_file(round_dir, 1, "Alice") + self._create_sim_file(round_dir, 2, "Bob") + self._create_sim_file(round_dir, 3, "Bob") + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + assert stats.winner == RESULT_TIE + assert stats.scores["Alice"] == 2 + assert stats.scores["Bob"] == 2 + + +class TestBattleSnakeConfig: + """Tests for BattleSnakeArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert BattleSnakeArena.name == "BattleSnake" + + def test_submission_file(self): + """Test that submission file is main.py.""" + assert BattleSnakeArena.submission == "main.py" + + def test_default_args(self): + """Test default arena arguments.""" + assert BattleSnakeArena.default_args["width"] == 11 + assert BattleSnakeArena.default_args["height"] == 11 + assert BattleSnakeArena.default_args["browser"] is False diff --git a/tests/arenas/test_corewar.py b/tests/arenas/test_corewar.py new file mode 100644 index 00000000..c06d6e6c --- /dev/null +++ b/tests/arenas/test_corewar.py @@ -0,0 +1,200 @@ +""" +Unit tests for CoreWarArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.corewar.corewar import COREWAR_LOG, CoreWarArena + +from .conftest import MockPlayer + +VALID_WARRIOR = """;redcode-94 +;name Imp +;author A. K. Dewdney +;strategy A simple imp that marches through memory. + +MOV 0, 1 +""" + + +class TestCoreWarValidation: + """Tests for CoreWarArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create CoreWarArena instance with mocked environment.""" + arena = CoreWarArena.__new__(CoreWarArena) + arena.submission = "warrior.red" + arena.log_local = tmp_log_dir + arena.run_cmd_round = "./src/pmars" + return arena + + def test_valid_submission(self, arena, mock_player_factory): + """Test that a valid warrior file passes validation.""" + player = mock_player_factory( + name="test_player", + files={"warrior.red": VALID_WARRIOR}, + command_outputs={ + "ls": {"output": "warrior.red\n", "returncode": 0}, + "./src/pmars warrior.red /home/dwarf.red": { + "output": "warrior.red by Imp scores 10\ndwarf.red by Dwarf scores 5", + "returncode": 0, + }, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_warrior_file(self, arena, mock_player_factory): + """Test that missing warrior.red fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "ls": {"output": "other.txt\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "warrior.red" in error + + def test_malformed_warrior_file(self, arena, mock_player_factory): + """Test that malformed warrior file fails validation.""" + player = mock_player_factory( + name="test_player", + files={"warrior.red": "invalid redcode syntax"}, + command_outputs={ + "ls": {"output": "warrior.red\n", "returncode": 0}, + "./src/pmars warrior.red /home/dwarf.red": { + "output": "Error: Invalid instruction at line 1\n", + "returncode": 0, # pmars returns 0 even on parse errors + }, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "malformed" in error.lower() + + +class TestCoreWarResults: + """Tests for CoreWarArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create CoreWarArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "CoreWar" + config["game"]["sims_per_round"] = 100 + arena = CoreWarArena.__new__(CoreWarArena) + arena.submission = "warrior.red" + arena.log_local = tmp_log_dir + arena.config = config + arena.logger = type( + "Logger", + (), + { + "debug": lambda self, msg: None, + "info": lambda self, msg: None, + "error": lambda self, msg: None, + }, + )() + return arena + + def _create_sim_log(self, round_dir, scores: list[tuple[str, str, int]]): + """ + Create a simulation log file. + + Args: + scores: List of (warrior_name, author, score) tuples + """ + log_file = round_dir / COREWAR_LOG + lines = ["pMARS 0.8.6\n", "Starting simulation...\n"] + for warrior_name, author, score in scores: + lines.append(f"{warrior_name} by {author} scores {score}\n") + log_file.write_text("".join(lines)) + + def test_parse_results_player1_wins(self, arena, tmp_log_dir): + """Test parsing results when player 1 has higher score.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_sim_log( + round_dir, + [ + ("alice_warrior.red", "Alice", 150), + ("bob_warrior.red", "Bob", 100), + ], + ) + + 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"] == 150 + assert stats.scores["Bob"] == 100 + + def test_parse_results_player2_wins(self, arena, tmp_log_dir): + """Test parsing results when player 2 has higher score.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_sim_log( + round_dir, + [ + ("alice_warrior.red", "Alice", 80), + ("bob_warrior.red", "Bob", 200), + ], + ) + + 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"] == 80 + assert stats.scores["Bob"] == 200 + + def test_parse_results_tie(self, arena, tmp_log_dir): + """Test parsing results when scores are equal.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_sim_log( + round_dir, + [ + ("alice_warrior.red", "Alice", 150), + ("bob_warrior.red", "Bob", 150), + ], + ) + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + # With equal scores, first player with max wins (implementation detail) + assert stats.scores["Alice"] == 150 + assert stats.scores["Bob"] == 150 + + +class TestCoreWarConfig: + """Tests for CoreWarArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert CoreWarArena.name == "CoreWar" + + def test_submission_file(self): + """Test that submission file is warrior.red.""" + assert CoreWarArena.submission == "warrior.red" + + def test_description_mentions_redcode(self): + """Test that description mentions Redcode language.""" + assert "redcode" in CoreWarArena.description.lower() diff --git a/tests/arenas/test_dummy.py b/tests/arenas/test_dummy.py new file mode 100644 index 00000000..a49f4e84 --- /dev/null +++ b/tests/arenas/test_dummy.py @@ -0,0 +1,169 @@ +""" +Unit tests for DummyArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.dummy.dummy import DummyArena + +from .conftest import MockPlayer + + +class TestDummyValidation: + """Tests for DummyArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create DummyArena instance with mocked environment.""" + config = minimal_config.copy() + config["game"]["name"] = "Dummy" + arena = DummyArena.__new__(DummyArena) + arena.submission = "main.py" + arena.log_local = tmp_log_dir + return arena + + def test_valid_submission(self, arena, mock_player_factory): + """Test that a valid Python submission passes validation.""" + player = mock_player_factory( + name="test_player", + files={"main.py": "print('hello world')"}, + command_outputs={ + "python -m py_compile main.py": {"output": "", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_submission_file(self, arena, mock_player_factory): + """Test validation with missing main.py - DummyArena accepts all submissions.""" + player = mock_player_factory( + name="test_player", + files={}, # No files + ) + is_valid, error = arena.validate_code(player) + # DummyArena is for testing infrastructure, so it accepts all submissions + assert is_valid is True + assert error is None + + def test_empty_submission_file(self, arena, mock_player_factory): + """Test validation with empty main.py - DummyArena accepts all submissions.""" + player = mock_player_factory( + name="test_player", + files={"main.py": ""}, # Empty file + ) + is_valid, error = arena.validate_code(player) + # DummyArena is for testing infrastructure, so it accepts all submissions + assert is_valid is True + assert error is None + + def test_syntax_error_in_submission(self, arena, mock_player_factory): + """Test validation with syntax errors - DummyArena accepts all submissions.""" + player = mock_player_factory( + name="test_player", + files={"main.py": "def broken(:\n pass"}, + ) + is_valid, error = arena.validate_code(player) + # DummyArena is for testing infrastructure, so it accepts all submissions + assert is_valid is True + assert error is None + + +class TestDummyResults: + """Tests for DummyArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create DummyArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "Dummy" + arena = DummyArena.__new__(DummyArena) + arena.submission = "main.py" + arena.log_local = tmp_log_dir + return arena + + def test_parse_results_player1_wins(self, arena, tmp_log_dir): + """Test parsing results when player 1 wins.""" + # Create round log directory and result file + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + result_file = round_dir / "result.log" + result_file.write_text( + """ +Running simulation... +FINAL_RESULTS +Bot_1_main: 7 rounds won +Bot_2_main: 3 rounds won +""" + ) + + 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"] == 7 + assert stats.scores["Bob"] == 3 + assert stats.player_stats["Alice"].score == 7 + assert stats.player_stats["Bob"].score == 3 + + def test_parse_results_player2_wins(self, arena, tmp_log_dir): + """Test parsing results when player 2 wins.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + result_file = round_dir / "result.log" + result_file.write_text( + """ +FINAL_RESULTS +Bot_1_main: 2 rounds won +Bot_2_main: 8 rounds won +""" + ) + + 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"] == 2 + assert stats.scores["Bob"] == 8 + + def test_parse_results_tie(self, arena, tmp_log_dir): + """Test parsing results when scores are equal.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + result_file = round_dir / "result.log" + result_file.write_text( + """ +FINAL_RESULTS +Bot_1_main: 5 rounds won +Bot_2_main: 5 rounds won +""" + ) + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + # With equal scores, max() returns the first one found + # The current implementation doesn't explicitly handle ties + assert stats.scores["Alice"] == 5 + assert stats.scores["Bob"] == 5 + + +class TestDummyConfig: + """Tests for DummyArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert DummyArena.name == "Dummy" + + def test_submission_file(self): + """Test that submission file is main.py.""" + assert DummyArena.submission == "main.py" diff --git a/tests/arenas/test_halite.py b/tests/arenas/test_halite.py new file mode 100644 index 00000000..80caf7bd --- /dev/null +++ b/tests/arenas/test_halite.py @@ -0,0 +1,245 @@ +""" +Unit tests for HaliteArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.halite.halite import ( + HALITE_HIDDEN_EXEC, + HALITE_LOG, + MAP_FILE_TYPE_TO_COMPILE, + MAP_FILE_TYPE_TO_RUN, + HaliteArena, +) +from codeclash.constants import RESULT_TIE + +from .conftest import MockPlayer + +VALID_C_BOT = """ +#include +int main() { + printf("MyBot ready\\n"); + return 0; +} +""" + +VALID_PY_BOT = """ +import sys +print("MyBot ready") +""" + + +class TestHaliteValidation: + """Tests for HaliteArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create HaliteArena instance with mocked environment.""" + arena = HaliteArena.__new__(HaliteArena) + arena.submission = "submission" + arena.log_local = tmp_log_dir + arena.run_cmd_round = "./environment/halite" + arena.logger = type("Logger", (), {"debug": lambda self, msg: None, "info": lambda self, msg: None})() + return arena + + def test_valid_py_submission(self, arena, mock_player_factory): + """Test that a valid Python submission passes validation.""" + player = mock_player_factory( + name="test_player", + files={ + "submission/main.py": VALID_PY_BOT, + }, + command_outputs={ + "test -d submission && echo 'exists'": {"output": "exists", "returncode": 0}, + "ls": {"output": "main.py\n", "returncode": 0}, + "./environment/halite": {"output": "Player #1 won", "returncode": 0}, + f'echo "python submission/main.py" > {HALITE_HIDDEN_EXEC}': {"output": "", "returncode": 0}, + }, + ) + player.environment.config.cwd = "/workspace" + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_submission_folder(self, arena, mock_player_factory): + """Test that missing submission folder fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "test -d submission && echo 'exists'": {"output": "", "returncode": 1}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "submission" in error.lower() + + def test_missing_main_file(self, arena, mock_player_factory): + """Test that missing main file fails validation.""" + player = mock_player_factory( + name="test_player", + files={"submission/other.py": "pass"}, + command_outputs={ + "test -d submission && echo 'exists'": {"output": "exists", "returncode": 0}, + "ls": {"output": "other.py\n", "returncode": 0}, + }, + ) + player.environment.config.cwd = "/workspace" + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "main" in error.lower() + + def test_multiple_main_files(self, arena, mock_player_factory): + """Test that multiple main files fail validation.""" + player = mock_player_factory( + name="test_player", + files={ + "submission/main.py": VALID_PY_BOT, + "submission/main.cpp": VALID_C_BOT, + }, + command_outputs={ + "test -d submission && echo 'exists'": {"output": "exists", "returncode": 0}, + "ls": {"output": "main.py\nmain.cpp\n", "returncode": 0}, + }, + ) + player.environment.config.cwd = "/workspace" + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "one" in error.lower() or "exactly" in error.lower() + + def test_compilation_failure(self, arena, mock_player_factory): + """Test that compilation failure fails validation.""" + player = mock_player_factory( + name="test_player", + files={"submission/main.cpp": "invalid c++ code"}, + command_outputs={ + "test -d submission && echo 'exists'": {"output": "exists", "returncode": 0}, + "ls": {"output": "main.cpp\n", "returncode": 0}, + "g++ -std=c++11 main.cpp -o main.o": { + "output": "error: invalid syntax", + "returncode": 1, + }, + }, + ) + player.environment.config.cwd = "/workspace" + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "compilation" in error.lower() or "failed" in error.lower() + + +class TestHaliteResults: + """Tests for HaliteArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create HaliteArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "Halite" + config["game"]["sims_per_round"] = 3 + arena = HaliteArena.__new__(HaliteArena) + arena.submission = "submission" + 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, results: list[tuple[int, str, int]]): + """ + Create a simulation log file. + + Args: + results: List of (player_num, name, rank) tuples + """ + log_file = round_dir / HALITE_LOG.format(idx=idx) + lines = ["Starting simulation...\n"] + for player_num, name, rank in results: + lines.append(f"Player #{player_num}, {name}, came in rank #{rank}\n") + log_file.write_text("".join(lines)) + + def test_parse_results_player1_wins(self, arena, tmp_log_dir): + """Test parsing results when player 1 wins most games.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Alice wins 2 games, Bob wins 1 + self._create_sim_log(round_dir, 0, [(1, "Alice", 1), (2, "Bob", 2)]) + self._create_sim_log(round_dir, 1, [(1, "Alice", 1), (2, "Bob", 2)]) + self._create_sim_log(round_dir, 2, [(1, "Alice", 2), (2, "Bob", 1)]) + + 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_player2_wins(self, arena, tmp_log_dir): + """Test parsing results when player 2 wins most games.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Alice wins 1 game, Bob wins 2 + self._create_sim_log(round_dir, 0, [(1, "Alice", 2), (2, "Bob", 1)]) + self._create_sim_log(round_dir, 1, [(1, "Alice", 2), (2, "Bob", 1)]) + self._create_sim_log(round_dir, 2, [(1, "Alice", 1), (2, "Bob", 2)]) + + 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_tie(self, arena, tmp_log_dir): + """Test parsing results when players have equal wins.""" + arena.config["game"]["sims_per_round"] = 4 + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Each player wins 2 games + self._create_sim_log(round_dir, 0, [(1, "Alice", 1), (2, "Bob", 2)]) + self._create_sim_log(round_dir, 1, [(1, "Alice", 1), (2, "Bob", 2)]) + self._create_sim_log(round_dir, 2, [(1, "Alice", 2), (2, "Bob", 1)]) + self._create_sim_log(round_dir, 3, [(1, "Alice", 2), (2, "Bob", 1)]) + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + assert stats.winner == RESULT_TIE + assert stats.scores["Alice"] == 2 + assert stats.scores["Bob"] == 2 + + +class TestHaliteConfig: + """Tests for HaliteArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert HaliteArena.name == "Halite" + + def test_submission_folder(self): + """Test that submission folder is correct.""" + assert HaliteArena.submission == "submission" + + def test_supported_file_types(self): + """Test that expected file types are supported.""" + supported = set(MAP_FILE_TYPE_TO_RUN.keys()) + assert ".py" in supported + assert ".cpp" in supported + assert ".c" in supported + + def test_compilable_languages(self): + """Test that compilable languages have compile commands.""" + assert ".cpp" in MAP_FILE_TYPE_TO_COMPILE + assert ".c" in MAP_FILE_TYPE_TO_COMPILE + # Python shouldn't need compilation + assert ".py" not in MAP_FILE_TYPE_TO_COMPILE diff --git a/tests/arenas/test_huskybench.py b/tests/arenas/test_huskybench.py new file mode 100644 index 00000000..4a8fbaab --- /dev/null +++ b/tests/arenas/test_huskybench.py @@ -0,0 +1,218 @@ +""" +Unit tests for HuskyBenchArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +from unittest.mock import patch + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.huskybench.huskybench import ( + HB_LOG_ENGINE, + HB_PORT, + HB_REGEX_SCORE, + HB_SCRIPT, + HuskyBenchArena, +) + +from .conftest import MockPlayer + +VALID_PLAYER_PY = """ +from client.base_player import BasePlayer + +class Player(BasePlayer): + def get_action(self, game_state): + return 'fold' +""" + + +class TestHuskyBenchValidation: + """Tests for HuskyBenchArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create HuskyBenchArena instance with mocked environment.""" + from pathlib import Path + + config = minimal_config.copy() + config["game"]["sims_per_round"] = 10 + arena = HuskyBenchArena.__new__(HuskyBenchArena) + arena.submission = "client/player.py" + arena.log_local = tmp_log_dir + arena.log_env = Path("/logs") # Container log path + arena.config = config + arena.num_players = 2 + arena.run_engine = f"python engine/main.py --port {HB_PORT} --players 2 --sim --sim-rounds 10" + arena.logger = type("Logger", (), {"debug": lambda self, msg: None, "info": lambda self, msg: None})() + return arena + + @patch("codeclash.arenas.huskybench.huskybench.create_file_in_container") + def test_valid_submission(self, mock_create_file, arena, mock_player_factory): + """Test that a valid player.py passes validation.""" + player = mock_player_factory( + name="test_player", + files={ + "client/main.py": "# client main", + "client/player.py": VALID_PLAYER_PY, + }, + command_outputs={ + "ls client": {"output": "main.py\nplayer.py\n", "returncode": 0}, + f"chmod +x {HB_SCRIPT}; ./{HB_SCRIPT}": {"output": "", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_main_file(self, arena, mock_player_factory): + """Test that missing client/main.py fails validation.""" + player = mock_player_factory( + name="test_player", + files={"client/player.py": VALID_PLAYER_PY}, + command_outputs={ + "ls client": {"output": "player.py\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "main.py" in error + + def test_missing_player_file(self, arena, mock_player_factory): + """Test that missing client/player.py fails validation.""" + player = mock_player_factory( + name="test_player", + files={"client/main.py": "# main"}, + command_outputs={ + "ls client": {"output": "main.py\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "player.py" in error + + +class TestHuskyBenchResults: + """Tests for HuskyBenchArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create HuskyBenchArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "HuskyBench" + config["game"]["sims_per_round"] = 10 + arena = HuskyBenchArena.__new__(HuskyBenchArena) + arena.submission = "client/player.py" + arena.log_local = tmp_log_dir + arena.config = config + arena.num_players = 2 + arena.logger = type("Logger", (), {"debug": lambda self, msg: None, "info": lambda self, msg: None})() + return arena + + def _create_player_log(self, round_dir, player_name: str, player_id: str): + """Create a player log file with connection info.""" + log_file = round_dir / f"{player_name}.log" + log_file.write_text(f"Starting client...\nConnected with player ID: {player_id}\nGame started.\n") + + def _create_engine_log(self, round_dir, scores: list[tuple[str, int]]): + """ + Create engine log file with final scores. + + Args: + scores: List of (player_id, final_money) tuples + """ + log_file = round_dir / HB_LOG_ENGINE + lines = ["Engine starting...\n", "Game initialized.\n"] + for player_id, money in scores: + lines.append(f"Player {player_id} delta updated: +100 - 50 = 50, money: 1000 -> {money}\n") + log_file.write_text("".join(lines)) + + def test_parse_results_player1_wins(self, arena, tmp_log_dir): + """Test parsing results when player 1 has more chips.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Create player logs with their IDs + self._create_player_log(round_dir, "Alice", "1") + self._create_player_log(round_dir, "Bob", "2") + + # Create engine log with final scores + self._create_engine_log(round_dir, [("1", 1500), ("2", 500)]) + + 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"] == 1500 + assert stats.scores["Bob"] == 500 + + def test_parse_results_player2_wins(self, arena, tmp_log_dir): + """Test parsing results when player 2 has more chips.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_player_log(round_dir, "Alice", "1") + self._create_player_log(round_dir, "Bob", "2") + self._create_engine_log(round_dir, [("1", 300), ("2", 1700)]) + + 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"] == 300 + assert stats.scores["Bob"] == 1700 + + +class TestHuskyBenchRegex: + """Tests for the score parsing regex.""" + + def test_regex_matches_score_line(self): + """Test that the score regex correctly parses a score line.""" + line = "Player 1 delta updated: +100 - 50 = 50, money: 1000 -> 1050" + match = HB_REGEX_SCORE.search(line) + assert match is not None + assert match.group(1) == "1" # Player ID + assert match.group(2) == "1050" # Final money + + def test_regex_matches_multiple_digits(self): + """Test regex with larger numbers.""" + line = "Player 42 delta updated: +500 - 200 = 300, money: 10000 -> 15000" + match = HB_REGEX_SCORE.search(line) + assert match is not None + assert match.group(1) == "42" + assert match.group(2) == "15000" + + def test_regex_does_not_match_other_lines(self): + """Test that regex doesn't match non-score lines.""" + lines = [ + "Game started", + "Player 1 folded", + "Round complete", + ] + for line in lines: + assert HB_REGEX_SCORE.search(line) is None + + +class TestHuskyBenchConfig: + """Tests for HuskyBenchArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert HuskyBenchArena.name == "HuskyBench" + + def test_submission_path(self): + """Test that submission path is correct.""" + assert HuskyBenchArena.submission == "client/player.py" + + def test_port_constant(self): + """Test that port constant is defined.""" + assert HB_PORT == 8000 + + def test_description_mentions_poker(self): + """Test that description mentions poker.""" + assert "poker" in HuskyBenchArena.description.lower() diff --git a/tests/arenas/test_robocode.py b/tests/arenas/test_robocode.py new file mode 100644 index 00000000..31e232d9 --- /dev/null +++ b/tests/arenas/test_robocode.py @@ -0,0 +1,255 @@ +""" +Unit tests for RoboCodeArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.robocode.robocode import RC_FILE, SIMS_PER_RUN, RoboCodeArena + +from .conftest import MockPlayer + +VALID_JAVA_TANK = """ +package custom; + +import robocode.*; + +public class MyTank extends Robot { + public void run() { + while (true) { + ahead(100); + turnGunRight(360); + back(100); + turnGunRight(360); + } + } + + public void onScannedRobot(ScannedRobotEvent e) { + fire(1); + } +} +""" + + +class TestRoboCodeValidation: + """Tests for RoboCodeArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create RoboCodeArena instance with mocked environment.""" + arena = RoboCodeArena.__new__(RoboCodeArena) + arena.submission = "robots/custom/" + arena.log_local = tmp_log_dir + arena.run_cmd_round = "./robocode.sh -nodisplay -nosound" + return arena + + def test_valid_submission(self, arena, mock_player_factory): + """Test that a valid Java tank passes validation.""" + player = mock_player_factory( + name="test_player", + files={ + "robots/custom/MyTank.java": VALID_JAVA_TANK, + }, + command_outputs={ + "ls": {"output": "robots\n", "returncode": 0}, + "ls robots": {"output": "custom\n", "returncode": 0}, + "ls robots/custom": {"output": "MyTank.java\n", "returncode": 0}, + 'javac -cp "libs/robocode.jar" robots/custom/*.java': { + "output": "", + "returncode": 0, + }, + }, + ) + # After compilation, class file exists + player.environment.command_outputs["ls robots/custom"] = { + "output": "MyTank.java\nMyTank.class\n", + "returncode": 0, + } + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_robots_directory(self, arena, mock_player_factory): + """Test that missing robots/ directory fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "ls": {"output": "src\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "robots" in error.lower() + + def test_missing_custom_directory(self, arena, mock_player_factory): + """Test that missing robots/custom/ directory fails validation.""" + player = mock_player_factory( + name="test_player", + files={"robots/sample/Sample.java": "public class Sample {}"}, + command_outputs={ + "ls": {"output": "robots\n", "returncode": 0}, + "ls robots": {"output": "sample\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "custom" in error.lower() + + def test_missing_mytank_file(self, arena, mock_player_factory): + """Test that missing MyTank.java fails validation.""" + player = mock_player_factory( + name="test_player", + files={"robots/custom/OtherBot.java": "public class OtherBot {}"}, + command_outputs={ + "ls": {"output": "robots\n", "returncode": 0}, + "ls robots": {"output": "custom\n", "returncode": 0}, + "ls robots/custom": {"output": "OtherBot.java\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "MyTank.java" in error + + def test_compilation_failure(self, arena, mock_player_factory): + """Test that Java compilation failure fails validation.""" + player = mock_player_factory( + name="test_player", + files={"robots/custom/MyTank.java": "invalid java code {{{"}, + command_outputs={ + "ls": {"output": "robots\n", "returncode": 0}, + "ls robots": {"output": "custom\n", "returncode": 0}, + "ls robots/custom": {"output": "MyTank.java\n", "returncode": 0}, + 'javac -cp "libs/robocode.jar" robots/custom/*.java': { + "output": "error: class, interface, or enum expected", + "returncode": 1, + }, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "compilation" in error.lower() + + +class TestRoboCodeResults: + """Tests for RoboCodeArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create RoboCodeArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "RoboCode" + config["game"]["sims_per_round"] = 20 # 2 * SIMS_PER_RUN + arena = RoboCodeArena.__new__(RoboCodeArena) + arena.submission = "robots/custom/" + 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_results_file(self, round_dir, idx: int, results: list[tuple[int, str, int]]): + """ + Create a results file. + + Args: + results: List of (rank, bot_name, total_score) tuples + """ + results_file = round_dir / f"results_{idx}.txt" + lines = ["Results:\n", "BattlefieldWidth, 800\n", "BattlefieldHeight, 600\n"] + for rank, bot_name, total_score in results: + lines.append(f"{rank}st: {bot_name} {total_score}\n") + results_file.write_text("".join(lines)) + + def test_parse_results_player1_wins(self, arena, tmp_log_dir): + """Test parsing results when player 1 has higher total score.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Create results files for 2 simulations (20 sims / SIMS_PER_RUN=10 = 2) + self._create_results_file( + round_dir, + 0, + [ + (1, "Alice.MyTank", 5000), + (2, "Bob.MyTank", 3000), + ], + ) + self._create_results_file( + round_dir, + 1, + [ + (1, "Alice.MyTank", 4500), + (2, "Bob.MyTank", 3500), + ], + ) + + 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"] == 9500 + assert stats.scores["Bob"] == 6500 + + def test_parse_results_player2_wins(self, arena, tmp_log_dir): + """Test parsing results when player 2 has higher total score.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_results_file( + round_dir, + 0, + [ + (2, "Alice.MyTank", 2000), + (1, "Bob.MyTank", 5000), + ], + ) + self._create_results_file( + round_dir, + 1, + [ + (2, "Alice.MyTank", 2500), + (1, "Bob.MyTank", 4500), + ], + ) + + 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"] == 4500 + assert stats.scores["Bob"] == 9500 + + +class TestRoboCodeConfig: + """Tests for RoboCodeArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert RoboCodeArena.name == "RoboCode" + + def test_submission_path(self): + """Test that submission path is correct.""" + assert RoboCodeArena.submission == "robots/custom/" + + def test_main_file(self): + """Test that main file is MyTank.java.""" + assert str(RC_FILE) == "MyTank.java" + + def test_sims_per_run(self): + """Test that simulations per run is configured.""" + assert SIMS_PER_RUN == 10 + + def test_default_args(self): + """Test default arguments include nodisplay and nosound.""" + assert RoboCodeArena.default_args.get("nodisplay") is True + assert RoboCodeArena.default_args.get("nosound") is True + + def test_description_mentions_java(self): + """Test that description mentions Java as the language.""" + assert "java" in RoboCodeArena.description.lower() diff --git a/tests/arenas/test_robotrumble.py b/tests/arenas/test_robotrumble.py new file mode 100644 index 00000000..53892944 --- /dev/null +++ b/tests/arenas/test_robotrumble.py @@ -0,0 +1,264 @@ +""" +Unit tests for RobotRumbleArena. + +Tests validate_code() and get_results() methods without requiring Docker. +""" + +import json + +import pytest + +from codeclash.arenas.arena import RoundStats +from codeclash.arenas.robotrumble.robotrumble import ( + MAP_EXT_TO_HEADER, + ROBOTRUMBLE_HIDDEN_EXEC, + RobotRumbleArena, +) +from codeclash.constants import RESULT_TIE + +from .conftest import MockPlayer + +VALID_JS_ROBOT = """ +function robot(state, unit) { + if (state.turn % 2 === 0) { + return Action.move(Direction.East); + } + return Action.attack(Direction.South); +} +""" + +VALID_PY_ROBOT = """ +def robot(state, unit): + if state.turn % 2 == 0: + return Action.move(Direction.East) + return Action.attack(Direction.South) +""" + + +class TestRobotRumbleValidation: + """Tests for RobotRumbleArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create RobotRumbleArena instance with mocked environment.""" + arena = RobotRumbleArena.__new__(RobotRumbleArena) + arena.submission = "robot.js" + arena.log_local = tmp_log_dir + arena.run_cmd_round = "./rumblebot run term --raw" + return arena + + def test_valid_js_submission(self, arena, mock_player_factory): + """Test that a valid JavaScript robot passes validation.""" + player = mock_player_factory( + name="test_player", + files={"robot.js": VALID_JS_ROBOT}, + command_outputs={ + "test -f robot.js && echo 'exists'": {"output": "exists", "returncode": 0}, + "cat robot.js": {"output": VALID_JS_ROBOT, "returncode": 0}, + f'echo "robot.js" > {ROBOTRUMBLE_HIDDEN_EXEC}': {"output": "", "returncode": 0}, + "./rumblebot run term --raw robot.js robot.js -t 1": { + "output": "Blue won", + "returncode": 0, + }, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_valid_py_submission(self, arena, mock_player_factory): + """Test that a valid Python robot passes validation.""" + player = mock_player_factory( + name="test_player", + files={"robot.py": VALID_PY_ROBOT}, + command_outputs={ + "test -f robot.js && echo 'exists'": {"output": "", "returncode": 1}, + "test -f robot.py && echo 'exists'": {"output": "exists", "returncode": 0}, + "cat robot.py": {"output": VALID_PY_ROBOT, "returncode": 0}, + f'echo "robot.py" > {ROBOTRUMBLE_HIDDEN_EXEC}': {"output": "", "returncode": 0}, + "./rumblebot run term --raw robot.py robot.py -t 1": { + "output": "Blue won", + "returncode": 0, + }, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_robot_file(self, arena, mock_player_factory): + """Test that missing robot file fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "test -f robot.js && echo 'exists'": {"output": "", "returncode": 1}, + "test -f robot.py && echo 'exists'": {"output": "", "returncode": 1}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "robot.js" in error or "robot.py" in error + + def test_missing_robot_function_js(self, arena, mock_player_factory): + """Test that missing robot function in JS fails validation.""" + invalid_code = "function notRobot(state, unit) { return null; }" + player = mock_player_factory( + name="test_player", + files={"robot.js": invalid_code}, + command_outputs={ + "test -f robot.js && echo 'exists'": {"output": "exists", "returncode": 0}, + "cat robot.js": {"output": invalid_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "robot function" in error.lower() + + def test_missing_robot_function_py(self, arena, mock_player_factory): + """Test that missing robot function in Python fails validation.""" + invalid_code = "def not_robot(state, unit):\n return None" + player = mock_player_factory( + name="test_player", + files={"robot.py": invalid_code}, + command_outputs={ + "test -f robot.js && echo 'exists'": {"output": "", "returncode": 1}, + "test -f robot.py && echo 'exists'": {"output": "exists", "returncode": 0}, + "cat robot.py": {"output": invalid_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "robot function" in error.lower() + + +class TestRobotRumbleResults: + """Tests for RobotRumbleArena.get_results()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create RobotRumbleArena instance.""" + config = minimal_config.copy() + config["game"]["name"] = "RobotRumble" + config["game"]["sims_per_round"] = 5 + config["game"]["args"] = {"raw": True} + arena = RobotRumbleArena.__new__(RobotRumbleArena) + arena.submission = "robot.js" + arena.log_local = tmp_log_dir + arena.config = config + arena.sim_ext = "json" + arena.logger = type("Logger", (), {"warning": lambda self, msg: None, "info": lambda self, msg: None})() + return arena + + def _create_json_sim_file(self, round_dir, idx: int, winner: str): + """Helper to create a JSON simulation result file.""" + sim_file = round_dir / f"sim_{idx}.json" + result = {"winner": winner} # "Blue", "Red", or "Tie" + sim_file.write_text(json.dumps(result)) + + def _create_txt_sim_file(self, round_dir, idx: int, winner_text: str): + """Helper to create a TXT simulation result file.""" + sim_file = round_dir / f"sim_{idx}.txt" + sim_file.write_text(f"Turn 100\n{winner_text}\n") + + def test_parse_json_results_blue_wins(self, arena, tmp_log_dir): + """Test parsing JSON results when Blue (player 1) wins more.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Blue (Alice) wins 3, Red (Bob) wins 2 + self._create_json_sim_file(round_dir, 0, "Blue") + self._create_json_sim_file(round_dir, 1, "Blue") + self._create_json_sim_file(round_dir, 2, "Blue") + self._create_json_sim_file(round_dir, 3, "Red") + self._create_json_sim_file(round_dir, 4, "Red") + + 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"] == 3 + assert stats.scores["Bob"] == 2 + + def test_parse_json_results_red_wins(self, arena, tmp_log_dir): + """Test parsing JSON results when Red (player 2) wins more.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + # Blue (Alice) wins 1, Red (Bob) wins 4 + self._create_json_sim_file(round_dir, 0, "Blue") + self._create_json_sim_file(round_dir, 1, "Red") + self._create_json_sim_file(round_dir, 2, "Red") + self._create_json_sim_file(round_dir, 3, "Red") + self._create_json_sim_file(round_dir, 4, "Red") + + 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"] == 4 + + def test_parse_json_results_tie(self, arena, tmp_log_dir): + """Test parsing JSON results with ties.""" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_json_sim_file(round_dir, 0, "Blue") + self._create_json_sim_file(round_dir, 1, "Blue") + self._create_json_sim_file(round_dir, 2, "Red") + self._create_json_sim_file(round_dir, 3, "Red") + self._create_json_sim_file(round_dir, 4, "Tie") + + agents = [MockPlayer("Alice"), MockPlayer("Bob")] + stats = RoundStats(round_num=1, agents=agents) + + arena.get_results(agents, round_num=1, stats=stats) + + assert stats.winner == RESULT_TIE + assert stats.scores["Alice"] == 2 + assert stats.scores["Bob"] == 2 + + def test_parse_txt_results(self, arena, tmp_log_dir): + """Test parsing TXT format results.""" + arena.sim_ext = "txt" + round_dir = tmp_log_dir / "rounds" / "1" + round_dir.mkdir(parents=True) + + self._create_txt_sim_file(round_dir, 0, "Blue won") + self._create_txt_sim_file(round_dir, 1, "Blue won") + self._create_txt_sim_file(round_dir, 2, "Red won") + self._create_txt_sim_file(round_dir, 3, "it was a tie") + self._create_txt_sim_file(round_dir, 4, "Blue won") + + 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"] == 3 + assert stats.scores["Bob"] == 1 + + +class TestRobotRumbleConfig: + """Tests for RobotRumbleArena configuration and properties.""" + + def test_arena_name(self): + """Test that arena has correct name.""" + assert RobotRumbleArena.name == "RobotRumble" + + def test_submission_file(self): + """Test that submission file is robot.js.""" + assert RobotRumbleArena.submission == "robot.js" + + def test_supported_extensions(self): + """Test that both JS and Python are supported.""" + assert "js" in MAP_EXT_TO_HEADER + assert "py" in MAP_EXT_TO_HEADER + assert "function robot(state, unit) {" in MAP_EXT_TO_HEADER["js"] + assert "def robot(state, unit):" in MAP_EXT_TO_HEADER["py"]