279 lines
9.9 KiB
Python
279 lines
9.9 KiB
Python
"""Unit tests for configuration management system."""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from openclaw.core.config import (
|
|
ConfigLoader,
|
|
CostStructure,
|
|
LLMConfig,
|
|
OpenClawConfig,
|
|
SurvivalThresholds,
|
|
get_config,
|
|
reload_config,
|
|
)
|
|
|
|
|
|
class TestCostStructure:
|
|
"""Test CostStructure configuration model."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""Test default cost structure values."""
|
|
cost = CostStructure()
|
|
assert cost.llm_input_per_1m == 2.5
|
|
assert cost.llm_output_per_1m == 10.0
|
|
assert cost.market_data_per_call == 0.01
|
|
assert cost.trade_fee_rate == 0.001
|
|
|
|
def test_custom_values(self) -> None:
|
|
"""Test custom cost structure values."""
|
|
cost = CostStructure(
|
|
llm_input_per_1m=5.0,
|
|
llm_output_per_1m=20.0,
|
|
market_data_per_call=0.02,
|
|
trade_fee_rate=0.002,
|
|
)
|
|
assert cost.llm_input_per_1m == 5.0
|
|
assert cost.llm_output_per_1m == 20.0
|
|
assert cost.market_data_per_call == 0.02
|
|
assert cost.trade_fee_rate == 0.002
|
|
|
|
def test_validation_positive_values(self) -> None:
|
|
"""Test validation of positive values."""
|
|
with pytest.raises(ValueError):
|
|
CostStructure(llm_input_per_1m=-1.0)
|
|
|
|
with pytest.raises(ValueError):
|
|
CostStructure(llm_output_per_1m=0)
|
|
|
|
with pytest.raises(ValueError):
|
|
CostStructure(market_data_per_call=-0.01)
|
|
|
|
with pytest.raises(ValueError):
|
|
CostStructure(trade_fee_rate=-0.001)
|
|
|
|
with pytest.raises(ValueError):
|
|
CostStructure(trade_fee_rate=1.5)
|
|
|
|
|
|
class TestSurvivalThresholds:
|
|
"""Test SurvivalThresholds configuration model."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""Test default survival threshold values."""
|
|
thresholds = SurvivalThresholds()
|
|
assert thresholds.thriving_multiplier == 3.0
|
|
assert thresholds.stable_multiplier == 1.5
|
|
assert thresholds.struggling_multiplier == 0.8
|
|
assert thresholds.bankrupt_multiplier == 0.1
|
|
|
|
def test_threshold_order(self) -> None:
|
|
"""Test that thresholds are in correct order."""
|
|
thresholds = SurvivalThresholds()
|
|
assert thresholds.thriving_multiplier > thresholds.stable_multiplier
|
|
assert thresholds.stable_multiplier > thresholds.struggling_multiplier
|
|
assert thresholds.struggling_multiplier > thresholds.bankrupt_multiplier
|
|
|
|
def test_validation_greater_than_one(self) -> None:
|
|
"""Test thriving multiplier must be > 1."""
|
|
with pytest.raises(ValueError):
|
|
SurvivalThresholds(thriving_multiplier=0.5)
|
|
|
|
with pytest.raises(ValueError):
|
|
SurvivalThresholds(thriving_multiplier=1.0)
|
|
|
|
|
|
class TestLLMConfig:
|
|
"""Test LLMConfig configuration model."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""Test default LLM configuration values."""
|
|
config = LLMConfig()
|
|
assert config.model == "gpt-4o"
|
|
assert config.temperature == 0.7
|
|
assert config.timeout == 30
|
|
assert config.api_key is None
|
|
assert config.base_url is None
|
|
|
|
def test_custom_values(self) -> None:
|
|
"""Test custom LLM configuration values."""
|
|
config = LLMConfig(
|
|
model="claude-3-5-sonnet",
|
|
temperature=0.5,
|
|
timeout=60,
|
|
api_key="test-key",
|
|
base_url="https://api.example.com",
|
|
)
|
|
assert config.model == "claude-3-5-sonnet"
|
|
assert config.temperature == 0.5
|
|
assert config.timeout == 60
|
|
assert config.api_key == "test-key"
|
|
assert config.base_url == "https://api.example.com"
|
|
|
|
def test_temperature_validation(self) -> None:
|
|
"""Test temperature must be between 0 and 2."""
|
|
with pytest.raises(ValueError):
|
|
LLMConfig(temperature=-0.1)
|
|
|
|
with pytest.raises(ValueError):
|
|
LLMConfig(temperature=2.1)
|
|
|
|
|
|
class TestOpenClawConfig:
|
|
"""Test OpenClawConfig main configuration model."""
|
|
|
|
def test_default_initialization(self) -> None:
|
|
"""Test default configuration initialization."""
|
|
config = OpenClawConfig()
|
|
assert config.initial_capital["trader"] == 10000.0
|
|
assert config.initial_capital["analyst"] == 5000.0
|
|
assert config.cost_structure.llm_input_per_1m == 2.5
|
|
assert config.simulation_days == 30
|
|
assert config.log_level == "INFO"
|
|
|
|
def test_initial_capital_validation(self) -> None:
|
|
"""Test initial capital must be positive."""
|
|
with pytest.raises(ValueError):
|
|
OpenClawConfig(initial_capital={"trader": -1000.0})
|
|
|
|
with pytest.raises(ValueError):
|
|
OpenClawConfig(initial_capital={"analyst": 0.0})
|
|
|
|
def test_nested_models(self) -> None:
|
|
"""Test nested configuration models."""
|
|
config = OpenClawConfig(
|
|
cost_structure=CostStructure(llm_input_per_1m=5.0),
|
|
survival_thresholds=SurvivalThresholds(thriving_multiplier=4.0),
|
|
)
|
|
assert config.cost_structure.llm_input_per_1m == 5.0
|
|
assert config.survival_thresholds.thriving_multiplier == 4.0
|
|
|
|
|
|
class TestConfigLoading:
|
|
"""Test configuration loading from files."""
|
|
|
|
def test_load_from_yaml(self, tmp_path: Path) -> None:
|
|
"""Test loading configuration from YAML file."""
|
|
config_file = tmp_path / "test_config.yaml"
|
|
config_data = {
|
|
"initial_capital": {"trader": 20000.0, "analyst": 10000.0},
|
|
"simulation_days": 60,
|
|
"log_level": "DEBUG",
|
|
}
|
|
config_file.write_text(yaml.dump(config_data))
|
|
|
|
config = ConfigLoader.load(config_file)
|
|
assert config.initial_capital["trader"] == 20000.0
|
|
assert config.initial_capital["analyst"] == 10000.0
|
|
assert config.simulation_days == 60
|
|
assert config.log_level == "DEBUG"
|
|
|
|
def test_load_from_json(self, tmp_path: Path) -> None:
|
|
"""Test loading configuration from JSON file."""
|
|
config_file = tmp_path / "test_config.json"
|
|
config_data = {
|
|
"initial_capital": {"trader": 15000.0},
|
|
"cost_structure": {"llm_input_per_1m": 3.0},
|
|
}
|
|
import json
|
|
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
config = ConfigLoader.load(config_file)
|
|
assert config.initial_capital["trader"] == 15000.0
|
|
assert config.cost_structure.llm_input_per_1m == 3.0
|
|
|
|
def test_load_nonexistent_file(self) -> None:
|
|
"""Test loading from non-existent file raises FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError):
|
|
ConfigLoader.load("/nonexistent/config.yaml")
|
|
|
|
def test_create_default_config(self, tmp_path: Path) -> None:
|
|
"""Test creating default configuration file."""
|
|
output_path = tmp_path / "default_config.yaml"
|
|
path = ConfigLoader.create_default_config(output_path)
|
|
assert path.exists()
|
|
|
|
# Verify it can be loaded
|
|
config = ConfigLoader.load(path)
|
|
assert config.initial_capital["trader"] == 10000.0
|
|
|
|
|
|
class TestEnvironmentVariables:
|
|
"""Test environment variable overrides."""
|
|
|
|
def test_env_prefix_filtering(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Test that environment variables with correct prefix are used."""
|
|
# This tests the SettingsConfigDict env_prefix behavior
|
|
monkeypatch.setenv("OPENCLAW_SIMULATION_DAYS", "100")
|
|
monkeypatch.setenv("OPENCLAW_LOG_LEVEL", "ERROR")
|
|
|
|
# Reload to pick up environment variables
|
|
config = reload_config()
|
|
assert config.simulation_days == 100
|
|
assert config.log_level == "ERROR"
|
|
|
|
def test_env_nested_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Test environment variables for nested config values."""
|
|
# Nested values should be accessible via double underscore
|
|
monkeypatch.setenv("OPENCLAW_COST_STRUCTURE__LLM_INPUT_PER_1M", "5.5")
|
|
|
|
config = reload_config()
|
|
assert config.cost_structure.llm_input_per_1m == 5.5
|
|
|
|
|
|
class TestGlobalConfig:
|
|
"""Test global configuration instance."""
|
|
|
|
def test_get_config_singleton(self) -> None:
|
|
"""Test that get_config returns the same instance."""
|
|
config1 = get_config()
|
|
config2 = get_config()
|
|
assert config1 is config2
|
|
|
|
def test_reload_config_updates_global(self) -> None:
|
|
"""Test that reload_config updates the global instance."""
|
|
# First clear any existing config
|
|
from openclaw.core.config import set_config
|
|
set_config(None)
|
|
|
|
config1 = get_config()
|
|
config2 = reload_config()
|
|
# reload_config creates a new instance and updates the global
|
|
# config1 is the old instance, config2 is the new global instance
|
|
assert config1 is not config2 # Different instances
|
|
assert config2 is get_config() # But config2 is now the global
|
|
|
|
|
|
class TestConfigValidation:
|
|
"""Test configuration validation edge cases."""
|
|
|
|
def test_empty_initial_capital(self) -> None:
|
|
"""Test empty initial capital dict."""
|
|
config = OpenClawConfig(initial_capital={})
|
|
# Should use empty dict, not defaults
|
|
assert config.initial_capital == {}
|
|
|
|
def test_partial_config_file(self, tmp_path: Path) -> None:
|
|
"""Test loading partial configuration from file."""
|
|
config_file = tmp_path / "partial.yaml"
|
|
config_file.write_text("simulation_days: 45\n")
|
|
|
|
config = ConfigLoader.load(config_file)
|
|
assert config.simulation_days == 45
|
|
# Other values should use defaults
|
|
assert config.initial_capital["trader"] == 10000.0
|
|
|
|
def test_invalid_yaml_raises_error(self, tmp_path: Path) -> None:
|
|
"""Test that invalid YAML raises ValueError."""
|
|
config_file = tmp_path / "invalid.yaml"
|
|
config_file.write_text("invalid: yaml: content: [")
|
|
|
|
# Should raise ValueError for invalid YAML
|
|
with pytest.raises(ValueError, match="Invalid YAML"):
|
|
ConfigLoader.load(config_file)
|