"""Tests for environment variable parsing functions.""" from pathlib import Path import pytest from meshmon.env import ( Config, _parse_config_value, get_bool, get_config, get_float, get_int, get_path, get_str, ) class TestParseConfigValue: """Test _parse_config_value function.""" def test_empty_string(self): """Empty string returns empty.""" assert _parse_config_value("") == "" assert _parse_config_value(" ") == "" def test_unquoted_value(self): """Unquoted values are returned trimmed.""" assert _parse_config_value("hello") == "hello" assert _parse_config_value(" hello ") == "hello" assert _parse_config_value("hello world") == "hello world" def test_double_quoted_value(self): """Double quoted values extract content.""" assert _parse_config_value('"hello"') == "hello" assert _parse_config_value('"hello world"') == "hello world" assert _parse_config_value('"value with spaces"') == "value with spaces" def test_single_quoted_value(self): """Single quoted values extract content.""" assert _parse_config_value("'hello'") == "hello" assert _parse_config_value("'hello world'") == "hello world" def test_unclosed_quotes(self): """Unclosed quotes return content after quote.""" assert _parse_config_value('"hello') == "hello" assert _parse_config_value("'hello") == "hello" def test_inline_comments_stripped(self): """Inline comments (# preceded by space) are stripped.""" assert _parse_config_value("hello # comment") == "hello" assert _parse_config_value("value # another comment") == "value" def test_hash_without_space_kept(self): """Hash without preceding space is kept (e.g., color codes).""" assert _parse_config_value("#ffffff") == "#ffffff" assert _parse_config_value("test#value") == "test#value" def test_quoted_values_preserve_comments(self): """Quoted values preserve comment-like content.""" assert _parse_config_value('"hello # not a comment"') == "hello # not a comment" assert _parse_config_value("'value # preserved'") == "value # preserved" def test_empty_quoted_string(self): """Empty quoted string returns empty.""" assert _parse_config_value('""') == "" assert _parse_config_value("''") == "" class TestGetStr: """Test get_str function.""" def test_returns_value_when_set(self, monkeypatch): """Returns env var value when set.""" monkeypatch.setenv("TEST_VAR", "hello") assert get_str("TEST_VAR") == "hello" def test_returns_none_when_not_set(self): """Returns None when env var not set and no default.""" assert get_str("NONEXISTENT_VAR_12345") is None def test_returns_default_when_not_set(self): """Returns default when env var not set.""" assert get_str("NONEXISTENT_VAR_12345", "default") == "default" def test_empty_string_is_valid(self, monkeypatch): """Empty string is a valid value, not replaced by default.""" monkeypatch.setenv("TEST_VAR", "") assert get_str("TEST_VAR", "default") == "" class TestGetInt: """Test get_int function.""" def test_returns_value_when_set(self, monkeypatch): """Returns parsed int when set.""" monkeypatch.setenv("TEST_INT", "42") assert get_int("TEST_INT", 0) == 42 def test_returns_default_when_not_set(self): """Returns default when not set.""" assert get_int("NONEXISTENT_VAR_12345", 99) == 99 def test_returns_default_on_invalid(self, monkeypatch): """Returns default when value is not a valid integer.""" monkeypatch.setenv("TEST_INT", "not_a_number") assert get_int("TEST_INT", 99) == 99 def test_negative_integers(self, monkeypatch): """Handles negative integers.""" monkeypatch.setenv("TEST_INT", "-42") assert get_int("TEST_INT", 0) == -42 def test_zero(self, monkeypatch): """Zero is a valid value.""" monkeypatch.setenv("TEST_INT", "0") assert get_int("TEST_INT", 99) == 0 def test_float_string_returns_default(self, monkeypatch): """Float string is not a valid integer.""" monkeypatch.setenv("TEST_INT", "3.14") assert get_int("TEST_INT", 99) == 99 class TestGetBool: """Test get_bool function.""" def test_returns_default_when_not_set(self): """Returns default when not set.""" assert get_bool("NONEXISTENT_VAR_12345", False) is False assert get_bool("NONEXISTENT_VAR_12345", True) is True @pytest.mark.parametrize("value", ["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"]) def test_truthy_values(self, value, monkeypatch): """Various truthy values return True.""" monkeypatch.setenv("TEST_BOOL", value) assert get_bool("TEST_BOOL") is True @pytest.mark.parametrize("value", ["0", "false", "False", "no", "No", "off", "anything"]) def test_falsy_values(self, value, monkeypatch): """Non-truthy values return False.""" monkeypatch.setenv("TEST_BOOL", value) assert get_bool("TEST_BOOL") is False def test_empty_string_returns_default(self, monkeypatch): """Empty string returns default.""" monkeypatch.setenv("TEST_BOOL", "") assert get_bool("TEST_BOOL", True) is True assert get_bool("TEST_BOOL", False) is False class TestGetFloat: """Test get_float function.""" def test_returns_value_when_set(self, monkeypatch): """Returns parsed float when set.""" monkeypatch.setenv("TEST_FLOAT", "3.14") assert get_float("TEST_FLOAT", 0.0) == pytest.approx(3.14) def test_returns_default_when_not_set(self): """Returns default when not set.""" assert get_float("NONEXISTENT_VAR_12345", 99.9) == 99.9 def test_returns_default_on_invalid(self, monkeypatch): """Returns default when value is not a valid float.""" monkeypatch.setenv("TEST_FLOAT", "not_a_number") assert get_float("TEST_FLOAT", 99.9) == 99.9 def test_integer_string_valid(self, monkeypatch): """Integer string is valid as float.""" monkeypatch.setenv("TEST_FLOAT", "42") assert get_float("TEST_FLOAT", 0.0) == 42.0 def test_negative_floats(self, monkeypatch): """Handles negative floats.""" monkeypatch.setenv("TEST_FLOAT", "-3.14") assert get_float("TEST_FLOAT", 0.0) == pytest.approx(-3.14) def test_scientific_notation(self, monkeypatch): """Handles scientific notation.""" monkeypatch.setenv("TEST_FLOAT", "1e-3") assert get_float("TEST_FLOAT", 0.0) == pytest.approx(0.001) class TestGetPath: """Test get_path function.""" def test_returns_path_from_env(self, monkeypatch, tmp_path): """Returns Path from env var value.""" monkeypatch.setenv("TEST_PATH", str(tmp_path)) result = get_path("TEST_PATH", "/default") assert result == tmp_path def test_returns_default_when_not_set(self): """Returns Path from default when not set.""" result = get_path("NONEXISTENT_VAR_12345", "/some/path") assert result == Path("/some/path") def test_expands_user(self, monkeypatch, tmp_path): """Expands ~ to user home directory.""" monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.setenv("TEST_PATH", "~/subdir") result = get_path("TEST_PATH", "/default") assert result == (tmp_path / "subdir").resolve() def test_resolves_to_absolute(self, monkeypatch, tmp_path): """Relative paths are resolved to absolute from CWD.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("TEST_PATH", "relative/path") result = get_path("TEST_PATH", "/default") assert result == (tmp_path / "relative/path").resolve() class TestConfig: """Test Config class.""" def test_default_values(self, clean_env): """Config uses defaults when env vars not set.""" config = Config() # Connection defaults assert config.mesh_transport == "serial" assert config.mesh_serial_port is None assert config.mesh_serial_baud == 115200 assert config.mesh_debug is False # Timing defaults assert config.companion_step == 60 assert config.repeater_step == 900 assert config.remote_timeout_s == 10 assert config.remote_retry_attempts == 2 assert config.remote_cb_fails == 6 assert config.remote_cb_cooldown_s == 3600 # Telemetry defaults assert config.telemetry_enabled is False assert config.telemetry_timeout_s == 10 assert config.display_unit_system == "metric" # Display defaults assert config.repeater_display_name == "Repeater Node" assert config.companion_display_name == "Companion Node" def test_reads_env_vars(self, monkeypatch, clean_env): """Config reads values from environment.""" monkeypatch.setenv("MESH_TRANSPORT", "tcp") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyUSB0") monkeypatch.setenv("MESH_DEBUG", "1") monkeypatch.setenv("COMPANION_STEP", "120") monkeypatch.setenv("REPEATER_NAME", "TestRepeater") monkeypatch.setenv("TELEMETRY_ENABLED", "true") monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial") monkeypatch.setenv("REPORT_LAT", "51.5074") config = Config() assert config.mesh_transport == "tcp" assert config.mesh_serial_port == "/dev/ttyUSB0" assert config.mesh_debug is True assert config.companion_step == 120 assert config.repeater_name == "TestRepeater" assert config.telemetry_enabled is True assert config.display_unit_system == "imperial" assert config.report_lat == pytest.approx(51.5074) def test_invalid_display_unit_system_falls_back_to_metric(self, monkeypatch, clean_env): """Invalid DISPLAY_UNIT_SYSTEM falls back to metric.""" monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "custom") config = Config() assert config.display_unit_system == "metric" def test_paths_are_path_objects(self, monkeypatch, clean_env, tmp_path): """Path configs are Path objects.""" state_dir = tmp_path / "state" out_dir = tmp_path / "out" monkeypatch.setenv("STATE_DIR", str(state_dir)) monkeypatch.setenv("OUT_DIR", str(out_dir)) config = Config() assert isinstance(config.state_dir, Path) assert isinstance(config.out_dir, Path) class TestGetConfig: """Test get_config singleton function.""" def test_returns_config_instance(self, clean_env): """Returns a Config instance.""" config = get_config() assert isinstance(config, Config) def test_returns_same_instance(self, clean_env): """Returns the same instance on subsequent calls.""" config1 = get_config() config2 = get_config() assert config1 is config2 def test_reset_creates_new_instance(self, clean_env): """After reset, creates new instance.""" import meshmon.env config1 = get_config() # Reset singleton meshmon.env._config = None config2 = get_config() assert config1 is not config2