mirror of
https://github.com/jorijn/meshcore-stats.git
synced 2026-05-03 03:52:49 +02:00
* test: add comprehensive pytest test suite with 95% coverage Add full unit and integration test coverage for the meshcore-stats project: - 1020 tests covering all modules (db, charts, html, reports, client, etc.) - 95.95% code coverage with pytest-cov (95% threshold enforced) - GitHub Actions CI workflow for automated testing on push/PR - Proper mocking of external dependencies (meshcore, serial, filesystem) - SVG snapshot infrastructure for chart regression testing - Integration tests for collection and rendering pipelines Test organization: - tests/charts/: Chart rendering and statistics - tests/client/: MeshCore client and connection handling - tests/config/: Environment and configuration parsing - tests/database/: SQLite operations and migrations - tests/html/: HTML generation and Jinja templates - tests/reports/: Report generation and formatting - tests/retry/: Circuit breaker and retry logic - tests/unit/: Pure unit tests for utilities - tests/integration/: End-to-end pipeline tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add test-engineer agent configuration Add project-local test-engineer agent for pytest test development, coverage analysis, and test review tasks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: comprehensive test suite review with 956 tests analyzed Conducted thorough review of all 956 test cases across 47 test files: - Unit Tests: 338 tests (battery, metrics, log, telemetry, env, charts, html, reports, formatters) - Config Tests: 53 tests (env loading, config file parsing) - Database Tests: 115 tests (init, insert, queries, migrations, maintenance, validation) - Retry Tests: 59 tests (circuit breaker, async retries, factory) - Charts Tests: 76 tests (transforms, statistics, timeseries, rendering, I/O) - HTML Tests: 81 tests (site generation, Jinja2, metrics builders, reports index) - Reports Tests: 149 tests (location, JSON/TXT formatting, aggregation, counter totals) - Client Tests: 63 tests (contacts, connection, meshcore availability, commands) - Integration Tests: 22 tests (reports, collection, rendering pipelines) Results: - Overall Pass Rate: 99.7% (953/956) - 3 tests marked for improvement (empty test bodies in client tests) - 0 tests requiring fixes Key findings documented in test_review/tests.md including quality observations, F.I.R.S.T. principle adherence, and recommendations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: implement snapshot testing for charts and reports Add comprehensive snapshot testing infrastructure: SVG Chart Snapshots: - Deterministic fixtures with fixed timestamps (2024-01-15 12:00:00) - Tests for gauge/counter metrics in light/dark themes - Empty chart and single-point edge cases - Extended normalize_svg_for_snapshot_full() for reproducible comparisons TXT Report Snapshots: - Monthly/yearly report snapshots for repeater and companion - Empty report handling tests - Tests in tests/reports/test_snapshots.py Infrastructure: - tests/snapshots/conftest.py with shared fixtures - UPDATE_SNAPSHOTS=1 environment variable for regeneration - scripts/generate_snapshots.py for batch snapshot generation Run `UPDATE_SNAPSHOTS=1 pytest tests/charts/test_chart_render.py::TestSvgSnapshots` to generate initial snapshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: fix SVG normalization and generate initial snapshots Fix normalize_svg_for_snapshot() to handle: - clipPath IDs like id="p47c77a2a6e" - url(#p...) references - xlink:href="#p..." references - <dc:date> timestamps Generated initial snapshot files: - 7 SVG chart snapshots (gauge, counter, empty, single-point in light/dark) - 6 TXT report snapshots (monthly/yearly for repeater/companion + empty) All 13 snapshot tests now pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: fix SVG normalization to preserve axis rendering The SVG normalization was replacing all matplotlib-generated IDs with the same value, causing duplicate IDs that broke SVG rendering: - Font glyphs, clipPaths, and tick marks all got id="normalized" - References couldn't resolve to the correct elements - X and Y axes failed to render in normalized snapshots Fix uses type-specific prefixes with sequential numbering: - glyph_N for font glyphs (DejaVuSans-XX patterns) - clip_N for clipPath definitions (p[0-9a-f]{8,} patterns) - tick_N for tick marks (m[0-9a-f]{8,} patterns) This ensures all IDs remain unique while still being deterministic for snapshot comparison. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add coverage and pytest artifacts to gitignore Add .coverage, .coverage.*, htmlcov/, and .pytest_cache/ to prevent test artifacts from being committed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix all ruff lint errors across codebase - Sort and organize imports (I001) - Use modern type annotations (X | Y instead of Union, collections.abc) - Remove unused imports (F401) - Combine nested if statements (SIM102) - Use ternary operators where appropriate (SIM108) - Combine nested with statements (SIM117) - Use contextlib.suppress instead of try-except-pass (SIM105) - Add noqa comments for intentional SIM115 violations (file locks) - Add TYPE_CHECKING import for forward references - Fix exception chaining (B904) All 1033 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add TDD workflow and pre-commit requirements to CLAUDE.md - Add mandatory test-driven development workflow (write tests first) - Add pre-commit requirements (must run lint and tests before committing) - Document test organization and running commands - Document 95% coverage requirement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: resolve mypy type checking errors with proper structural fixes - charts.py: Create PeriodConfig dataclass for type-safe period configuration, use mdates.date2num() for matplotlib datetime handling, fix x-axis limits for single-point charts - db.py: Add explicit int() conversion with None handling for SQLite returns - env.py: Add class-level type annotations to Config class - html.py: Add MetricDisplay TypedDict, fix import order, add proper type annotations for table data functions - meshcore_client.py: Add return type annotation Update tests to use new dataclass attribute access and regenerate SVG snapshots. Add mypy step to CLAUDE.md pre-commit requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: cast Jinja2 template.render() to str for mypy Jinja2's type stubs declare render() as returning Any, but it actually returns str. Wrap with str() to satisfy mypy's no-any-return check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: improve workflow security and reliability - test.yml: Pin all actions by SHA, add concurrency control to cancel in-progress runs on rapid pushes - release-please.yml: Pin action by SHA, add 10-minute timeout - conftest.py: Fix snapshot_base_time to use explicit UTC timezone for consistent behavior across CI and local environments Regenerate SVG snapshots with UTC-aware timestamps. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add mypy command to permissions in settings.local.json * test: add comprehensive script tests with coroutine warning fixes - Add tests/scripts/ with tests for collect_companion, collect_repeater, and render scripts (1135 tests total, 96% coverage) - Fix unawaited coroutine warnings by using AsyncMock properly for async functions and async_context_manager_factory fixture for context managers - Add --cov=scripts to CI workflow and pyproject.toml coverage config - Omit scripts/generate_snapshots.py from coverage (dev utility) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: migrate claude setup to codex skills * feat: migrate dependencies to uv (#31) * fix: run tests through uv * test: fix ruff lint issues in tests Consolidate patch context managers and clean unused imports/variables Use datetime.UTC in snapshot fixtures * test: avoid unawaited async mocks in entrypoint tests * ci: replace codecov with github coverage artifacts Add junit XML output and coverage summary in job output Upload HTML and XML coverage artifacts (3.12 only) on every run --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
286 lines
9.8 KiB
Python
286 lines
9.8 KiB
Python
"""Tests for HTML formatting functions in html.py."""
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from meshmon.html import (
|
|
STATUS_ONLINE_THRESHOLD,
|
|
STATUS_STALE_THRESHOLD,
|
|
_fmt_val_day,
|
|
_fmt_val_month,
|
|
_fmt_val_plain,
|
|
_fmt_val_time,
|
|
_format_stat_value,
|
|
_load_svg_content,
|
|
get_status,
|
|
)
|
|
|
|
|
|
class TestFormatStatValue:
|
|
"""Test _format_stat_value function."""
|
|
|
|
def test_none_returns_dash(self):
|
|
"""None value returns dash."""
|
|
assert _format_stat_value(None, "bat") == "-"
|
|
assert _format_stat_value(None, "last_rssi") == "-"
|
|
|
|
def test_battery_voltage(self):
|
|
"""Battery voltage metrics format as V with 2 decimals."""
|
|
assert _format_stat_value(3.85, "bat") == "3.85 V"
|
|
assert _format_stat_value(4.20, "battery_mv") == "4.20 V"
|
|
|
|
def test_battery_percentage(self):
|
|
"""Battery percentage formats as % with no decimals."""
|
|
assert _format_stat_value(85.5, "bat_pct") == "86%"
|
|
assert _format_stat_value(100.0, "bat_pct") == "100%"
|
|
|
|
def test_rssi(self):
|
|
"""RSSI formats as dBm with no decimals."""
|
|
assert _format_stat_value(-85.3, "last_rssi") == "-85 dBm"
|
|
|
|
def test_noise_floor(self):
|
|
"""Noise floor formats as dBm with no decimals."""
|
|
assert _format_stat_value(-115.7, "noise_floor") == "-116 dBm"
|
|
|
|
def test_snr(self):
|
|
"""SNR formats as dB with 1 decimal."""
|
|
assert _format_stat_value(7.53, "last_snr") == "7.5 dB"
|
|
|
|
def test_contacts(self):
|
|
"""Contacts format as integer."""
|
|
assert _format_stat_value(5.0, "contacts") == "5"
|
|
|
|
def test_tx_queue(self):
|
|
"""TX queue formats as integer."""
|
|
assert _format_stat_value(3.0, "tx_queue_len") == "3"
|
|
|
|
def test_uptime(self):
|
|
"""Uptime formats as days with 1 decimal."""
|
|
assert _format_stat_value(7.5, "uptime") == "7.5 d"
|
|
assert _format_stat_value(2.3, "uptime_secs") == "2.3 d"
|
|
|
|
def test_packet_counters(self):
|
|
"""Packet counters format as per-minute rate."""
|
|
assert _format_stat_value(12.5, "recv") == "12.5/min"
|
|
assert _format_stat_value(8.3, "sent") == "8.3/min"
|
|
assert _format_stat_value(100.0, "nb_recv") == "100.0/min"
|
|
assert _format_stat_value(50.2, "nb_sent") == "50.2/min"
|
|
|
|
def test_flood_counters(self):
|
|
"""Flood packet counters format as per-minute rate."""
|
|
assert _format_stat_value(5.0, "recv_flood") == "5.0/min"
|
|
assert _format_stat_value(3.2, "sent_flood") == "3.2/min"
|
|
|
|
def test_direct_counters(self):
|
|
"""Direct packet counters format as per-minute rate."""
|
|
assert _format_stat_value(2.1, "recv_direct") == "2.1/min"
|
|
assert _format_stat_value(1.8, "sent_direct") == "1.8/min"
|
|
|
|
def test_dups_counters(self):
|
|
"""Duplicate counters format as per-minute rate."""
|
|
assert _format_stat_value(0.5, "flood_dups") == "0.5/min"
|
|
assert _format_stat_value(0.1, "direct_dups") == "0.1/min"
|
|
|
|
def test_airtime(self):
|
|
"""Airtime formats as seconds per minute."""
|
|
assert _format_stat_value(2.5, "airtime") == "2.5 s/min"
|
|
assert _format_stat_value(5.0, "rx_airtime") == "5.0 s/min"
|
|
|
|
def test_unknown_metric(self):
|
|
"""Unknown metrics format with 2 decimals."""
|
|
assert _format_stat_value(123.456, "unknown_metric") == "123.46"
|
|
|
|
|
|
class TestLoadSvgContent:
|
|
"""Test _load_svg_content function."""
|
|
|
|
def test_nonexistent_file_returns_none(self, tmp_path):
|
|
"""Non-existent file returns None."""
|
|
result = _load_svg_content(tmp_path / "nonexistent.svg")
|
|
assert result is None
|
|
|
|
def test_loads_svg_content(self, tmp_path):
|
|
"""Existing file content is loaded."""
|
|
svg_file = tmp_path / "test.svg"
|
|
svg_content = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
|
svg_file.write_text(svg_content)
|
|
|
|
result = _load_svg_content(svg_file)
|
|
assert result == svg_content
|
|
|
|
def test_read_error_returns_none(self, tmp_path):
|
|
"""Read errors return None (logged)."""
|
|
svg_file = tmp_path / "test.svg"
|
|
svg_file.write_text("content")
|
|
|
|
# Make file unreadable by mocking
|
|
with patch.object(Path, "read_text", side_effect=PermissionError("denied")):
|
|
result = _load_svg_content(svg_file)
|
|
assert result is None
|
|
|
|
|
|
class TestFmtValTime:
|
|
"""Test _fmt_val_time function."""
|
|
|
|
def test_none_returns_dash(self):
|
|
"""None value returns dash."""
|
|
assert _fmt_val_time(None, datetime.now()) == "-"
|
|
|
|
def test_formats_value_with_time(self):
|
|
"""Formats value with time in small tag."""
|
|
dt = datetime(2024, 6, 15, 14, 30, 45)
|
|
result = _fmt_val_time(3.85, dt)
|
|
assert "3.85" in result
|
|
assert "<small>14:30</small>" in result
|
|
|
|
def test_custom_format(self):
|
|
"""Custom value format works."""
|
|
dt = datetime(2024, 6, 15, 14, 30)
|
|
result = _fmt_val_time(3.8567, dt, fmt=".3f")
|
|
assert "3.857" in result
|
|
|
|
def test_custom_time_format(self):
|
|
"""Custom time format works."""
|
|
dt = datetime(2024, 6, 15, 14, 30)
|
|
result = _fmt_val_time(3.85, dt, time_fmt="%H:%M:%S")
|
|
assert "14:30:00" in result
|
|
|
|
def test_none_time_obj(self):
|
|
"""None time object returns value without time."""
|
|
result = _fmt_val_time(3.85, None)
|
|
assert result == "3.85"
|
|
|
|
|
|
class TestFmtValDay:
|
|
"""Test _fmt_val_day function."""
|
|
|
|
def test_none_returns_dash(self):
|
|
"""None value returns dash."""
|
|
assert _fmt_val_day(None, datetime.now()) == "-"
|
|
|
|
def test_formats_value_with_day(self):
|
|
"""Formats value with day number in small tag."""
|
|
dt = datetime(2024, 6, 15)
|
|
result = _fmt_val_day(3.85, dt)
|
|
assert "3.85" in result
|
|
assert "<small>15</small>" in result
|
|
|
|
def test_day_zero_padded(self):
|
|
"""Day number is zero-padded."""
|
|
dt = datetime(2024, 6, 5)
|
|
result = _fmt_val_day(3.85, dt)
|
|
assert "<small>05</small>" in result
|
|
|
|
def test_custom_format(self):
|
|
"""Custom value format works."""
|
|
dt = datetime(2024, 6, 15)
|
|
result = _fmt_val_day(3.8567, dt, fmt=".1f")
|
|
assert "3.9" in result
|
|
|
|
def test_none_time_obj(self):
|
|
"""None time object returns value without day."""
|
|
result = _fmt_val_day(3.85, None)
|
|
assert result == "3.85"
|
|
|
|
|
|
class TestFmtValMonth:
|
|
"""Test _fmt_val_month function."""
|
|
|
|
def test_none_returns_dash(self):
|
|
"""None value returns dash."""
|
|
assert _fmt_val_month(None, datetime.now()) == "-"
|
|
|
|
def test_formats_value_with_month(self):
|
|
"""Formats value with month abbreviation in small tag."""
|
|
dt = datetime(2024, 6, 15)
|
|
result = _fmt_val_month(3.85, dt)
|
|
assert "3.85" in result
|
|
assert "<small>Jun</small>" in result
|
|
|
|
def test_january(self):
|
|
"""January formats correctly."""
|
|
dt = datetime(2024, 1, 15)
|
|
result = _fmt_val_month(3.85, dt)
|
|
assert "<small>Jan</small>" in result
|
|
|
|
def test_december(self):
|
|
"""December formats correctly."""
|
|
dt = datetime(2024, 12, 15)
|
|
result = _fmt_val_month(3.85, dt)
|
|
assert "<small>Dec</small>" in result
|
|
|
|
def test_none_time_obj(self):
|
|
"""None time object returns value without month."""
|
|
result = _fmt_val_month(3.85, None)
|
|
assert result == "3.85"
|
|
|
|
|
|
class TestFmtValPlain:
|
|
"""Test _fmt_val_plain function."""
|
|
|
|
def test_none_returns_dash(self):
|
|
"""None value returns dash."""
|
|
assert _fmt_val_plain(None) == "-"
|
|
|
|
def test_default_two_decimals(self):
|
|
"""Default format is 2 decimal places."""
|
|
assert _fmt_val_plain(3.8567) == "3.86"
|
|
|
|
def test_custom_format(self):
|
|
"""Custom format works."""
|
|
assert _fmt_val_plain(3.8567, fmt=".1f") == "3.9"
|
|
assert _fmt_val_plain(3.8567, fmt=".0f") == "4"
|
|
assert _fmt_val_plain(3.8567, fmt=".4f") == "3.8567"
|
|
|
|
|
|
class TestGetStatus:
|
|
"""Test get_status function."""
|
|
|
|
def test_none_timestamp(self):
|
|
"""None timestamp returns offline."""
|
|
status_class, status_text = get_status(None)
|
|
assert status_class == "offline"
|
|
assert status_text == "No data"
|
|
|
|
def test_zero_timestamp(self):
|
|
"""Zero timestamp (falsy) returns offline."""
|
|
status_class, status_text = get_status(0)
|
|
assert status_class == "offline"
|
|
assert status_text == "No data"
|
|
|
|
def test_recent_timestamp_online(self):
|
|
"""Recent timestamp (< 30 min) returns online."""
|
|
recent_ts = int(datetime.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):
|
|
"""Stale timestamp (30 min - 2 hours) returns stale."""
|
|
stale_ts = int(datetime.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):
|
|
"""Old timestamp (> 2 hours) returns offline."""
|
|
old_ts = int(datetime.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):
|
|
"""Timestamps exactly at thresholds."""
|
|
now = int(datetime.now().timestamp())
|
|
|
|
# Just under online threshold - still online
|
|
ts_just_online = now - STATUS_ONLINE_THRESHOLD + 1
|
|
status, _ = get_status(ts_just_online)
|
|
assert status == "online"
|
|
|
|
# Just under stale threshold - still stale
|
|
ts_just_stale = now - STATUS_STALE_THRESHOLD + 1
|
|
status, _ = get_status(ts_just_stale)
|
|
assert status == "stale"
|