from __future__ import annotations import json from pathlib import Path from unittest.mock import patch import pytest from netsleuth.models import PingResult, SpeedResult from netsleuth.watch import ( WATCH_SCHEMA_VERSION, WatchSession, is_speedtest_cycle, next_delay, render_dashboard, summarize_cycle, write_session, ) def ping(label: str, avg: float | None, loss: float = 0.0) -> PingResult: return PingResult( label=label, host="1.1.1.1", resolved_ip="1.1.1.1", method="icmp_win", sent=5, received=5 if avg is not None else 0, loss_pct=loss, avg_ms=avg, jitter_ms=1.5 if avg is not None else None, ) def test_the_first_cycle_measures_speed_so_the_session_has_a_baseline(): assert is_speedtest_cycle(1, every_n=10) is True def test_speed_is_measured_once_every_n_cycles(): assert [c for c in range(1, 25) if is_speedtest_cycle(c, every_n=10)] == [1, 11, 21] def test_an_interval_of_one_measures_speed_every_cycle(): assert all(is_speedtest_cycle(c, every_n=1) for c in range(1, 6)) def test_a_non_positive_interval_disables_the_speedtest_entirely(): assert [c for c in range(1, 10) if is_speedtest_cycle(c, every_n=0)] == [] assert is_speedtest_cycle(3, every_n=-5) is False def test_cycle_numbers_below_one_are_never_speedtest_cycles(): assert is_speedtest_cycle(0, every_n=10) is False def test_next_delay_subtracts_the_time_the_cycle_already_took(): assert next_delay(cycle_started=100.0, now=104.0, interval=60.0) == pytest.approx(56.0) def test_a_cycle_that_overran_its_interval_does_not_sleep_negatively(): assert next_delay(cycle_started=100.0, now=400.0, interval=60.0) == 0.0 def test_summarize_cycle_flattens_the_hosts_and_omits_speed_when_it_did_not_run(): summary = summarize_cycle(3, "2026-08-08T19:15:00Z", [ping("cloudflare-dns", 12.4)], None) assert summary["cycle"] == 3 assert summary["at"] == "2026-08-08T19:15:00Z" assert summary["hosts"]["cloudflare-dns"]["avg_ms"] == 12.4 assert summary["hosts"]["cloudflare-dns"]["loss_pct"] == 0.0 assert summary["speed"] is None def test_summarize_cycle_keeps_the_speed_figures_when_it_did_run(): speed = SpeedResult(method="cloudflare", download_mbps=284.3, upload_mbps=41.7, bufferbloat_grade="B") summary = summarize_cycle(1, "2026-08-08T19:12:00Z", [ping("cloudflare-dns", 12.4)], speed) assert summary["speed"]["download_mbps"] == 284.3 assert summary["speed"]["bufferbloat_grade"] == "B" def test_summarize_cycle_records_a_dead_host_without_inventing_a_number(): summary = summarize_cycle(2, "2026-08-08T19:13:00Z", [ping("google-dns", None, loss=100.0)], None) assert summary["hosts"]["google-dns"]["avg_ms"] is None assert summary["hosts"]["google-dns"]["loss_pct"] == 100.0 def test_summarize_cycle_reports_ok_verdict_for_a_healthy_tick(): summary = summarize_cycle(1, "2026-08-08T19:12:00Z", [ping("cloudflare-dns", 12.4)], None) assert summary["status"] == "ok" assert summary["score"] == 100 assert summary["finding_ids"] == [] def test_summarize_cycle_reports_a_degraded_verdict_with_finding_ids(): summary = summarize_cycle(1, "2026-08-08T19:12:00Z", [ping("cloudflare-dns", None, loss=100.0)], None) assert summary["status"] == "crit" assert summary["score"] < 100 assert any(fid.startswith("latency.unreachable") for fid in summary["finding_ids"]) def test_summarize_cycle_verdict_considers_a_bad_speed_cycle_too(): speed = SpeedResult(method="cloudflare", download_mbps=100.0, upload_mbps=20.0, bufferbloat_down_ms=250.0) summary = summarize_cycle(1, "2026-08-08T19:12:00Z", [ping("cloudflare-dns", 12.4)], speed) assert summary["status"] in ("warn", "crit") assert any(fid.startswith("speed.bufferbloat") for fid in summary["finding_ids"]) def test_session_history_returns_the_series_for_one_host_with_gaps_preserved(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS64500") session.add(summarize_cycle(1, "t1", [ping("cloudflare-dns", 12.0)], None)) session.add(summarize_cycle(2, "t2", [ping("cloudflare-dns", None, loss=100.0)], None)) session.add(summarize_cycle(3, "t3", [ping("cloudflare-dns", 14.0)], None)) assert session.history("cloudflare-dns") == [12.0, None, 14.0] assert session.history("absent-host") == [None, None, None] def test_session_filename_marks_it_as_a_watch_artifact(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS64500") assert session.filename() == "watch_AS64500_19-12-00Z.json" assert WatchSession(started_at="2026-08-08T19:12:00Z").filename().startswith("watch_unknown_") @patch("netsleuth.watch.utc_now_iso") def test_watch_session_to_report_includes_all_metadata(mock_utc_now_iso): mock_utc_now_iso.return_value = "2026-08-08T19:20:00Z" session = WatchSession( started_at="2026-08-08T19:12:00Z", asn="AS64500", interval_seconds=60, speedtest_every_n_cycles=10, interface="eth0", bind_ipv4="192.168.1.100", ) session.alerts_fired = 2 report = session.to_report() assert report["schema_version"] == WATCH_SCHEMA_VERSION assert report["kind"] == "watch" assert report["meta"] == { "started_at": "2026-08-08T19:12:00Z", "finished_at": "2026-08-08T19:20:00Z", "asn": "AS64500", "interval_seconds": 60, "speedtest_every_n_cycles": 10, "cycle_count": 0, "interface": "eth0", "bind_ipv4": "192.168.1.100", "alerts_fired": 2, } assert report["cycles"] == [] @patch("netsleuth.watch.utc_now_iso") def test_watch_session_to_report_serializes_cycles_to_jsonable(mock_utc_now_iso): mock_utc_now_iso.return_value = "2026-08-08T19:20:00Z" session = WatchSession(started_at="2026-08-08T19:12:00Z") cycle_data = summarize_cycle(1, "2026-08-08T19:15:00Z", [ping("cloudflare-dns", 12.4)], None) session.add(cycle_data) report = session.to_report() assert report["meta"]["cycle_count"] == 1 assert len(report["cycles"]) == 1 first_cycle = report["cycles"][0] assert first_cycle["cycle"] == 1 assert first_cycle["at"] == "2026-08-08T19:15:00Z" assert first_cycle["status"] == "ok" def test_a_session_writes_exactly_one_artifact_holding_every_cycle(tmp_path: Path): session = WatchSession( started_at="2026-08-08T19:12:00Z", asn="AS64500", interval_seconds=60, speedtest_every_n_cycles=10 ) for cycle in range(1, 4): session.add(summarize_cycle(cycle, f"t{cycle}", [ping("cloudflare-dns", 12.0 + cycle)], None)) path = write_session(session, tmp_path) assert list((tmp_path / "2026" / "08" / "08").iterdir()) == [path] payload = json.loads(path.read_text(encoding="utf-8")) assert payload["schema_version"] == WATCH_SCHEMA_VERSION assert payload["kind"] == "watch" assert payload["meta"]["interval_seconds"] == 60 assert payload["meta"]["speedtest_every_n_cycles"] == 10 assert [c["cycle"] for c in payload["cycles"]] == [1, 2, 3] def test_an_empty_session_still_writes_a_well_formed_artifact(tmp_path: Path): path = write_session(WatchSession(started_at="2026-08-08T19:12:00Z"), tmp_path) payload = json.loads(path.read_text(encoding="utf-8")) assert payload["cycles"] == [] assert payload["meta"]["finished_at"] def test_render_dashboard_empty_session(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS12345") table = render_dashboard(session) assert "AS12345" in table.title assert "cycle 0" in table.title columns = [col.header for col in table.columns] assert columns == ["Host", "Avg ms", "Jitter", "Loss", "Trend"] assert len(list(table.rows)) == 0 assert table.caption is None def test_render_dashboard_with_cycle(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS12345") session.add(summarize_cycle(1, "t1", [ping("cloudflare-dns", 12.4)], None)) table = render_dashboard(session) assert "cycle 1" in table.title rows = list(table.rows) assert len(rows) == 1 cells = list(table.columns[i].cells for i in range(len(table.columns))) assert list(cells[0])[0] == "cloudflare-dns" assert list(cells[1])[0] == "12.4" assert list(cells[2])[0] == "1.5" assert list(cells[3])[0] == "0.0%" assert list(cells[4])[0] != "" # sparkline has content assert "verdict: ok (100/100)" in table.caption def test_render_dashboard_with_missing_host_data(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS12345") session.add(summarize_cycle(1, "t1", [ping("google-dns", None, loss=100.0)], None)) table = render_dashboard(session) cells = list(table.columns[i].cells for i in range(len(table.columns))) assert list(cells[0])[0] == "google-dns" assert list(cells[1])[0] == "—" assert list(cells[2])[0] == "—" assert list(cells[3])[0] == "100.0%" def test_render_dashboard_with_speed_results(): session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS12345") speed = SpeedResult(method="cloudflare", download_mbps=284.3, upload_mbps=41.7, bufferbloat_grade="B") session.add(summarize_cycle(1, "t1", [ping("cloudflare-dns", 12.4)], speed)) table = render_dashboard(session) assert "last speedtest: 284.3 / 41.7 Mbps via cloudflare · bufferbloat B" in table.caption