| """Unit tests for alternating_loop orchestrator — CPU-only, no GPU.""" |
| from __future__ import annotations |
|
|
| import sys |
| from pathlib import Path |
| from unittest.mock import patch, call |
|
|
| import pytest |
|
|
| from sre_arena_env.training.alternating_loop import ( |
| load_config, |
| get_opponent_checkpoint, |
| _role_for_gen, |
| run_alternating_loop, |
| ) |
|
|
| _CONFIGS = Path(__file__).parent.parent / "configs" |
|
|
|
|
| class TestLoadConfig: |
| def test_colab_demo_has_num_generations(self): |
| cfg = load_config(str(_CONFIGS / "colab_demo.yaml")) |
| assert cfg["num_generations"] == 3 |
|
|
| def test_missing_file_raises(self): |
| with pytest.raises(FileNotFoundError): |
| load_config("nonexistent_config.yaml") |
|
|
|
|
| class TestGetOpponentCheckpoint: |
| def test_gen0_returns_none(self): |
| assert get_opponent_checkpoint(0, "defender", "./checkpoints/base") is None |
|
|
| def test_gen1_attacker_training_returns_defender_gen0_path(self): |
| result = get_opponent_checkpoint(1, "attacker", "./checkpoints/base") |
| assert result == "./checkpoints/base_defender_gen0" |
|
|
| def test_gen2_defender_training_returns_attacker_gen1_path(self): |
| result = get_opponent_checkpoint(2, "defender", "./checkpoints/base") |
| assert result == "./checkpoints/base_attacker_gen1" |
|
|
| def test_gen1_defender_returns_attacker_gen0_path(self): |
| result = get_opponent_checkpoint(1, "defender", "./checkpoints/base") |
| assert result == "./checkpoints/base_attacker_gen0" |
|
|
|
|
| class TestRoleForGen: |
| def test_per_gen_list_respected(self): |
| per_gen = [{"role": "defender"}, {"role": "attacker"}, {"role": "defender"}] |
| assert _role_for_gen(0, per_gen) == "defender" |
| assert _role_for_gen(1, per_gen) == "attacker" |
| assert _role_for_gen(2, per_gen) == "defender" |
|
|
| def test_fallback_even_odd_when_empty_list(self): |
| assert _role_for_gen(0, []) == "defender" |
| assert _role_for_gen(1, []) == "attacker" |
| assert _role_for_gen(4, []) == "defender" |
|
|
|
|
| class TestRunAlternatingLoop: |
| def _cfg(self) -> dict: |
| return { |
| "num_generations": 3, |
| "per_generation": [ |
| {"role": "defender", "episodes": 20}, |
| {"role": "attacker", "episodes": 20}, |
| {"role": "defender", "episodes": 20}, |
| ], |
| "training": {"output_dir": "./checkpoints/base"}, |
| } |
|
|
| def test_dry_run_never_calls_training_functions(self): |
| with patch("sre_arena_env.training.alternating_loop.train_defender") as td, \ |
| patch("sre_arena_env.training.alternating_loop.train_attacker") as ta: |
| run_alternating_loop(self._cfg(), dry_run=True) |
| td.assert_not_called() |
| ta.assert_not_called() |
|
|
| def test_correct_role_call_counts(self): |
| with patch("sre_arena_env.training.alternating_loop.train_defender", return_value="ckpt_d") as td, \ |
| patch("sre_arena_env.training.alternating_loop.train_attacker", return_value="ckpt_a") as ta: |
| run_alternating_loop(self._cfg(), dry_run=False) |
| assert td.call_count == 2 |
| assert ta.call_count == 1 |
|
|
| def test_gen0_gets_none_opponent_checkpoint(self): |
| with patch("sre_arena_env.training.alternating_loop.train_defender", return_value="ckpt") as td, \ |
| patch("sre_arena_env.training.alternating_loop.train_attacker", return_value="ckpt"): |
| run_alternating_loop(self._cfg(), dry_run=False) |
| first_call = td.call_args_list[0] |
| assert first_call.kwargs["opponent_checkpoint"] is None |
| assert first_call.kwargs["gen_idx"] == 0 |
|
|
| def test_gen1_attacker_gets_defender_gen0_checkpoint(self): |
| with patch("sre_arena_env.training.alternating_loop.train_defender", return_value="ckpt"), \ |
| patch("sre_arena_env.training.alternating_loop.train_attacker", return_value="ckpt") as ta: |
| run_alternating_loop(self._cfg(), dry_run=False) |
| first_ta_call = ta.call_args_list[0] |
| assert first_ta_call.kwargs["opponent_checkpoint"] == "./checkpoints/base_defender_gen0" |
| assert first_ta_call.kwargs["gen_idx"] == 1 |
|
|
| def test_dry_run_cli_exits_cleanly(self): |
| import subprocess |
| result = subprocess.run( |
| [ |
| sys.executable, "-m", "training.alternating_loop", |
| "--config", str(_CONFIGS / "colab_demo.yaml"), |
| "--dry-run", |
| ], |
| cwd=str(Path(__file__).parent.parent), |
| capture_output=True, |
| text=True, |
| ) |
| assert result.returncode == 0, f"stderr: {result.stderr}" |
|
|