"""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)