Files
meshcore-stats/tests/unit/test_env_parsing.py
Jorijn Schrijvershof 137bbe3c66 feat: add telemetry chart discovery and unit display (#109)
* add telemetry chart discovery and unit display

* fix: tests were failing
2026-02-09 13:12:44 +01:00

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