"""AZ-700 — render_map CLI + HTML renderer unit tests.
Covers AC-1 (CLI smoke + valid HTML), AC-2 (two distinct
polylines), AC-3 (4 markers + 100 m + 50 m circles), and AC-4
(summary embedding). AC-5 (offline-tiles flag) is exercised via a
dedicated test.
Folium is an optional dependency (``[operator-tools]`` group);
these tests skip cleanly when it is not importable so the airborne
test suite stays green even when the operator extra is absent.
Style: every test follows the Arrange / Act / Assert pattern.
"""
from __future__ import annotations
import json
import struct
from pathlib import Path
import pytest
folium = pytest.importorskip(
"folium",
reason="folium is an operator-only dep; install gps-denied-onboard[operator-tools]",
)
from gps_denied_onboard.cli.render_map import (
RenderInputs,
_build_argparser,
load_estimated_track,
load_ground_truth_track,
main,
render_map_html,
)
def _write_minimal_tlog(path: Path, fixes: list[tuple[float, float, float]]) -> None:
"""Write a tiny binary tlog with ``GLOBAL_POSITION_INT`` only.
Format: ````,
repeated. ``load_tlog_ground_truth`` ignores everything except
``GLOBAL_POSITION_INT`` / ``GPS_RAW_INT``, so the minimal schema
is just one ``GLOBAL_POSITION_INT`` per fix.
"""
from pymavlink.dialects.v20 import ardupilotmega as mavlink
mav = mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
with path.open("wb") as fp:
for i, (lat, lon, alt) in enumerate(fixes):
time_boot_ms = i * 500
msg = mav.global_position_int_encode(
time_boot_ms=time_boot_ms,
lat=int(lat * 1e7),
lon=int(lon * 1e7),
alt=int(alt * 1000),
relative_alt=int(alt * 1000),
vx=0,
vy=0,
vz=0,
hdg=0,
)
payload = msg.pack(mav)
ts_us = i * 500_000
fp.write(struct.pack(">Q", ts_us))
fp.write(payload)
# ---------------------------------------------------------------------
# Helpers
def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
def _example_inputs() -> RenderInputs:
return RenderInputs(
estimated_track=[
(50.0, 30.0),
(50.001, 30.001),
(50.002, 30.002),
],
truth_track=[
(50.0, 30.0),
(50.0005, 30.0005),
(50.001, 30.001),
],
summary_markdown=None,
title="unit-test",
)
# ---------------------------------------------------------------------
# load_estimated_track / load_ground_truth_track
def test_load_estimated_track_skips_blank_lines(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text(
'{"position_wgs84":{"lat_deg":50.0,"lon_deg":30.0,"alt_m":100}}\n'
"\n"
'{"position_wgs84":{"lat_deg":50.1,"lon_deg":30.1,"alt_m":110}}\n'
)
# Act
track = load_estimated_track(path)
# Assert
assert track == [(50.0, 30.0), (50.1, 30.1)]
def test_load_estimated_track_raises_on_missing_position(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text('{"frame_id":1}\n')
# Act / Assert
with pytest.raises(ValueError, match="missing position_wgs84"):
load_estimated_track(path)
def test_load_estimated_track_raises_on_non_numeric_lat(tmp_path: Path) -> None:
# Arrange
path = tmp_path / "out.jsonl"
path.write_text(
'{"position_wgs84":{"lat_deg":"oops","lon_deg":30.0}}\n'
)
# Act / Assert
with pytest.raises(ValueError, match="non-numeric lat/lon"):
load_estimated_track(path)
# ---------------------------------------------------------------------
# render_map_html
def test_render_map_html_emits_two_polylines() -> None:
# Act
html = render_map_html(_example_inputs())
# Assert — AC-2: two distinct polyline layers with our pinned colors.
assert html.count("L.polyline") == 2, (
"expected exactly 2 polylines (truth + estimated); "
f"saw {html.count('L.polyline')}"
)
assert '"color": "red"' in html, "truth polyline (red) missing"
assert '"color": "blue"' in html, "estimated polyline (blue) missing"
def test_render_map_html_emits_four_markers_and_two_circles() -> None:
# Act
html = render_map_html(_example_inputs())
# Assert — AC-3: 2 markers per track (start + end) = 4 total.
assert html.count("L.marker") == 4, (
f"expected 4 markers; saw {html.count('L.marker')}"
)
# Scale circles at the truth start: radius 100 + 50.
assert html.count("L.circle") == 2, (
f"expected 2 scale circles (100 m + 50 m); "
f"saw {html.count('L.circle')}"
)
assert '"radius": 100.0' in html
assert '"radius": 50.0' in html
def test_render_map_html_embeds_summary_when_provided() -> None:
# Arrange
inputs = RenderInputs(
estimated_track=[(50.0, 30.0), (50.001, 30.001)],
truth_track=[(50.0, 30.0), (50.0005, 30.0005)],
summary_markdown=(
"# Real-flight validation — 2026-05-20\n"
"**Verdict**: PASS\n"
"| Mean | 12.3 |"
),
title="t",
)
# Act
html = render_map_html(inputs)
# Assert — AC-4: the markdown body shows up in the HTML.
assert "Real-flight validation" in html
assert "**Verdict**: PASS" in html # noqa: E501 — escape allowed since `*` is not HTML-special
# HTML special chars are escaped — pipe characters stay raw, but
# the angle brackets used in markdown's emphasis would. We don't
# want script injection, so confirm the wrapper div is present.
assert "white-space:pre-wrap" in html
def test_render_map_html_raises_on_both_tracks_empty() -> None:
# Arrange
inputs = RenderInputs(
estimated_track=[],
truth_track=[],
summary_markdown=None,
title="empty",
)
# Act / Assert
with pytest.raises(ValueError, match="empty"):
render_map_html(inputs)
def test_render_map_html_offline_tiles_omits_openstreetmap() -> None:
# Act
html_default = render_map_html(_example_inputs())
html_offline = render_map_html(_example_inputs(), offline_tiles=True)
# Assert — `tiles=None` removes the default OpenStreetMap tile URL.
assert "openstreetmap" in html_default.lower()
assert "openstreetmap" not in html_offline.lower()
def test_render_map_html_offline_tiles_template_uses_local_url() -> None:
# Act
html = render_map_html(
_example_inputs(),
offline_tiles_template="file:///opt/tiles/{z}/{x}/{y}.png",
)
# Assert
assert "file:///opt/tiles/{z}/{x}/{y}.png" in html
assert "local offline tile bundle" in html
# ---------------------------------------------------------------------
# CLI smoke (AC-1)
def test_cli_writes_html_with_default_tiles(tmp_path: Path) -> None:
# Arrange
estimated = tmp_path / "estimator.jsonl"
_write_jsonl(
estimated,
[
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
{"position_wgs84": {"lat_deg": 50.001, "lon_deg": 30.001, "alt_m": 101}},
],
)
truth = tmp_path / "synth.tlog"
_write_minimal_tlog(
truth,
[(50.0, 30.0, 100.0), (50.0005, 30.0005, 100.0), (50.001, 30.001, 100.0)],
)
output = tmp_path / "map.html"
# Act
rc = main(
[
"--estimated", str(estimated),
"--truth", str(truth),
"--output", str(output),
]
)
# Assert — AC-1: clean exit + non-empty HTML.
assert rc == 0
assert output.is_file()
body = output.read_text()
assert body.startswith("")
assert len(body) > 1000
def test_cli_embeds_summary_when_flag_supplied(tmp_path: Path) -> None:
# Arrange
estimated = tmp_path / "estimator.jsonl"
_write_jsonl(
estimated,
[
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
{"position_wgs84": {"lat_deg": 50.001, "lon_deg": 30.001, "alt_m": 101}},
],
)
truth = tmp_path / "synth.tlog"
_write_minimal_tlog(truth, [(50.0, 30.0, 100.0), (50.001, 30.001, 100.0)])
summary = tmp_path / "real_flight_validation_2026-05-20.md"
summary.write_text(
"# Real-flight validation — 2026-05-20\n"
"**Verdict**: FAIL\n\n"
"## Horizontal error (metres)\n"
"| Mean | 142.5 |\n"
)
output = tmp_path / "map.html"
# Act
rc = main(
[
"--estimated", str(estimated),
"--truth", str(truth),
"--output", str(output),
"--summary", str(summary),
]
)
# Assert — AC-4
assert rc == 0
body = output.read_text()
assert "Real-flight validation" in body
assert "**Verdict**: FAIL" in body
assert "Mean | 142.5" in body
def test_cli_fails_fast_when_summary_path_missing(tmp_path: Path) -> None:
# Arrange
estimated = tmp_path / "estimator.jsonl"
_write_jsonl(
estimated,
[
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
],
)
truth = tmp_path / "synth.tlog"
_write_minimal_tlog(truth, [(50.0, 30.0, 100.0), (50.001, 30.001, 100.0)])
output = tmp_path / "map.html"
missing_summary = tmp_path / "does_not_exist.md"
# Act
rc = main(
[
"--estimated", str(estimated),
"--truth", str(truth),
"--output", str(output),
"--summary", str(missing_summary),
]
)
# Assert
assert rc == 2
assert not output.exists(), "must not write the map when summary path is invalid"
def test_argparser_requires_three_paths() -> None:
# Arrange
parser = _build_argparser()
# Act / Assert
with pytest.raises(SystemExit):
parser.parse_args([])
with pytest.raises(SystemExit):
parser.parse_args(["--estimated", "/tmp/a.jsonl"])
# ---------------------------------------------------------------------
# load_ground_truth_track integration with AZ-697
def test_load_ground_truth_track_returns_lat_lon_pairs(tmp_path: Path) -> None:
# Arrange — synthesize a minimal tlog and round-trip through AZ-697.
tlog_path = tmp_path / "synth.tlog"
_write_minimal_tlog(
tlog_path,
[(50.000, 30.000, 100.0), (50.001, 30.001, 101.0), (50.002, 30.002, 102.0)],
)
# Act
track = load_ground_truth_track(tlog_path)
# Assert
assert len(track) == 3
for lat, lon in track:
assert 49.99 < lat < 50.01
assert 29.99 < lon < 30.01