From ca13e31aae1bff561b278608c16df8e17424f9eb Mon Sep 17 00:00:00 2001 From: Jorijn Schrijvershof Date: Thu, 8 Jan 2026 21:20:34 +0100 Subject: [PATCH] test: stabilize suite and broaden integration coverage (#32) * tests: cache integration/report fixtures to speed up tests * fix: speed up yearly aggregation and refresh timings report * chore: remove the report * fix: unrecognized named-value: 'runner'. Located at position 1 within expression: runner.temp * fix: ruff linting error * test: strengthen assertions and stabilize tests * test(integration): expand rendered chart metrics --- .github/workflows/test.yml | 5 + src/meshmon/reports.py | 16 +- tests/charts/test_chart_io.py | 5 +- tests/charts/test_chart_render.py | 21 +- tests/charts/test_statistics.py | 12 +- tests/charts/test_timeseries.py | 14 +- tests/charts/test_transforms.py | 22 +- tests/client/test_connect.py | 107 ++++---- tests/client/test_contacts.py | 40 ++- tests/client/test_meshcore_available.py | 59 +++-- tests/client/test_run_command.py | 29 ++- tests/config/test_config_file.py | 65 ++--- tests/config/test_env.py | 4 +- tests/database/test_db_insert.py | 42 ++-- tests/database/test_db_maintenance.py | 14 +- tests/database/test_db_queries.py | 42 ++-- tests/html/test_jinja_env.py | 24 +- tests/html/test_metrics_builders.py | 11 +- tests/html/test_page_context.py | 73 +++--- tests/html/test_reports_index.py | 24 +- tests/html/test_write_site.py | 153 +++++++++--- tests/integration/conftest.py | 229 +++++++++++++++--- tests/integration/test_collection_pipeline.py | 39 +-- tests/integration/test_rendering_pipeline.py | 115 +++------ tests/integration/test_reports_pipeline.py | 104 ++++++-- tests/reports/test_aggregation.py | 81 +++++-- tests/reports/test_aggregation_helpers.py | 36 +++ tests/reports/test_format_json.py | 72 +++++- tests/reports/test_format_txt.py | 89 ++++--- tests/reports/test_location.py | 72 +++--- tests/reports/test_table_builders.py | 103 ++++++-- tests/retry/conftest.py | 13 +- tests/retry/test_circuit_breaker.py | 67 ++--- tests/retry/test_with_retries.py | 62 +++-- tests/scripts/test_collect_repeater.py | 17 +- tests/scripts/test_render_scripts.py | 26 +- tests/unit/test_charts_helpers.py | 144 +++++++---- tests/unit/test_env_parsing.py | 14 +- tests/unit/test_formatters.py | 44 ++-- tests/unit/test_html_builders.py | 93 ++++++- tests/unit/test_html_formatters.py | 39 ++- tests/unit/test_log.py | 74 +++--- tests/unit/test_reports_formatting.py | 57 ++--- 43 files changed, 1573 insertions(+), 799 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db4af91..6427c4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,11 @@ jobs: - name: Install dependencies run: uv sync --locked --extra dev + - name: Set up matplotlib cache + run: | + echo "MPLCONFIGDIR=$RUNNER_TEMP/matplotlib" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/matplotlib" + - name: Run tests with coverage run: | uv run pytest \ diff --git a/src/meshmon/reports.py b/src/meshmon/reports.py index faacd4f..1455aea 100644 --- a/src/meshmon/reports.py +++ b/src/meshmon/reports.py @@ -507,12 +507,18 @@ def aggregate_yearly(role: str, year: int) -> YearlyAggregate: """ agg = YearlyAggregate(year=year, role=role) metrics = get_metrics_for_role(role) + today = date.today() - # Process month by month to limit memory usage - for month in range(1, 13): - # Don't aggregate future months - if date(year, month, 1) > date.today(): - break + periods = get_available_periods(role) + months_with_data = sorted({month for y, month in periods if y == year}) + + if year > today.year: + months_with_data = [] + elif year == today.year: + months_with_data = [month for month in months_with_data if month <= today.month] + + # Process only months that have data to avoid unnecessary daily scans. + for month in months_with_data: monthly = aggregate_monthly(role, year, month) if monthly.daily: # Has data agg.monthly.append(monthly) diff --git a/tests/charts/test_chart_io.py b/tests/charts/test_chart_io.py index b6e6086..4a1e269 100644 --- a/tests/charts/test_chart_io.py +++ b/tests/charts/test_chart_io.py @@ -116,10 +116,9 @@ class TestLoadChartStats: def test_returns_empty_on_invalid_json(self, configured_env): """Returns empty dict on invalid JSON.""" - cfg = __import__("meshmon.env", fromlist=["get_config"]).get_config() - stats_path = cfg.out_dir / "assets" / "repeater" / "chart_stats.json" + stats_path = configured_env["out_dir"] / "assets" / "repeater" / "chart_stats.json" stats_path.parent.mkdir(parents=True, exist_ok=True) - stats_path.write_text("not valid json {{{") + stats_path.write_text("not valid json {{{", encoding="utf-8") loaded = load_chart_stats("repeater") diff --git a/tests/charts/test_chart_render.py b/tests/charts/test_chart_render.py index aecdcc3..0a4bc89 100644 --- a/tests/charts/test_chart_render.py +++ b/tests/charts/test_chart_render.py @@ -3,6 +3,7 @@ import os from datetime import datetime, timedelta from pathlib import Path +from xml.etree import ElementTree as ET import pytest @@ -16,6 +17,14 @@ from meshmon.charts import ( from .conftest import extract_svg_data_attributes, normalize_svg_for_snapshot +def _svg_viewbox_dims(svg: str) -> tuple[float, float]: + root = ET.fromstring(svg) + viewbox = root.attrib.get("viewBox") + assert viewbox is not None + _, _, width, height = viewbox.split() + return float(width), float(height) + + class TestRenderChartSvg: """Tests for render_chart_svg function.""" @@ -34,10 +43,14 @@ class TestRenderChartSvg: def test_respects_width_height(self, sample_timeseries, light_theme): """SVG respects specified dimensions.""" - svg = render_chart_svg(sample_timeseries, light_theme, width=600, height=200) + svg_default = render_chart_svg(sample_timeseries, light_theme) + svg_small = render_chart_svg(sample_timeseries, light_theme, width=600, height=200) - # Check viewBox or dimensions in SVG attributes - assert "600" in svg or "viewBox" in svg + default_w, default_h = _svg_viewbox_dims(svg_default) + small_w, small_h = _svg_viewbox_dims(svg_small) + + assert small_w < default_w + assert small_h < default_h def test_uses_theme_colors(self, sample_timeseries, light_theme, dark_theme): """Different themes produce different colors.""" @@ -97,6 +110,8 @@ class TestDataPointsInjection: for point in data["points"]: assert "ts" in point assert "v" in point + assert isinstance(point["ts"], int) + assert isinstance(point["v"], (int, float)) def test_includes_metadata_attributes(self, sample_timeseries, light_theme): """SVG includes metric, period, theme attributes.""" diff --git a/tests/charts/test_statistics.py b/tests/charts/test_statistics.py index a2b367d..aaeed22 100644 --- a/tests/charts/test_statistics.py +++ b/tests/charts/test_statistics.py @@ -11,6 +11,8 @@ from meshmon.charts import ( calculate_statistics, ) +BASE_TIME = datetime(2024, 1, 1, 0, 0, 0) + class TestCalculateStatistics: """Tests for calculate_statistics function.""" @@ -113,7 +115,7 @@ class TestStatisticsWithVariousData: def test_constant_values(self): """All same values gives min=avg=max.""" - now = datetime.now() + now = BASE_TIME points = [DataPoint(timestamp=now + timedelta(hours=i), value=5.0) for i in range(10)] ts = TimeSeries(metric="test", role="companion", period="day", points=points) @@ -125,7 +127,7 @@ class TestStatisticsWithVariousData: def test_increasing_values(self): """Increasing values have correct stats.""" - now = datetime.now() + now = BASE_TIME points = [DataPoint(timestamp=now + timedelta(hours=i), value=float(i)) for i in range(10)] ts = TimeSeries(metric="test", role="companion", period="day", points=points) @@ -138,7 +140,7 @@ class TestStatisticsWithVariousData: def test_negative_values(self): """Handles negative values correctly.""" - now = datetime.now() + now = BASE_TIME points = [ DataPoint(timestamp=now, value=-10.0), DataPoint(timestamp=now + timedelta(hours=1), value=-5.0), @@ -154,7 +156,7 @@ class TestStatisticsWithVariousData: def test_large_values(self): """Handles large values correctly.""" - now = datetime.now() + now = BASE_TIME points = [ DataPoint(timestamp=now, value=1e10), DataPoint(timestamp=now + timedelta(hours=1), value=1e11), @@ -168,7 +170,7 @@ class TestStatisticsWithVariousData: def test_small_decimal_values(self): """Handles small decimal values correctly.""" - now = datetime.now() + now = BASE_TIME points = [ DataPoint(timestamp=now, value=0.001), DataPoint(timestamp=now + timedelta(hours=1), value=0.002), diff --git a/tests/charts/test_timeseries.py b/tests/charts/test_timeseries.py index 7018058..e32a708 100644 --- a/tests/charts/test_timeseries.py +++ b/tests/charts/test_timeseries.py @@ -11,13 +11,15 @@ from meshmon.charts import ( ) from meshmon.db import insert_metrics +BASE_TIME = datetime(2024, 1, 1, 0, 0, 0) + class TestDataPoint: """Tests for DataPoint dataclass.""" def test_stores_timestamp_and_value(self): """Stores timestamp and value.""" - ts = datetime.now() + ts = BASE_TIME dp = DataPoint(timestamp=ts, value=3.85) assert dp.timestamp == ts @@ -25,13 +27,13 @@ class TestDataPoint: def test_value_types(self): """Accepts float and int values.""" - ts = datetime.now() + ts = BASE_TIME dp_float = DataPoint(timestamp=ts, value=3.85) assert dp_float.value == 3.85 - dp_int = DataPoint(timestamp=ts, value=100.0) - assert dp_int.value == 100.0 + dp_int = DataPoint(timestamp=ts, value=100) + assert dp_int.value == 100 class TestTimeSeries: @@ -123,7 +125,7 @@ class TestLoadTimeseriesFromDb: ts = load_timeseries_from_db( role="repeater", metric="bat", - end_time=datetime.now(), + end_time=BASE_TIME, lookback=timedelta(hours=1), period="week", ) @@ -157,7 +159,7 @@ class TestLoadTimeseriesFromDb: ts = load_timeseries_from_db( role="repeater", metric="nonexistent_metric", - end_time=datetime.now(), + end_time=BASE_TIME, lookback=timedelta(hours=1), period="day", ) diff --git a/tests/charts/test_transforms.py b/tests/charts/test_transforms.py index 616f09f..4b03f50 100644 --- a/tests/charts/test_transforms.py +++ b/tests/charts/test_transforms.py @@ -10,6 +10,8 @@ from meshmon.charts import ( ) from meshmon.db import insert_metrics +BASE_TIME = datetime(2024, 1, 1, 0, 0, 0) + class TestCounterToRateConversion: """Tests for counter metric rate conversion.""" @@ -35,8 +37,9 @@ class TestCounterToRateConversion: assert len(ts.points) == 4 # All rates should be positive (counter increasing) + expected_rate = (100.0 / 900.0) * 60.0 for p in ts.points: - assert p.value >= 0 + assert p.value == pytest.approx(expected_rate) def test_handles_counter_reset(self, initialized_db, configured_env): """Counter resets (negative delta) are skipped.""" @@ -58,6 +61,11 @@ class TestCounterToRateConversion: # Reset point should be skipped, so fewer points assert len(ts.points) == 2 # Only valid deltas + expected_rate = (100.0 / 900.0) * 60.0 + assert ts.points[0].timestamp == datetime.fromtimestamp(base_ts + 900) + assert ts.points[1].timestamp == datetime.fromtimestamp(base_ts + 2700) + assert ts.points[0].value == pytest.approx(expected_rate) + assert ts.points[1].value == pytest.approx(expected_rate) def test_applies_scale_factor(self, initialized_db, configured_env): """Counter rate is scaled (typically x60 for per-minute).""" @@ -136,19 +144,19 @@ class TestGaugeValueTransform: class TestTimeBinning: """Tests for time series aggregation/binning.""" - def test_no_binning_for_day(self, initialized_db, configured_env): + def test_no_binning_for_day(self): """Day period uses raw data (no binning).""" assert PERIOD_CONFIG["day"].bin_seconds is None - def test_30_min_bins_for_week(self, initialized_db, configured_env): + def test_30_min_bins_for_week(self): """Week period uses 30-minute bins.""" assert PERIOD_CONFIG["week"].bin_seconds == 1800 - def test_2_hour_bins_for_month(self, initialized_db, configured_env): + def test_2_hour_bins_for_month(self): """Month period uses 2-hour bins.""" assert PERIOD_CONFIG["month"].bin_seconds == 7200 - def test_1_day_bins_for_year(self, initialized_db, configured_env): + def test_1_day_bins_for_year(self): """Year period uses 1-day bins.""" assert PERIOD_CONFIG["year"].bin_seconds == 86400 @@ -181,7 +189,7 @@ class TestEmptyData: ts = load_timeseries_from_db( role="repeater", metric="nonexistent", - end_time=datetime.now(), + end_time=BASE_TIME, lookback=timedelta(days=1), period="day", ) @@ -199,7 +207,7 @@ class TestEmptyData: ts = load_timeseries_from_db( role="repeater", metric="bat", - end_time=datetime.now(), + end_time=BASE_TIME, lookback=timedelta(hours=1), period="day", ) diff --git a/tests/client/test_connect.py b/tests/client/test_connect.py index d8c273e..c899f07 100644 --- a/tests/client/test_connect.py +++ b/tests/client/test_connect.py @@ -1,6 +1,6 @@ """Tests for MeshCore connection functions.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -12,6 +12,13 @@ from meshmon.meshcore_client import ( ) +def _reset_config(): + import meshmon.env + + meshmon.env._config = None + return meshmon.env.get_config() + + class TestAutoDetectSerialPort: """Tests for auto_detect_serial_port function.""" @@ -63,12 +70,20 @@ class TestAutoDetectSerialPort: assert result is None - def test_handles_import_error(self): + def test_handles_import_error(self, monkeypatch): """Returns None when pyserial not installed.""" - with patch.dict("sys.modules", {"serial": None, "serial.tools": None, "serial.tools.list_ports": None}): - # Force re-import to test import error handling - # This test may need adjustment based on actual import structure - pass + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name in {"serial", "serial.tools.list_ports"}: + raise ImportError("No module named 'serial'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + assert auto_detect_serial_port() is None class TestConnectFromEnv: @@ -89,19 +104,22 @@ class TestConnectFromEnv: monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") + monkeypatch.setenv("MESH_SERIAL_BAUD", "57600") + monkeypatch.setenv("MESH_DEBUG", "1") - import meshmon.env - meshmon.env._config = None + _reset_config() - mock_create = AsyncMock(return_value=MagicMock()) + mock_client = MagicMock() + mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) - await connect_from_env() + result = await connect_from_env() - mock_create.assert_called_once() + assert result is mock_client + mock_create.assert_called_once_with("/dev/ttyACM0", 57600, debug=True) @pytest.mark.asyncio async def test_tcp_connection(self, configured_env, monkeypatch): @@ -111,18 +129,19 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_TCP_HOST", "localhost") monkeypatch.setenv("MESH_TCP_PORT", "4403") - import meshmon.env - meshmon.env._config = None + _reset_config() - mock_create = AsyncMock(return_value=MagicMock()) + mock_client = MagicMock() + mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_tcp = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) - await connect_from_env() + result = await connect_from_env() - mock_create.assert_called_once() + assert result is mock_client + mock_create.assert_called_once_with("localhost", 4403) @pytest.mark.asyncio async def test_unknown_transport(self, configured_env, monkeypatch): @@ -130,8 +149,7 @@ class TestConnectFromEnv: monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "unknown") - import meshmon.env - meshmon.env._config = None + _reset_config() result = await connect_from_env() @@ -144,8 +162,7 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None + _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() @@ -156,6 +173,7 @@ class TestConnectFromEnv: result = await connect_from_env() assert result is None + mock_create.assert_called_once() @pytest.mark.asyncio async def test_ble_connection(self, configured_env, monkeypatch): @@ -165,18 +183,19 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_BLE_ADDR", "AA:BB:CC:DD:EE:FF") monkeypatch.setenv("MESH_BLE_PIN", "123456") - import meshmon.env - meshmon.env._config = None + _reset_config() - mock_create = AsyncMock(return_value=MagicMock()) + mock_client = MagicMock() + mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_ble = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) - await connect_from_env() + result = await connect_from_env() - mock_create.assert_called_once() + assert result is mock_client + mock_create.assert_called_once_with("AA:BB:CC:DD:EE:FF", pin="123456") @pytest.mark.asyncio async def test_ble_missing_address(self, configured_env, monkeypatch): @@ -185,8 +204,7 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_TRANSPORT", "ble") # Don't set MESH_BLE_ADDR - import meshmon.env - meshmon.env._config = None + _reset_config() result = await connect_from_env() @@ -199,23 +217,24 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_TRANSPORT", "serial") # Don't set MESH_SERIAL_PORT to trigger auto-detection - import meshmon.env - meshmon.env._config = None + _reset_config() # Set up mock port detection mock_port = MagicMock() mock_port.device = "/dev/ttyACM0" mock_serial_port.tools.list_ports.comports.return_value = [mock_port] - mock_create = AsyncMock(return_value=MagicMock()) + mock_client = MagicMock() + mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) - await connect_from_env() + result = await connect_from_env() - mock_create.assert_called_once() + assert result is mock_client + mock_create.assert_called_once_with("/dev/ttyACM0", 115200, debug=False) @pytest.mark.asyncio async def test_serial_auto_detect_fails(self, configured_env, monkeypatch, mock_serial_port): @@ -224,8 +243,7 @@ class TestConnectFromEnv: monkeypatch.setenv("MESH_TRANSPORT", "serial") # Don't set MESH_SERIAL_PORT to trigger auto-detection - import meshmon.env - meshmon.env._config = None + _reset_config() # No ports available mock_serial_port.tools.list_ports.comports.return_value = [] @@ -245,8 +263,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None + _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() @@ -269,8 +286,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None + _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() @@ -288,9 +304,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None - cfg = meshmon.env.get_config() + cfg = _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() @@ -313,9 +327,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TCP_HOST", "localhost") monkeypatch.setenv("MESH_TCP_PORT", "4403") - import meshmon.env - meshmon.env._config = None - cfg = meshmon.env.get_config() + cfg = _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() @@ -338,8 +350,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None + _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock(side_effect=Exception("Disconnect error")) @@ -363,8 +374,7 @@ class TestConnectWithLock: monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") - import meshmon.env - meshmon.env._config = None + cfg = _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() @@ -377,7 +387,6 @@ class TestConnectWithLock: # Lock should be released after exiting context # We can verify by acquiring it again without timeout - cfg = meshmon.env.get_config() lock_path = cfg.state_dir / "serial.lock" if lock_path.exists(): import fcntl diff --git a/tests/client/test_contacts.py b/tests/client/test_contacts.py index d3c96cb..d43d9d3 100644 --- a/tests/client/test_contacts.py +++ b/tests/client/test_contacts.py @@ -1,5 +1,6 @@ """Tests for contact lookup functions.""" +from types import SimpleNamespace from unittest.mock import MagicMock @@ -28,6 +29,7 @@ class TestGetContactByName: result = get_contact_by_name(mock_meshcore_client, "NonExistent") assert result is None + mock_meshcore_client.get_contact_by_name.assert_called_once_with("NonExistent") def test_returns_none_when_method_not_available(self): """Returns None when get_contact_by_name method not available.""" @@ -48,6 +50,7 @@ class TestGetContactByName: result = get_contact_by_name(mock_meshcore_client, "TestNode") assert result is None + mock_meshcore_client.get_contact_by_name.assert_called_once_with("TestNode") class TestGetContactByKeyPrefix: @@ -75,6 +78,7 @@ class TestGetContactByKeyPrefix: result = get_contact_by_key_prefix(mock_meshcore_client, "xyz789") assert result is None + mock_meshcore_client.get_contact_by_key_prefix.assert_called_once_with("xyz789") def test_returns_none_when_method_not_available(self): """Returns None when get_contact_by_key_prefix method not available.""" @@ -95,6 +99,7 @@ class TestGetContactByKeyPrefix: result = get_contact_by_key_prefix(mock_meshcore_client, "abc123") assert result is None + mock_meshcore_client.get_contact_by_key_prefix.assert_called_once_with("abc123") class TestExtractContactInfo: @@ -126,13 +131,14 @@ class TestExtractContactInfo: """Extracts info from object-based contact.""" from meshmon.meshcore_client import extract_contact_info - contact = MagicMock() - contact.adv_name = "TestNode" - contact.name = "test" - contact.pubkey_prefix = "abc123" - contact.public_key = "abc123def456" - contact.type = 1 - contact.flags = 0 + contact = SimpleNamespace( + adv_name="TestNode", + name="test", + pubkey_prefix="abc123", + public_key="abc123def456", + type=1, + flags=0, + ) result = extract_contact_info(contact) @@ -158,14 +164,10 @@ class TestExtractContactInfo: """Converts bytes values from object attributes to hex.""" from meshmon.meshcore_client import extract_contact_info - contact = MagicMock() - contact.adv_name = "TestNode" - contact.public_key = bytes.fromhex("deadbeef") - # Make sure other attributes return AttributeError when accessed - del contact.name - del contact.pubkey_prefix - del contact.type - del contact.flags + contact = SimpleNamespace( + adv_name="TestNode", + public_key=bytes.fromhex("deadbeef"), + ) result = extract_contact_info(contact) @@ -231,13 +233,7 @@ class TestListContactsSummary: """Handles mix of dict and object contacts.""" from meshmon.meshcore_client import list_contacts_summary - obj_contact = MagicMock() - obj_contact.adv_name = "ObjectNode" - del obj_contact.name - del obj_contact.pubkey_prefix - del obj_contact.public_key - del obj_contact.type - del obj_contact.flags + obj_contact = SimpleNamespace(adv_name="ObjectNode") contacts = [ {"adv_name": "DictNode"}, diff --git a/tests/client/test_meshcore_available.py b/tests/client/test_meshcore_available.py index fbcd64f..4cfc418 100644 --- a/tests/client/test_meshcore_available.py +++ b/tests/client/test_meshcore_available.py @@ -1,6 +1,6 @@ """Tests for MESHCORE_AVAILABLE flag handling.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -28,6 +28,7 @@ class TestMeshcoreAvailableTrue: assert success is True assert event_type == "SELF_INFO" + assert payload == {"bat": 3850} assert error is None @pytest.mark.asyncio @@ -55,7 +56,7 @@ class TestMeshcoreAvailableTrue: result = await connect_from_env() assert result == mock_mc - mock_meshcore.create_serial.assert_called_once() + mock_meshcore.create_serial.assert_called_once_with("/dev/ttyACM0", 115200, debug=False) class TestMeshcoreAvailableFalse: @@ -109,28 +110,54 @@ class TestMeshcoreAvailableFalse: class TestMeshcoreImportFallback: """Tests for import fallback behavior.""" - def test_meshcore_none_when_import_fails(self): + def test_meshcore_none_when_import_fails(self, monkeypatch): """MeshCore is None when import fails.""" - # This tests the behavior defined in the module - # When meshcore library is not installed, MESHCORE_AVAILABLE should be False - # and MeshCore/EventType should be None + import builtins + import importlib - # We can verify the fallback behavior by checking module-level attributes - # after patching the import - with patch.dict("sys.modules", {"meshcore": None}): - # Importing with meshcore unavailable - # Note: This test verifies the pattern used in the module - # The actual import fallback is tested implicitly by the other tests - pass + import meshmon.meshcore_client as module - def test_event_type_check_handles_none(self, monkeypatch): + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "meshcore": + raise ImportError("No module named 'meshcore'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + module = importlib.reload(module) + + assert module.MESHCORE_AVAILABLE is False + assert module.MeshCore is None + assert module.EventType is None + + monkeypatch.setattr(builtins, "__import__", real_import) + importlib.reload(module) + + @pytest.mark.asyncio + async def test_event_type_check_handles_none(self, monkeypatch): """EventType checks handle None gracefully.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setattr("meshmon.meshcore_client.EventType", None) + from meshmon.meshcore_client import run_command - # When EventType is None, the error check should be skipped - # This is tested by the run_command success tests which mock EventType + from .conftest import make_mock_event + + event = make_mock_event("SELF_INFO", {"bat": 3850}) + + async def cmd(): + return event + + success, event_type, payload, error = await run_command( + MagicMock(), cmd(), "test" + ) + + assert success is True + assert event_type == "SELF_INFO" + assert payload == {"bat": 3850} + assert error is None class TestContactFunctionsWithUnavailableMeshcore: diff --git a/tests/client/test_run_command.py b/tests/client/test_run_command.py index 345adc0..1f2b8f7 100644 --- a/tests/client/test_run_command.py +++ b/tests/client/test_run_command.py @@ -70,7 +70,7 @@ class TestRunCommandSuccess: mock_meshcore_client, cmd(), "test" ) - assert "voltage" in payload + assert payload == {"voltage": 3.85} @pytest.mark.asyncio async def test_converts_namedtuple_payload(self, mock_meshcore_client, monkeypatch): @@ -91,8 +91,7 @@ class TestRunCommandSuccess: mock_meshcore_client, cmd(), "test" ) - assert payload["voltage"] == 3.85 - assert payload["uptime"] == 86400 + assert payload == {"voltage": 3.85, "uptime": 86400} class TestRunCommandFailure: @@ -118,7 +117,9 @@ class TestRunCommandFailure: cmd_coro.close() assert success is False - assert "not available" in error + assert event_type is None + assert payload is None + assert error == "meshcore not available" @pytest.mark.asyncio async def test_returns_failure_on_none_event(self, mock_meshcore_client, monkeypatch): @@ -133,7 +134,7 @@ class TestRunCommandFailure: ) assert success is False - assert "No response" in error + assert error == "No response received" @pytest.mark.asyncio async def test_returns_failure_on_error_event(self, mock_meshcore_client, monkeypatch): @@ -157,7 +158,9 @@ class TestRunCommandFailure: ) assert success is False - assert error is not None + assert event_type == "ERROR" + assert payload is None + assert error == "Command failed" @pytest.mark.asyncio async def test_returns_failure_on_timeout(self, mock_meshcore_client, monkeypatch): @@ -172,7 +175,7 @@ class TestRunCommandFailure: ) assert success is False - assert "Timeout" in error + assert error == "Timeout" @pytest.mark.asyncio async def test_returns_failure_on_exception(self, mock_meshcore_client, monkeypatch): @@ -187,7 +190,7 @@ class TestRunCommandFailure: ) assert success is False - assert "Connection lost" in error + assert error == "Connection lost" class TestRunCommandEventTypeParsing: @@ -203,11 +206,14 @@ class TestRunCommandEventTypeParsing: async def cmd(): return event - _, event_type, _, _ = await run_command( + success, event_type, payload, error = await run_command( mock_meshcore_client, cmd(), "test" ) + assert success is True assert event_type == "CUSTOM_EVENT" + assert payload == {} + assert error is None @pytest.mark.asyncio async def test_falls_back_to_str_type(self, mock_meshcore_client, monkeypatch): @@ -221,8 +227,11 @@ class TestRunCommandEventTypeParsing: async def cmd(): return event - _, event_type, _, _ = await run_command( + success, event_type, payload, error = await run_command( mock_meshcore_client, cmd(), "test" ) + assert success is True assert event_type == "STRING_TYPE" + assert payload == {} + assert error is None diff --git a/tests/config/test_config_file.py b/tests/config/test_config_file.py index 1c5cc85..5c79062 100644 --- a/tests/config/test_config_file.py +++ b/tests/config/test_config_file.py @@ -1,10 +1,24 @@ """Tests for meshcore.conf file parsing.""" -from unittest.mock import patch +import os from meshmon.env import _parse_config_value +def _load_config_from_content(tmp_path, monkeypatch, content: str | None) -> None: + import meshmon.env as env + + config_path = tmp_path / "meshcore.conf" + if content is not None: + config_path.write_text(content) + + fake_env_path = tmp_path / "src" / "meshmon" / "env.py" + fake_env_path.parent.mkdir(parents=True, exist_ok=True) + fake_env_path.write_text("") + + monkeypatch.setattr(env, "__file__", str(fake_env_path)) + env._load_config_file() + class TestParseConfigValueDetailed: """Detailed tests for _parse_config_value.""" @@ -112,15 +126,11 @@ class TestParseConfigValueDetailed: class TestLoadConfigFileBehavior: """Tests for _load_config_file behavior.""" - def test_nonexistent_file_no_error(self, tmp_path, monkeypatch): + def test_nonexistent_file_no_error(self, tmp_path, monkeypatch, isolate_config_loading): """Missing config file doesn't raise error.""" - # Point to non-existent path - fake_module_path = tmp_path / "src" / "meshmon" / "env.py" - fake_module_path.parent.mkdir(parents=True) - fake_module_path.write_text("") + _load_config_from_content(tmp_path, monkeypatch, content=None) - # No exception should be raised - # The function checks for existence first + assert "MESH_TRANSPORT" not in os.environ def test_skips_empty_lines(self, tmp_path, monkeypatch, isolate_config_loading): """Empty lines are skipped.""" @@ -130,41 +140,38 @@ MESH_TRANSPORT=tcp MESH_DEBUG=1 """ - config_path = tmp_path / "meshcore.conf" - config_path.write_text(config_content) + _load_config_from_content(tmp_path, monkeypatch, config_content) - # Mock the config path location - with patch("meshmon.env.Path") as mock_path: - mock_path.return_value.resolve.return_value.parent.parent.parent.__truediv__.return_value = config_path - mock_path.return_value.resolve.return_value.parent.parent.parent / "meshcore.conf" - # _load_config_file() would need to be called manually or tested via Config + assert os.environ["MESH_TRANSPORT"] == "tcp" + assert os.environ["MESH_DEBUG"] == "1" - def test_skips_comment_lines(self, tmp_path): + def test_skips_comment_lines(self, tmp_path, monkeypatch, isolate_config_loading): """Lines starting with # are skipped.""" config_content = """# This is a comment MESH_TRANSPORT=tcp # Another comment """ - config_path = tmp_path / "meshcore.conf" - config_path.write_text(config_content) - # The parsing logic skips lines starting with # + _load_config_from_content(tmp_path, monkeypatch, config_content) - def test_handles_export_prefix(self, tmp_path): + assert os.environ["MESH_TRANSPORT"] == "tcp" + + def test_handles_export_prefix(self, tmp_path, monkeypatch, isolate_config_loading): """Lines with 'export ' prefix are handled.""" config_content = "export MESH_TRANSPORT=tcp\n" - config_path = tmp_path / "meshcore.conf" - config_path.write_text(config_content) - # The parsing logic removes 'export ' prefix + _load_config_from_content(tmp_path, monkeypatch, config_content) - def test_skips_lines_without_equals(self, tmp_path): + assert os.environ["MESH_TRANSPORT"] == "tcp" + + def test_skips_lines_without_equals(self, tmp_path, monkeypatch, isolate_config_loading): """Lines without = are skipped.""" config_content = """MESH_TRANSPORT=tcp this line has no equals MESH_DEBUG=1 """ - config_path = tmp_path / "meshcore.conf" - config_path.write_text(config_content) - # Invalid lines are skipped + _load_config_from_content(tmp_path, monkeypatch, config_content) + + assert os.environ["MESH_TRANSPORT"] == "tcp" + assert os.environ["MESH_DEBUG"] == "1" def test_env_vars_take_precedence(self, tmp_path, monkeypatch, isolate_config_loading): """Environment variables override config file values.""" @@ -173,11 +180,9 @@ MESH_DEBUG=1 # Config file has different value config_content = "MESH_TRANSPORT=serial\n" - config_path = tmp_path / "meshcore.conf" - config_path.write_text(config_content) + _load_config_from_content(tmp_path, monkeypatch, config_content) # After loading, env var should still be "ble" - import os assert os.environ.get("MESH_TRANSPORT") == "ble" diff --git a/tests/config/test_env.py b/tests/config/test_env.py index 1516cc5..f49915e 100644 --- a/tests/config/test_env.py +++ b/tests/config/test_env.py @@ -35,7 +35,7 @@ class TestGetIntEdgeCases: assert get_int("TEST_INT", 0) == 42 def test_whitespace_around_number(self, monkeypatch): - """Whitespace around number causes fallback to default.""" + """Whitespace around number is tolerated by int().""" monkeypatch.setenv("TEST_INT", " 42 ") # Python's int() handles whitespace assert get_int("TEST_INT", 0) == 42 @@ -50,7 +50,7 @@ class TestGetBoolEdgeCases: assert get_bool("TEST_BOOL") is True def test_with_spaces(self, monkeypatch): - """Spaces are ignored after lowering.""" + """Whitespace causes a non-match since get_bool does not strip.""" monkeypatch.setenv("TEST_BOOL", " yes ") # .lower() doesn't strip, so " yes " != "yes" # This will return False diff --git a/tests/database/test_db_insert.py b/tests/database/test_db_insert.py index 8f34022..4012c75 100644 --- a/tests/database/test_db_insert.py +++ b/tests/database/test_db_insert.py @@ -1,7 +1,5 @@ """Tests for database insert functions.""" -import time - import pytest from meshmon.db import ( @@ -10,13 +8,15 @@ from meshmon.db import ( insert_metrics, ) +BASE_TS = 1704067200 + class TestInsertMetric: """Tests for insert_metric function.""" def test_inserts_single_metric(self, initialized_db): """Inserts a single metric successfully.""" - ts = int(time.time()) + ts = BASE_TS result = insert_metric(ts, "companion", "battery_mv", 3850.0, initialized_db) @@ -33,7 +33,7 @@ class TestInsertMetric: def test_returns_false_on_duplicate(self, initialized_db): """Returns False for duplicate (ts, role, metric) tuple.""" - ts = int(time.time()) + ts = BASE_TS # First insert succeeds assert insert_metric(ts, "companion", "test", 1.0, initialized_db) is True @@ -43,28 +43,28 @@ class TestInsertMetric: def test_different_roles_not_duplicate(self, initialized_db): """Same ts/metric with different roles are not duplicates.""" - ts = int(time.time()) + ts = BASE_TS assert insert_metric(ts, "companion", "test", 1.0, initialized_db) is True assert insert_metric(ts, "repeater", "test", 2.0, initialized_db) is True def test_different_metrics_not_duplicate(self, initialized_db): """Same ts/role with different metrics are not duplicates.""" - ts = int(time.time()) + ts = BASE_TS assert insert_metric(ts, "companion", "test1", 1.0, initialized_db) is True assert insert_metric(ts, "companion", "test2", 2.0, initialized_db) is True def test_invalid_role_raises(self, initialized_db): """Invalid role raises ValueError.""" - ts = int(time.time()) + ts = BASE_TS with pytest.raises(ValueError, match="Invalid role"): insert_metric(ts, "invalid", "test", 1.0, initialized_db) def test_sql_injection_blocked(self, initialized_db): """SQL injection attempt raises ValueError.""" - ts = int(time.time()) + ts = BASE_TS with pytest.raises(ValueError, match="Invalid role"): insert_metric(ts, "'; DROP TABLE metrics; --", "test", 1.0, initialized_db) @@ -75,7 +75,7 @@ class TestInsertMetrics: def test_inserts_multiple_metrics(self, initialized_db): """Inserts multiple metrics from dict.""" - ts = int(time.time()) + ts = BASE_TS metrics = { "battery_mv": 3850.0, "contacts": 5, @@ -95,7 +95,7 @@ class TestInsertMetrics: def test_returns_insert_count(self, initialized_db): """Returns correct count of inserted metrics.""" - ts = int(time.time()) + ts = BASE_TS metrics = {"a": 1.0, "b": 2.0, "c": 3.0} count = insert_metrics(ts, "companion", metrics, initialized_db) @@ -104,7 +104,7 @@ class TestInsertMetrics: def test_skips_non_numeric_values(self, initialized_db): """Non-numeric values are silently skipped.""" - ts = int(time.time()) + ts = BASE_TS metrics = { "battery_mv": 3850.0, # Numeric - inserted "name": "test", # String - skipped @@ -119,7 +119,7 @@ class TestInsertMetrics: def test_handles_int_and_float(self, initialized_db): """Both int and float values are inserted.""" - ts = int(time.time()) + ts = BASE_TS metrics = { "int_value": 42, "float_value": 3.14, @@ -131,7 +131,7 @@ class TestInsertMetrics: def test_converts_int_to_float(self, initialized_db): """Integer values are stored as float.""" - ts = int(time.time()) + ts = BASE_TS metrics = {"contacts": 5} insert_metrics(ts, "companion", metrics, initialized_db) @@ -146,7 +146,7 @@ class TestInsertMetrics: def test_empty_dict_returns_zero(self, initialized_db): """Empty dict returns 0.""" - ts = int(time.time()) + ts = BASE_TS count = insert_metrics(ts, "companion", {}, initialized_db) @@ -154,7 +154,7 @@ class TestInsertMetrics: def test_skips_duplicates_silently(self, initialized_db): """Duplicate metrics are skipped without error.""" - ts = int(time.time()) + ts = BASE_TS metrics = {"test": 1.0} # First insert @@ -167,7 +167,7 @@ class TestInsertMetrics: def test_partial_duplicates(self, initialized_db): """Partial duplicates: some inserted, some skipped.""" - ts = int(time.time()) + ts = BASE_TS # First insert insert_metrics(ts, "companion", {"existing": 1.0}, initialized_db) @@ -183,25 +183,25 @@ class TestInsertMetrics: def test_invalid_role_raises(self, initialized_db): """Invalid role raises ValueError.""" - ts = int(time.time()) + ts = BASE_TS with pytest.raises(ValueError, match="Invalid role"): insert_metrics(ts, "invalid", {"test": 1.0}, initialized_db) def test_companion_metrics(self, initialized_db, sample_companion_metrics): """Inserts companion metrics dict.""" - ts = int(time.time()) + ts = BASE_TS count = insert_metrics(ts, "companion", sample_companion_metrics, initialized_db) # Should insert all numeric fields - assert count >= 4 # At least battery_mv, uptime_secs, contacts, recv, sent + assert count == len(sample_companion_metrics) def test_repeater_metrics(self, initialized_db, sample_repeater_metrics): """Inserts repeater metrics dict.""" - ts = int(time.time()) + ts = BASE_TS count = insert_metrics(ts, "repeater", sample_repeater_metrics, initialized_db) # Should insert all numeric fields - assert count >= 10 # Many metrics + assert count == len(sample_repeater_metrics) diff --git a/tests/database/test_db_maintenance.py b/tests/database/test_db_maintenance.py index d012de6..fb68d2d 100644 --- a/tests/database/test_db_maintenance.py +++ b/tests/database/test_db_maintenance.py @@ -26,16 +26,24 @@ class TestVacuumDb: # Should not raise vacuum_db(initialized_db) - def test_runs_analyze(self, initialized_db, capfd): + def test_runs_analyze(self, initialized_db): """ANALYZE should be run after VACUUM.""" + conn = sqlite3.connect(initialized_db) + conn.execute( + "INSERT INTO metrics (ts, role, metric, value) VALUES (1, 'companion', 'test', 1.0)" + ) + conn.commit() + conn.close() + # Vacuum includes ANALYZE vacuum_db(initialized_db) # Check that database stats were updated conn = sqlite3.connect(initialized_db) - conn.execute("SELECT * FROM sqlite_stat1") - # After ANALYZE, sqlite_stat1 should have entries if tables have data + cursor = conn.execute("SELECT COUNT(*) FROM sqlite_stat1") + count = cursor.fetchone()[0] conn.close() + assert count > 0 def test_uses_default_path_when_none(self, configured_env, monkeypatch): """Uses get_db_path() when no path provided.""" diff --git a/tests/database/test_db_queries.py b/tests/database/test_db_queries.py index 832c79f..bf38fca 100644 --- a/tests/database/test_db_queries.py +++ b/tests/database/test_db_queries.py @@ -1,7 +1,5 @@ """Tests for database query functions.""" -import time - import pytest from meshmon.db import ( @@ -13,13 +11,15 @@ from meshmon.db import ( insert_metrics, ) +BASE_TS = 1704067200 + class TestGetMetricsForPeriod: """Tests for get_metrics_for_period function.""" def test_returns_dict_by_metric(self, initialized_db): """Returns dict with metric names as keys.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", { "battery_mv": 3850.0, "contacts": 5, @@ -35,7 +35,7 @@ class TestGetMetricsForPeriod: def test_returns_timestamp_value_tuples(self, initialized_db): """Each metric has list of (ts, value) tuples.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"test": 1.0}, initialized_db) result = get_metrics_for_period( @@ -47,7 +47,7 @@ class TestGetMetricsForPeriod: def test_sorted_by_timestamp(self, initialized_db): """Results are sorted by timestamp ascending.""" - base_ts = int(time.time()) + base_ts = BASE_TS # Insert out of order insert_metrics(base_ts + 200, "companion", {"test": 3.0}, initialized_db) @@ -63,7 +63,7 @@ class TestGetMetricsForPeriod: def test_respects_time_range(self, initialized_db): """Only returns data within specified time range.""" - base_ts = int(time.time()) + base_ts = BASE_TS insert_metrics(base_ts - 200, "companion", {"test": 1.0}, initialized_db) # Outside insert_metrics(base_ts, "companion", {"test": 2.0}, initialized_db) # Inside @@ -78,7 +78,7 @@ class TestGetMetricsForPeriod: def test_filters_by_role(self, initialized_db): """Only returns data for specified role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"test": 1.0}, initialized_db) insert_metrics(ts, "repeater", {"test": 2.0}, initialized_db) @@ -90,7 +90,7 @@ class TestGetMetricsForPeriod: def test_computes_bat_pct(self, initialized_db): """Computes bat_pct from battery voltage.""" - ts = int(time.time()) + ts = BASE_TS # 4200 mV = 4.2V = 100% insert_metrics(ts, "companion", {"battery_mv": 4200.0}, initialized_db) @@ -103,7 +103,7 @@ class TestGetMetricsForPeriod: def test_bat_pct_for_repeater(self, initialized_db): """Computes bat_pct for repeater using 'bat' field.""" - ts = int(time.time()) + ts = BASE_TS # 3000 mV = 3.0V = 0% insert_metrics(ts, "repeater", {"bat": 3000.0}, initialized_db) @@ -133,7 +133,7 @@ class TestGetLatestMetrics: def test_returns_most_recent(self, initialized_db): """Returns metrics at most recent timestamp.""" - base_ts = int(time.time()) + base_ts = BASE_TS insert_metrics(base_ts, "companion", {"test": 1.0}, initialized_db) insert_metrics(base_ts + 100, "companion", {"test": 2.0}, initialized_db) @@ -145,7 +145,7 @@ class TestGetLatestMetrics: def test_includes_ts(self, initialized_db): """Result includes 'ts' key with timestamp.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"test": 1.0}, initialized_db) result = get_latest_metrics("companion", initialized_db) @@ -155,7 +155,7 @@ class TestGetLatestMetrics: def test_includes_all_metrics(self, initialized_db): """Result includes all metrics at that timestamp.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", { "battery_mv": 3850.0, "contacts": 5, @@ -170,7 +170,7 @@ class TestGetLatestMetrics: def test_computes_bat_pct(self, initialized_db): """Computes bat_pct from battery voltage.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"battery_mv": 3820.0}, initialized_db) result = get_latest_metrics("companion", initialized_db) @@ -186,7 +186,7 @@ class TestGetLatestMetrics: def test_filters_by_role(self, initialized_db): """Only returns data for specified role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"test": 1.0}, initialized_db) insert_metrics(ts + 100, "repeater", {"test": 2.0}, initialized_db) @@ -206,7 +206,7 @@ class TestGetMetricCount: def test_counts_rows(self, initialized_db): """Counts total metric rows for role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"a": 1.0, "b": 2.0, "c": 3.0}, initialized_db) count = get_metric_count("companion", initialized_db) @@ -215,7 +215,7 @@ class TestGetMetricCount: def test_filters_by_role(self, initialized_db): """Only counts rows for specified role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"a": 1.0}, initialized_db) insert_metrics(ts, "repeater", {"b": 2.0, "c": 3.0}, initialized_db) @@ -238,7 +238,7 @@ class TestGetDistinctTimestamps: def test_counts_unique_timestamps(self, initialized_db): """Counts distinct timestamps.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"a": 1.0, "b": 2.0}, initialized_db) # 1 ts insert_metrics(ts + 100, "companion", {"a": 3.0}, initialized_db) # 2nd ts @@ -248,7 +248,7 @@ class TestGetDistinctTimestamps: def test_filters_by_role(self, initialized_db): """Only counts timestamps for specified role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"a": 1.0}, initialized_db) insert_metrics(ts + 100, "companion", {"a": 2.0}, initialized_db) insert_metrics(ts, "repeater", {"a": 3.0}, initialized_db) @@ -267,7 +267,7 @@ class TestGetAvailableMetrics: def test_returns_metric_names(self, initialized_db): """Returns list of distinct metric names.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", { "battery_mv": 3850.0, "contacts": 5, @@ -282,7 +282,7 @@ class TestGetAvailableMetrics: def test_sorted_alphabetically(self, initialized_db): """Metrics are sorted alphabetically.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", { "zebra": 1.0, "apple": 2.0, @@ -295,7 +295,7 @@ class TestGetAvailableMetrics: def test_filters_by_role(self, initialized_db): """Only returns metrics for specified role.""" - ts = int(time.time()) + ts = BASE_TS insert_metrics(ts, "companion", {"companion_metric": 1.0}, initialized_db) insert_metrics(ts, "repeater", {"repeater_metric": 2.0}, initialized_db) diff --git a/tests/html/test_jinja_env.py b/tests/html/test_jinja_env.py index ff61ac6..7559707 100644 --- a/tests/html/test_jinja_env.py +++ b/tests/html/test_jinja_env.py @@ -1,5 +1,7 @@ """Tests for Jinja2 environment and custom filters.""" +import re + import pytest from jinja2 import Environment @@ -32,9 +34,7 @@ class TestGetJinjaEnv: """Returns the same environment instance (cached).""" env1 = get_jinja_env() env2 = get_jinja_env() - # Implementation may or may not cache - just verify both work - assert env1 is not None - assert env2 is not None + assert env1 is env2 class TestJinjaFilters: @@ -54,16 +54,14 @@ class TestJinjaFilters: template = env.from_string("{{ value|format_number }}") result = template.render(value=1234567) - # Should have some separator - assert "1234567" not in result or len(result) > 7 + assert result == "1,234,567" def test_format_number_handles_none(self, env): """format_number handles None gracefully.""" template = env.from_string("{{ value|format_number }}") result = template.render(value=None) - # Should return dash or empty string for None - assert result in ["-", "N/A", "None", "", " - "] + assert result == "N/A" def test_format_time_filter_exists(self, env): """format_time filter is registered.""" @@ -73,19 +71,16 @@ class TestJinjaFilters: """format_time formats Unix timestamp.""" template = env.from_string("{{ value|format_time }}") - # Use a recent timestamp - import time - ts = int(time.time()) - 3600 + ts = 1704067200 result = template.render(value=ts) - # Should produce some formatted time string - assert len(result) > 0 + assert re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", result) def test_format_time_handles_none(self, env): """format_time handles None gracefully.""" template = env.from_string("{{ value|format_time }}") result = template.render(value=None) - assert result in ["-", "N/A", "None", "", " - "] + assert result == "N/A" def test_format_uptime_filter_exists(self, env): """format_uptime filter is registered.""" @@ -97,8 +92,7 @@ class TestJinjaFilters: # 1 day, 2 hours, 30 minutes = 95400 seconds result = template.render(value=95400) - # Should produce some duration output - assert len(result) > 0 + assert result == "1d 2h 30m" def test_format_duration_filter_exists(self, env): """format_duration filter is registered.""" diff --git a/tests/html/test_metrics_builders.py b/tests/html/test_metrics_builders.py index e356979..01d9f3e 100644 --- a/tests/html/test_metrics_builders.py +++ b/tests/html/test_metrics_builders.py @@ -96,8 +96,8 @@ class TestBuildNodeDetails: result = build_node_details("repeater") # Should have hardware in one of the items - labels = [item.get("label", "").lower() for item in result] - assert "hardware" in labels + hardware = next(item for item in result if item.get("label") == "Hardware") + assert hardware["value"] == "Test LoRa Device" def test_different_roles(self, configured_env): """Different roles return details.""" @@ -132,8 +132,8 @@ class TestBuildRadioConfig: result = build_radio_config() - values = [item.get("value", "") for item in result] - assert any("869" in str(v) for v in values) + freq = next(item for item in result if item.get("label") == "Frequency") + assert freq["value"] == "869.618 MHz" def test_handles_missing_config(self, configured_env): """Returns list even with default config.""" @@ -167,6 +167,9 @@ class TestBuildTrafficTableRows: assert "label" in row assert "rx" in row assert "tx" in row + assert "rx_raw" in row + assert "tx_raw" in row + assert "unit" in row def test_handles_empty_list(self): """Handles empty traffic metrics list.""" diff --git a/tests/html/test_page_context.py b/tests/html/test_page_context.py index 9863d06..212b0ca 100644 --- a/tests/html/test_page_context.py +++ b/tests/html/test_page_context.py @@ -1,6 +1,6 @@ """Tests for page context building.""" -import time +from datetime import datetime, timedelta import pytest @@ -9,41 +9,54 @@ from meshmon.html import ( get_status, ) +FIXED_NOW = datetime(2024, 1, 1, 12, 0, 0) + + +@pytest.fixture +def fixed_now(monkeypatch): + class FixedDatetime(datetime): + @classmethod + def now(cls): + return FIXED_NOW + + monkeypatch.setattr("meshmon.html.datetime", FixedDatetime) + return FIXED_NOW + class TestGetStatus: """Tests for get_status function.""" - def test_online_for_recent_data(self): + def test_online_for_recent_data(self, fixed_now): """Returns 'online' for data less than 30 minutes old.""" # 10 minutes ago - recent_ts = int(time.time()) - 600 + recent_ts = int(fixed_now.timestamp()) - 600 status_class, status_label = get_status(recent_ts) assert status_class == "online" - def test_stale_for_medium_age_data(self): + def test_stale_for_medium_age_data(self, fixed_now): """Returns 'stale' for data 30 minutes to 2 hours old.""" # 1 hour ago - medium_ts = int(time.time()) - 3600 + medium_ts = int(fixed_now.timestamp()) - 3600 status_class, status_label = get_status(medium_ts) assert status_class == "stale" - def test_offline_for_old_data(self): + def test_offline_for_old_data(self, fixed_now): """Returns 'offline' for data more than 2 hours old.""" # 3 hours ago - old_ts = int(time.time()) - 10800 + old_ts = int(fixed_now.timestamp()) - 10800 status_class, status_label = get_status(old_ts) assert status_class == "offline" - def test_offline_for_very_old_data(self): + def test_offline_for_very_old_data(self, fixed_now): """Returns 'offline' for very old data.""" # 7 days ago - very_old_ts = int(time.time()) - 604800 + very_old_ts = int(fixed_now.timestamp()) - int(timedelta(days=7).total_seconds()) status_class, status_label = get_status(very_old_ts) @@ -61,41 +74,39 @@ class TestGetStatus: assert status_class == "offline" - def test_online_for_current_time(self): + def test_online_for_current_time(self, fixed_now): """Returns 'online' for current timestamp.""" - now_ts = int(time.time()) + now_ts = int(fixed_now.timestamp()) status_class, status_label = get_status(now_ts) assert status_class == "online" - def test_boundary_30_minutes(self): + def test_boundary_30_minutes(self, fixed_now): """Tests boundary at exactly 30 minutes.""" # Exactly 30 minutes ago - boundary_ts = int(time.time()) - 1800 + boundary_ts = int(fixed_now.timestamp()) - 1800 status_class, _ = get_status(boundary_ts) - # At boundary, could be either online or stale depending on implementation - assert status_class in ["online", "stale"] + assert status_class == "stale" - def test_boundary_2_hours(self): + def test_boundary_2_hours(self, fixed_now): """Tests boundary at exactly 2 hours.""" # Exactly 2 hours ago - boundary_ts = int(time.time()) - 7200 + boundary_ts = int(fixed_now.timestamp()) - 7200 status_class, _ = get_status(boundary_ts) - # At boundary, could be either stale or offline - assert status_class in ["stale", "offline"] + assert status_class == "offline" - def test_returns_tuple(self): + def test_returns_tuple(self, fixed_now): """Returns tuple of (status_class, status_label).""" - status = get_status(int(time.time())) + status = get_status(int(fixed_now.timestamp())) assert isinstance(status, tuple) assert len(status) == 2 - def test_status_label_is_string(self): + def test_status_label_is_string(self, fixed_now): """Status label is a string.""" - _, status_label = get_status(int(time.time())) + _, status_label = get_status(int(fixed_now.timestamp())) assert isinstance(status_label, str) @@ -103,10 +114,10 @@ class TestBuildPageContext: """Tests for build_page_context function.""" @pytest.fixture - def sample_row(self, sample_repeater_metrics): + def sample_row(self, sample_repeater_metrics, fixed_now): """Create a sample row with timestamp.""" row = sample_repeater_metrics.copy() - row["ts"] = int(time.time()) - 300 # 5 minutes ago + row["ts"] = int(fixed_now.timestamp()) - 300 # 5 minutes ago return row def test_returns_dict(self, configured_env, sample_row): @@ -141,8 +152,7 @@ class TestBuildPageContext: at_root=True, ) - assert "status_class" in context - assert context["status_class"] in ["online", "stale", "offline"] + assert context["status_class"] == "online" def test_handles_none_row(self, configured_env): """Handles None row gracefully.""" @@ -183,10 +193,10 @@ class TestBuildPageContext: assert "period" in context assert context["period"] == "day" - def test_different_roles(self, configured_env, sample_row, sample_companion_metrics): + def test_different_roles(self, configured_env, sample_row, sample_companion_metrics, fixed_now): """Context varies by role.""" companion_row = sample_companion_metrics.copy() - companion_row["ts"] = int(time.time()) - 300 + companion_row["ts"] = int(fixed_now.timestamp()) - 300 repeater_context = build_page_context( role="repeater", @@ -219,6 +229,5 @@ class TestBuildPageContext: at_root=False, ) - # Non-root pages need relative path to CSS - assert "css_path" in root_context or "at_root" in root_context - assert "css_path" in non_root_context or "at_root" in non_root_context + assert root_context["css_path"] == "/" + assert non_root_context["css_path"] == "../" diff --git a/tests/html/test_reports_index.py b/tests/html/test_reports_index.py index d471ede..1615e3f 100644 --- a/tests/html/test_reports_index.py +++ b/tests/html/test_reports_index.py @@ -56,14 +56,13 @@ class TestRenderReportsIndex: """Index page includes title.""" html = render_reports_index(sample_report_sections) - assert "" in html - assert "Report" in html or "report" in html + assert "Reports Archive" in html def test_includes_year(self, configured_env, sample_report_sections): """Lists available years.""" html = render_reports_index(sample_report_sections) - assert "2024" in html + assert "/reports/repeater/2024/" in html def test_handles_empty_sections(self, configured_env): """Handles empty report sections.""" @@ -76,8 +75,21 @@ class TestRenderReportsIndex: """Includes role names in output.""" html = render_reports_index(sample_report_sections) - # Should mention both roles - assert "repeater" in html.lower() or "Repeater" in html + assert "Repeater" in html + assert "Companion" in html + + def test_includes_descriptions(self, configured_env, sample_report_sections, monkeypatch): + """Includes role descriptions from config.""" + monkeypatch.setenv("REPEATER_DISPLAY_NAME", "Alpha Repeater") + monkeypatch.setenv("COMPANION_DISPLAY_NAME", "Beta Node") + monkeypatch.setenv("REPORT_LOCATION_SHORT", "Test Ridge") + import meshmon.env + meshmon.env._config = None + + html = render_reports_index(sample_report_sections) + + assert "Alpha Repeater — Remote node in Test Ridge" in html + assert "Beta Node — Local USB-connected node" in html def test_includes_css_reference(self, configured_env, sample_report_sections): """Includes reference to stylesheet.""" @@ -95,4 +107,4 @@ class TestRenderReportsIndex: html = render_reports_index(sections) assert isinstance(html, str) - assert "</html>" in html + assert "No reports available yet." in html diff --git a/tests/html/test_write_site.py b/tests/html/test_write_site.py index 9879df6..b4533a1 100644 --- a/tests/html/test_write_site.py +++ b/tests/html/test_write_site.py @@ -1,6 +1,5 @@ """Tests for write_site and related output functions.""" - import pytest from meshmon.db import get_latest_metrics @@ -9,9 +8,96 @@ from meshmon.html import ( write_site, ) +BASE_TS = 1704067200 + + +def _sample_companion_metrics() -> dict[str, float]: + return { + "battery_mv": 3850.0, + "uptime_secs": 86400.0, + "contacts": 5.0, + "recv": 1234.0, + "sent": 567.0, + "errors": 0.0, + } + + +def _sample_repeater_metrics() -> dict[str, float]: + return { + "bat": 3920.0, + "uptime": 172800.0, + "last_rssi": -85.0, + "last_snr": 7.5, + "noise_floor": -115.0, + "tx_queue_len": 0.0, + "nb_recv": 5678.0, + "nb_sent": 2345.0, + "airtime": 3600.0, + "rx_airtime": 7200.0, + "flood_dups": 12.0, + "direct_dups": 5.0, + "sent_flood": 100.0, + "recv_flood": 200.0, + "sent_direct": 50.0, + "recv_direct": 75.0, + } + + +@pytest.fixture(scope="module") +def html_db_cache(tmp_path_factory): + """Create and populate a shared DB once for HTML write_site tests.""" + from meshmon.db import init_db, insert_metrics + + root_dir = tmp_path_factory.mktemp("html-db") + state_dir = root_dir / "state" + state_dir.mkdir() + + db_path = state_dir / "metrics.db" + init_db(db_path=db_path) + + now = BASE_TS + day_seconds = 86400 + + sample_companion_metrics = _sample_companion_metrics() + sample_repeater_metrics = _sample_repeater_metrics() + + # Insert 7 days of companion data (every hour) + for day in range(7): + for hour in range(24): + ts = now - (day * day_seconds) - (hour * 3600) + metrics = sample_companion_metrics.copy() + metrics["battery_mv"] = 3700 + (hour * 10) + (day * 5) + metrics["recv"] = 100 * (day + 1) + hour + metrics["sent"] = 50 * (day + 1) + hour + insert_metrics(ts, "companion", metrics, db_path=db_path) + + # Insert 7 days of repeater data (every 15 minutes) + for day in range(7): + for interval in range(96): # 24 * 4 + ts = now - (day * day_seconds) - (interval * 900) + metrics = sample_repeater_metrics.copy() + metrics["bat"] = 3700 + (interval * 2) + (day * 5) + metrics["nb_recv"] = 1000 * (day + 1) + interval * 10 + metrics["nb_sent"] = 500 * (day + 1) + interval * 5 + insert_metrics(ts, "repeater", metrics, db_path=db_path) + + return {"state_dir": state_dir, "db_path": db_path} + @pytest.fixture -def metrics_rows(populated_db): +def html_env(html_db_cache, tmp_out_dir, monkeypatch): + """Env with shared DB and per-test output directory.""" + monkeypatch.setenv("STATE_DIR", str(html_db_cache["state_dir"])) + monkeypatch.setenv("OUT_DIR", str(tmp_out_dir)) + + import meshmon.env + meshmon.env._config = None + + return {"state_dir": html_db_cache["state_dir"], "out_dir": tmp_out_dir} + + +@pytest.fixture +def metrics_rows(html_env): """Get latest metrics rows for both roles.""" companion_row = get_latest_metrics("companion") repeater_row = get_latest_metrics("repeater") @@ -21,17 +107,17 @@ def metrics_rows(populated_db): class TestWriteSite: """Tests for write_site function.""" - def test_creates_output_directory(self, configured_env, metrics_rows): + def test_creates_output_directory(self, html_env, metrics_rows): """Creates output directory if it doesn't exist.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) assert out_dir.exists() - def test_generates_repeater_pages(self, configured_env, metrics_rows): + def test_generates_repeater_pages(self, html_env, metrics_rows): """Generates repeater HTML pages at root.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -39,9 +125,9 @@ class TestWriteSite: for period in ["day", "week", "month", "year"]: assert (out_dir / f"{period}.html").exists() - def test_generates_companion_pages(self, configured_env, metrics_rows): + def test_generates_companion_pages(self, html_env, metrics_rows): """Generates companion HTML pages in subdirectory.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -50,9 +136,9 @@ class TestWriteSite: for period in ["day", "week", "month", "year"]: assert (companion_dir / f"{period}.html").exists() - def test_html_files_are_valid(self, configured_env, metrics_rows): + def test_html_files_are_valid(self, html_env, metrics_rows): """Generated HTML files have valid structure.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -76,9 +162,9 @@ class TestWriteSite: class TestCopyStaticAssets: """Tests for copy_static_assets function.""" - def test_copies_css(self, configured_env): + def test_copies_css(self, html_env): """Copies CSS stylesheet.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] out_dir.mkdir(parents=True, exist_ok=True) copy_static_assets() @@ -86,9 +172,9 @@ class TestCopyStaticAssets: css_file = out_dir / "styles.css" assert css_file.exists() - def test_copies_javascript(self, configured_env): + def test_copies_javascript(self, html_env): """Copies JavaScript files.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] out_dir.mkdir(parents=True, exist_ok=True) copy_static_assets() @@ -96,9 +182,9 @@ class TestCopyStaticAssets: js_file = out_dir / "chart-tooltip.js" assert js_file.exists() - def test_css_is_valid(self, configured_env): + def test_css_is_valid(self, html_env): """Copied CSS has expected content.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] out_dir.mkdir(parents=True, exist_ok=True) copy_static_assets() @@ -106,12 +192,11 @@ class TestCopyStaticAssets: css_file = out_dir / "styles.css" content = css_file.read_text() - # Should have CSS variables - assert "--" in content or "{" in content + assert "--bg-primary" in content - def test_requires_output_directory(self, configured_env): + def test_requires_output_directory(self, html_env): """Requires output directory to exist.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] # Ensure out_dir exists out_dir.mkdir(parents=True, exist_ok=True) @@ -120,9 +205,9 @@ class TestCopyStaticAssets: assert (out_dir / "styles.css").exists() - def test_overwrites_existing(self, configured_env): + def test_overwrites_existing(self, html_env): """Overwrites existing static files.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] out_dir.mkdir(parents=True, exist_ok=True) # Create a fake CSS file @@ -133,15 +218,15 @@ class TestCopyStaticAssets: # Should be overwritten with real content content = css_file.read_text() - assert "/* fake */" not in content or len(content) > 20 - + assert content != "/* fake */" + class TestHtmlOutput: """Tests for HTML output structure.""" - def test_pages_include_navigation(self, configured_env, metrics_rows): + def test_pages_include_navigation(self, html_env, metrics_rows): """HTML pages include navigation.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -151,9 +236,9 @@ class TestHtmlOutput: assert "week" in content.lower() assert "month" in content.lower() - def test_pages_include_meta_tags(self, configured_env, metrics_rows): + def test_pages_include_meta_tags(self, html_env, metrics_rows): """HTML pages include meta tags.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -162,9 +247,9 @@ class TestHtmlOutput: assert "<meta" in content assert "charset" in content.lower() or "utf-8" in content.lower() - def test_pages_include_title(self, configured_env, metrics_rows): + def test_pages_include_title(self, html_env, metrics_rows): """HTML pages include title tag.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -173,9 +258,9 @@ class TestHtmlOutput: assert "<title>" in content assert "" in content - def test_pages_reference_css(self, configured_env, metrics_rows): + def test_pages_reference_css(self, html_env, metrics_rows): """HTML pages reference stylesheet.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) @@ -183,9 +268,9 @@ class TestHtmlOutput: assert "styles.css" in content - def test_companion_pages_relative_css(self, configured_env, metrics_rows): + def test_companion_pages_relative_css(self, html_env, metrics_rows): """Companion pages use relative path to CSS.""" - out_dir = configured_env["out_dir"] + out_dir = html_env["out_dir"] write_site(metrics_rows["companion"], metrics_rows["repeater"]) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b3836ea..863e31e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,46 +1,222 @@ """Integration test fixtures.""" +import os import time from unittest.mock import AsyncMock, MagicMock import pytest +_INTEGRATION_ENV = { + "REPORT_LOCATION_NAME": "Test Location", + "REPORT_LOCATION_SHORT": "Test", + "REPEATER_DISPLAY_NAME": "Test Repeater", + "COMPANION_DISPLAY_NAME": "Test Companion", + "MESH_TRANSPORT": "serial", + "MESH_SERIAL_PORT": "/dev/ttyACM0", +} +RENDERED_CHART_METRICS = { + "companion": ["battery_mv", "recv", "contacts"], + "repeater": ["bat", "nb_recv", "last_rssi"], +} -@pytest.fixture -def populated_db_with_history(initialized_db, sample_companion_metrics, sample_repeater_metrics): - """Database populated with 30 days of historical data for integration tests.""" + +def _sample_companion_metrics() -> dict[str, float]: + return { + "battery_mv": 3850.0, + "uptime_secs": 86400.0, + "contacts": 5.0, + "recv": 1234.0, + "sent": 567.0, + "errors": 0.0, + } + + +def _sample_repeater_metrics() -> dict[str, float]: + return { + "bat": 3920.0, + "uptime": 172800.0, + "last_rssi": -85.0, + "last_snr": 7.5, + "noise_floor": -115.0, + "tx_queue_len": 0.0, + "nb_recv": 5678.0, + "nb_sent": 2345.0, + "airtime": 3600.0, + "rx_airtime": 7200.0, + "flood_dups": 12.0, + "direct_dups": 5.0, + "sent_flood": 100.0, + "recv_flood": 200.0, + "sent_direct": 50.0, + "recv_direct": 75.0, + } + + +def _populate_db_with_history( + db_path, + sample_companion_metrics: dict[str, float], + sample_repeater_metrics: dict[str, float], + days: int = 30, + companion_step_seconds: int = 3600, + repeater_step_seconds: int = 900, +) -> None: from meshmon.db import insert_metrics now = int(time.time()) day_seconds = 86400 + companion_steps = day_seconds // companion_step_seconds + repeater_steps = day_seconds // repeater_step_seconds - # Insert 30 days of companion data (every hour) - for day in range(30): - for hour in range(24): - ts = now - (day * day_seconds) - (hour * 3600) + # Insert companion data (default: 30 days, hourly) + for day in range(days): + for step in range(companion_steps): + ts = now - (day * day_seconds) - (step * companion_step_seconds) metrics = sample_companion_metrics.copy() # Vary values to create realistic patterns - metrics["battery_mv"] = 3700 + (hour * 5) + (day % 7) * 10 - metrics["recv"] = 100 + day * 10 + hour - metrics["sent"] = 50 + day * 5 + hour - metrics["uptime_secs"] = (30 - day) * day_seconds + hour * 3600 - insert_metrics(ts, "companion", metrics, initialized_db) + metrics["battery_mv"] = 3700 + (step * 5) + (day % 7) * 10 + metrics["recv"] = 100 + day * 10 + step + metrics["sent"] = 50 + day * 5 + step + metrics["uptime_secs"] = (days - day) * day_seconds + step * companion_step_seconds + insert_metrics(ts, "companion", metrics, db_path=db_path) - # Insert 30 days of repeater data (every 15 minutes) - for day in range(30): - for interval in range(96): # 24 * 4 = 96 intervals per day - ts = now - (day * day_seconds) - (interval * 900) + # Insert repeater data (default: 30 days, every 15 minutes) + for day in range(days): + for interval in range(repeater_steps): + ts = now - (day * day_seconds) - (interval * repeater_step_seconds) metrics = sample_repeater_metrics.copy() # Vary values to create realistic patterns metrics["bat"] = 3800 + (interval % 24) * 5 + (day % 7) * 10 metrics["nb_recv"] = 1000 + day * 100 + interval metrics["nb_sent"] = 500 + day * 50 + interval - metrics["uptime"] = (30 - day) * day_seconds + interval * 900 + metrics["uptime"] = (days - day) * day_seconds + interval * repeater_step_seconds metrics["last_rssi"] = -90 + (interval % 20) metrics["last_snr"] = 5 + (interval % 10) * 0.5 - insert_metrics(ts, "repeater", metrics, initialized_db) + insert_metrics(ts, "repeater", metrics, db_path=db_path) - return initialized_db + +@pytest.fixture +def reports_env(reports_db_cache, tmp_out_dir, monkeypatch): + """Integration env wired to the shared reports DB and per-test output.""" + monkeypatch.setenv("STATE_DIR", str(reports_db_cache["state_dir"])) + monkeypatch.setenv("OUT_DIR", str(tmp_out_dir)) + for key, value in _INTEGRATION_ENV.items(): + monkeypatch.setenv(key, value) + + import meshmon.env + meshmon.env._config = None + + return { + "state_dir": reports_db_cache["state_dir"], + "out_dir": tmp_out_dir, + } + + +@pytest.fixture(scope="session") +def rendered_chart_metrics(): + """Minimal chart set to keep integration rendering tests fast.""" + return RENDERED_CHART_METRICS + + +@pytest.fixture +def populated_db_with_history(reports_db_cache, reports_env): + """Shared database populated with a fixed history window for integration tests.""" + return reports_db_cache["db_path"] + + +@pytest.fixture(scope="module") +def reports_db_cache(tmp_path_factory): + """Create and populate a shared reports DB once per module.""" + from meshmon.db import init_db + + root_dir = tmp_path_factory.mktemp("reports-db") + state_dir = root_dir / "state" + state_dir.mkdir() + + db_path = state_dir / "metrics.db" + init_db(db_path=db_path) + _populate_db_with_history( + db_path, + _sample_companion_metrics(), + _sample_repeater_metrics(), + days=14, + companion_step_seconds=7200, + repeater_step_seconds=7200, + ) + + return { + "state_dir": state_dir, + "db_path": db_path, + } + + +@pytest.fixture(scope="module") +def rendered_charts_cache(tmp_path_factory): + """Cache rendered charts once per module to speed up integration tests.""" + from meshmon.charts import render_all_charts, save_chart_stats + from meshmon.db import init_db + + root_dir = tmp_path_factory.mktemp("rendered-charts") + state_dir = root_dir / "state" + out_dir = root_dir / "out" + state_dir.mkdir() + out_dir.mkdir() + + env_keys = ["STATE_DIR", "OUT_DIR", *_INTEGRATION_ENV.keys()] + previous_env = {key: os.environ.get(key) for key in env_keys} + + os.environ["STATE_DIR"] = str(state_dir) + os.environ["OUT_DIR"] = str(out_dir) + for key, value in _INTEGRATION_ENV.items(): + os.environ[key] = value + + import meshmon.env + meshmon.env._config = None + + db_path = state_dir / "metrics.db" + init_db(db_path=db_path) + _populate_db_with_history( + db_path, + _sample_companion_metrics(), + _sample_repeater_metrics(), + days=7, + companion_step_seconds=3600, + repeater_step_seconds=3600, + ) + + for role in ["companion", "repeater"]: + charts, stats = render_all_charts(role, metrics=RENDERED_CHART_METRICS[role]) + save_chart_stats(role, stats) + + yield { + "state_dir": state_dir, + "out_dir": out_dir, + "db_path": db_path, + } + + for key, value in previous_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + meshmon.env._config = None + + +@pytest.fixture +def rendered_charts(rendered_charts_cache, monkeypatch): + """Expose cached charts with env wired for per-test access.""" + state_dir = rendered_charts_cache["state_dir"] + out_dir = rendered_charts_cache["out_dir"] + + monkeypatch.setenv("STATE_DIR", str(state_dir)) + monkeypatch.setenv("OUT_DIR", str(out_dir)) + for key, value in _INTEGRATION_ENV.items(): + monkeypatch.setenv(key, value) + + import meshmon.env + meshmon.env._config = None + + return rendered_charts_cache @pytest.fixture @@ -89,18 +265,9 @@ def mock_meshcore_successful_collection(sample_companion_metrics): @pytest.fixture def full_integration_env(configured_env, monkeypatch): - """Full integration environment with all directories set up. - - Builds on top of configured_env from root conftest.py to ensure - consistent directory paths when used with other fixtures like - initialized_db and populated_db_with_history. - """ - monkeypatch.setenv("REPORT_LOCATION_NAME", "Test Location") - monkeypatch.setenv("REPORT_LOCATION_SHORT", "Test") - monkeypatch.setenv("REPEATER_DISPLAY_NAME", "Test Repeater") - monkeypatch.setenv("COMPANION_DISPLAY_NAME", "Test Companion") - monkeypatch.setenv("MESH_TRANSPORT", "serial") - monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") + """Full integration environment with per-test directories.""" + for key, value in _INTEGRATION_ENV.items(): + monkeypatch.setenv(key, value) import meshmon.env meshmon.env._config = None diff --git a/tests/integration/test_collection_pipeline.py b/tests/integration/test_collection_pipeline.py index 83432a6..d1b5f25 100644 --- a/tests/integration/test_collection_pipeline.py +++ b/tests/integration/test_collection_pipeline.py @@ -5,6 +5,10 @@ from unittest.mock import patch import pytest +from tests.scripts.conftest import load_script_module + +BASE_TS = 1704067200 + @pytest.mark.integration class TestCompanionCollectionPipeline: @@ -36,12 +40,10 @@ class TestCompanionCollectionPipeline: # Import and run collection (inline to avoid import issues) # Note: We import the function directly rather than the script - import time - from meshmon.db import insert_metrics # Simulate collection logic - ts = int(time.time()) + ts = BASE_TS metrics = {} async with mock_connect_with_lock() as mc: @@ -115,22 +117,31 @@ class TestCollectionWithCircuitBreaker: self, full_integration_env, monkeypatch ): """Collection should be skipped when circuit breaker is open.""" - import time - from meshmon.retry import CircuitBreaker # Create an open circuit breaker state_dir = full_integration_env["state_dir"] cb = CircuitBreaker(state_dir / "repeater_circuit.json") - cb.consecutive_failures = 10 - cb.cooldown_until = time.time() + 3600 # 1 hour from now - cb._save() # Use private method + cb.record_failure(max_failures=1, cooldown_s=3600) # Verify circuit is open assert cb.is_open() is True - # Collection should check circuit breaker and skip - # This tests the pattern used in collect_repeater.py + module = load_script_module("collect_repeater.py") + connect_called = False + + @asynccontextmanager + async def mock_connect_with_lock(*args, **kwargs): + nonlocal connect_called + connect_called = True + yield None + + monkeypatch.setattr(module, "connect_with_lock", mock_connect_with_lock) + + result = await module.collect_repeater() + + assert result == 0 + assert connect_called is False @pytest.mark.asyncio async def test_circuit_breaker_records_failure(self, full_integration_env, monkeypatch): @@ -157,8 +168,6 @@ class TestCollectionWithCircuitBreaker: @pytest.mark.asyncio async def test_circuit_breaker_state_persists(self, full_integration_env): """Circuit breaker state should persist to disk.""" - import time - from meshmon.retry import CircuitBreaker state_dir = full_integration_env["state_dir"] @@ -166,12 +175,10 @@ class TestCollectionWithCircuitBreaker: # Create and configure circuit breaker cb1 = CircuitBreaker(state_file) - cb1.consecutive_failures = 5 - cb1.cooldown_until = time.time() + 1800 - cb1._save() # Use private method + cb1.record_failure(max_failures=1, cooldown_s=1800) # Load in new instance cb2 = CircuitBreaker(state_file) - assert cb2.consecutive_failures == 5 + assert cb2.consecutive_failures == 1 assert cb2.cooldown_until == cb1.cooldown_until diff --git a/tests/integration/test_rendering_pipeline.py b/tests/integration/test_rendering_pipeline.py index 3916b8b..3ed3cdd 100644 --- a/tests/integration/test_rendering_pipeline.py +++ b/tests/integration/test_rendering_pipeline.py @@ -9,42 +9,21 @@ import pytest class TestChartRenderingPipeline: """Test chart rendering end-to-end.""" - def test_renders_all_chart_periods(self, populated_db_with_history, full_integration_env): + def test_renders_all_chart_periods(self, rendered_charts): """Should render charts for all periods (day/week/month/year).""" - from meshmon.charts import render_all_charts, save_chart_stats - from meshmon.db import get_metric_count + out_dir = rendered_charts["out_dir"] - # Verify data exists - companion_count = get_metric_count("companion") - repeater_count = get_metric_count("repeater") + for role in ["companion", "repeater"]: + assets_dir = out_dir / "assets" / role + assert assets_dir.exists() - assert companion_count > 0 - assert repeater_count > 0 + for period in ["day", "week", "month", "year"]: + period_svgs = list(assets_dir.glob(f"*_{period}_*.svg")) + assert period_svgs, f"No {period} charts found for {role}" - # Render companion charts - charts, stats = render_all_charts("companion") - save_chart_stats("companion", stats) - - # Should have charts for multiple metrics and periods - assert len(charts) > 0 - assert len(stats) > 0 - - # Render repeater charts - charts, stats = render_all_charts("repeater") - save_chart_stats("repeater", stats) - - assert len(charts) > 0 - assert len(stats) > 0 - - def test_chart_files_created(self, populated_db_with_history, full_integration_env): + def test_chart_files_created(self, rendered_charts): """Should create SVG chart files in output directory.""" - from meshmon.charts import render_all_charts, save_chart_stats - - out_dir = full_integration_env["out_dir"] - - # Render charts - charts, stats = render_all_charts("repeater") - save_chart_stats("repeater", stats) + out_dir = rendered_charts["out_dir"] # Check SVG files exist assets_dir = out_dir / "assets" / "repeater" @@ -58,13 +37,9 @@ class TestChartRenderingPipeline: stats_file = assets_dir / "chart_stats.json" assert stats_file.exists() - def test_chart_statistics_calculated(self, populated_db_with_history, full_integration_env): + def test_chart_statistics_calculated(self, rendered_charts): """Should calculate correct statistics for charts.""" - from meshmon.charts import load_chart_stats, render_all_charts, save_chart_stats - - # Render charts - charts, stats = render_all_charts("repeater") - save_chart_stats("repeater", stats) + from meshmon.charts import load_chart_stats # Load and verify stats loaded_stats = load_chart_stats("repeater") @@ -81,24 +56,19 @@ class TestChartRenderingPipeline: assert "min" in period_stats assert "max" in period_stats assert "avg" in period_stats + assert "current" in period_stats @pytest.mark.integration class TestHtmlRenderingPipeline: """Test HTML site rendering end-to-end.""" - def test_renders_site_pages(self, populated_db_with_history, full_integration_env): + def test_renders_site_pages(self, rendered_charts): """Should render all HTML site pages.""" - from meshmon.charts import render_all_charts, save_chart_stats from meshmon.db import get_latest_metrics from meshmon.html import write_site - out_dir = full_integration_env["out_dir"] - - # First render charts (needed for site) - for role in ["repeater", "companion"]: - charts, stats = render_all_charts(role) - save_chart_stats(role, stats) + out_dir = rendered_charts["out_dir"] # Get latest metrics for write_site companion_row = get_latest_metrics("companion") @@ -116,6 +86,8 @@ class TestHtmlRenderingPipeline: # Check companion pages exist assert (out_dir / "companion" / "day.html").exists() assert (out_dir / "companion" / "week.html").exists() + assert (out_dir / "companion" / "month.html").exists() + assert (out_dir / "companion" / "year.html").exists() def test_copies_static_assets(self, full_integration_env): """Should copy static assets (CSS, JS).""" @@ -129,18 +101,12 @@ class TestHtmlRenderingPipeline: assert (out_dir / "styles.css").exists() assert (out_dir / "chart-tooltip.js").exists() - def test_html_contains_chart_data(self, populated_db_with_history, full_integration_env): + def test_html_contains_chart_data(self, rendered_charts): """HTML should contain embedded chart SVGs.""" - from meshmon.charts import render_all_charts, save_chart_stats from meshmon.db import get_latest_metrics from meshmon.html import write_site - out_dir = full_integration_env["out_dir"] - - # Render charts first - for role in ["repeater", "companion"]: - charts, stats = render_all_charts(role) - save_chart_stats(role, stats) + out_dir = rendered_charts["out_dir"] # Get latest metrics for write_site companion_row = get_latest_metrics("companion") @@ -155,22 +121,17 @@ class TestHtmlRenderingPipeline: # Should contain SVG elements assert " charts -> HTML.""" def test_full_chain_from_database_to_html( - self, populated_db_with_history, full_integration_env + self, rendered_charts ): """Complete chain: database metrics -> charts -> HTML site.""" - from meshmon.charts import render_all_charts, save_chart_stats from meshmon.db import get_latest_metrics, get_metric_count from meshmon.html import copy_static_assets, write_site - out_dir = full_integration_env["out_dir"] + out_dir = rendered_charts["out_dir"] # 1. Verify database has data assert get_metric_count("repeater") > 0 assert get_metric_count("companion") > 0 - # 2. Render charts for both roles - total_charts = 0 + # 2. Verify rendered charts exist for both roles for role in ["repeater", "companion"]: - charts, stats = render_all_charts(role) - save_chart_stats(role, stats) - total_charts += len(charts) - - assert total_charts > 0 + assets_dir = out_dir / "assets" / role + svg_files = list(assets_dir.glob("*.svg")) + assert svg_files, f"No charts found for {role}" # 3. Copy static assets copy_static_assets() @@ -234,7 +191,11 @@ class TestFullRenderingChain: assert "" in html_content or "" in html_content.lower() assert "" in html_content - def test_empty_database_renders_gracefully(self, full_integration_env): + def test_empty_database_renders_gracefully( + self, + full_integration_env, + rendered_chart_metrics, + ): """Should handle empty database gracefully.""" from meshmon.charts import render_all_charts, save_chart_stats from meshmon.db import get_latest_metrics, get_metric_count, init_db @@ -251,7 +212,9 @@ class TestFullRenderingChain: # Rendering with no data should not crash for role in ["repeater", "companion"]: - charts, stats = render_all_charts(role) + charts, stats = render_all_charts( + role, metrics=rendered_chart_metrics[role] + ) save_chart_stats(role, stats) # Should have no charts (or empty charts) # The important thing is it doesn't crash diff --git a/tests/integration/test_reports_pipeline.py b/tests/integration/test_reports_pipeline.py index b2e11ee..0199bc0 100644 --- a/tests/integration/test_reports_pipeline.py +++ b/tests/integration/test_reports_pipeline.py @@ -1,33 +1,43 @@ """Integration tests for report generation pipeline.""" +import calendar import json -import time from datetime import datetime import pytest +BASE_TS = 1704067200 + @pytest.mark.integration class TestReportGenerationPipeline: """Test report generation end-to-end.""" - def test_generates_monthly_reports(self, populated_db_with_history, full_integration_env): + def test_generates_monthly_reports(self, populated_db_with_history, reports_env): """Should generate monthly reports for available data.""" from meshmon.html import render_report_page from meshmon.reports import aggregate_monthly, format_monthly_txt, get_available_periods # Get available periods periods = get_available_periods("repeater") - assert len(periods) > 0 + assert periods # Get the current month (should have data) year, month = periods[-1] + month_name = calendar.month_name[month] # Aggregate monthly data agg = aggregate_monthly("repeater", year, month) assert agg is not None - assert len(agg.daily) > 0 + assert agg.year == year + assert agg.month == month + assert agg.role == "repeater" + assert agg.daily + assert agg.summary["bat"].count > 0 + assert agg.summary["bat"].min_value is not None + assert agg.summary["nb_recv"].total is not None + assert agg.summary["nb_recv"].count > 0 # Generate TXT report from meshmon.reports import LocationInfo @@ -42,15 +52,19 @@ class TestReportGenerationPipeline: assert txt_report is not None assert len(txt_report) > 0 - assert "Test Repeater" in txt_report or "Test Location" in txt_report + assert f"MONTHLY MESHCORE REPORT for {month_name} {year}" in txt_report + assert "NODE: Test Repeater" in txt_report + assert "NAME: Test Location" in txt_report # Generate HTML report html_report = render_report_page(agg, "Test Repeater", "monthly") assert html_report is not None assert " 0 + assert agg.year == year + assert agg.role == "repeater" + assert agg.monthly + assert agg.summary["bat"].count > 0 + assert agg.summary["nb_recv"].total is not None # Generate TXT report from meshmon.reports import LocationInfo @@ -81,14 +99,17 @@ class TestReportGenerationPipeline: assert txt_report is not None assert len(txt_report) > 0 + assert f"YEARLY MESHCORE REPORT for {year}" in txt_report + assert "NODE: Test Repeater" in txt_report # Generate HTML report html_report = render_report_page(agg, "Test Repeater", "yearly") assert html_report is not None assert " 0 assert len((report_dir / "report.txt").read_text()) > 0 assert len((report_dir / "report.json").read_text()) > 0 + assert f"{month_name} {year}" in (report_dir / "index.html").read_text() + assert "NODE: Test Repeater" in (report_dir / "report.txt").read_text() + + parsed_json = json.loads((report_dir / "report.json").read_text()) + assert parsed_json["report_type"] == "monthly" + assert parsed_json["year"] == year + assert parsed_json["month"] == month @pytest.mark.integration class TestReportsIndex: """Test reports index page generation.""" - def test_generates_reports_index(self, populated_db_with_history, full_integration_env): + def test_generates_reports_index(self, populated_db_with_history, reports_env): """Should generate reports index with all available periods.""" - import calendar - from meshmon.html import render_reports_index from meshmon.reports import get_available_periods - out_dir = full_integration_env["out_dir"] + out_dir = reports_env["out_dir"] # Build sections data (mimicking render_reports.py) sections = [] + latest_periods: dict[str, tuple[int, int]] = {} for role in ["repeater", "companion"]: periods = get_available_periods(role) if not periods: sections.append({"role": role, "years": []}) continue + latest_periods[role] = periods[-1] years_data = {} for year, month in periods: @@ -216,7 +253,11 @@ class TestReportsIndex: assert html is not None assert " 0 + assert bat_stats.count == 4 + assert bat_stats.min_value == 3700.0 + assert bat_stats.max_value == 4000.0 + assert bat_stats.mean == pytest.approx(3850.0) + assert bat_stats.min_time == datetime.fromtimestamp(BASE_TS) + assert bat_stats.max_time == datetime.fromtimestamp(BASE_TS + 3 * 3600) def test_calculates_counter_total(self, initialized_db, configured_env): """Calculates total for counter metrics.""" - base_ts = int(datetime(2024, 1, 15, 0, 0, 0).timestamp()) - # Insert increasing counter values for i in range(5): - insert_metrics(base_ts + i * 900, "repeater", {"nb_recv": float(i * 100)}) + insert_metrics(BASE_TS + i * 900, "repeater", {"nb_recv": float(i * 100)}) - result = aggregate_daily("repeater", date(2024, 1, 15)) + result = aggregate_daily("repeater", BASE_DATE) assert "nb_recv" in result.metrics - # Counter should have total - assert result.metrics["nb_recv"].total is not None or result.metrics["nb_recv"].count > 0 + counter_stats = result.metrics["nb_recv"] + assert counter_stats.count == 5 + assert counter_stats.reboot_count == 0 + assert counter_stats.total == 400 def test_returns_empty_for_no_data(self, initialized_db, configured_env): """Returns aggregate with empty metrics when no data.""" - result = aggregate_daily("repeater", date(2024, 1, 15)) + result = aggregate_daily("repeater", BASE_DATE) assert isinstance(result, DailyAggregate) - # May have empty metrics or just no count - assert result.snapshot_count == 0 or len(result.metrics) == 0 + assert result.snapshot_count == 0 + assert result.metrics == {} class TestAggregateMonthly: @@ -126,8 +136,15 @@ class TestAggregateMonthly: # Should have daily data assert result.year == 2024 assert result.month == 1 - # May have daily aggregates - assert len(result.daily) >= 0 # Can be 0 if aggregation skips empty days + assert len(result.daily) == 5 + assert all(d.snapshot_count == 1 for d in result.daily) + summary = result.summary["bat"] + assert summary.count == 5 + assert summary.min_value == 3810.0 + assert summary.max_value == 4110.0 + assert summary.mean == pytest.approx(3944.0) + assert summary.min_time.day == 1 + assert summary.max_time.day == 31 def test_handles_partial_month(self, initialized_db, configured_env): """Handles months with partial data.""" @@ -140,6 +157,10 @@ class TestAggregateMonthly: assert result.year == 2024 assert result.month == 1 + assert len(result.daily) == 3 + summary = result.summary["bat"] + assert summary.count == 3 + assert summary.mean == pytest.approx(3800.0) class TestAggregateYearly: @@ -163,7 +184,14 @@ class TestAggregateYearly: assert result.year == 2024 # Should have monthly aggregates - assert len(result.monthly) >= 0 + assert len(result.monthly) == 4 + summary = result.summary["bat"] + assert summary.count == 4 + assert summary.min_value == 3810.0 + assert summary.max_value == 3920.0 + assert summary.mean == pytest.approx(3855.0) + assert summary.min_time.month == 1 + assert summary.max_time.month == 12 def test_returns_empty_for_no_data(self, initialized_db, configured_env): """Returns aggregate with empty monthly when no data.""" @@ -171,7 +199,7 @@ class TestAggregateYearly: assert result.year == 2024 # Empty year may have no monthly data - assert isinstance(result.monthly, list) + assert result.monthly == [] def test_handles_leap_year(self, initialized_db, configured_env): """Correctly handles leap years.""" @@ -182,3 +210,6 @@ class TestAggregateYearly: result = aggregate_yearly("repeater", 2024) assert result.year == 2024 + months = [monthly.month for monthly in result.monthly] + assert 2 in months + assert result.summary["bat"].count == 1 diff --git a/tests/reports/test_aggregation_helpers.py b/tests/reports/test_aggregation_helpers.py index d0498ed..0322b5d 100644 --- a/tests/reports/test_aggregation_helpers.py +++ b/tests/reports/test_aggregation_helpers.py @@ -41,6 +41,7 @@ class TestComputeGaugeStats: assert result.min_value == 3.8 assert result.max_value == 4.0 assert result.mean == pytest.approx(3.9) + assert result.count == 3 def test_handles_single_value(self): """Handles single value correctly.""" @@ -49,6 +50,9 @@ class TestComputeGaugeStats: assert result.min_value == 3.85 assert result.max_value == 3.85 assert result.mean == 3.85 + assert result.count == 1 + assert result.min_time == datetime(2024, 1, 1, 0, 0) + assert result.max_time == datetime(2024, 1, 1, 0, 0) def test_handles_empty_list(self): """Handles empty list gracefully.""" @@ -56,6 +60,7 @@ class TestComputeGaugeStats: assert result.min_value is None assert result.max_value is None assert result.mean is None + assert result.count == 0 def test_tracks_count(self): """Tracks the number of values.""" @@ -110,6 +115,8 @@ class TestComputeCounterStats: result = _compute_counter_stats(values) # Total should be 100 (50 + 50) assert result.total == 100 + assert result.count == 3 + assert result.reboot_count == 0 def test_handles_counter_reboot(self): """Handles counter reboot (value decrease).""" @@ -122,6 +129,8 @@ class TestComputeCounterStats: result = _compute_counter_stats(values) # Total: 50 + 20 + 30 = 100 assert result.total == 100 + assert result.reboot_count == 1 + assert result.count == 4 def test_tracks_reboot_count(self): """Tracks number of reboots.""" @@ -134,11 +143,15 @@ class TestComputeCounterStats: ] result = _compute_counter_stats(values) assert result.reboot_count == 2 + assert result.total == 110 + assert result.count == 5 def test_handles_empty_list(self): """Handles empty list gracefully.""" result = _compute_counter_stats([]) assert result.total is None + assert result.count == 0 + assert result.reboot_count == 0 def test_handles_single_value(self): """Handles single value (no delta possible).""" @@ -146,6 +159,8 @@ class TestComputeCounterStats: result = _compute_counter_stats(values) # Single value means no delta can be computed assert result.total is None + assert result.count == 1 + assert result.reboot_count == 0 class TestAggregateDailyGaugeToSummary: @@ -196,17 +211,20 @@ class TestAggregateDailyGaugeToSummary: """Finds minimum across all days.""" result = _aggregate_daily_gauge_to_summary(daily_gauge_data, "battery") assert result.min_value == 3.6 + assert result.min_time == datetime(2024, 1, 2, 4, 0) def test_finds_overall_max(self, daily_gauge_data): """Finds maximum across all days.""" result = _aggregate_daily_gauge_to_summary(daily_gauge_data, "battery") assert result.max_value == 4.1 + assert result.max_time == datetime(2024, 1, 3, 18, 0) def test_computes_weighted_mean(self, daily_gauge_data): """Computes weighted mean based on count.""" result = _aggregate_daily_gauge_to_summary(daily_gauge_data, "battery") # All have same count, so simple average: (3.8 + 3.85 + 3.95) / 3 = 3.8667 assert result.mean == pytest.approx(3.8667, rel=0.01) + assert result.count == 288 def test_handles_empty_list(self): """Handles empty daily list.""" @@ -214,12 +232,15 @@ class TestAggregateDailyGaugeToSummary: assert result.min_value is None assert result.max_value is None assert result.mean is None + assert result.count == 0 def test_handles_missing_metric(self, daily_gauge_data): """Handles when metric doesn't exist in daily data.""" result = _aggregate_daily_gauge_to_summary(daily_gauge_data, "nonexistent") assert result.min_value is None assert result.max_value is None + assert result.mean is None + assert result.count == 0 class TestAggregateDailyCounterToSummary: @@ -258,6 +279,7 @@ class TestAggregateDailyCounterToSummary: """Sums totals across all days.""" result = _aggregate_daily_counter_to_summary(daily_counter_data, "packets_rx") assert result.total == 3300 # 1000 + 1500 + 800 + assert result.count == 288 def test_sums_reboots(self, daily_counter_data): """Sums reboot counts across all days.""" @@ -268,11 +290,15 @@ class TestAggregateDailyCounterToSummary: """Handles empty daily list.""" result = _aggregate_daily_counter_to_summary([], "packets_rx") assert result.total is None + assert result.count == 0 + assert result.reboot_count == 0 def test_handles_missing_metric(self, daily_counter_data): """Handles when metric doesn't exist in daily data.""" result = _aggregate_daily_counter_to_summary(daily_counter_data, "nonexistent") assert result.total is None + assert result.count == 0 + assert result.reboot_count == 0 class TestAggregateMonthlyGaugeToSummary: @@ -317,11 +343,13 @@ class TestAggregateMonthlyGaugeToSummary: """Finds minimum across all months.""" result = _aggregate_monthly_gauge_to_summary(monthly_gauge_data, "battery") assert result.min_value == 3.5 + assert result.min_time == datetime(2024, 2, 10, 5, 0) def test_finds_overall_max(self, monthly_gauge_data): """Finds maximum across all months.""" result = _aggregate_monthly_gauge_to_summary(monthly_gauge_data, "battery") assert result.max_value == 4.1 + assert result.max_time == datetime(2024, 2, 25, 16, 0) def test_computes_weighted_mean(self, monthly_gauge_data): """Computes weighted mean based on count.""" @@ -329,12 +357,15 @@ class TestAggregateMonthlyGaugeToSummary: # Weighted: (3.8 * 2976 + 3.9 * 2784) / (2976 + 2784) expected = (3.8 * 2976 + 3.9 * 2784) / (2976 + 2784) assert result.mean == pytest.approx(expected, rel=0.01) + assert result.count == 5760 def test_handles_empty_list(self): """Handles empty monthly list.""" result = _aggregate_monthly_gauge_to_summary([], "battery") assert result.min_value is None assert result.max_value is None + assert result.mean is None + assert result.count == 0 class TestAggregateMonthlyCounterToSummary: @@ -371,6 +402,7 @@ class TestAggregateMonthlyCounterToSummary: """Sums totals across all months.""" result = _aggregate_monthly_counter_to_summary(monthly_counter_data, "packets_rx") assert result.total == 95000 + assert result.count == 5760 def test_sums_reboots(self, monthly_counter_data): """Sums reboot counts across all months.""" @@ -381,8 +413,12 @@ class TestAggregateMonthlyCounterToSummary: """Handles empty monthly list.""" result = _aggregate_monthly_counter_to_summary([], "packets_rx") assert result.total is None + assert result.count == 0 + assert result.reboot_count == 0 def test_handles_missing_metric(self, monthly_counter_data): """Handles when metric doesn't exist in monthly data.""" result = _aggregate_monthly_counter_to_summary(monthly_counter_data, "nonexistent") assert result.total is None + assert result.count == 0 + assert result.reboot_count == 0 diff --git a/tests/reports/test_format_json.py b/tests/reports/test_format_json.py index 4e6c360..bf250a9 100644 --- a/tests/reports/test_format_json.py +++ b/tests/reports/test_format_json.py @@ -1,7 +1,7 @@ """Tests for JSON report formatting.""" import json -from datetime import date +from datetime import date, datetime import pytest @@ -43,7 +43,17 @@ class TestMonthlyToJson: month=1, role="repeater", daily=daily_data, - summary={"bat": MetricStats(min_value=3.6, max_value=3.9, mean=3.775, count=48)}, + summary={ + "bat": MetricStats( + min_value=3.6, + min_time=datetime(2024, 1, 2, 1, 0), + max_value=3.9, + max_time=datetime(2024, 1, 1, 23, 0), + mean=3.775, + count=48, + ), + "nb_recv": MetricStats(total=1560, count=48, reboot_count=1), + }, ) def test_returns_dict(self, sample_monthly_aggregate): @@ -54,7 +64,7 @@ class TestMonthlyToJson: def test_includes_report_type(self, sample_monthly_aggregate): """Includes report_type field.""" result = monthly_to_json(sample_monthly_aggregate) - assert result.get("report_type") == "monthly" or "year" in result + assert result["report_type"] == "monthly" def test_includes_year_and_month(self, sample_monthly_aggregate): """Includes year and month.""" @@ -72,12 +82,31 @@ class TestMonthlyToJson: result = monthly_to_json(sample_monthly_aggregate) assert "daily" in result assert len(result["daily"]) == 2 + assert result["days_with_data"] == 2 def test_daily_data_has_date(self, sample_monthly_aggregate): """Daily data includes date.""" result = monthly_to_json(sample_monthly_aggregate) first_day = result["daily"][0] assert "date" in first_day + assert first_day["date"] == "2024-01-01" + + def test_daily_metrics_include_units_and_values(self, sample_monthly_aggregate): + """Daily metrics include units and expected values.""" + result = monthly_to_json(sample_monthly_aggregate) + first_day = result["daily"][0] + + bat_stats = first_day["metrics"]["bat"] + assert bat_stats["unit"] == "mV" + assert bat_stats["min"] == 3.7 + assert bat_stats["max"] == 3.9 + assert bat_stats["mean"] == 3.8 + assert bat_stats["count"] == 24 + + rx_stats = first_day["metrics"]["nb_recv"] + assert rx_stats["unit"] == "packets" + assert rx_stats["total"] == 720 + assert rx_stats["count"] == 24 def test_is_json_serializable(self, sample_monthly_aggregate): """Result is JSON serializable.""" @@ -86,6 +115,16 @@ class TestMonthlyToJson: json_str = json.dumps(result) assert isinstance(json_str, str) + def test_summary_includes_times_and_reboots(self, sample_monthly_aggregate): + """Summary includes time fields and reboot counts when provided.""" + result = monthly_to_json(sample_monthly_aggregate) + summary = result["summary"] + + assert summary["bat"]["min_time"] == "2024-01-02T01:00:00" + assert summary["bat"]["max_time"] == "2024-01-01T23:00:00" + assert summary["nb_recv"]["total"] == 1560 + assert summary["nb_recv"]["reboot_count"] == 1 + def test_handles_empty_daily(self): """Handles aggregate with no daily data.""" agg = MonthlyAggregate( @@ -98,6 +137,8 @@ class TestMonthlyToJson: result = monthly_to_json(agg) assert result["daily"] == [] + assert result["days_with_data"] == 0 + assert result["summary"] == {} class TestYearlyToJson: @@ -138,7 +179,7 @@ class TestYearlyToJson: def test_includes_report_type(self, sample_yearly_aggregate): """Includes report_type field.""" result = yearly_to_json(sample_yearly_aggregate) - assert result.get("report_type") == "yearly" or "year" in result + assert result["report_type"] == "yearly" def test_includes_year(self, sample_yearly_aggregate): """Includes year.""" @@ -155,6 +196,7 @@ class TestYearlyToJson: result = yearly_to_json(sample_yearly_aggregate) assert "monthly" in result assert len(result["monthly"]) == 2 + assert result["months_with_data"] == 2 def test_is_json_serializable(self, sample_yearly_aggregate): """Result is JSON serializable.""" @@ -162,6 +204,19 @@ class TestYearlyToJson: json_str = json.dumps(result) assert isinstance(json_str, str) + def test_summary_and_monthly_entries(self, sample_yearly_aggregate): + """Summary and monthly entries include expected fields.""" + result = yearly_to_json(sample_yearly_aggregate) + + assert result["summary"]["bat"]["count"] == 1392 + assert result["summary"]["bat"]["unit"] == "mV" + + first_month = result["monthly"][0] + assert first_month["year"] == 2024 + assert first_month["month"] == 1 + assert first_month["days_with_data"] == 0 + assert first_month["summary"]["bat"]["mean"] == 3.75 + def test_handles_empty_monthly(self): """Handles aggregate with no monthly data.""" agg = YearlyAggregate( @@ -173,6 +228,8 @@ class TestYearlyToJson: result = yearly_to_json(agg) assert result["monthly"] == [] + assert result["months_with_data"] == 0 + assert result["summary"] == {} class TestJsonStructure: @@ -191,8 +248,11 @@ class TestJsonStructure: result = monthly_to_json(agg) # Summary should contain stats - if "summary" in result: - assert isinstance(result["summary"], dict) + assert isinstance(result["summary"], dict) + assert result["summary"]["bat"]["min"] == 3.5 + assert result["summary"]["bat"]["max"] == 4.0 + assert result["summary"]["bat"]["mean"] == 3.75 + assert result["summary"]["bat"]["unit"] == "mV" def test_nested_structure_serializes(self): """Nested structures serialize correctly.""" diff --git a/tests/reports/test_format_txt.py b/tests/reports/test_format_txt.py index ab24580..a2ea23d 100644 --- a/tests/reports/test_format_txt.py +++ b/tests/reports/test_format_txt.py @@ -23,12 +23,11 @@ class TestColumn: def test_format_with_value(self): """Formats value with specified width and alignment.""" - col = Column(width=10, align="right") + col = Column(width=6, align="right") - result = col.format(42.5) + result = col.format(42) - assert len(result) == 10 - assert "42" in result + assert result == " 42" def test_format_with_none(self): """Formats None as dash.""" @@ -36,7 +35,7 @@ class TestColumn: result = col.format(None) - assert "-" in result + assert result == "-".rjust(10) def test_left_alignment(self): """Left alignment pads on right.""" @@ -44,7 +43,7 @@ class TestColumn: result = col.format("Hi") - assert result.startswith("Hi") + assert result == "Hi".ljust(10) def test_right_alignment(self): """Right alignment pads on left.""" @@ -52,7 +51,7 @@ class TestColumn: result = col.format("Hi") - assert result.endswith("Hi") + assert result == "Hi".rjust(10) def test_center_alignment(self): """Center alignment pads on both sides.""" @@ -60,8 +59,7 @@ class TestColumn: result = col.format("Hi") - assert len(result) == 10 - assert "Hi" in result + assert result == "Hi".center(10) def test_decimals_formatting(self): """Formats floats with specified decimals.""" @@ -69,7 +67,7 @@ class TestColumn: result = col.format(3.14159) - assert "3.14" in result + assert result == "3.14".rjust(10) def test_comma_separator(self): """Uses comma separator for large integers.""" @@ -77,7 +75,7 @@ class TestColumn: result = col.format(1000000) - assert "1,000,000" in result + assert result == "1,000,000".rjust(15) class TestFormatRow: @@ -92,9 +90,7 @@ class TestFormatRow: row = _format_row(columns, [1, 2]) - assert "1" in row - assert "2" in row - assert len(row) == 10 + assert row == " 1 2" def test_handles_fewer_values(self): """Handles fewer values than columns.""" @@ -110,6 +106,7 @@ class TestFormatRow: assert row is not None assert "X" in row assert "Y" in row + assert len(row) == 10 class TestFormatSeparator: @@ -124,7 +121,7 @@ class TestFormatSeparator: separator = _format_separator(columns) - assert "-" in separator + assert separator == "-" * 18 def test_matches_total_width(self): """Separator width matches total column width.""" @@ -136,6 +133,7 @@ class TestFormatSeparator: separator = _format_separator(columns) assert len(separator) == 20 + assert set(separator) == {"-"} def test_custom_separator_char(self): """Uses custom separator character.""" @@ -143,8 +141,7 @@ class TestFormatSeparator: separator = _format_separator(columns, char="=") - assert "=" in separator - assert "-" not in separator + assert separator == "=" * 10 class TestFormatMonthlyTxt: @@ -198,8 +195,7 @@ class TestFormatMonthlyTxt: """Includes report header with month/year.""" result = format_monthly_txt(sample_monthly_aggregate, "Test Repeater", sample_location) - assert "2024" in result - assert "January" in result + assert "MONTHLY MESHCORE REPORT for January 2024" in result def test_includes_node_name(self, sample_monthly_aggregate, sample_location): """Includes node name.""" @@ -211,8 +207,18 @@ class TestFormatMonthlyTxt: """Has ASCII table structure with separators.""" result = format_monthly_txt(sample_monthly_aggregate, "Test Repeater", sample_location) - # Should have separator lines - assert "-" in result or "=" in result + assert "BATTERY (V)" in result + assert result.count("-" * 95) == 2 + + def test_daily_rows_rendered(self, sample_monthly_aggregate, sample_location): + """Renders one row per day with battery values.""" + result = format_monthly_txt(sample_monthly_aggregate, "Test Repeater", sample_location) + lines = result.splitlines() + daily_lines = [line for line in lines if line[:3].strip().isdigit()] + + assert [line[:3].strip() for line in daily_lines] == ["1", "2"] + assert any("3.80" in line for line in daily_lines) + assert any("3.75" in line for line in daily_lines) def test_handles_empty_daily(self, sample_location): """Handles aggregate with no daily data.""" @@ -227,12 +233,17 @@ class TestFormatMonthlyTxt: result = format_monthly_txt(agg, "Test Repeater", sample_location) assert isinstance(result, str) + lines = result.splitlines() + daily_lines = [line for line in lines if line[:3].strip().isdigit()] + assert daily_lines == [] def test_includes_location_info(self, sample_monthly_aggregate, sample_location): """Includes location information.""" result = format_monthly_txt(sample_monthly_aggregate, "Test Repeater", sample_location) - assert "Test Location" in result or "52" in result + assert "NAME: Test Location" in result + assert "COORDS:" in result + assert "ELEV: 10 meters" in result class TestFormatYearlyTxt: @@ -285,15 +296,18 @@ class TestFormatYearlyTxt: """Includes year in header.""" result = format_yearly_txt(sample_yearly_aggregate, "Test Repeater", sample_location) - assert "2024" in result + assert "YEARLY MESHCORE REPORT for 2024" in result + assert "NODE: Test Repeater" in result + assert "NAME: Test Location" in result def test_has_monthly_breakdown(self, sample_yearly_aggregate, sample_location): """Shows monthly breakdown.""" result = format_yearly_txt(sample_yearly_aggregate, "Test Repeater", sample_location) - # Should mention months (as numbers: 01, 02) - months_numeric = ["01", "02"] - assert any(m in result for m in months_numeric) + lines = result.splitlines() + monthly_lines = [line for line in lines if line.strip().startswith("2024")] + months = [line[4:8].strip() for line in monthly_lines] + assert months == ["01", "02"] def test_handles_empty_monthly(self, sample_location): """Handles aggregate with no monthly data.""" @@ -390,7 +404,9 @@ class TestFormatYearlyCompanionTxt: """Includes year in header.""" result = format_yearly_txt(sample_companion_yearly_aggregate, "Test Companion", sample_location) - assert "2024" in result + assert "YEARLY MESHCORE REPORT for 2024" in result + assert "NODE: Test Companion" in result + assert "NAME: Test Location" in result def test_includes_node_name(self, sample_companion_yearly_aggregate, sample_location): """Includes node name.""" @@ -402,9 +418,10 @@ class TestFormatYearlyCompanionTxt: """Shows monthly breakdown.""" result = format_yearly_txt(sample_companion_yearly_aggregate, "Test Companion", sample_location) - # Should mention months (as numbers: 01, 02) - months_numeric = ["01", "02"] - assert any(m in result for m in months_numeric) + lines = result.splitlines() + monthly_lines = [line for line in lines if line.strip().startswith("2024")] + months = [line[4:8].strip() for line in monthly_lines] + assert months == ["01", "02"] def test_has_battery_data(self, sample_companion_yearly_aggregate, sample_location): """Contains battery voltage data.""" @@ -511,14 +528,16 @@ class TestFormatMonthlyCompanionTxt: """Includes month and year in header.""" result = format_monthly_txt(sample_companion_monthly_aggregate, "Test Companion", sample_location) - assert "2024" in result + assert "MONTHLY MESHCORE REPORT for January 2024" in result + assert "NODE: Test Companion" in result def test_has_daily_breakdown(self, sample_companion_monthly_aggregate, sample_location): """Shows daily breakdown.""" result = format_monthly_txt(sample_companion_monthly_aggregate, "Test Companion", sample_location) - # Should contain day numbers - assert "01" in result or "1" in result + lines = result.splitlines() + daily_lines = [line for line in lines if line[:3].strip().isdigit()] + assert [line[:3].strip() for line in daily_lines] == ["1", "2"] def test_has_packet_counts(self, sample_companion_monthly_aggregate, sample_location): """Contains packet count data.""" @@ -623,4 +642,6 @@ class TestCompanionFormatting: result = format_monthly_txt(companion_monthly_aggregate, "Test Companion", sample_location) assert isinstance(result, str) - assert "2024" in result + assert "MONTHLY MESHCORE REPORT for January 2024" in result + assert "NODE: Test Companion" in result + assert "NAME: Test Location" in result diff --git a/tests/reports/test_location.py b/tests/reports/test_location.py index 1f31b62..c55b6f7 100644 --- a/tests/reports/test_location.py +++ b/tests/reports/test_location.py @@ -13,52 +13,53 @@ class TestFormatLatLon: def test_formats_positive_coordinates(self): """Formats positive lat/lon with N/E.""" - lat_str, lon_str = format_lat_lon(51.5074, -0.1278) + lat_str, lon_str = format_lat_lon(51.5074, 0.1278) - assert "51" in lat_str - assert "N" in lat_str + assert lat_str == "51-30.44 N" + assert lon_str == "000-07.67 E" def test_formats_negative_latitude(self): """Negative latitude shows S.""" lat_str, lon_str = format_lat_lon(-33.8688, 151.2093) - assert "S" in lat_str + assert lat_str == "33-52.13 S" + assert lon_str == "151-12.56 E" def test_formats_negative_longitude(self): """Negative longitude shows W.""" lat_str, lon_str = format_lat_lon(51.5074, -0.1278) - assert "W" in lon_str + assert lon_str == "000-07.67 W" def test_formats_positive_longitude(self): """Positive longitude shows E.""" - lat_str, lon_str = format_lat_lon(-33.8688, 151.2093) + lat_str, lon_str = format_lat_lon(0.0, 4.0) - assert "E" in lon_str + assert lon_str == "004-00.00 E" def test_includes_degrees_minutes(self): """Includes degrees and minutes.""" - lat_str, lon_str = format_lat_lon(51.5074, -0.1278) + lat_str, lon_str = format_lat_lon(3.5, 7.25) - # Should have dash separator between degrees and minutes - assert "-" in lat_str or "." in lat_str + assert lat_str.startswith("03-") + assert lon_str.startswith("007-") def test_handles_zero(self): """Handles zero coordinates.""" lat_str, lon_str = format_lat_lon(0.0, 0.0) - assert "0" in lat_str - assert "0" in lon_str + assert lat_str == "00-00.00 N" + assert lon_str == "000-00.00 E" def test_handles_extremes(self): """Handles extreme coordinates.""" # North pole lat_str_north, lon_str_north = format_lat_lon(90.0, 0.0) - assert "90" in lat_str_north + assert lat_str_north == "90-00.00 N" # South pole lat_str_south, lon_str_south = format_lat_lon(-90.0, 0.0) - assert "90" in lat_str_south + assert lat_str_south == "90-00.00 S" class TestFormatLatLonDms: @@ -68,37 +69,32 @@ class TestFormatLatLonDms: """Returns degrees-minutes-seconds format.""" result = format_lat_lon_dms(51.5074, -0.1278) - # Should have degrees, minutes, seconds indicators - assert "°" in result or "'" in result or '"' in result + assert result == "51°30'26\"N 000°07'40\"W" def test_includes_direction(self): """Includes N/S/E/W directions.""" result = format_lat_lon_dms(51.5074, -0.1278) - assert any(d in result for d in ["N", "S", "E", "W"]) + assert "N" in result + assert "W" in result def test_correct_conversion(self): """Converts decimal to DMS correctly.""" - # 51.5074° ≈ 51° 30' 26.64" - result = format_lat_lon_dms(51.5074, 0.0) + result = format_lat_lon_dms(0.0, 0.0) - assert "51" in result - assert "30" in result or "'" in result + assert result == "00°00'00\"N 000°00'00\"E" def test_handles_fractional_seconds(self): """Handles fractional seconds.""" result = format_lat_lon_dms(51.123456, -0.987654) - # Should have some numeric content - assert any(c.isdigit() for c in result) + assert result == "51°07'24\"N 000°59'15\"W" def test_combines_lat_and_lon(self): """Returns combined string with both lat and lon.""" result = format_lat_lon_dms(52.0, 4.0) - # Should have both N and E - assert "N" in result or "S" in result - assert "E" in result or "W" in result + assert result == "52°00'00\"N 004°00'00\"E" class TestLocationInfo: @@ -129,8 +125,10 @@ class TestLocationInfo: header = loc.format_header() - assert isinstance(header, str) - assert "Test Location" in header + assert header == ( + "NAME: Test Location\n" + "COORDS: 51°30'26\"N 000°07'40\"W ELEV: 11 meters" + ) def test_format_header_includes_coordinates(self): """Header includes formatted coordinates.""" @@ -143,8 +141,7 @@ class TestLocationInfo: header = loc.format_header() - # Should have lat/lon info - assert any(x in header for x in ["51", "N", "S", "°"]) + assert "COORDS: 51°30'26\"N 000°07'40\"W" in header def test_format_header_includes_elevation(self): """Header includes elevation with unit.""" @@ -157,8 +154,7 @@ class TestLocationInfo: header = loc.format_header() - assert "11" in header - assert "meters" in header.lower() or "m" in header + assert "ELEV: 11 meters" in header class TestLocationCoordinates: @@ -168,22 +164,26 @@ class TestLocationCoordinates: """Handles equator (0° latitude).""" lat_str, lon_str = format_lat_lon(0.0, 45.0) - assert "0" in lat_str + assert lat_str == "00-00.00 N" + assert lon_str == "045-00.00 E" def test_prime_meridian(self): """Handles prime meridian (0° longitude).""" lat_str, lon_str = format_lat_lon(45.0, 0.0) - assert "0" in lon_str + assert lat_str == "45-00.00 N" + assert lon_str == "000-00.00 E" def test_international_date_line(self): """Handles international date line (180° longitude).""" lat_str, lon_str = format_lat_lon(0.0, 180.0) - assert "180" in lon_str + assert lat_str == "00-00.00 N" + assert lon_str == "180-00.00 E" def test_very_precise_coordinates(self): """Handles high-precision coordinates.""" lat_str, lon_str = format_lat_lon(51.50735509, -0.12775829) - assert "51" in lat_str + assert lat_str == "51-30.44 N" + assert lon_str == "000-07.67 W" diff --git a/tests/reports/test_table_builders.py b/tests/reports/test_table_builders.py index 7a3a5e5..f98e997 100644 --- a/tests/reports/test_table_builders.py +++ b/tests/reports/test_table_builders.py @@ -72,15 +72,27 @@ class TestBuildMonthlyTableData: # Should have 2 data rows + 1 summary row = 3 total data_rows = [r for r in rows if not r.get("is_summary", False)] assert len(data_rows) == 2 + assert len(rows) == 3 + assert rows[-1]["is_summary"] is True def test_headers_have_labels(self, sample_monthly_aggregate): """Headers include label information.""" _, headers, _ = build_monthly_table_data(sample_monthly_aggregate, "repeater") - assert len(headers) > 0 - for header in headers: - assert isinstance(header, dict) - assert "label" in header or "name" in header or "key" in header + expected_labels = [ + "Day", + "Avg V", + "Avg %", + "Min V", + "Max V", + "RSSI", + "SNR", + "Noise", + "RX", + "TX", + "Secs", + ] + assert [header["label"] for header in headers] == expected_labels def test_rows_have_date(self, sample_monthly_aggregate): """Each data row includes date information via cells.""" @@ -93,6 +105,20 @@ class TestBuildMonthlyTableData: assert "cells" in row # First cell should be the day assert len(row["cells"]) > 0 + assert [row["cells"][0]["value"] for row in data_rows] == ["01", "02"] + + def test_daily_row_values(self, sample_monthly_aggregate): + """Daily rows include formatted values and placeholders.""" + _, _, rows = build_monthly_table_data(sample_monthly_aggregate, "repeater") + first_row = next(r for r in rows if not r.get("is_summary", False)) + cells = first_row["cells"] + + assert cells[0]["value"] == "01" + assert cells[1]["value"] == "3.80" + assert cells[2]["value"] == "-" + assert cells[5]["value"] == "-87" + assert cells[6]["value"] == "-" + assert cells[8]["value"] == "720" def test_handles_empty_aggregate(self): """Handles aggregate with no daily data.""" @@ -162,29 +188,45 @@ class TestBuildYearlyTableData: # Should have 2 data rows + 1 summary row data_rows = [r for r in rows if not r.get("is_summary", False)] assert len(data_rows) == 2 + assert len(rows) == 3 + assert rows[-1]["is_summary"] is True def test_headers_have_labels(self, sample_yearly_aggregate): """Headers include label information.""" _, headers, _ = build_yearly_table_data(sample_yearly_aggregate, "repeater") - assert len(headers) > 0 - for header in headers: - assert isinstance(header, dict) - assert "label" in header or "name" in header or "key" in header + expected_labels = [ + "Year", + "Mo", + "Volt", + "%", + "High", + "Low", + "RSSI", + "SNR", + "RX", + "TX", + ] + assert [header["label"] for header in headers] == expected_labels def test_rows_have_month(self, sample_yearly_aggregate): """Each row includes month information.""" _, _, rows = build_yearly_table_data(sample_yearly_aggregate, "repeater") - for row in rows: - assert isinstance(row, dict) - # Row should have month name or number - has_month = ( - "month" in row - or any("month" in str(v).lower() or "jan" in str(v).lower() or "feb" in str(v).lower() - for v in row.values()) - ) - assert has_month or len(row) > 0 # At minimum should have data + data_rows = [r for r in rows if not r.get("is_summary", False)] + months = [row["cells"][1]["value"] for row in data_rows] + assert months == ["01", "02"] + + def test_yearly_row_values(self, sample_yearly_aggregate): + """Yearly rows include formatted values and placeholders.""" + _, _, rows = build_yearly_table_data(sample_yearly_aggregate, "repeater") + first_row = next(r for r in rows if not r.get("is_summary", False)) + cells = first_row["cells"] + + assert cells[0]["value"] == "2024" + assert cells[1]["value"] == "01" + assert cells[2]["value"] == "3.75" + assert cells[3]["value"] == "-" def test_handles_empty_aggregate(self): """Handles aggregate with no monthly data.""" @@ -231,11 +273,13 @@ class TestTableColumnGroups: """Column groups have expected structure.""" column_groups, _, _ = build_monthly_table_data(monthly_aggregate_with_data, "repeater") - for group in column_groups: - assert isinstance(group, dict) - # Should have label and span info - assert "label" in group or "name" in group - assert "span" in group or "colspan" in group or "columns" in group + assert column_groups == [ + {"label": "", "colspan": 1}, + {"label": "Battery", "colspan": 4}, + {"label": "Signal", "colspan": 3}, + {"label": "Packets", "colspan": 2}, + {"label": "Air", "colspan": 1}, + ] def test_column_groups_span_matches_headers(self, monthly_aggregate_with_data): """Column group spans should add up to header count.""" @@ -246,9 +290,7 @@ class TestTableColumnGroups: for g in column_groups ) - # Total span should match or be close to header count - # (implementation may vary in how headers are structured) - assert total_span > 0 or len(headers) > 0 + assert total_span == len(headers) class TestTableRolesHandling: @@ -283,6 +325,16 @@ class TestTableRolesHandling: # 1 data row + summary row data_rows = [r for r in rows if not r.get("is_summary", False)] assert len(data_rows) == 1 + assert [header["label"] for header in headers] == [ + "Day", + "Avg V", + "Avg %", + "Min V", + "Max V", + "Contacts", + "RX", + "TX", + ] def test_different_roles_different_columns(self, companion_aggregate): """Different roles may have different column structures.""" @@ -308,3 +360,4 @@ class TestTableRolesHandling: # Both should return valid data assert len(companion_result) == 3 assert len(repeater_result) == 3 + assert [h["label"] for h in companion_result[1]] != [h["label"] for h in repeater_result[1]] diff --git a/tests/retry/conftest.py b/tests/retry/conftest.py index 9d5cd33..ca8b0e5 100644 --- a/tests/retry/conftest.py +++ b/tests/retry/conftest.py @@ -1,10 +1,11 @@ """Fixtures for retry and circuit breaker tests.""" import json -import time import pytest +BASE_TS = 1704067200 + @pytest.fixture def circuit_state_file(tmp_path): @@ -18,7 +19,7 @@ def closed_circuit(circuit_state_file): state = { "consecutive_failures": 0, "cooldown_until": 0, - "last_success": time.time(), + "last_success": BASE_TS, } circuit_state_file.write_text(json.dumps(state)) return circuit_state_file @@ -29,8 +30,8 @@ def open_circuit(circuit_state_file): """Circuit breaker state file with open circuit (in cooldown).""" state = { "consecutive_failures": 10, - "cooldown_until": time.time() + 3600, # 1 hour from now - "last_success": time.time() - 7200, # 2 hours ago + "cooldown_until": BASE_TS + 3600, # 1 hour from BASE_TS + "last_success": BASE_TS - 7200, # 2 hours before BASE_TS } circuit_state_file.write_text(json.dumps(state)) return circuit_state_file @@ -41,8 +42,8 @@ def expired_cooldown_circuit(circuit_state_file): """Circuit breaker state file with expired cooldown.""" state = { "consecutive_failures": 10, - "cooldown_until": time.time() - 100, # Expired 100s ago - "last_success": time.time() - 7200, + "cooldown_until": BASE_TS - 100, # Expired 100s before BASE_TS + "last_success": BASE_TS - 7200, } circuit_state_file.write_text(json.dumps(state)) return circuit_state_file diff --git a/tests/retry/test_circuit_breaker.py b/tests/retry/test_circuit_breaker.py index 9a3c767..dad1ef8 100644 --- a/tests/retry/test_circuit_breaker.py +++ b/tests/retry/test_circuit_breaker.py @@ -1,10 +1,25 @@ """Tests for CircuitBreaker class.""" import json -import time + +import pytest from meshmon.retry import CircuitBreaker +BASE_TS = 1704067200 + + +@pytest.fixture +def time_controller(monkeypatch): + """Control time.time() within meshmon.retry.""" + state = {"now": BASE_TS} + + def _time(): + return state["now"] + + monkeypatch.setattr("meshmon.retry.time.time", _time) + return state + class TestCircuitBreakerInit: """Tests for CircuitBreaker initialization.""" @@ -25,12 +40,12 @@ class TestCircuitBreakerInit: assert cb.cooldown_until == 0 assert cb.last_success > 0 - def test_loads_open_circuit_state(self, open_circuit): + def test_loads_open_circuit_state(self, open_circuit, time_controller): """Loads open circuit state correctly.""" cb = CircuitBreaker(open_circuit) assert cb.consecutive_failures == 10 - assert cb.cooldown_until > time.time() + assert cb.cooldown_until == BASE_TS + 3600 assert cb.is_open() is True def test_handles_corrupted_file(self, corrupted_state_file): @@ -74,24 +89,24 @@ class TestCircuitBreakerIsOpen: assert cb.is_open() is False - def test_open_circuit_returns_true(self, open_circuit): + def test_open_circuit_returns_true(self, open_circuit, time_controller): """Open circuit (in cooldown) returns True.""" cb = CircuitBreaker(open_circuit) assert cb.is_open() is True - def test_expired_cooldown_returns_false(self, expired_cooldown_circuit): + def test_expired_cooldown_returns_false(self, expired_cooldown_circuit, time_controller): """Expired cooldown returns False (circuit closes).""" cb = CircuitBreaker(expired_cooldown_circuit) assert cb.is_open() is False - def test_cooldown_expiry(self, circuit_state_file): + def test_cooldown_expiry(self, circuit_state_file, time_controller): """Circuit closes when cooldown expires.""" - # Set cooldown to 0.1 seconds from now + # Set cooldown to 10 seconds from now state = { "consecutive_failures": 10, - "cooldown_until": time.time() + 0.1, + "cooldown_until": BASE_TS + 10, "last_success": 0, } circuit_state_file.write_text(json.dumps(state)) @@ -99,7 +114,7 @@ class TestCircuitBreakerIsOpen: cb = CircuitBreaker(circuit_state_file) assert cb.is_open() is True - time.sleep(0.15) + time_controller["now"] = BASE_TS + 11 assert cb.is_open() is False @@ -112,11 +127,11 @@ class TestCooldownRemaining: assert cb.cooldown_remaining() == 0 - def test_returns_seconds_when_open(self, circuit_state_file): + def test_returns_seconds_when_open(self, circuit_state_file, time_controller): """Returns remaining seconds when in cooldown.""" state = { "consecutive_failures": 10, - "cooldown_until": time.time() + 100, + "cooldown_until": BASE_TS + 100, "last_success": 0, } circuit_state_file.write_text(json.dumps(state)) @@ -124,7 +139,7 @@ class TestCooldownRemaining: cb = CircuitBreaker(circuit_state_file) remaining = cb.cooldown_remaining() - assert 98 <= remaining <= 100 + assert remaining == 100 def test_returns_zero_when_expired(self, expired_cooldown_circuit): """Returns 0 when cooldown has expired.""" @@ -132,7 +147,7 @@ class TestCooldownRemaining: assert cb.cooldown_remaining() == 0 - def test_returns_integer(self, open_circuit): + def test_returns_integer(self, open_circuit, time_controller): """Returns an integer, not float.""" cb = CircuitBreaker(open_circuit) @@ -156,14 +171,13 @@ class TestRecordSuccess: assert cb.consecutive_failures == 0 - def test_updates_last_success(self, circuit_state_file): + def test_updates_last_success(self, circuit_state_file, time_controller): """Success updates last_success timestamp.""" cb = CircuitBreaker(circuit_state_file) - before = time.time() + time_controller["now"] = BASE_TS + 5 cb.record_success() - after = time.time() - assert before <= cb.last_success <= after + assert cb.last_success == BASE_TS + 5 def test_persists_to_file(self, circuit_state_file): """Success state is persisted to file.""" @@ -195,7 +209,7 @@ class TestRecordFailure: assert cb.consecutive_failures == 1 - def test_opens_circuit_at_threshold(self, circuit_state_file): + def test_opens_circuit_at_threshold(self, circuit_state_file, time_controller): """Circuit opens when failures reach threshold.""" cb = CircuitBreaker(circuit_state_file) @@ -204,9 +218,9 @@ class TestRecordFailure: cb.record_failure(max_failures=5, cooldown_s=3600) assert cb.is_open() is True - assert cb.cooldown_until > time.time() + assert cb.cooldown_until == BASE_TS + 3600 - def test_does_not_open_before_threshold(self, circuit_state_file): + def test_does_not_open_before_threshold(self, circuit_state_file, time_controller): """Circuit stays closed before reaching threshold.""" cb = CircuitBreaker(circuit_state_file) @@ -215,18 +229,15 @@ class TestRecordFailure: assert cb.is_open() is False - def test_cooldown_duration(self, circuit_state_file): + def test_cooldown_duration(self, circuit_state_file, time_controller): """Cooldown is set to specified duration.""" cb = CircuitBreaker(circuit_state_file) - before = time.time() for _ in range(5): cb.record_failure(max_failures=5, cooldown_s=100) - after = time.time() # Cooldown should be ~100 seconds from now - assert cb.cooldown_until >= before + 100 - assert cb.cooldown_until <= after + 100 + assert cb.cooldown_until == BASE_TS + 100 def test_persists_to_file(self, circuit_state_file): """Failure state is persisted to file.""" @@ -251,14 +262,14 @@ class TestToDict: assert "is_open" in d assert "cooldown_remaining_s" in d - def test_is_open_reflects_state(self, open_circuit): + def test_is_open_reflects_state(self, open_circuit, time_controller): """is_open in dict reflects actual circuit state.""" cb = CircuitBreaker(open_circuit) d = cb.to_dict() assert d["is_open"] is True - def test_cooldown_remaining_reflects_state(self, open_circuit): + def test_cooldown_remaining_reflects_state(self, open_circuit, time_controller): """cooldown_remaining_s reflects actual remaining time.""" cb = CircuitBreaker(open_circuit) d = cb.to_dict() @@ -301,7 +312,7 @@ class TestStatePersistence: cb2 = CircuitBreaker(circuit_state_file) assert cb2.consecutive_failures == 0 - def test_open_state_survives_reload(self, circuit_state_file): + def test_open_state_survives_reload(self, circuit_state_file, time_controller): """Open circuit state persists across instances.""" cb1 = CircuitBreaker(circuit_state_file) for _ in range(10): diff --git a/tests/retry/test_with_retries.py b/tests/retry/test_with_retries.py index 97339c7..de5f5b0 100644 --- a/tests/retry/test_with_retries.py +++ b/tests/retry/test_with_retries.py @@ -7,6 +7,18 @@ import pytest from meshmon.retry import with_retries +@pytest.fixture +def sleep_spy(monkeypatch): + """Capture asyncio.sleep calls without waiting.""" + calls = [] + + async def fake_sleep(delay): + calls.append(delay) + + monkeypatch.setattr("meshmon.retry.asyncio.sleep", fake_sleep) + return calls + + class TestWithRetriesSuccess: """Tests for successful operation scenarios.""" @@ -69,7 +81,7 @@ class TestWithRetriesFailure: raise ValueError("always fails") success, result, exception = await with_retries( - failing_fn, attempts=3, backoff_s=0.01 + failing_fn, attempts=3, backoff_s=0 ) assert success is False @@ -86,7 +98,7 @@ class TestWithRetriesFailure: call_count += 1 raise RuntimeError("fail") - await with_retries(failing_fn, attempts=5, backoff_s=0.01) + await with_retries(failing_fn, attempts=5, backoff_s=0) assert call_count == 5 @@ -101,7 +113,7 @@ class TestWithRetriesFailure: raise ValueError(f"error {attempt}") success, result, exception = await with_retries( - changing_error_fn, attempts=3, backoff_s=0.01 + changing_error_fn, attempts=3, backoff_s=0 ) assert str(exception) == "error 3" @@ -123,7 +135,7 @@ class TestWithRetriesRetryBehavior: return "success" success, result, exception = await with_retries( - eventually_succeeds, attempts=5, backoff_s=0.01 + eventually_succeeds, attempts=5, backoff_s=0 ) assert success is True @@ -132,36 +144,24 @@ class TestWithRetriesRetryBehavior: assert attempt == 3 @pytest.mark.asyncio - async def test_backoff_timing(self): + async def test_backoff_timing(self, sleep_spy): """Waits backoff_s between retries.""" - import time - async def failing_fn(): raise RuntimeError("fail") - start = time.time() await with_retries(failing_fn, attempts=3, backoff_s=0.1) - elapsed = time.time() - start - # Should wait ~0.2s total (2 backoffs between 3 attempts) - assert elapsed >= 0.18 - assert elapsed < 0.5 # Allow some overhead + assert sleep_spy == [0.1, 0.1] @pytest.mark.asyncio - async def test_no_backoff_after_last_attempt(self): + async def test_no_backoff_after_last_attempt(self, sleep_spy): """Does not wait after final failed attempt.""" - import time - async def failing_fn(): raise RuntimeError("fail") - start = time.time() await with_retries(failing_fn, attempts=2, backoff_s=0.5) - elapsed = time.time() - start - # Only 1 backoff between 2 attempts (~0.5s) - assert elapsed >= 0.45 - assert elapsed < 0.8 # Should not wait twice + assert sleep_spy == [0.5] class TestWithRetriesParameters: @@ -177,7 +177,7 @@ class TestWithRetriesParameters: call_count += 1 raise RuntimeError("fail") - await with_retries(failing_fn, backoff_s=0.01) + await with_retries(failing_fn, backoff_s=0) assert call_count == 2 @@ -191,7 +191,7 @@ class TestWithRetriesParameters: call_count += 1 raise RuntimeError("fail") - await with_retries(failing_fn, attempts=1, backoff_s=0.01) + await with_retries(failing_fn, attempts=1, backoff_s=0) assert call_count == 1 @@ -210,17 +210,27 @@ class TestWithRetriesParameters: assert call_count == 3 @pytest.mark.asyncio - async def test_name_parameter_for_logging(self, capfd): + async def test_name_parameter_for_logging(self, monkeypatch, sleep_spy): """Name parameter is used in logging.""" + messages = [] + + def fake_info(msg): + messages.append(msg) + + def fake_debug(msg): + messages.append(msg) + + monkeypatch.setattr("meshmon.retry.log.info", fake_info) + monkeypatch.setattr("meshmon.retry.log.debug", fake_debug) + async def failing_fn(): raise RuntimeError("fail") await with_retries( - failing_fn, attempts=2, backoff_s=0.01, name="test_operation" + failing_fn, attempts=2, backoff_s=0.1, name="test_operation" ) - # The function logs with the operation name - # (actual output depends on log configuration) + assert any("test_operation" in msg for msg in messages) class TestWithRetriesExceptionTypes: diff --git a/tests/scripts/test_collect_repeater.py b/tests/scripts/test_collect_repeater.py index 324acb6..6d701ac 100644 --- a/tests/scripts/test_collect_repeater.py +++ b/tests/scripts/test_collect_repeater.py @@ -99,6 +99,7 @@ class TestFindRepeaterContact: contact = await module.find_repeater_contact(mc) assert contact is not None + assert contact["public_key"] == "abc123def456" mock_get.assert_called_once() @pytest.mark.asyncio @@ -148,6 +149,7 @@ class TestFindRepeaterContact: contact = await module.find_repeater_contact(mc) assert contact is not None + assert contact["adv_name"] == "MyRepeater" @pytest.mark.asyncio async def test_returns_none_when_not_found(self, configured_env, monkeypatch): @@ -205,13 +207,17 @@ class TestCircuitBreakerIntegration: mock_cb.is_open.return_value = True mock_cb.cooldown_remaining.return_value = 1800 - with patch.object(module, "get_repeater_circuit_breaker", return_value=mock_cb): + with ( + patch.object(module, "get_repeater_circuit_breaker", return_value=mock_cb), + patch.object(module, "connect_with_lock") as mock_connect, + ): exit_code = await module.collect_repeater() # Should return 0 (not an error, just skipped) assert exit_code == 0 # Should not have tried to connect mock_cb.is_open.assert_called_once() + mock_connect.assert_not_called() @pytest.mark.asyncio async def test_records_success_on_successful_status( @@ -247,6 +253,7 @@ class TestCircuitBreakerIntegration: await module.collect_repeater() mock_cb.record_success.assert_called_once() + mock_cb.record_failure.assert_not_called() @pytest.mark.asyncio async def test_records_failure_on_status_timeout( @@ -281,6 +288,7 @@ class TestCircuitBreakerIntegration: exit_code = await module.collect_repeater() mock_cb.record_failure.assert_called_once() + mock_cb.record_success.assert_not_called() assert exit_code == 1 @@ -312,7 +320,7 @@ class TestCollectRepeaterExitCodes: patch.object(module, "run_command") as mock_run, patch.object(module, "find_repeater_contact") as mock_find, patch.object(module, "query_repeater_with_retry") as mock_query, - patch.object(module, "insert_metrics", return_value=3), + patch.object(module, "insert_metrics") as mock_insert, ): mock_run.return_value = (True, "OK", {}, None) mock_find.return_value = {"adv_name": "TestRepeater"} @@ -325,6 +333,11 @@ class TestCollectRepeaterExitCodes: exit_code = await module.collect_repeater() assert exit_code == 0 + mock_insert.assert_called_once() + insert_kwargs = mock_insert.call_args.kwargs + assert insert_kwargs["role"] == "repeater" + assert insert_kwargs["metrics"]["bat"] == 3850 + assert insert_kwargs["metrics"]["nb_recv"] == 100 @pytest.mark.asyncio async def test_returns_one_on_connection_failure( diff --git a/tests/scripts/test_render_scripts.py b/tests/scripts/test_render_scripts.py index e30285b..41d7664 100644 --- a/tests/scripts/test_render_scripts.py +++ b/tests/scripts/test_render_scripts.py @@ -51,7 +51,10 @@ class TestRenderChartsImport: module.main() # Should check both companion and repeater - assert mock_count.call_count == 2 + assert [call.args[0] for call in mock_count.call_args_list] == [ + "companion", + "repeater", + ] def test_main_renders_when_data_exists(self, configured_env): """main() should render charts when data exists.""" @@ -61,13 +64,20 @@ class TestRenderChartsImport: patch.object(module, "init_db"), patch.object(module, "get_metric_count", return_value=100), patch.object(module, "render_all_charts") as mock_render, - patch.object(module, "save_chart_stats"), + patch.object(module, "save_chart_stats") as mock_save, ): mock_render.return_value = (["chart1.svg"], {"bat": {}}) module.main() # Should render for both roles - assert mock_render.call_count == 2 + assert [call.args[0] for call in mock_render.call_args_list] == [ + "companion", + "repeater", + ] + assert [call.args[0] for call in mock_save.call_args_list] == [ + "companion", + "repeater", + ] class TestRenderSiteImport: @@ -106,7 +116,10 @@ class TestRenderSiteImport: module.main() # Should get metrics for both companion and repeater - assert mock_get.call_count == 2 + assert [call.args[0] for call in mock_get.call_args_list] == [ + "companion", + "repeater", + ] def test_main_calls_write_site(self, configured_env): """main() should call write_site with metrics.""" @@ -196,7 +209,10 @@ class TestRenderReportsImport: module.main() # Should check periods for both roles - assert mock_periods.call_count == 2 + assert [call.args[0] for call in mock_periods.call_args_list] == [ + "repeater", + "companion", + ] class TestRenderReportsHelpers: diff --git a/tests/unit/test_charts_helpers.py b/tests/unit/test_charts_helpers.py index 8d92a36..c001f51 100644 --- a/tests/unit/test_charts_helpers.py +++ b/tests/unit/test_charts_helpers.py @@ -2,8 +2,9 @@ import json from datetime import datetime, timedelta -from unittest.mock import MagicMock +import matplotlib.dates as mdates +import matplotlib.pyplot as plt import pytest from meshmon.charts import ( @@ -19,6 +20,9 @@ from meshmon.charts import ( calculate_statistics, ) +BASE_TIME = datetime(2024, 1, 1, 12, 0, 0) +BASE_DAY_START = datetime(2024, 1, 1, 0, 0, 0) + class TestHexToRgba: """Test _hex_to_rgba function.""" @@ -154,44 +158,101 @@ class TestConfigureXAxis: def test_day_period_format(self): """Day period uses HH:MM format with 4-hour intervals.""" - fig, ax = self._create_mock_ax() - _configure_x_axis(ax, "day") - ax.xaxis.set_major_formatter.assert_called_once() - ax.xaxis.set_major_locator.assert_called_once() + fig, ax = plt.subplots() + try: + _configure_x_axis(ax, "day") + formatter = ax.xaxis.get_major_formatter() + locator = ax.xaxis.get_major_locator() + assert isinstance(formatter, mdates.DateFormatter) + assert formatter.fmt == "%H:%M" + assert isinstance(locator, mdates.HourLocator) + ticks = locator.tick_values( + BASE_DAY_START, BASE_DAY_START + timedelta(days=1) + ) + tick_times = [ + mdates.num2date(tick).replace(tzinfo=None) for tick in ticks + ] + assert tick_times[1] - tick_times[0] == timedelta(hours=4) + finally: + plt.close(fig) def test_week_period_format(self): """Week period uses weekday format with daily intervals.""" - fig, ax = self._create_mock_ax() - _configure_x_axis(ax, "week") - ax.xaxis.set_major_formatter.assert_called_once() - ax.xaxis.set_major_locator.assert_called_once() + fig, ax = plt.subplots() + try: + _configure_x_axis(ax, "week") + formatter = ax.xaxis.get_major_formatter() + locator = ax.xaxis.get_major_locator() + assert isinstance(formatter, mdates.DateFormatter) + assert formatter.fmt == "%a" + assert isinstance(locator, mdates.DayLocator) + ticks = locator.tick_values( + BASE_DAY_START, BASE_DAY_START + timedelta(days=7) + ) + tick_times = [ + mdates.num2date(tick).replace(tzinfo=None) for tick in ticks + ] + assert tick_times[1] - tick_times[0] == timedelta(days=1) + finally: + plt.close(fig) def test_month_period_format(self): """Month period uses day-of-month format with 5-day intervals.""" - fig, ax = self._create_mock_ax() - _configure_x_axis(ax, "month") - ax.xaxis.set_major_formatter.assert_called_once() - ax.xaxis.set_major_locator.assert_called_once() + fig, ax = plt.subplots() + try: + _configure_x_axis(ax, "month") + formatter = ax.xaxis.get_major_formatter() + locator = ax.xaxis.get_major_locator() + assert isinstance(formatter, mdates.DateFormatter) + assert formatter.fmt == "%d" + assert isinstance(locator, mdates.DayLocator) + ticks = locator.tick_values( + BASE_DAY_START, BASE_DAY_START + timedelta(days=31) + ) + tick_times = [ + mdates.num2date(tick).replace(tzinfo=None) for tick in ticks + ] + assert tick_times[1] - tick_times[0] == timedelta(days=5) + finally: + plt.close(fig) def test_year_period_format(self): """Year period uses month abbreviation format.""" - fig, ax = self._create_mock_ax() - _configure_x_axis(ax, "year") - ax.xaxis.set_major_formatter.assert_called_once() - ax.xaxis.set_major_locator.assert_called_once() + fig, ax = plt.subplots() + try: + _configure_x_axis(ax, "year") + formatter = ax.xaxis.get_major_formatter() + locator = ax.xaxis.get_major_locator() + assert isinstance(formatter, mdates.DateFormatter) + assert formatter.fmt == "%b" + assert isinstance(locator, mdates.MonthLocator) + ticks = locator.tick_values( + BASE_DAY_START, BASE_DAY_START + timedelta(days=365) + ) + tick_times = [ + mdates.num2date(tick).replace(tzinfo=None) for tick in ticks + ] + assert len(tick_times) > 1 + assert all(tick.day == 1 for tick in tick_times) + for current, nxt in zip(tick_times, tick_times[1:], strict=False): + expected_month = 1 if current.month == 12 else current.month + 1 + expected_year = current.year + (1 if current.month == 12 else 0) + assert (nxt.year, nxt.month) == (expected_year, expected_month) + finally: + plt.close(fig) def test_unknown_period_defaults_to_year(self): """Unknown period defaults to year format.""" - fig, ax = self._create_mock_ax() - _configure_x_axis(ax, "unknown") - ax.xaxis.set_major_formatter.assert_called_once() - - def _create_mock_ax(self): - """Create a mock axes object.""" - ax = MagicMock() - ax.xaxis = MagicMock() - ax.xaxis.get_majorticklabels.return_value = [] - return None, ax + fig, ax = plt.subplots() + try: + _configure_x_axis(ax, "unknown") + formatter = ax.xaxis.get_major_formatter() + locator = ax.xaxis.get_major_locator() + assert isinstance(formatter, mdates.DateFormatter) + assert formatter.fmt == "%b" + assert isinstance(locator, mdates.MonthLocator) + finally: + plt.close(fig) class TestInjectDataAttributes: @@ -267,7 +328,7 @@ class TestInjectDataAttributes: def _create_sample_timeseries(self) -> TimeSeries: """Create sample time series for testing.""" - now = datetime.now() + now = BASE_TIME return TimeSeries( metric="bat", role="repeater", @@ -316,7 +377,7 @@ class TestCalculateStatistics: metric="bat", role="repeater", period="day", - points=[DataPoint(timestamp=datetime.now(), value=3.8)], + points=[DataPoint(timestamp=BASE_TIME, value=3.8)], ) stats = calculate_statistics(ts) assert stats.min_value == 3.8 @@ -326,15 +387,14 @@ class TestCalculateStatistics: def test_multiple_points(self): """Multiple points calculate correct statistics.""" - now = datetime.now() ts = TimeSeries( metric="bat", role="repeater", period="day", points=[ - DataPoint(timestamp=now - timedelta(hours=2), value=3.0), - DataPoint(timestamp=now - timedelta(hours=1), value=4.0), - DataPoint(timestamp=now, value=5.0), + DataPoint(timestamp=BASE_TIME - timedelta(hours=2), value=3.0), + DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=4.0), + DataPoint(timestamp=BASE_TIME, value=5.0), ], ) stats = calculate_statistics(ts) @@ -345,15 +405,14 @@ class TestCalculateStatistics: def test_current_is_last_point(self): """Current value is the most recent (last) point.""" - now = datetime.now() ts = TimeSeries( metric="bat", role="repeater", period="day", points=[ - DataPoint(timestamp=now - timedelta(hours=2), value=100.0), - DataPoint(timestamp=now - timedelta(hours=1), value=50.0), - DataPoint(timestamp=now, value=75.0), + DataPoint(timestamp=BASE_TIME - timedelta(hours=2), value=100.0), + DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=50.0), + DataPoint(timestamp=BASE_TIME, value=75.0), ], ) stats = calculate_statistics(ts) @@ -365,14 +424,13 @@ class TestTimeSeries: def test_timestamps_property(self): """timestamps property returns list of timestamps.""" - now = datetime.now() ts = TimeSeries( metric="bat", role="repeater", period="day", points=[ - DataPoint(timestamp=now - timedelta(hours=1), value=3.8), - DataPoint(timestamp=now, value=3.9), + DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=3.8), + DataPoint(timestamp=BASE_TIME, value=3.9), ], ) timestamps = ts.timestamps @@ -386,8 +444,8 @@ class TestTimeSeries: role="repeater", period="day", points=[ - DataPoint(timestamp=datetime.now(), value=3.8), - DataPoint(timestamp=datetime.now(), value=3.9), + DataPoint(timestamp=BASE_TIME, value=3.8), + DataPoint(timestamp=BASE_TIME + timedelta(minutes=1), value=3.9), ], ) values = ts.values @@ -404,7 +462,7 @@ class TestTimeSeries: metric="bat", role="repeater", period="day", - points=[DataPoint(timestamp=datetime.now(), value=3.8)], + points=[DataPoint(timestamp=BASE_TIME, value=3.8)], ) assert ts.is_empty is False diff --git a/tests/unit/test_env_parsing.py b/tests/unit/test_env_parsing.py index ac3d4a1..e15d729 100644 --- a/tests/unit/test_env_parsing.py +++ b/tests/unit/test_env_parsing.py @@ -196,18 +196,20 @@ class TestGetPath: result = get_path("NONEXISTENT_VAR_12345", "/some/path") assert result == Path("/some/path") - def test_expands_user(self, monkeypatch): + 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 "~" not in str(result) - assert result.is_absolute() + assert result == (tmp_path / "subdir").resolve() - def test_resolves_to_absolute(self, monkeypatch): - """Relative paths are resolved to absolute.""" + 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.is_absolute() + assert result == (tmp_path / "relative/path").resolve() class TestConfig: diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py index 284119c..f10f98b 100644 --- a/tests/unit/test_formatters.py +++ b/tests/unit/test_formatters.py @@ -1,5 +1,7 @@ """Tests for shared formatting functions.""" +from datetime import datetime + from meshmon.formatters import ( format_compact_number, format_duration, @@ -21,14 +23,9 @@ class TestFormatTime: def test_valid_timestamp(self): """Valid timestamp formats correctly.""" - import time - # Use a recent timestamp and verify it produces a datetime string - ts = int(time.time()) - 3600 # 1 hour ago + ts = int(datetime(2024, 1, 2, 3, 4, 5).timestamp()) result = format_time(ts) - # Should produce a datetime string with format YYYY-MM-DD HH:MM:SS - assert len(result) == 19 # "2024-06-15 14:30:45" format - assert "-" in result - assert ":" in result + assert result == "2024-01-02 03:04:05" def test_zero_timestamp(self): """Zero timestamp (epoch) formats correctly.""" @@ -108,41 +105,34 @@ class TestFormatDuration: assert format_duration(0) == "0s" def test_seconds_only(self): - """Less than a minute shows seconds only (with 0h 0m prefix).""" + """Less than a minute shows seconds only.""" result = format_duration(45) - assert "45s" in result + assert result == "45s" def test_minutes_and_seconds(self): """Minutes and seconds format.""" result = format_duration(125) # 2m 5s - assert "2m" in result - assert "5s" in result + assert result == "2m 5s" def test_hours_minutes_seconds(self): """Hours, minutes, and seconds format.""" result = format_duration(3725) # 1h 2m 5s - assert "1h" in result - assert "2m" in result - assert "5s" in result + assert result == "1h 2m 5s" def test_days_hours_minutes_seconds(self): """Full duration with days.""" result = format_duration(90125) # 1d 1h 2m 5s - assert "1d" in result - assert "1h" in result - assert "2m" in result - assert "5s" in result + assert result == "1d 1h 2m 5s" def test_exact_day(self): """Exactly one day.""" result = format_duration(86400) - assert "1d" in result - assert "0h" in result + assert result == "1d 0h 0m 0s" def test_multiple_days(self): """Multiple days.""" result = format_duration(172800) # 2 days - assert "2d" in result + assert result == "2d 0h 0m 0s" class TestFormatUptime: @@ -167,23 +157,17 @@ class TestFormatUptime: def test_hours_and_minutes(self): """Hours and minutes format.""" result = format_uptime(3720) # 1h 2m - assert "1h" in result - assert "2m" in result + assert result == "1h 2m" def test_days_hours_minutes(self): """Days, hours, and minutes format (no seconds in uptime).""" result = format_uptime(90120) # 1d 1h 2m - assert "1d" in result - assert "1h" in result - assert "2m" in result - # Seconds not included in uptime format - assert "s" not in result + assert result == "1d 1h 2m" def test_exact_hour(self): """Exactly one hour shows 0m.""" result = format_uptime(3600) - assert "1h" in result - assert "0m" in result + assert result == "1h 0m" class TestFormatVoltageWithPct: diff --git a/tests/unit/test_html_builders.py b/tests/unit/test_html_builders.py index 97c50ba..22fa885 100644 --- a/tests/unit/test_html_builders.py +++ b/tests/unit/test_html_builders.py @@ -36,6 +36,7 @@ class TestBuildTrafficTableRows: assert result[0]["rx_raw"] == 1200 assert result[0]["tx"] == "800" assert result[0]["tx_raw"] == 800 + assert result[0]["unit"] == "packets" def test_flood_rx_tx(self): """Flood RX/TX become Flood row.""" @@ -49,6 +50,9 @@ class TestBuildTrafficTableRows: assert result[0]["label"] == "Flood" assert result[0]["rx"] == "500" assert result[0]["tx"] == "300" + assert result[0]["rx_raw"] == 500 + assert result[0]["tx_raw"] == 300 + assert result[0]["unit"] == "packets" def test_direct_rx_tx(self): """Direct RX/TX become Direct row.""" @@ -62,6 +66,9 @@ class TestBuildTrafficTableRows: assert result[0]["label"] == "Direct" assert result[0]["rx"] == "200" assert result[0]["tx"] == "100" + assert result[0]["rx_raw"] == 200 + assert result[0]["tx_raw"] == 100 + assert result[0]["unit"] == "packets" def test_airtime_rx_tx(self): """Airtime TX/RX become Airtime row.""" @@ -75,6 +82,9 @@ class TestBuildTrafficTableRows: assert result[0]["label"] == "Airtime" assert result[0]["tx"] == "1h 30m" assert result[0]["rx"] == "3h 0m" + assert result[0]["rx_raw"] == 10800 + assert result[0]["tx_raw"] == 5400 + assert result[0]["unit"] == "seconds" def test_output_order(self): """Output follows order: Packets, Flood, Direct, Airtime.""" @@ -98,6 +108,8 @@ class TestBuildTrafficTableRows: assert result[0]["rx"] == "500" assert result[0]["tx"] is None + assert result[0]["rx_raw"] == 500 + assert result[0]["tx_raw"] is None def test_unrecognized_label_skipped(self): """Unrecognized labels are skipped.""" @@ -138,6 +150,10 @@ class TestBuildNodeDetails: # Check specific values location = next(d for d in result if d["label"] == "Location") assert location["value"] == "Test Location" + coords = next(d for d in result if d["label"] == "Coordinates") + assert coords["value"] == "51.5074°N, 0.1278°W" + elevation = next(d for d in result if d["label"] == "Elevation") + assert elevation["value"] == "11 m" hardware = next(d for d in result if d["label"] == "Hardware") assert hardware["value"] == "RAK 4631" @@ -155,6 +171,8 @@ class TestBuildNodeDetails: labels = [d["label"] for d in result] assert "Hardware" in labels assert "Connection" in labels + assert next(d for d in result if d["label"] == "Connection")["value"] == "USB Serial" + assert next(d for d in result if d["label"] == "Hardware")["value"] == "T-Beam Supreme" # No location info for companion assert "Location" not in labels @@ -171,8 +189,7 @@ class TestBuildNodeDetails: result = build_node_details("repeater") coords = next(d for d in result if d["label"] == "Coordinates") - assert "S" in coords["value"] # South - assert "E" in coords["value"] # East + assert coords["value"] == "33.8688°S, 151.2093°E" class TestBuildRadioConfig: @@ -198,6 +215,9 @@ class TestBuildRadioConfig: freq = next(d for d in result if d["label"] == "Frequency") assert freq["value"] == "915.0 MHz" + assert next(d for d in result if d["label"] == "Bandwidth")["value"] == "125 kHz" + assert next(d for d in result if d["label"] == "Spread Factor")["value"] == "SF12" + assert next(d for d in result if d["label"] == "Coding Rate")["value"] == "CR5" class TestBuildRepeaterMetrics: @@ -239,16 +259,56 @@ class TestBuildRepeaterMetrics: result = build_repeater_metrics(row) # Critical metrics - assert len(result["critical_metrics"]) >= 4 - battery = next(m for m in result["critical_metrics"] if m["label"] == "Battery") - assert battery["value"] == "3.85" - assert battery["unit"] == "V" + assert [m["label"] for m in result["critical_metrics"]] == [ + "Battery", + "Charge", + "RSSI", + "SNR", + ] + battery = result["critical_metrics"][0] + assert battery == { + "value": "3.85", + "unit": "V", + "label": "Battery", + "bar_pct": 55, + } + assert result["critical_metrics"][1] == { + "value": "55", + "unit": "%", + "label": "Charge", + } + assert result["critical_metrics"][2] == { + "value": "-85", + "unit": "dBm", + "label": "RSSI", + } + assert result["critical_metrics"][3] == { + "value": "7.50", + "unit": "dB", + "label": "SNR", + } # Secondary metrics - assert len(result["secondary_metrics"]) >= 2 + assert result["secondary_metrics"] == [ + {"label": "Uptime", "value": "1d 0h"}, + {"label": "Noise Floor", "value": "-115 dBm"}, + {"label": "TX Queue", "value": "0"}, + ] # Traffic metrics - assert len(result["traffic_metrics"]) >= 8 + assert [ + (metric["label"], metric["value"], metric["raw_value"], metric["unit"]) + for metric in result["traffic_metrics"] + ] == [ + ("RX", "1,234", 1234, "packets"), + ("TX", "567", 567, "packets"), + ("Flood RX", "500", 500, "packets"), + ("Flood TX", "200", 200, "packets"), + ("Direct RX", "100", 100, "packets"), + ("Direct TX", "50", 50, "packets"), + ("Airtime TX", "1h 0m", 3600, "seconds"), + ("Airtime RX", "2h 0m", 7200, "seconds"), + ] def test_battery_converts_mv_to_v(self): """Battery value is converted from mV to V.""" @@ -290,13 +350,26 @@ class TestBuildCompanionMetrics: result = build_companion_metrics(row) # Critical metrics - assert len(result["critical_metrics"]) == 4 # Battery, Charge, Contacts, Uptime + assert result["critical_metrics"] == [ + { + "value": "3.85", + "unit": "V", + "label": "Battery", + "bar_pct": 55, + }, + {"value": "55", "unit": "%", "label": "Charge"}, + {"value": "5", "unit": None, "label": "Contacts"}, + {"value": "1d 0h", "unit": None, "label": "Uptime"}, + ] # Secondary metrics (empty for companion) assert result["secondary_metrics"] == [] # Traffic metrics - assert len(result["traffic_metrics"]) == 2 # RX and TX + assert result["traffic_metrics"] == [ + {"label": "RX", "value": "1,234", "raw_value": 1234, "unit": "packets"}, + {"label": "TX", "value": "567", "raw_value": 567, "unit": "packets"}, + ] def test_battery_converts_mv_to_v(self): """Battery value is converted from mV to V.""" diff --git a/tests/unit/test_html_formatters.py b/tests/unit/test_html_formatters.py index 7734a40..ec39943 100644 --- a/tests/unit/test_html_formatters.py +++ b/tests/unit/test_html_formatters.py @@ -4,6 +4,8 @@ from datetime import datetime from pathlib import Path from unittest.mock import patch +import pytest + from meshmon.html import ( STATUS_ONLINE_THRESHOLD, STATUS_STALE_THRESHOLD, @@ -16,6 +18,21 @@ from meshmon.html import ( get_status, ) +BASE_NOW = datetime(2024, 1, 2, 12, 0, 0) + + +@pytest.fixture +def fixed_now(monkeypatch): + """Freeze meshmon.html datetime.now() for deterministic status tests.""" + import meshmon.html + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return BASE_NOW if tz is None else BASE_NOW.astimezone(tz) + + monkeypatch.setattr(meshmon.html, "datetime", FixedDatetime) + return BASE_NOW class TestFormatStatValue: """Test _format_stat_value function.""" @@ -125,7 +142,7 @@ class TestFmtValTime: def test_none_returns_dash(self): """None value returns dash.""" - assert _fmt_val_time(None, datetime.now()) == "-" + assert _fmt_val_time(None, BASE_NOW) == "-" def test_formats_value_with_time(self): """Formats value with time in small tag.""" @@ -157,7 +174,7 @@ class TestFmtValDay: def test_none_returns_dash(self): """None value returns dash.""" - assert _fmt_val_day(None, datetime.now()) == "-" + assert _fmt_val_day(None, BASE_NOW) == "-" def test_formats_value_with_day(self): """Formats value with day number in small tag.""" @@ -189,7 +206,7 @@ class TestFmtValMonth: def test_none_returns_dash(self): """None value returns dash.""" - assert _fmt_val_month(None, datetime.now()) == "-" + assert _fmt_val_month(None, BASE_NOW) == "-" def test_formats_value_with_month(self): """Formats value with month abbreviation in small tag.""" @@ -249,30 +266,30 @@ class TestGetStatus: assert status_class == "offline" assert status_text == "No data" - def test_recent_timestamp_online(self): + def test_recent_timestamp_online(self, fixed_now): """Recent timestamp (< 30 min) returns online.""" - recent_ts = int(datetime.now().timestamp()) - 60 # 1 minute ago + recent_ts = int(fixed_now.timestamp()) - 60 # 1 minute ago status_class, status_text = get_status(recent_ts) assert status_class == "online" assert status_text == "Online" - def test_stale_timestamp(self): + def test_stale_timestamp(self, fixed_now): """Stale timestamp (30 min - 2 hours) returns stale.""" - stale_ts = int(datetime.now().timestamp()) - (STATUS_ONLINE_THRESHOLD + 60) + stale_ts = int(fixed_now.timestamp()) - (STATUS_ONLINE_THRESHOLD + 60) status_class, status_text = get_status(stale_ts) assert status_class == "stale" assert status_text == "Stale" - def test_old_timestamp_offline(self): + def test_old_timestamp_offline(self, fixed_now): """Old timestamp (> 2 hours) returns offline.""" - old_ts = int(datetime.now().timestamp()) - (STATUS_STALE_THRESHOLD + 60) + old_ts = int(fixed_now.timestamp()) - (STATUS_STALE_THRESHOLD + 60) status_class, status_text = get_status(old_ts) assert status_class == "offline" assert status_text == "Offline" - def test_exactly_at_threshold(self): + def test_exactly_at_threshold(self, fixed_now): """Timestamps exactly at thresholds.""" - now = int(datetime.now().timestamp()) + now = int(fixed_now.timestamp()) # Just under online threshold - still online ts_just_online = now - STATUS_ONLINE_THRESHOLD + 1 diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py index fe6bb6f..337f94a 100644 --- a/tests/unit/test_log.py +++ b/tests/unit/test_log.py @@ -39,29 +39,37 @@ class TestTimestamp: assert result == "2024-01-15 10:30:45" +@pytest.fixture +def fixed_ts(monkeypatch): + """Freeze log timestamp for deterministic output assertions.""" + timestamp = "2024-01-15 10:30:45" + monkeypatch.setattr(log, "_ts", lambda: timestamp) + return timestamp + + class TestInfoLog: """Test the info() function.""" - def test_prints_to_stdout(self, capsys): + def test_prints_to_stdout(self, capsys, fixed_ts): """info() should print to stdout.""" log.info("test message") captured = capsys.readouterr() - assert "test message" in captured.out + assert captured.out == f"[{fixed_ts}] test message\n" assert captured.err == "" - def test_includes_timestamp(self, capsys): + def test_includes_timestamp(self, capsys, fixed_ts): """info() output should include timestamp.""" log.info("test") captured = capsys.readouterr() # Should have format: [YYYY-MM-DD HH:MM:SS] message - assert captured.out.startswith("[") - assert "]" in captured.out + assert captured.out.startswith(f"[{fixed_ts}]") + assert captured.out.endswith("test\n") - def test_message_appears_after_timestamp(self, capsys): + def test_message_appears_after_timestamp(self, capsys, fixed_ts): """Message should appear after the timestamp.""" log.info("unique_test_message") captured = capsys.readouterr() - assert "unique_test_message" in captured.out + assert captured.out == f"[{fixed_ts}] unique_test_message\n" # Message should be after the closing bracket bracket_pos = captured.out.index("]") message_pos = captured.out.index("unique_test_message") @@ -71,7 +79,7 @@ class TestInfoLog: class TestDebugLog: """Test the debug() function.""" - def test_no_output_when_debug_disabled(self, capsys, monkeypatch): + def test_no_output_when_debug_disabled(self, capsys, monkeypatch, fixed_ts): """debug() should not print when MESH_DEBUG is not set.""" # Clean env should already have MESH_DEBUG unset import meshmon.env @@ -82,7 +90,7 @@ class TestDebugLog: assert captured.out == "" assert captured.err == "" - def test_prints_when_debug_enabled(self, capsys, monkeypatch): + def test_prints_when_debug_enabled(self, capsys, monkeypatch, fixed_ts): """debug() should print when MESH_DEBUG=1.""" monkeypatch.setenv("MESH_DEBUG", "1") import meshmon.env @@ -90,10 +98,9 @@ class TestDebugLog: log.debug("debug message") captured = capsys.readouterr() - assert "debug message" in captured.out - assert "DEBUG:" in captured.out + assert captured.out == f"[{fixed_ts}] DEBUG: debug message\n" - def test_debug_prefix(self, capsys, monkeypatch): + def test_debug_prefix(self, capsys, monkeypatch, fixed_ts): """debug() output should include DEBUG: prefix.""" monkeypatch.setenv("MESH_DEBUG", "1") import meshmon.env @@ -101,75 +108,74 @@ class TestDebugLog: log.debug("test") captured = capsys.readouterr() - assert "DEBUG:" in captured.out + assert captured.out == f"[{fixed_ts}] DEBUG: test\n" class TestErrorLog: """Test the error() function.""" - def test_prints_to_stderr(self, capsys): + def test_prints_to_stderr(self, capsys, fixed_ts): """error() should print to stderr.""" log.error("error message") captured = capsys.readouterr() assert captured.out == "" - assert "error message" in captured.err + assert captured.err == f"[{fixed_ts}] ERROR: error message\n" - def test_includes_error_prefix(self, capsys): + def test_includes_error_prefix(self, capsys, fixed_ts): """error() output should include ERROR: prefix.""" log.error("test error") captured = capsys.readouterr() - assert "ERROR:" in captured.err + assert captured.err == f"[{fixed_ts}] ERROR: test error\n" - def test_includes_timestamp(self, capsys): + def test_includes_timestamp(self, capsys, fixed_ts): """error() output should include timestamp.""" log.error("test") captured = capsys.readouterr() - assert captured.err.startswith("[") - assert "]" in captured.err + assert captured.err == f"[{fixed_ts}] ERROR: test\n" class TestWarnLog: """Test the warn() function.""" - def test_prints_to_stderr(self, capsys): + def test_prints_to_stderr(self, capsys, fixed_ts): """warn() should print to stderr.""" log.warn("warning message") captured = capsys.readouterr() assert captured.out == "" - assert "warning message" in captured.err + assert captured.err == f"[{fixed_ts}] WARN: warning message\n" - def test_includes_warn_prefix(self, capsys): + def test_includes_warn_prefix(self, capsys, fixed_ts): """warn() output should include WARN: prefix.""" log.warn("test warning") captured = capsys.readouterr() - assert "WARN:" in captured.err + assert captured.err == f"[{fixed_ts}] WARN: test warning\n" - def test_includes_timestamp(self, capsys): + def test_includes_timestamp(self, capsys, fixed_ts): """warn() output should include timestamp.""" log.warn("test") captured = capsys.readouterr() - assert captured.err.startswith("[") - assert "]" in captured.err + assert captured.err == f"[{fixed_ts}] WARN: test\n" class TestLogMessageFormatting: """Test message formatting across all log functions.""" - def test_info_handles_special_characters(self, capsys): + def test_info_handles_special_characters(self, capsys, fixed_ts): """info() should handle special characters in messages.""" log.info("Message with 'quotes' and \"double quotes\"") captured = capsys.readouterr() - assert "'quotes'" in captured.out - assert '"double quotes"' in captured.out + assert captured.out == ( + f"[{fixed_ts}] Message with 'quotes' and \"double quotes\"\n" + ) - def test_error_handles_newlines(self, capsys): + def test_error_handles_newlines(self, capsys, fixed_ts): """error() should handle newlines in messages.""" log.error("Line1\nLine2") captured = capsys.readouterr() - assert "Line1\nLine2" in captured.err + assert captured.err == f"[{fixed_ts}] ERROR: Line1\nLine2\n" - def test_warn_handles_unicode(self, capsys): + def test_warn_handles_unicode(self, capsys, fixed_ts): """warn() should handle unicode characters.""" log.warn("Warning: \u26a0 Alert!") captured = capsys.readouterr() - assert "\u26a0" in captured.err + assert captured.err == f"[{fixed_ts}] WARN: Warning: \u26a0 Alert!\n" diff --git a/tests/unit/test_reports_formatting.py b/tests/unit/test_reports_formatting.py index a41c7e0..a181235 100644 --- a/tests/unit/test_reports_formatting.py +++ b/tests/unit/test_reports_formatting.py @@ -26,40 +26,36 @@ class TestFormatLatLon: def test_north_east(self): """Positive lat/lon formats as N/E.""" lat_str, lon_str = format_lat_lon(51.5074, 0.1278) - assert "N" in lat_str - assert "E" in lon_str - assert "51" in lat_str + assert lat_str == "51-30.44 N" + assert lon_str == "000-07.67 E" def test_south_west(self): """Negative lat/lon formats as S/W.""" lat_str, lon_str = format_lat_lon(-33.8688, -151.2093) - assert "S" in lat_str - assert "W" in lon_str + assert lat_str == "33-52.13 S" + assert lon_str == "151-12.56 W" def test_degrees_minutes_format(self): """Output is in DD-MM.MM format.""" lat_str, lon_str = format_lat_lon(51.5074, -0.1278) - # Latitude: 51°30.44'N -> 51-30.44 N - assert "-" in lat_str - # Longitude: 0°7.67'W -> 000-07.67 W - assert "-" in lon_str + assert lat_str == "51-30.44 N" + assert lon_str == "000-07.67 W" def test_zero_coordinates(self): """Zero coordinates at equator/prime meridian.""" lat_str, lon_str = format_lat_lon(0.0, 0.0) - assert "N" in lat_str # 0 is treated as North - assert "E" in lon_str # 0 is treated as East - assert "00-00.00" in lat_str + assert lat_str == "00-00.00 N" + assert lon_str == "000-00.00 E" def test_latitude_format_width(self): """Latitude degrees is 2 digits.""" lat_str, _ = format_lat_lon(5.5, 0.0) - assert lat_str.startswith("05") + assert lat_str == "05-30.00 N" def test_longitude_format_width(self): """Longitude degrees is 3 digits.""" _, lon_str = format_lat_lon(0.0, 5.5) - assert lon_str.startswith("005") + assert lon_str == "005-30.00 E" class TestFormatLatLonDms: @@ -68,31 +64,27 @@ class TestFormatLatLonDms: def test_basic_format(self): """Returns combined DMS string.""" result = format_lat_lon_dms(51.5074, -0.1278) - assert "°" in result - assert "'" in result - assert '"' in result + assert result == "51°30'26\"N 000°07'40\"W" def test_north_east_directions(self): """Positive coordinates show N and E.""" result = format_lat_lon_dms(51.5074, 0.1278) - assert "N" in result - assert "E" in result + assert result == "51°30'26\"N 000°07'40\"E" def test_south_west_directions(self): """Negative coordinates show S and W.""" result = format_lat_lon_dms(-33.8688, -151.2093) - assert "S" in result - assert "W" in result + assert result == "33°52'07\"S 151°12'33\"W" def test_lat_two_digit_degrees(self): """Latitude has 2-digit degrees.""" result = format_lat_lon_dms(5.0, 0.0) - assert "05°" in result + assert result == "05°00'00\"N 000°00'00\"E" def test_lon_three_digit_degrees(self): """Longitude has 3-digit degrees.""" result = format_lat_lon_dms(0.0, 5.0) - assert "005°" in result + assert result == "00°00'00\"N 005°00'00\"E" class TestLocationInfo: @@ -108,9 +100,11 @@ class TestLocationInfo: ) header = loc.format_header() - assert "NAME: Test Station" in header - assert "COORDS:" in header - assert "ELEV: 11 meters" in header + assert ( + header + == "NAME: Test Station\n" + "COORDS: 51°30'26\"N 000°07'40\"W ELEV: 11 meters" + ) def test_format_header_with_coordinates(self): """Header includes DMS coordinates.""" @@ -121,8 +115,11 @@ class TestLocationInfo: elev=0.0, ) header = loc.format_header() - # Should contain degrees-minutes-seconds format - assert "°" in header + assert ( + header + == "NAME: Test\n" + "COORDS: 51°30'26\"N 000°07'40\"W ELEV: 0 meters" + ) class TestColumn: @@ -180,9 +177,7 @@ class TestFormatRow: values = [1, 3.14, 12345] result = _format_row(cols, values) - assert " 1" in result - assert "3.1" in result - assert "12,345" in result + assert result == " 1 3.1 12,345" def test_total_width(self): """Row has correct total width."""