mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 16:52:03 +03:00
unavailable() only ever read module.errors for the placeholder detail
text, so a deliberately skipped section (speed in --quick or target
mode, which reports its reason as a warning, not an error) always
fell through to the generic 'no data was collected for this section'
line even though the real reason ('speedtest skipped in target mode')
was sitting right there in the json. spec says a skipped speedtest
must say why, not just that it's missing.
619 lines
20 KiB
Python
619 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from netcheck.exporter import (
|
|
SCHEMA_VERSION,
|
|
SECTION_ORDER,
|
|
atomic_write,
|
|
build_report,
|
|
compact_timestamp,
|
|
dump_json,
|
|
egress_asn,
|
|
flatten_errors,
|
|
report_filename,
|
|
sanitize_name,
|
|
write_report,
|
|
)
|
|
from netcheck.models import (
|
|
Finding,
|
|
IpGeo,
|
|
LocalNet,
|
|
ModuleResult,
|
|
PingResult,
|
|
ProbeError,
|
|
SpeedResult,
|
|
)
|
|
|
|
|
|
def meta() -> dict:
|
|
return {
|
|
"run_id": "b7f1",
|
|
"started_at": "2026-08-08T19:12:00Z",
|
|
"finished_at": "2026-08-08T19:13:04Z",
|
|
"mode": "auto",
|
|
"target": None,
|
|
"flags": {"quick": True, "full": False, "dnsbl": False, "ndt7": False, "tcp_trace": False},
|
|
"host_os": "Windows",
|
|
"capabilities": {"os_name": "Windows", "chosen_latency_backend": "icmp_win"},
|
|
}
|
|
|
|
|
|
def modules() -> dict[str, ModuleResult]:
|
|
return {
|
|
"connection": ModuleResult(name="connection", status="ok", data=LocalNet(iface_name="Wi-Fi 2")),
|
|
"ip_geo": ModuleResult(
|
|
name="ip_geo",
|
|
status="ok",
|
|
data={
|
|
"egress_v4": IpGeo(ip="203.0.113.44", asn="AS64500", as_name="Example Telecom"),
|
|
"egress_v6": None,
|
|
"cf_trace": None,
|
|
"dual_stack_note": None,
|
|
},
|
|
),
|
|
"latency": ModuleResult(
|
|
name="latency",
|
|
status="partial",
|
|
data=[PingResult(label="cloudflare-dns", host="1.1.1.1", method="icmp_win", sent=5, received=5)],
|
|
errors=[ProbeError(source="quad9-dns", kind="timeout", message="2s", retryable=True)],
|
|
warnings=["quad9 did not answer"],
|
|
duration_ms=1500,
|
|
),
|
|
"speed": ModuleResult(name="speed", status="skipped", data=None),
|
|
}
|
|
|
|
|
|
def test_report_carries_every_top_level_key_from_the_spec():
|
|
report = build_report(meta(), modules(), [], {"ip-api": {"status": "success"}})
|
|
expected = {"schema_version", "meta", "interpretation", "errors", "raw", *SECTION_ORDER}
|
|
assert expected.issubset(report.keys())
|
|
assert report["schema_version"] == SCHEMA_VERSION
|
|
|
|
|
|
def test_missing_modules_are_rendered_as_skipped_sections_not_dropped():
|
|
report = build_report(meta(), modules(), [], {})
|
|
assert report["bgp"]["status"] == "skipped"
|
|
assert report["bgp"]["data"] is None
|
|
assert set(SECTION_ORDER) == {
|
|
"connection",
|
|
"ip_geo",
|
|
"vpn_assessment",
|
|
"bgp",
|
|
"reputation",
|
|
"latency",
|
|
"path",
|
|
"speed",
|
|
}
|
|
|
|
|
|
def test_interpretation_is_computed_from_the_findings():
|
|
findings = [Finding(id="a", severity="warn", title="Jitter high", detail="d")]
|
|
report = build_report(meta(), modules(), findings, {})
|
|
assert report["interpretation"]["overall_status"] == "warn"
|
|
assert report["interpretation"]["overall_score"] == 90
|
|
assert report["interpretation"]["findings"][0]["id"] == "a"
|
|
|
|
|
|
def test_errors_are_flattened_to_the_top_level_with_their_module():
|
|
flat = flatten_errors(modules())
|
|
assert flat == [
|
|
{
|
|
"source": "quad9-dns",
|
|
"kind": "timeout",
|
|
"message": "2s",
|
|
"retryable": True,
|
|
"module": "latency",
|
|
}
|
|
]
|
|
|
|
|
|
def test_raw_payloads_are_stored_verbatim_under_their_source_key():
|
|
raw = {"ip-api": {"status": "success", "as": "AS64500 Example"}, "cf-trace": "ip=203.0.113.44"}
|
|
report = build_report(meta(), modules(), [], raw)
|
|
assert report["raw"]["ip-api"]["as"] == "AS64500 Example"
|
|
assert report["raw"]["cf-trace"] == "ip=203.0.113.44"
|
|
|
|
|
|
def test_non_finite_numbers_that_reached_the_pipeline_serialize_as_null():
|
|
# Zero-duration timing math produces exactly this on the failure paths the
|
|
# report most needs to show; allow_nan=False would otherwise abort the write.
|
|
broken = modules()
|
|
broken["speed"] = ModuleResult(
|
|
name="speed",
|
|
status="partial",
|
|
data=SpeedResult(
|
|
method="cloudflare",
|
|
download_mbps=float("inf"),
|
|
upload_mbps=float("nan"),
|
|
idle_rtt_ms=float("-inf"),
|
|
bufferbloat_down_ms=12.5,
|
|
),
|
|
)
|
|
report = build_report(meta(), broken, [], {"cloudflare": {"rate": float("inf")}})
|
|
text = dump_json(report)
|
|
back = json.loads(text)
|
|
assert back["speed"]["data"]["download_mbps"] is None
|
|
assert back["speed"]["data"]["upload_mbps"] is None
|
|
assert back["speed"]["data"]["idle_rtt_ms"] is None
|
|
assert back["speed"]["data"]["bufferbloat_down_ms"] == 12.5
|
|
assert back["raw"]["cloudflare"]["rate"] is None
|
|
assert "Infinity" not in text
|
|
assert "NaN" not in text
|
|
|
|
|
|
def test_dump_json_is_strict_and_round_trips():
|
|
report = build_report(meta(), modules(), [], {})
|
|
back = json.loads(dump_json(report))
|
|
assert back["meta"]["run_id"] == "b7f1"
|
|
assert back["latency"]["data"][0]["label"] == "cloudflare-dns"
|
|
assert math.isfinite(back["latency"]["duration_ms"])
|
|
|
|
|
|
def test_compact_timestamp_strips_the_characters_windows_forbids():
|
|
assert compact_timestamp("2026-08-08T19:12:00Z") == "20260808T191200Z"
|
|
assert compact_timestamp("") == "unknown"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
("AS64500", "AS64500"),
|
|
("AS64500 Example Telecom", "AS64500_Example_Telecom"),
|
|
("AS64500/../etc", "AS64500_.._etc"),
|
|
("AS:64500", "AS_64500"),
|
|
("", "unknown"),
|
|
(None, "unknown"),
|
|
("///", "unknown"),
|
|
],
|
|
)
|
|
def test_sanitize_name_produces_windows_safe_fragments(value, expected):
|
|
assert sanitize_name(value) == expected
|
|
|
|
|
|
def test_report_filename_matches_the_documented_pattern():
|
|
assert (
|
|
report_filename("AS64500", "2026-08-08T19:12:00Z", "json")
|
|
== "report_AS64500_20260808T191200Z.json"
|
|
)
|
|
assert (
|
|
report_filename(None, "2026-08-08T19:12:00Z", "md")
|
|
== "report_unknown_20260808T191200Z.md"
|
|
)
|
|
|
|
|
|
def test_egress_asn_is_read_out_of_the_assembled_report():
|
|
report = build_report(meta(), modules(), [], {})
|
|
assert egress_asn(report) == "AS64500"
|
|
assert egress_asn({"ip_geo": {"data": None}}) is None
|
|
assert egress_asn({}) is None
|
|
|
|
|
|
def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
|
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
|
json_path, md_path = write_report(report, "# netcheck report\n", tmp_path)
|
|
assert json_path.name == "report_AS15169_20260808T191200Z.json"
|
|
assert md_path.name == "report_AS15169_20260808T191200Z.md"
|
|
|
|
|
|
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
|
|
target = tmp_path / "logs" / "report.json"
|
|
atomic_write(target, '{"a": 1}')
|
|
assert target.read_text(encoding="utf-8") == '{"a": 1}'
|
|
assert list((tmp_path / "logs").iterdir()) == [target]
|
|
|
|
|
|
def test_atomic_write_replaces_an_existing_file(tmp_path: Path):
|
|
target = tmp_path / "report.json"
|
|
atomic_write(target, "old")
|
|
atomic_write(target, "new")
|
|
assert target.read_text(encoding="utf-8") == "new"
|
|
assert not list(tmp_path.glob("*.tmp"))
|
|
|
|
|
|
def test_write_report_emits_both_artifacts_with_matching_names(tmp_path: Path):
|
|
report = build_report(meta(), modules(), [], {})
|
|
json_path, md_path = write_report(report, "# netcheck report\n", tmp_path)
|
|
assert json_path.name == "report_AS64500_20260808T191200Z.json"
|
|
assert md_path.name == "report_AS64500_20260808T191200Z.md"
|
|
assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
|
assert md_path.read_text(encoding="utf-8").startswith("# netcheck report")
|
|
|
|
|
|
from netcheck.exporter import SPARK_CHARS, badge, first_loss_jump, render_markdown, sparkline
|
|
from netcheck.models import (
|
|
AdapterLeakResult,
|
|
BgpIntel,
|
|
CfTrace,
|
|
DnsLeak,
|
|
DnsblHit,
|
|
InternetDbResult,
|
|
IxpPresence,
|
|
Reputation,
|
|
Signal,
|
|
TierAttempt,
|
|
TraceHop,
|
|
TraceResult,
|
|
VpnAssessment,
|
|
)
|
|
|
|
|
|
def full_modules() -> dict[str, ModuleResult]:
|
|
return {
|
|
"connection": ModuleResult(
|
|
name="connection",
|
|
status="ok",
|
|
data=LocalNet(
|
|
iface_name="Wi-Fi 2",
|
|
local_ipv4="192.168.1.34",
|
|
iface_mtu=1500,
|
|
default_gateway_v4="192.168.1.1",
|
|
dns_servers_per_adapter={"Wi-Fi 2": ["192.168.1.1"]},
|
|
),
|
|
),
|
|
"ip_geo": ModuleResult(
|
|
name="ip_geo",
|
|
status="ok",
|
|
data={
|
|
"egress_v4": IpGeo(
|
|
ip="203.0.113.44",
|
|
asn="AS64500",
|
|
as_name="Example Telecom",
|
|
city="Amsterdam",
|
|
country="Netherlands",
|
|
country_code="NL",
|
|
ip_type="residential",
|
|
reverse_dns="host-203-0-113-44.example.net",
|
|
),
|
|
"egress_v6": IpGeo(ip="2001:db8::1", asn="AS64500"),
|
|
"cf_trace": CfTrace(ip="203.0.113.44", colo="AMS", warp="off"),
|
|
"dual_stack_note": None,
|
|
},
|
|
),
|
|
"vpn_assessment": ModuleResult(
|
|
name="vpn_assessment",
|
|
status="ok",
|
|
data=VpnAssessment(
|
|
verdict="likely",
|
|
confidence=0.55,
|
|
signals=[Signal(name="cf_warp", observed=True, weight=0.5, direction="vpn", note="on")],
|
|
tunnel_iface="wg0",
|
|
dns_leak=DnsLeak(
|
|
per_adapter=[
|
|
AdapterLeakResult(
|
|
adapter="Wi-Fi 2",
|
|
configured_resolvers=["192.168.1.1"],
|
|
echoed_ip="203.0.113.9",
|
|
echoed_asn="AS64501",
|
|
matches_egress_asn=False,
|
|
)
|
|
],
|
|
ecs_leaked=True,
|
|
note="ISP resolver still active on the Wi-Fi adapter.",
|
|
),
|
|
),
|
|
),
|
|
"bgp": ModuleResult(
|
|
name="bgp",
|
|
status="ok",
|
|
data=BgpIntel(
|
|
asn="AS64500",
|
|
holder="Example Telecom BV",
|
|
upstreams=["AS3356"],
|
|
prefix_count_v4=2,
|
|
prefix_count_v6=1,
|
|
stability="stable",
|
|
ixps=[IxpPresence(name="AMS-IX", city="Amsterdam", country="NL", speed_mbps=100000)],
|
|
asrank=1842,
|
|
cone_asns=37,
|
|
pdb_info_type="Cable/DSL/ISP",
|
|
),
|
|
),
|
|
"reputation": ModuleResult(
|
|
name="reputation",
|
|
status="ok",
|
|
data=Reputation(
|
|
internetdb=InternetDbResult(ip="203.0.113.44", ports=[80, 443], tags=["cdn"]),
|
|
firehol_hits=[],
|
|
dnsbl_hits=[DnsblHit(zone="bl.spamcop.net", codes=["127.0.0.2"])],
|
|
dnsbl_query_blocked=True,
|
|
captcha_risk="high",
|
|
rationale="listed on bl.spamcop.net",
|
|
),
|
|
),
|
|
"latency": ModuleResult(
|
|
name="latency",
|
|
status="ok",
|
|
data=[
|
|
PingResult(
|
|
label="cloudflare-dns",
|
|
host="1.1.1.1",
|
|
method="icmp_win",
|
|
sent=5,
|
|
received=5,
|
|
avg_ms=12.4,
|
|
min_ms=11.0,
|
|
max_ms=15.1,
|
|
jitter_ms=1.9,
|
|
samples=[11.0, 12.0, None, 15.1, 12.4],
|
|
),
|
|
PingResult(label="github", host="github.com", method="tcp", sent=5, received=4, loss_pct=20.0, avg_ms=42.0),
|
|
],
|
|
duration_ms=2400,
|
|
),
|
|
"path": ModuleResult(
|
|
name="path",
|
|
status="ok",
|
|
data=[
|
|
TraceResult(
|
|
target="1.1.1.1",
|
|
backend="icmp_win",
|
|
completed=True,
|
|
hops=[
|
|
TraceHop(ttl=1, ip="192.168.1.1", avg_ms=1.1, loss_pct=0.0),
|
|
TraceHop(ttl=2, ip="10.64.0.1", avg_ms=9.0, loss_pct=60.0),
|
|
TraceHop(ttl=3, ip="1.1.1.1", avg_ms=12.4, loss_pct=55.0),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
"speed": ModuleResult(
|
|
name="speed",
|
|
status="ok",
|
|
data=SpeedResult(
|
|
method="cloudflare",
|
|
tier_attempts=[
|
|
TierAttempt(tier="ookla_bin", ok=False, reason="binary not on PATH"),
|
|
TierAttempt(tier="cloudflare", ok=True),
|
|
],
|
|
download_mbps=284.3,
|
|
upload_mbps=41.7,
|
|
server="speed.cloudflare.com",
|
|
idle_rtt_ms=12.0,
|
|
loaded_rtt_down_ms=48.0,
|
|
bufferbloat_down_ms=36.0,
|
|
bufferbloat_grade="C",
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
def full_report() -> dict:
|
|
findings = [
|
|
Finding(
|
|
id="latency.loss.github",
|
|
severity="warn",
|
|
title="Loss to github.com",
|
|
detail="20.0% connection failures over 5 probes via tcp.",
|
|
metric="loss_pct",
|
|
value=20.0,
|
|
threshold=2.0,
|
|
advice="Sustained loss on every host points at the local link.",
|
|
)
|
|
]
|
|
return build_report(meta(), full_modules(), findings, {"ip-api": {"status": "success"}})
|
|
|
|
|
|
def test_sparkline_maps_a_series_across_the_glyph_ramp():
|
|
line = sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
|
assert len(line) == 5
|
|
assert line[0] == SPARK_CHARS[0]
|
|
assert line[-1] == SPARK_CHARS[-1]
|
|
assert SPARK_CHARS == "▁▂▃▅▇"
|
|
|
|
|
|
def test_sparkline_of_a_flat_series_is_all_baseline():
|
|
assert sparkline([7.0, 7.0, 7.0]) == SPARK_CHARS[0] * 3
|
|
|
|
|
|
def test_sparkline_renders_a_dropped_probe_as_a_gap():
|
|
line = sparkline([1.0, None, 5.0])
|
|
assert line[1] == " "
|
|
assert len(line) == 3
|
|
|
|
|
|
def test_sparkline_of_nothing_is_empty():
|
|
assert sparkline([]) == ""
|
|
assert sparkline([None, None]) == ""
|
|
|
|
|
|
def test_badge_is_gated_behind_the_emoji_setting():
|
|
assert badge("crit", emoji=True) == "🔴"
|
|
assert badge("warn", emoji=True) == "🟡"
|
|
assert badge("ok", emoji=True) == "🟢"
|
|
assert badge("crit", emoji=False) == "[crit]"
|
|
assert badge("warn", emoji=False) == "[warn]"
|
|
|
|
|
|
def test_first_loss_jump_needs_the_loss_to_persist_downstream():
|
|
hops = [
|
|
{"ttl": 1, "loss_pct": 0.0},
|
|
{"ttl": 2, "loss_pct": 60.0},
|
|
{"ttl": 3, "loss_pct": 55.0},
|
|
]
|
|
assert first_loss_jump(hops) == 2
|
|
|
|
|
|
def test_first_loss_jump_ignores_a_single_icmp_rate_limiting_hop():
|
|
hops = [{"ttl": 1, "loss_pct": 0.0}, {"ttl": 2, "loss_pct": 100.0}, {"ttl": 3, "loss_pct": 0.0}]
|
|
assert first_loss_jump(hops) is None
|
|
assert first_loss_jump([]) is None
|
|
|
|
|
|
def test_markdown_has_every_section_from_the_spec_in_order():
|
|
text = render_markdown(full_report())
|
|
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
|
assert headings == [
|
|
"## TL;DR",
|
|
"## Connection & identity",
|
|
"## VPN / proxy assessment",
|
|
"## ASN & BGP intelligence",
|
|
"## Reputation",
|
|
"## Latency",
|
|
"## Path",
|
|
"## Speed",
|
|
"## Problems & recommendations",
|
|
"## Run diagnostics",
|
|
]
|
|
assert text.startswith("# netcheck report")
|
|
|
|
|
|
def test_markdown_header_states_mode_timestamp_and_verdict():
|
|
text = render_markdown(full_report())
|
|
assert "auto" in text
|
|
assert "2026-08-08T19:12:00Z" in text
|
|
assert "90/100" in text
|
|
|
|
|
|
def test_markdown_reports_identity_and_both_stacks():
|
|
text = render_markdown(full_report())
|
|
assert "203.0.113.44" in text
|
|
assert "AS64500" in text
|
|
assert "2001:db8::1" in text
|
|
assert "Amsterdam" in text
|
|
assert "residential" in text
|
|
|
|
|
|
def test_markdown_vpn_section_lists_signals_and_the_dns_leak():
|
|
text = render_markdown(full_report())
|
|
assert "likely" in text
|
|
assert "cf_warp" in text
|
|
assert "Wi-Fi 2" in text
|
|
assert "AS64501" in text
|
|
assert "ISP resolver still active" in text
|
|
|
|
|
|
def test_markdown_dnsbl_error_range_is_never_shown_as_a_plain_listing():
|
|
text = render_markdown(full_report())
|
|
assert "bl.spamcop.net" in text
|
|
assert "not a listing" in text
|
|
|
|
|
|
def test_markdown_latency_table_carries_a_sparkline_in_a_fenced_block():
|
|
text = render_markdown(full_report())
|
|
assert "cloudflare-dns" in text
|
|
assert any(char in text for char in SPARK_CHARS)
|
|
assert text.count("```") >= 2
|
|
|
|
|
|
def test_markdown_flags_that_tcp_loss_is_a_different_metric():
|
|
text = render_markdown(full_report())
|
|
assert "failed TCP connections" in text
|
|
|
|
|
|
def test_markdown_path_section_marks_the_first_loss_jump():
|
|
text = render_markdown(full_report())
|
|
path_block = text.split("## Path")[1].split("## Speed")[0]
|
|
marked = [line for line in path_block.splitlines() if line.rstrip().endswith("<<")]
|
|
assert len(marked) == 1
|
|
assert "10.64.0.1" in marked[0]
|
|
|
|
|
|
def test_markdown_speed_section_explains_the_bufferbloat_grade_in_plain_language():
|
|
text = render_markdown(full_report())
|
|
assert "284.3" in text
|
|
assert "grade C" in text
|
|
assert "choppy" in text
|
|
assert "ookla_bin" in text
|
|
assert "binary not on PATH" in text
|
|
|
|
|
|
def test_markdown_run_diagnostics_lists_every_module_with_its_status():
|
|
text = render_markdown(full_report())
|
|
block = text.split("## Run diagnostics")[1]
|
|
for section in SECTION_ORDER:
|
|
assert section in block
|
|
|
|
|
|
def test_markdown_without_emoji_uses_text_badges():
|
|
text = render_markdown(full_report(), emoji=False)
|
|
assert "[warn]" in text
|
|
assert "🟡" not in text
|
|
|
|
|
|
from netcheck.exporter import unavailable
|
|
|
|
|
|
def dead_modules() -> dict[str, ModuleResult]:
|
|
return {
|
|
section: ModuleResult(
|
|
name=section,
|
|
status="failed",
|
|
data=None,
|
|
errors=[ProbeError(source=section, kind="unavailable", message="network unreachable", retryable=True)],
|
|
duration_ms=8000,
|
|
)
|
|
for section in SECTION_ORDER
|
|
}
|
|
|
|
|
|
def dead_report() -> dict:
|
|
return build_report(meta(), dead_modules(), [], {})
|
|
|
|
|
|
def test_unavailable_describes_a_failed_module_and_stays_quiet_on_a_healthy_one():
|
|
assert unavailable(full_report(), "bgp") is None
|
|
note = unavailable(dead_report(), "bgp")
|
|
assert note is not None
|
|
assert "failed" in note
|
|
assert "network unreachable" in note
|
|
|
|
|
|
def test_unavailable_explains_a_skipped_module_without_inventing_an_error():
|
|
report = build_report(meta(), {"speed": ModuleResult(name="speed", status="skipped")}, [], {})
|
|
note = unavailable(report, "speed")
|
|
assert note is not None
|
|
assert "skipped" in note
|
|
|
|
|
|
def test_unavailable_surfaces_the_skip_reason_from_warnings():
|
|
report = build_report(
|
|
meta(),
|
|
{"speed": ModuleResult(name="speed", status="skipped", warnings=["speedtest skipped in target mode"])},
|
|
[],
|
|
{},
|
|
)
|
|
note = unavailable(report, "speed")
|
|
assert note is not None
|
|
assert "speedtest skipped in target mode" in note
|
|
|
|
|
|
def test_an_all_modules_failed_run_still_renders_every_section():
|
|
text = render_markdown(dead_report())
|
|
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
|
assert len(headings) == 10
|
|
assert text.count("Not available") == 8
|
|
|
|
|
|
def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
|
report = dead_report()
|
|
back = json.loads(dump_json(report))
|
|
assert back["schema_version"] == SCHEMA_VERSION
|
|
assert len(back["errors"]) == len(SECTION_ORDER)
|
|
assert all(section in back for section in SECTION_ORDER)
|
|
assert back["interpretation"]["overall_status"] == "ok"
|
|
assert back["raw"] == {}
|
|
|
|
|
|
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
|
report = dead_report()
|
|
json_path, md_path = write_report(report, render_markdown(report), tmp_path)
|
|
assert json_path.name.startswith("report_unknown_")
|
|
assert md_path.name.startswith("report_unknown_")
|
|
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_the_diagnostics_table_still_names_every_failed_module():
|
|
block = render_markdown(dead_report()).split("## Run diagnostics")[1]
|
|
assert block.count("failed") == len(SECTION_ORDER)
|
|
assert "unavailable" in block
|
|
|
|
|
|
def test_a_partial_module_with_data_is_rendered_normally_not_as_a_placeholder():
|
|
report = build_report(meta(), modules(), [], {})
|
|
assert unavailable(report, "latency") is None
|
|
assert "cloudflare-dns" in render_markdown(report)
|