"""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 # --------------------------------------------------------------------- # AZ-960 — CSV-path dispatch for --truth def _write_minimal_csv(path: Path) -> None: """Write a tiny AZ-896-schema CSV with 3 GPS rows for AZ-960 tests.""" header = ( "timestamp(ms),Time," "SCALED_IMU2.xacc,SCALED_IMU2.yacc,SCALED_IMU2.zacc," "SCALED_IMU2.xgyro,SCALED_IMU2.ygyro,SCALED_IMU2.zgyro," "GLOBAL_POSITION_INT.lat,GLOBAL_POSITION_INT.lon," "GLOBAL_POSITION_INT.alt,GLOBAL_POSITION_INT.vx," "GLOBAL_POSITION_INT.vy,GLOBAL_POSITION_INT.vz," "GLOBAL_POSITION_INT.hdg" ) rows = [ "0,0.0,21,-3,-984,52,32,-5,50.000,30.000,141290,0,0,0,35041", "100,0.1,-68,-9,-995,58,-17,1,50.0005,30.0005,141360,0,0,0,35042", "200,0.2,9,108,-988,69,-65,13,50.001,30.001,141410,0,0,0,35048", ] path.write_text(header + "\n" + "\n".join(rows) + "\n") def test_load_ground_truth_track_dispatches_to_csv_loader( tmp_path: Path, ) -> None: # Arrange — AC-1: csv extension routes through load_csv_ground_truth csv_path = tmp_path / "data_imu.csv" _write_minimal_csv(csv_path) # Act track = load_ground_truth_track(csv_path) # Assert assert len(track) == 3 for lat, lon in track: assert 49.999 < lat < 50.002 assert 29.999 < lon < 30.002 def test_load_ground_truth_track_csv_propagates_schema_error( tmp_path: Path, ) -> None: # Arrange — AC-4: malformed CSV fails fast via ReplayInputAdapterError csv_path = tmp_path / "bad.csv" csv_path.write_text( "timestamp(ms),SCALED_IMU2.xacc,GLOBAL_POSITION_INT.lat\n" "0,0,50.0\n" ) from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError # Act / Assert with pytest.raises(ReplayInputAdapterError, match="Time"): load_ground_truth_track(csv_path) def test_cli_renders_map_with_csv_truth(tmp_path: Path) -> None: # Arrange — AC-1 end-to-end: CLI accepts --truth foo.csv csv_path = tmp_path / "data_imu.csv" _write_minimal_csv(csv_path) 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}}, ], ) output = tmp_path / "map.html" # Act rc = main( [ "--estimated", str(estimated), "--truth", str(csv_path), "--output", str(output), ] ) # Assert assert rc == 0 assert output.is_file() body = output.read_text() assert body.startswith("") assert "L.polyline" in body assert '"color": "red"' in body assert '"color": "blue"' in body