Files
meshcore-stats/tests/unit/test_html_builders.py

490 lines
17 KiB
Python

"""Tests for HTML builder functions in html.py."""
from meshmon.html import (
COMPANION_CHART_GROUPS,
PERIOD_CONFIG,
REPEATER_CHART_GROUPS,
_build_traffic_table_rows,
build_chart_groups,
build_companion_metrics,
build_node_details,
build_radio_config,
build_repeater_metrics,
get_jinja_env,
)
class TestBuildTrafficTableRows:
"""Test _build_traffic_table_rows function."""
def test_empty_list(self):
"""Empty list returns empty list."""
result = _build_traffic_table_rows([])
assert result == []
def test_rx_tx_packets(self):
"""RX and TX become Packets row."""
traffic = [
{"label": "RX", "value": "1.2k", "raw_value": 1200, "unit": "packets"},
{"label": "TX", "value": "800", "raw_value": 800, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
assert len(result) == 1
assert result[0]["label"] == "Packets"
assert result[0]["rx"] == "1.2k"
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."""
traffic = [
{"label": "Flood RX", "value": "500", "raw_value": 500, "unit": "packets"},
{"label": "Flood TX", "value": "300", "raw_value": 300, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
assert len(result) == 1
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."""
traffic = [
{"label": "Direct RX", "value": "200", "raw_value": 200, "unit": "packets"},
{"label": "Direct TX", "value": "100", "raw_value": 100, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
assert len(result) == 1
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."""
traffic = [
{"label": "Airtime TX", "value": "1h 30m", "raw_value": 5400, "unit": "seconds"},
{"label": "Airtime RX", "value": "3h 0m", "raw_value": 10800, "unit": "seconds"},
]
result = _build_traffic_table_rows(traffic)
assert len(result) == 1
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."""
traffic = [
{"label": "Airtime TX", "value": "1h", "raw_value": 3600, "unit": "seconds"},
{"label": "Direct RX", "value": "100", "raw_value": 100, "unit": "packets"},
{"label": "Flood RX", "value": "200", "raw_value": 200, "unit": "packets"},
{"label": "RX", "value": "500", "raw_value": 500, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
labels = [r["label"] for r in result]
assert labels == ["Packets", "Flood", "Direct", "Airtime"]
def test_missing_pair(self):
"""Missing pair leaves None for that direction."""
traffic = [
{"label": "RX", "value": "500", "raw_value": 500, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
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."""
traffic = [
{"label": "Unknown", "value": "100", "raw_value": 100, "unit": "packets"},
{"label": "RX", "value": "500", "raw_value": 500, "unit": "packets"},
]
result = _build_traffic_table_rows(traffic)
assert len(result) == 1
assert result[0]["label"] == "Packets"
class TestBuildNodeDetails:
"""Test build_node_details function."""
def test_repeater_details(self, configured_env, monkeypatch):
"""Repeater node details include location info."""
monkeypatch.setenv("REPORT_LOCATION_SHORT", "Test Location")
monkeypatch.setenv("REPORT_LAT", "51.5074")
monkeypatch.setenv("REPORT_LON", "-0.1278")
monkeypatch.setenv("REPORT_ELEV", "11")
monkeypatch.setenv("REPORT_ELEV_UNIT", "m")
monkeypatch.setenv("REPEATER_HARDWARE", "RAK 4631")
# Reset config to pick up new values
import meshmon.env
meshmon.env._config = None
result = build_node_details("repeater")
labels = [d["label"] for d in result]
assert "Location" in labels
assert "Coordinates" in labels
assert "Elevation" in labels
assert "Hardware" in labels
# 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"
def test_companion_details(self, configured_env, monkeypatch):
"""Companion node details are simpler."""
monkeypatch.setenv("COMPANION_HARDWARE", "T-Beam Supreme")
# Reset config
import meshmon.env
meshmon.env._config = None
result = build_node_details("companion")
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
assert "Coordinates" not in labels
def test_coordinate_directions(self, configured_env, monkeypatch):
"""Coordinate directions are correct for positive/negative."""
monkeypatch.setenv("REPORT_LAT", "-33.8688") # Sydney
monkeypatch.setenv("REPORT_LON", "151.2093")
import meshmon.env
meshmon.env._config = None
result = build_node_details("repeater")
coords = next(d for d in result if d["label"] == "Coordinates")
assert coords["value"] == "33.8688°S, 151.2093°E"
class TestBuildRadioConfig:
"""Test build_radio_config function."""
def test_returns_radio_settings(self, configured_env, monkeypatch):
"""Returns radio configuration from env."""
monkeypatch.setenv("RADIO_FREQUENCY", "915.0 MHz")
monkeypatch.setenv("RADIO_BANDWIDTH", "125 kHz")
monkeypatch.setenv("RADIO_SPREAD_FACTOR", "SF12")
monkeypatch.setenv("RADIO_CODING_RATE", "CR5")
import meshmon.env
meshmon.env._config = None
result = build_radio_config()
labels = [d["label"] for d in result]
assert "Frequency" in labels
assert "Bandwidth" in labels
assert "Spread Factor" in labels
assert "Coding Rate" in labels
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:
"""Test build_repeater_metrics function."""
def test_none_row_returns_empty(self):
"""None row returns empty metric lists."""
result = build_repeater_metrics(None)
assert result["critical_metrics"] == []
assert result["secondary_metrics"] == []
assert result["traffic_metrics"] == []
def test_empty_row_returns_empty(self):
"""Empty row returns empty metric lists."""
result = build_repeater_metrics({})
assert result["critical_metrics"] == []
assert result["secondary_metrics"] == []
assert result["traffic_metrics"] == []
def test_full_row_extracts_metrics(self):
"""Full row extracts all metric categories."""
row = {
"bat": 3850.0,
"bat_pct": 55.0,
"last_rssi": -85.0,
"last_snr": 7.5,
"uptime": 86400,
"noise_floor": -115.0,
"tx_queue_len": 0,
"nb_recv": 1234,
"nb_sent": 567,
"recv_flood": 500,
"sent_flood": 200,
"recv_direct": 100,
"sent_direct": 50,
"airtime": 3600,
"rx_airtime": 7200,
}
result = build_repeater_metrics(row)
# Critical metrics
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 result["secondary_metrics"] == [
{"label": "Uptime", "value": "1d 0h"},
{"label": "Noise Floor", "value": "-115 dBm"},
{"label": "TX Queue", "value": "0"},
]
# Traffic metrics
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."""
row = {"bat": 4200.0, "bat_pct": 100.0}
result = build_repeater_metrics(row)
battery = next(m for m in result["critical_metrics"] if m["label"] == "Battery")
assert battery["value"] == "4.20"
def test_bar_pct_for_battery(self):
"""Battery has bar_pct for progress display."""
row = {"bat": 3850.0, "bat_pct": 55.0}
result = build_repeater_metrics(row)
battery = next(m for m in result["critical_metrics"] if m["label"] == "Battery")
assert battery["bar_pct"] == 55
class TestBuildCompanionMetrics:
"""Test build_companion_metrics function."""
def test_none_row_returns_empty(self):
"""None row returns empty metric lists."""
result = build_companion_metrics(None)
assert result["critical_metrics"] == []
assert result["secondary_metrics"] == []
assert result["traffic_metrics"] == []
def test_full_row_extracts_metrics(self):
"""Full row extracts all metric categories."""
row = {
"battery_mv": 3850.0,
"bat_pct": 55.0,
"contacts": 5,
"uptime_secs": 86400,
"recv": 1234,
"sent": 567,
}
result = build_companion_metrics(row)
# Critical metrics
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 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."""
row = {"battery_mv": 4000.0, "bat_pct": 80.0}
result = build_companion_metrics(row)
battery = next(m for m in result["critical_metrics"] if m["label"] == "Battery")
assert battery["value"] == "4.00"
def test_contacts_displays_correctly(self):
"""Contacts are displayed as integer."""
row = {"contacts": 7}
result = build_companion_metrics(row)
contacts = next(m for m in result["critical_metrics"] if m["label"] == "Contacts")
assert contacts["value"] == "7"
assert contacts["unit"] is None
class TestGetJinjaEnv:
"""Test get_jinja_env function."""
def test_returns_environment(self):
"""Returns a Jinja2 Environment."""
from jinja2 import Environment
# Reset singleton for clean test
import meshmon.html
meshmon.html._jinja_env = None
env = get_jinja_env()
assert isinstance(env, Environment)
def test_returns_singleton(self):
"""Returns same instance on subsequent calls."""
import meshmon.html
meshmon.html._jinja_env = None
env1 = get_jinja_env()
env2 = get_jinja_env()
assert env1 is env2
def test_registers_custom_filters(self):
"""Custom format filters are registered."""
import meshmon.html
meshmon.html._jinja_env = None
env = get_jinja_env()
assert "format_time" in env.filters
assert "format_value" in env.filters
assert "format_number" in env.filters
assert "format_duration" in env.filters
assert "format_uptime" in env.filters
assert "format_compact_number" in env.filters
assert "format_duration_compact" in env.filters
class TestChartGroupConstants:
"""Test chart group configuration constants."""
def test_repeater_chart_groups_defined(self):
"""Repeater chart groups are defined."""
assert len(REPEATER_CHART_GROUPS) > 0
for group in REPEATER_CHART_GROUPS:
assert "title" in group
assert "metrics" in group
assert len(group["metrics"]) > 0
def test_companion_chart_groups_defined(self):
"""Companion chart groups are defined."""
assert len(COMPANION_CHART_GROUPS) > 0
for group in COMPANION_CHART_GROUPS:
assert "title" in group
assert "metrics" in group
assert len(group["metrics"]) > 0
def test_period_config_defined(self):
"""Period config has all expected periods."""
assert "day" in PERIOD_CONFIG
assert "week" in PERIOD_CONFIG
assert "month" in PERIOD_CONFIG
assert "year" in PERIOD_CONFIG
for _period, (title, subtitle) in PERIOD_CONFIG.items():
assert isinstance(title, str)
assert isinstance(subtitle, str)
class TestBuildChartGroups:
"""Tests for build_chart_groups."""
def test_png_paths_use_relative_prefix(self, configured_env):
"""PNG fallback paths respect provided asset prefix."""
out_dir = configured_env["out_dir"]
asset_dir = out_dir / "assets" / "repeater"
asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "bat_day_light.png").write_bytes(b"fake")
groups = build_chart_groups(
role="repeater",
period="day",
chart_stats={},
asset_prefix="../",
)
chart = next(
chart
for group in groups
for chart in group["charts"]
if chart["metric"] == "bat"
)
assert chart["use_svg"] is False
assert chart["src_light"] == "../assets/repeater/bat_day_light.png"
assert chart["src_dark"] == "../assets/repeater/bat_day_dark.png"