forked from iarv/meshcore-stats
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""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
|