openra-rl / tests /test_config.py
github-actions[bot]
Sync from GitHub ac82c3e
02f4a63
"""Tests for the unified configuration system."""
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from openra_env.config import (
TOOL_CATEGORIES,
AlertPromptsConfig,
AlertsConfig,
AgentConfig,
CompressionConfig,
GameConfig,
LLMConfig,
OpenRARLConfig,
OpponentConfig,
PlanningConfig,
PromptsConfig,
RewardConfig,
RewardVectorConfig,
ToolCategoriesConfig,
ToolsConfig,
_coerce_value,
_deep_merge,
_set_nested,
load_config,
should_register_tool,
)
# โ”€โ”€ Default Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDefaults:
def test_default_config_has_sane_values(self):
cfg = OpenRARLConfig()
assert cfg.game.mod == "ra"
assert cfg.game.grpc_port == 9999
assert cfg.opponent.bot_type == "easy"
assert cfg.planning.enabled is True
assert cfg.reward.victory == 1.0
assert cfg.llm.model == "qwen/qwen3-coder-next"
assert cfg.agent.max_time_s == 1800
def test_all_tool_categories_enabled_by_default(self):
cfg = OpenRARLConfig()
cats = cfg.tools.categories
for field in ToolCategoriesConfig.model_fields:
assert getattr(cats, field) is True, f"Category {field} should default to True"
def test_all_alerts_enabled_by_default(self):
cfg = OpenRARLConfig()
for field in AlertsConfig.model_fields:
if field == "max_alerts":
continue # max_alerts is an int, not a bool toggle
assert getattr(cfg.alerts, field) is True, f"Alert {field} should default to True"
def test_disabled_tools_list_empty_by_default(self):
cfg = OpenRARLConfig()
assert cfg.tools.disabled == []
def test_load_config_no_file_returns_defaults(self):
"""load_config() with no file and no env vars should return defaults."""
with _clean_env():
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.game.mod == "ra"
assert cfg.llm.base_url == "https://openrouter.ai/api/v1/chat/completions"
# โ”€โ”€ YAML Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestYAMLLoading:
def test_load_from_yaml(self):
data = {"game": {"mod": "cnc", "grpc_port": 5555}, "opponent": {"bot_type": "hard"}}
with _temp_yaml(data) as path, _clean_env():
cfg = load_config(config_path=path)
assert cfg.game.mod == "cnc"
assert cfg.game.grpc_port == 5555
assert cfg.opponent.bot_type == "hard"
# Unspecified fields keep defaults
assert cfg.game.map_name == "singles.oramap"
def test_partial_yaml_merges_with_defaults(self):
data = {"reward": {"victory": 5.0}}
with _temp_yaml(data) as path, _clean_env():
cfg = load_config(config_path=path)
assert cfg.reward.victory == 5.0
assert cfg.reward.defeat == -1.0 # default preserved
def test_empty_yaml_returns_defaults(self):
with _temp_yaml({}) as path, _clean_env():
cfg = load_config(config_path=path)
assert cfg.game.mod == "ra"
def test_llm_config_from_yaml(self):
data = {
"llm": {
"base_url": "http://localhost:11434/v1/chat/completions",
"model": "llama3.1:70b",
"api_key": "",
"extra_headers": {},
}
}
with _temp_yaml(data) as path, _clean_env():
cfg = load_config(config_path=path)
assert cfg.llm.base_url == "http://localhost:11434/v1/chat/completions"
assert cfg.llm.model == "llama3.1:70b"
assert cfg.llm.api_key == ""
assert cfg.llm.extra_headers == {}
# โ”€โ”€ Environment Variable Precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestEnvVarPrecedence:
def test_env_var_overrides_yaml(self):
data = {"opponent": {"bot_type": "easy"}}
with _temp_yaml(data) as path:
with _clean_env(BOT_TYPE="hard"):
cfg = load_config(config_path=path)
assert cfg.opponent.bot_type == "hard"
def test_env_var_overrides_default(self):
with _clean_env(BOT_TYPE="hard"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.opponent.bot_type == "hard"
def test_openra_path_env(self):
with _clean_env(OPENRA_PATH="/custom/openra"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.game.openra_path == "/custom/openra"
def test_planning_enabled_env(self):
with _clean_env(PLANNING_ENABLED="false"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.planning.enabled is False
def test_record_replays_env(self):
with _clean_env(RECORD_REPLAYS="yes"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.game.record_replays is True
def test_openrouter_api_key_env(self):
with _clean_env(OPENROUTER_API_KEY="sk-test-123"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.llm.api_key == "sk-test-123"
def test_llm_api_key_overrides_openrouter(self):
"""LLM_API_KEY should take precedence over OPENROUTER_API_KEY."""
with _clean_env(OPENROUTER_API_KEY="sk-old", LLM_API_KEY="sk-new"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.llm.api_key == "sk-new"
def test_llm_base_url_env(self):
with _clean_env(LLM_BASE_URL="http://localhost:1234/v1/chat/completions"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.llm.base_url == "http://localhost:1234/v1/chat/completions"
def test_llm_model_env(self):
with _clean_env(LLM_MODEL="my-local-model"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.llm.model == "my-local-model"
def test_max_time_env(self):
with _clean_env(MAX_TIME="3600"):
cfg = load_config(config_path="__nonexistent__.yaml")
assert cfg.agent.max_time_s == 3600
# โ”€โ”€ Constructor Override Precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestOverridePrecedence:
def test_overrides_beat_yaml(self):
data = {"game": {"mod": "cnc"}}
with _temp_yaml(data) as path, _clean_env():
cfg = load_config(config_path=path, game={"mod": "d2k"})
assert cfg.game.mod == "d2k"
def test_env_beats_overrides(self):
with _clean_env(BOT_TYPE="hard"):
cfg = load_config(config_path="__nonexistent__.yaml", opponent={"bot_type": "easy"})
assert cfg.opponent.bot_type == "hard"
def test_cli_overrides_beat_env(self):
"""Explicit CLI flags should beat environment variables."""
with _clean_env(OPENROUTER_MODEL="env-model"):
cfg = load_config(
config_path="__nonexistent__.yaml",
cli_overrides={"llm": {"model": "cli-model"}},
)
assert cfg.llm.model == "cli-model"
def test_cli_overrides_beat_yaml_and_env(self):
data = {"llm": {"model": "yaml-model"}}
with _temp_yaml(data) as path, _clean_env(LLM_MODEL="env-model"):
cfg = load_config(
config_path=path,
cli_overrides={"llm": {"model": "cli-model"}},
)
assert cfg.llm.model == "cli-model"
# โ”€โ”€ Boolean Coercion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestCoercion:
@pytest.mark.parametrize("val,expected", [
("true", True), ("True", True), ("TRUE", True),
("1", True), ("yes", True), ("Yes", True),
("false", False), ("False", False), ("FALSE", False),
("0", False), ("no", False), ("No", False),
])
def test_bool_coercion(self, val, expected):
assert _coerce_value(val) is expected
def test_int_coercion(self):
assert _coerce_value("42") == 42
assert isinstance(_coerce_value("42"), int)
def test_float_coercion(self):
assert _coerce_value("3.14") == 3.14
assert isinstance(_coerce_value("3.14"), float)
def test_string_passthrough(self):
assert _coerce_value("hello") == "hello"
# โ”€โ”€ Deep Merge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDeepMerge:
def test_simple_merge(self):
base = {"a": 1, "b": 2}
_deep_merge(base, {"b": 3, "c": 4})
assert base == {"a": 1, "b": 3, "c": 4}
def test_nested_merge(self):
base = {"game": {"mod": "ra", "port": 9999}}
_deep_merge(base, {"game": {"mod": "cnc"}})
assert base == {"game": {"mod": "cnc", "port": 9999}}
def test_override_replaces_non_dict(self):
base = {"a": {"b": 1}}
_deep_merge(base, {"a": "flat"})
assert base == {"a": "flat"}
class TestSetNested:
def test_single_level(self):
d: dict = {}
_set_nested(d, "key", "val")
assert d == {"key": "val"}
def test_multi_level(self):
d: dict = {}
_set_nested(d, "game.mod", "cnc")
assert d == {"game": {"mod": "cnc"}}
def test_preserves_siblings(self):
d = {"game": {"mod": "ra", "port": 9999}}
_set_nested(d, "game.mod", "cnc")
assert d == {"game": {"mod": "cnc", "port": 9999}}
# โ”€โ”€ Tool Filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestToolFiltering:
def test_all_tools_enabled_by_default(self):
cfg = ToolsConfig()
for tool_name in TOOL_CATEGORIES:
assert should_register_tool(tool_name, cfg) is True
def test_disable_category(self):
cfg = ToolsConfig(categories=ToolCategoriesConfig(knowledge=False))
assert should_register_tool("lookup_unit", cfg) is False
assert should_register_tool("lookup_building", cfg) is False
assert should_register_tool("lookup_tech_tree", cfg) is False
assert should_register_tool("lookup_faction", cfg) is False
# Other categories unaffected
assert should_register_tool("advance", cfg) is True
assert should_register_tool("move_units", cfg) is True
def test_disable_individual_tool(self):
cfg = ToolsConfig(disabled=["surrender", "sell_building"])
assert should_register_tool("surrender", cfg) is False
assert should_register_tool("sell_building", cfg) is False
# Other utility tools still enabled
assert should_register_tool("get_replay_path", cfg) is True
def test_disabled_list_overrides_category_enable(self):
cfg = ToolsConfig(
categories=ToolCategoriesConfig(movement=True),
disabled=["move_units"],
)
assert should_register_tool("move_units", cfg) is False
assert should_register_tool("attack_move", cfg) is True
def test_unknown_tool_defaults_to_enabled(self):
cfg = ToolsConfig()
assert should_register_tool("some_future_tool", cfg) is True
def test_all_tools_have_categories(self):
"""Every tool in TOOL_CATEGORIES should map to a valid category field."""
valid_categories = set(ToolCategoriesConfig.model_fields.keys())
for tool_name, category in TOOL_CATEGORIES.items():
assert category in valid_categories, f"Tool {tool_name} maps to unknown category {category}"
def test_tool_count(self):
"""Verify we have all 48 tools mapped."""
assert len(TOOL_CATEGORIES) == 48
# โ”€โ”€ Planning Sync Validator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestPlanningSync:
def test_planning_disabled_auto_disables_planning_tools(self):
cfg = OpenRARLConfig(planning=PlanningConfig(enabled=False))
assert cfg.tools.categories.planning is False
def test_planning_enabled_keeps_planning_tools(self):
cfg = OpenRARLConfig(planning=PlanningConfig(enabled=True))
assert cfg.tools.categories.planning is True
def test_planning_disabled_via_yaml(self):
data = {"planning": {"enabled": False}}
with _temp_yaml(data) as path, _clean_env():
cfg = load_config(config_path=path)
assert cfg.planning.enabled is False
assert cfg.tools.categories.planning is False
# โ”€โ”€ LLM Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestLLMConfig:
def test_local_model_no_key(self):
cfg = LLMConfig(
base_url="http://localhost:11434/v1/chat/completions",
api_key="",
model="llama3.1:70b",
)
assert cfg.api_key == ""
assert "localhost" in cfg.base_url
def test_remote_model_with_key(self):
cfg = LLMConfig(api_key="sk-test-123")
assert cfg.api_key == "sk-test-123"
def test_extra_headers_default(self):
cfg = LLMConfig()
assert "HTTP-Referer" in cfg.extra_headers
assert "X-Title" in cfg.extra_headers
def test_extra_headers_empty_for_local(self):
cfg = LLMConfig(extra_headers={})
assert cfg.extra_headers == {}
def test_temperature_default_none(self):
cfg = LLMConfig()
assert cfg.temperature is None
def test_temperature_set(self):
cfg = LLMConfig(temperature=0.7)
assert cfg.temperature == 0.7
# โ”€โ”€ Alert Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestAlertConfig:
def test_disable_specific_alerts(self):
cfg = AlertsConfig(under_attack=False, low_power=False)
assert cfg.under_attack is False
assert cfg.low_power is False
assert cfg.damaged_building is True # others unchanged
# โ”€โ”€ Backwards Compatibility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestBackwardsCompat:
def test_load_config_with_no_args(self):
"""Calling load_config() with no args should not raise."""
with _clean_env():
cfg = load_config(config_path="__nonexistent__.yaml")
assert isinstance(cfg, OpenRARLConfig)
def test_reward_config_matches_reward_weights(self):
"""RewardConfig fields should match the existing RewardWeights dataclass."""
from openra_env.reward import RewardWeights
rw = RewardWeights()
rc = RewardConfig()
assert rc.survival == rw.survival
assert rc.economic_efficiency == rw.economic_efficiency
assert rc.aggression == rw.aggression
assert rc.defense == rw.defense
assert rc.victory == rw.victory
assert rc.defeat == rw.defeat
class TestRewardVectorConfig:
"""Test reward vector configuration."""
def test_enabled_by_default(self):
cfg = RewardVectorConfig()
assert cfg.enabled is True
def test_default_weights(self):
cfg = RewardVectorConfig()
assert cfg.weights["combat"] == 0.30
assert cfg.weights["economy"] == 0.15
assert cfg.weights["outcome"] == 1.00
assert len(cfg.weights) == 8
def test_present_in_root_config(self):
cfg = OpenRARLConfig()
assert hasattr(cfg, "reward_vector")
assert isinstance(cfg.reward_vector, RewardVectorConfig)
assert cfg.reward_vector.enabled is True
def test_enable_via_yaml(self):
with _clean_env():
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump({"reward_vector": {"enabled": True}}, f)
f.flush()
cfg = load_config(config_path=f.name)
assert cfg.reward_vector.enabled is True
def test_custom_weights_via_yaml(self):
with _clean_env():
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump({"reward_vector": {"enabled": True, "weights": {"combat": 0.5}}}, f)
f.flush()
cfg = load_config(config_path=f.name)
assert cfg.reward_vector.weights["combat"] == 0.5
# โ”€โ”€ Validation Errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestValidation:
def test_invalid_grpc_port_type(self):
with pytest.raises(Exception): # Pydantic ValidationError
GameConfig(grpc_port="not_a_number")
def test_invalid_reward_weight(self):
with pytest.raises(Exception):
RewardConfig(victory="not_a_float")
# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_CONFIG_ENV_VARS = [
"OPENRA_PATH", "RECORD_REPLAYS", "BOT_TYPE", "AI_SLOT",
"PLANNING_ENABLED", "PLANNING_MAX_TURNS", "PLANNING_MAX_TIME",
"OPENROUTER_API_KEY", "OPENROUTER_MODEL",
"LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL",
"OPENRA_URL", "MAX_TIME", "LLM_AGENT_LOG",
]
class _clean_env:
"""Context manager that temporarily clears config-related env vars and sets new ones."""
def __init__(self, **overrides):
self._overrides = overrides
self._saved: dict[str, str | None] = {}
def __enter__(self):
# Save and clear all config env vars
for var in _CONFIG_ENV_VARS:
self._saved[var] = os.environ.pop(var, None)
# Set overrides
for key, val in self._overrides.items():
os.environ[key] = str(val)
return self
def __exit__(self, *args):
# Remove overrides
for key in self._overrides:
os.environ.pop(key, None)
# Restore saved values
for var, val in self._saved.items():
if val is not None:
os.environ[var] = val
def _temp_yaml(data: dict):
"""Context manager that writes *data* to a temp YAML file and yields its path."""
import contextlib
@contextlib.contextmanager
def _ctx():
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(data, f)
path = f.name
try:
yield path
finally:
Path(path).unlink(missing_ok=True)
return _ctx()
# โ”€โ”€ PromptsConfig Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestPromptsConfig:
"""Tests for the PromptsConfig system."""
def test_default_prompts_have_values(self):
"""PromptsConfig defaults should have non-empty values for key fields."""
p = PromptsConfig()
assert "end_planning_phase" in p.planning_nudge
assert "tool" in p.no_tool_nudge.lower()
assert "{building}" in p.power_warning
assert "{count}" in p.alerts.idle_army
def test_prompts_in_root_config(self):
"""OpenRARLConfig should have prompts field with defaults."""
config = OpenRARLConfig()
assert isinstance(config.prompts, PromptsConfig)
assert isinstance(config.prompts.alerts, AlertPromptsConfig)
assert config.prompts.planning_complete == "Planning complete. Game is now live."
def test_prompts_from_yaml(self):
"""Override prompts via config YAML."""
data = {
"prompts": {
"no_tool_nudge": "Please call a tool now.",
"alerts": {
"low_power": "Power is low: {balance}",
},
},
}
with _temp_yaml(data) as path:
config = load_config(config_path=path)
assert config.prompts.no_tool_nudge == "Please call a tool now."
assert config.prompts.alerts.low_power == "Power is low: {balance}"
# Other fields keep defaults
assert "combat units idle" in config.prompts.alerts.idle_army
def test_alert_template_format(self):
"""Alert templates should render with .format()."""
p = AlertPromptsConfig()
result = p.low_power.format(balance="-30")
assert "LOW POWER" in result
assert "-30" in result
def test_placement_template_format(self):
"""Placement templates should render with .format()."""
p = PromptsConfig()
result = p.placement_failed.format(building="powr", reason="no valid position")
assert "powr" in result
assert "no valid position" in result
def test_planning_prompt_template(self):
"""Planning prompt template should accept all expected variables."""
p = PromptsConfig()
result = p.planning_prompt.format(
max_turns=10, map_name="test", map_width=64, map_height=64,
base_x=10, base_y=10, enemy_x=50, enemy_y=50,
faction="russia", side="Soviet",
opponent_summary="Easy AI", planning_nudge=p.planning_nudge,
)
assert "10 turns" in result
assert "russia" in result
assert "end_planning_phase" in result
def test_backward_compat_system_prompt_migration(self):
"""agent.system_prompt should migrate to prompts.system_prompt."""
config = OpenRARLConfig(agent=AgentConfig(system_prompt="My custom prompt"))
assert config.prompts.system_prompt == "My custom prompt"
def test_prompts_system_prompt_takes_precedence(self):
"""prompts.system_prompt should win over agent.system_prompt."""
config = OpenRARLConfig(
agent=AgentConfig(system_prompt="agent version"),
prompts=PromptsConfig(system_prompt="prompts version"),
)
assert config.prompts.system_prompt == "prompts version"
def test_backward_compat_system_prompt_file_migration(self):
"""agent.system_prompt_file should migrate to prompts.system_prompt_file."""
config = OpenRARLConfig(agent=AgentConfig(system_prompt_file="/tmp/test.txt"))
assert config.prompts.system_prompt_file == "/tmp/test.txt"
def test_env_var_prompts_file(self):
"""PROMPTS_FILE env var should set prompts.prompts_file."""
with patch.dict(os.environ, {"PROMPTS_FILE": "/tmp/prompts.yaml"}, clear=False):
config = load_config(config_path="/nonexistent/config.yaml")
assert config.prompts.prompts_file == "/tmp/prompts.yaml"
def test_game_start_template(self):
"""Game start template should render correctly."""
p = PromptsConfig()
result = p.game_start.format(
strategy_section="\n\nRush strategy",
briefing="Map: test",
barracks_type="barr",
mcv_note=" Your MCV is unit 42.",
)
assert "Game started!" in result
assert "Rush strategy" in result
assert "barr" in result
assert "unit 42" in result
def test_insufficient_funds_template(self):
"""Insufficient funds template should render correctly."""
p = PromptsConfig()
result = p.insufficient_funds.format(available=500, item="3tnk", cost=950)
assert "500" in result
assert "3tnk" in result
assert "950" in result
def test_build_queued_template(self):
"""Build queued template should render correctly."""
p = PromptsConfig()
result = p.build_queued.format(building="powr", cost=300, ticks=180, seconds=7.2)
assert "powr" in result
assert "300" in result
assert "180" in result
assert "auto-places" in result
def test_build_unit_queued_template(self):
"""Build unit queued template should render correctly."""
p = PromptsConfig()
result = p.build_unit_queued.format(
count=3, unit="e1", cost=100, ticks_each=60,
ticks_total=180, seconds_total=7.2)
assert "3x" in result
assert "e1" in result
assert "60" in result
assert "180" in result
def test_build_already_pending_template(self):
"""Build already pending template should render correctly."""
p = PromptsConfig()
result = p.build_already_pending.format(building="powr")
assert "powr" in result
assert "already queued" in result
def test_max_alerts_default(self):
"""AlertsConfig max_alerts should default to 0 (unlimited)."""
cfg = AlertsConfig()
assert cfg.max_alerts == 0
# โ”€โ”€ Compression Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestCompressionConfig:
def test_defaults(self):
c = CompressionConfig()
assert c.include_strategy is True
assert c.include_military is True
assert c.include_production is True
def test_disable_strategy(self):
c = CompressionConfig(include_strategy=False)
assert c.include_strategy is False
def test_llm_compression_strategy_default(self):
llm = LLMConfig()
assert llm.compression_strategy == "sliding_window"
def test_llm_compression_trigger_default(self):
llm = LLMConfig()
assert llm.compression_trigger == 0
def test_compression_strategy_none(self):
llm = LLMConfig(compression_strategy="none")
assert llm.compression_strategy == "none"
def test_compression_trigger_custom(self):
llm = LLMConfig(compression_trigger=60)
assert llm.compression_trigger == 60
def test_prompts_compression_field(self):
p = PromptsConfig()
assert isinstance(p.compression, CompressionConfig)
assert p.compression.include_strategy is True
def test_move_eta_template(self):
p = PromptsConfig()
result = p.move_eta.format(ticks=183, seconds=7.3)
assert "183" in result
assert "7.3" in result
def test_full_config_compression_yaml(self):
"""Compression fields round-trip through YAML config loading."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump({
"llm": {
"compression_strategy": "none",
"compression_trigger": 60,
"keep_last_messages": 20,
},
"prompts": {
"compression": {
"include_strategy": False,
"include_military": True,
"include_production": False,
}
}
}, f)
f.flush()
cfg = load_config(f.name)
os.unlink(f.name)
assert cfg.llm.compression_strategy == "none"
assert cfg.llm.compression_trigger == 60
assert cfg.llm.keep_last_messages == 20
assert cfg.prompts.compression.include_strategy is False
assert cfg.prompts.compression.include_production is False
assert cfg.prompts.compression.include_military is True
# โ”€โ”€ Opponent Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestOpponentConfig:
def test_default_spawns_enemy(self):
"""Default opponent config spawns an enemy in Multi0."""
cfg = OpponentConfig()
assert cfg.ai_slot == "Multi0"
assert cfg.bot_type == "easy"
def test_disable_enemy_via_empty_slot(self):
cfg = OpponentConfig(ai_slot="")
assert cfg.ai_slot == ""
def test_custom_bot_type(self):
cfg = OpponentConfig(bot_type="hard")
assert cfg.bot_type == "hard"
# โ”€โ”€ Bot Type Mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestBotTypeMapping:
def test_beginner_maps_to_beginner(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
assert BOT_TYPE_MAP["beginner"] == "beginner"
def test_easy_maps_to_easy(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
assert BOT_TYPE_MAP["easy"] == "easy"
def test_medium_maps_to_medium(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
assert BOT_TYPE_MAP["medium"] == "medium"
def test_hard_maps_to_normal(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
assert BOT_TYPE_MAP["hard"] == "normal"
def test_brutal_maps_to_rush(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
assert BOT_TYPE_MAP["brutal"] == "rush"
def test_raw_names_pass_through(self):
from openra_env.server.openra_process import BOT_TYPE_MAP
for raw in ["rush", "normal", "turtle", "naval", "beginner", "easy", "medium"]:
assert BOT_TYPE_MAP.get(raw, raw) == raw
def test_build_command_maps_hard(self):
from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
openra_path = str(Path(__file__).parent.parent / "OpenRA")
config = OpenRAConfig(openra_path=openra_path, bot_type="hard")
manager = OpenRAProcessManager(config)
cmd = manager._build_command()
bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
assert "normal" in bots_arg
assert "hard" not in bots_arg
def test_build_command_maps_brutal(self):
from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
openra_path = str(Path(__file__).parent.parent / "OpenRA")
config = OpenRAConfig(openra_path=openra_path, bot_type="brutal")
manager = OpenRAProcessManager(config)
cmd = manager._build_command()
bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
assert "rush" in bots_arg
assert "brutal" not in bots_arg
def test_build_command_no_enemy_with_empty_slot(self):
from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
openra_path = str(Path(__file__).parent.parent / "OpenRA")
config = OpenRAConfig(openra_path=openra_path, ai_slot="")
manager = OpenRAProcessManager(config)
cmd = manager._build_command()
bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
assert bots_arg == "Launch.Bots=Multi1:rl-agent"
def test_default_config_spawns_enemy(self):
from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
openra_path = str(Path(__file__).parent.parent / "OpenRA")
config = OpenRAConfig(openra_path=openra_path)
manager = OpenRAProcessManager(config)
cmd = manager._build_command()
bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
# Default should include enemy (Multi0:normal)
assert "Multi0" in bots_arg
assert "normal" in bots_arg