[AZ-408] [AZ-410] [AZ-411] Batch 69: synth injectors + FT-P-02/03/14

AZ-408 (3pt) — Replace AZ-406 injector scaffolds with concrete generators:
- outlier.py: deterministic stride + far-away tile replacement; AC-2 ≥350m offset
- blackout_spoof.py: paired video blackout + FC GPS spoof with ≤40ms alignment;
  AC-4 realistic fix_type/hdop; AC-NEW-8 200-500m inter-spoof deltas
- multi_segment.py: ≥3 disjoint windows, ≥30s gaps, ≤25% coverage
- fc_proxy.py: timed-splice runtime proxy with pre-activate RuntimeError guard
- _common.py: derive_rng + tile-manifest reader + tmpfs helpers
- injector_fixtures.py: pytest fixtures wired via runner conftest

AZ-410 (3pt) — FT-P-02 cumulative drift between satellite anchors:
- anchor_pair_detector.py: AC-1 detection, AC-2/3 pass-fraction,
  AC-4 monotonicity check, CSV evidence
- test_ft_p_02_derkachi_drift.py: scenario gated on upstream helper
  NotImplementedError (frame_source_replay / fdr_reader / imu_replay)

AZ-411 (2pt) — FT-P-03 + FT-P-14 schema + WGS84:
- estimate_schema.py: AC-1 schema completeness, AC-2 source-label set
  containment, AC-3 WGS84 range + int32 1e-7 decode
- test_ft_p_03_14_schema_wgs84.py: shared single-image-push scenario

Tests: 248 unit tests pass (+91 vs batch 68).
Reports: batch_69_report.md, batch_69_review.md (PASS),
cumulative_review_batches_67-69_cycle1_report.md (PASS).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-16 17:54:00 +03:00
parent ff1b00200c
commit 702a0c0ff3
27 changed files with 4619 additions and 58 deletions
@@ -0,0 +1,229 @@
"""Behavioural tests for the AZ-408 blackout_spoof injector.
Covers:
* AC-1: ``(seed, window, offset, bearing)`` → deterministic schedule + outputs.
* AC-3: schedule's window/spoof timeline matches the documented ≤40 ms
alignment promise.
* AC-4: spoofed-GPS fields stay within realistic-flight ranges.
* AC-NEW-8: inter-spoof position deltas are in [200 m, 500 m].
* AC-6: tmpfs scratch isolation + no escapees.
The runtime alignment between video black frames and proxy spoof
emission is covered separately in ``test_fc_proxy.py`` (the proxy is
the runtime component; the injector here only emits the schedule).
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import pytest
from fixtures.injectors import blackout_spoof
from fixtures.injectors._common import haversine_m
def _build_synthetic_frames_dir(parent: Path, count: int = 600) -> Path:
from PIL import Image # noqa: PLC0415
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
img = Image.new("RGB", (256, 256), color=(40, 40, 40))
for i in range(count):
img.save(
frames_dir / f"AD{i + 1:06d}.jpg",
format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2,
)
return frames_dir
def test_blackout_window_lengths(tmp_path: Path) -> None:
"""The schedule's window is exactly the requested length (modulo clamping)."""
# Arrange — 3000 frames @ 30 fps = 100 s, window anchored at 30 s leaves
# 70 s of headroom — enough for the 5/15/35 s window family the spec asks
# for plus a 25 s probe.
frames = _build_synthetic_frames_dir(tmp_path / "src", count=3000)
for window in (5.0, 15.0, 25.0, 35.0):
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=window
)
# Act
report = blackout_spoof.build(plan, tmp_path / f"out_{int(window)}")
# Assert — window duration ≈ requested (allow ±1 ms for rounding)
duration_ms = report.schedule.window_end_ms - report.schedule.window_start_ms
assert abs(duration_ms - int(window * 1000)) <= 1
def test_blackout_seconds_must_be_positive(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=0.0
)
# Act / Assert
with pytest.raises(ValueError, match="blackout_seconds"):
blackout_spoof.build(plan, tmp_path / "out")
def test_build_is_seed_deterministic(tmp_path: Path) -> None:
"""AC-1: identical inputs → identical schedule.json + identical black-frame bytes."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames,
blackout_seconds=10.0,
seed=99,
spoof_offset_m=400.0,
spoof_bearing_deg=30.0,
)
# Act
out_a = tmp_path / "run_a"
out_b = tmp_path / "run_b"
blackout_spoof.build(plan, out_a)
blackout_spoof.build(plan, out_b)
# Assert
sched_a = (out_a / "schedule.json").read_bytes()
sched_b = (out_b / "schedule.json").read_bytes()
assert sched_a == sched_b
def test_spoof_track_inter_position_delta_in_range(tmp_path: Path) -> None:
"""AC-NEW-8: consecutive spoofed-GPS positions jump 200-500 m apart."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=20.0, seed=11
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
spoof = report.schedule.spoof_gps
assert len(spoof) > 1, "need at least 2 spoofed frames to measure deltas"
for prev, nxt in zip(spoof, spoof[1:]):
d = haversine_m(prev.lat_deg, prev.lon_deg, nxt.lat_deg, nxt.lon_deg)
assert 200.0 <= d <= 500.0, (
f"inter-spoof delta {d:.1f} m outside [200, 500] m"
)
def test_spoof_fields_are_realistic(tmp_path: Path) -> None:
"""AC-4: lat/lon/alt/fix_type/hdop stay inside typical-flight ranges."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=20.0, seed=22
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
for f in report.schedule.spoof_gps:
assert not math.isnan(f.lat_deg)
assert -90 <= f.lat_deg <= 90
assert -180 <= f.lon_deg <= 180
assert f.fix_type in (3, 4)
assert 0.5 <= f.hdop <= 2.5
# No sentinel values (e.g. 0 lat/lon or 999 alt)
assert abs(f.lat_deg) > 1e-6
assert abs(f.lon_deg) > 1e-6
assert 50 <= f.alt_m <= 1500
def test_schedule_has_max_alignment_err_per_ac3(tmp_path: Path) -> None:
"""AC-3: schedule records the ≤40 ms alignment-error budget."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=15.0
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
assert report.schedule.max_alignment_err_ms == 40.0
def test_blackout_frames_are_black(tmp_path: Path) -> None:
"""Every frame index inside the blackout window has all-zero pixels."""
# Arrange
from PIL import Image # noqa: PLC0415
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
# Act
report = blackout_spoof.build(plan, out_root)
# Assert
for idx in report.schedule.blackout_frame_indices[:5]:
name = f"AD{idx + 1:06d}.jpg"
img = Image.open(out_root / "frames" / name).convert("RGB")
# Sample pixel — synthesised black JPEGs round-trip to (0,0,0)
# within JPEG compression noise.
r, g, b = img.getpixel((128, 128)) # type: ignore[misc]
assert r < 5 and g < 5 and b < 5, f"frame {name} pixel ({r},{g},{b}) is not black"
def test_normal_frames_pass_through(tmp_path: Path) -> None:
"""Frames OUTSIDE the blackout window are byte-equal to the source."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
blackout_spoof.build(plan, out_root)
# Act / Assert — the very first frame is always outside (window starts
# at 30 % of source).
src_bytes = (frames / "AD000001.jpg").read_bytes()
out_bytes = (out_root / "frames" / "AD000001.jpg").read_bytes()
assert src_bytes == out_bytes
def test_schedule_json_round_trips(tmp_path: Path) -> None:
"""schedule.json is well-formed JSON with the expected top-level keys."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=10.0
)
# Act
blackout_spoof.build(plan, tmp_path / "out")
payload = json.loads((tmp_path / "out" / "schedule.json").read_text())
# Assert
assert {"window_start_ms", "window_end_ms", "spoof_gps", "blackout_frame_indices"} <= set(
payload.keys()
)
assert isinstance(payload["spoof_gps"], list)
def test_build_overwrites_existing_out_root(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
blackout_spoof.build(plan, out_root)
(out_root / "stale.bin").write_bytes(b"stale")
# Act
blackout_spoof.build(plan, out_root)
# Assert
assert not (out_root / "stale.bin").exists()
+184
View File
@@ -0,0 +1,184 @@
"""Behavioural tests for the AZ-408 FC inbound proxy patch.
Covers AC-3 (video↔proxy alignment ≤ 40 ms — verified end-to-end via the
fake clock here; the runtime path observes the same invariant) and the
proxy's pass-through / spoof-replace semantics.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy, SpoofGpsRecord
class _FakeClock:
"""Monotonic ms clock that the test advances manually."""
def __init__(self, start_ms: int = 0) -> None:
self.now_ms = start_ms
def __call__(self) -> int:
return self.now_ms
def advance(self, ms: int) -> None:
self.now_ms += ms
def _spoof_records() -> list[SpoofGpsRecord]:
return [
SpoofGpsRecord(monotonic_ms=1000 + i * 100, lat_deg=50.0 + i * 0.001,
lon_deg=36.1, alt_m=300.0, fix_type=3, hdop=1.0)
for i in range(5)
]
def test_proxy_passes_through_outside_window() -> None:
# Arrange — schedule the first blackout 500 ms in the future. The
# activate() call binds proxy_time(now) = 0; the window opens at
# window_start_ms = 500 in proxy time. Now (proxy_time = 0) is
# outside [500, 1000], so the proxy must pass through.
clock = _FakeClock(start_ms=1000)
proxy = BlackoutSpoofProxy(window_start_ms=500, window_end_ms=1000,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=1500)
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
# Act
out = proxy.process_inbound_message(msg)
# Assert
assert out == msg
assert "__spoofed__" not in out
def test_proxy_spoofs_inside_window() -> None:
# Arrange
clock = _FakeClock(start_ms=0)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
# Act — clock=0 ⇒ proxy_time(0) = 0 (inside window)
out = proxy.process_inbound_message(msg)
# Assert
assert out["__spoofed__"] is True
assert out["lat_deg"] != msg["lat_deg"]
assert out["fix_type"] == 3
def test_proxy_returns_to_passthrough_after_window() -> None:
# Arrange
clock = _FakeClock(start_ms=0)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
# Act — advance past end of window
clock.advance(1000)
msg = {"lat_deg": 50.0, "lon_deg": 36.0, "alt_m": 300.0}
out = proxy.process_inbound_message(msg)
# Assert
assert out == msg
def test_alignment_err_below_40ms_when_clock_matches_first_blackout() -> None:
"""AC-3: when the test harness calls activate() at the same ms the
first blackout frame fires, alignment error is 0."""
# Arrange
clock = _FakeClock(start_ms=12_345)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
# Act
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_345)
# Assert
assert report.alignment_err_ms == 0
assert report.alignment_err_ms <= 40
def test_alignment_err_within_budget_under_normal_clock_skew() -> None:
"""Real harness can have a 30 ms skew between video & proxy; still inside AC-3."""
# Arrange
clock = _FakeClock(start_ms=12_400)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
# Act — first_blackout_ms is 30 ms earlier than clock (harness skew)
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_370)
# Assert
assert report.alignment_err_ms == 30
assert report.alignment_err_ms <= 40
def test_exhausting_spoof_list_repeats_last() -> None:
"""When the spoofed-GPS list is drained, the FC keeps seeing the last record."""
# Arrange
clock = _FakeClock(start_ms=0)
spoofs = _spoof_records()
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=10_000, spoof_gps=spoofs)
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
# Act — pull 10 frames (more than the 5 in the list)
outs = [proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0}) for _ in range(10)]
# Assert — last 5 outputs all reuse the final spoof record
last = spoofs[-1]
for o in outs[-3:]:
assert o["lat_deg"] == last.lat_deg
assert o["lon_deg"] == last.lon_deg
def test_from_schedule_file_round_trip(tmp_path: Path) -> None:
# Arrange
sched_path = tmp_path / "schedule.json"
sched_path.write_text(
json.dumps(
{
"window_start_ms": 0,
"window_end_ms": 200,
"max_alignment_err_ms": 40.0,
"blackout_frame_indices": [0, 1, 2],
"spoof_gps": [
{"monotonic_ms": 0, "lat_deg": 50.0, "lon_deg": 36.0,
"alt_m": 300.0, "fix_type": 3, "hdop": 1.0},
],
}
)
)
# Act
proxy = BlackoutSpoofProxy.from_schedule_file(sched_path)
proxy.activate(now_ms_provider=lambda: 0)
out = proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0})
# Assert
assert out["__spoofed__"] is True
assert out["lat_deg"] == 50.0
def test_from_schedule_file_missing_raises(tmp_path: Path) -> None:
# Arrange / Act / Assert
with pytest.raises(FileNotFoundError):
BlackoutSpoofProxy.from_schedule_file(tmp_path / "missing.json")
def test_process_before_activate_raises() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=_spoof_records())
# Act / Assert
with pytest.raises(RuntimeError, match="not activated"):
proxy.process_inbound_message({})
def test_in_window_false_before_activate() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=[])
# Act / Assert
assert proxy.in_window() is False
@@ -1,8 +1,10 @@
"""Unit tests for the injector public surfaces.
"""Public-surface contract tests for the AZ-408 injector dataclasses.
AZ-406 commits to the type signatures + the NotImplementedError pointer.
AZ-408 will replace each NotImplementedError with a real generator; these
tests will then be updated alongside the implementation.
AZ-406 commits to module locations; AZ-408 owns the concrete dataclass
shapes. These tests assert the API surface (frozen dataclasses, public
``build()`` functions returning typed reports). Behavioural tests live
in their own files (``test_outlier.py``, ``test_blackout_spoof.py``,
``test_multi_segment.py``, ``test_fc_proxy.py``).
"""
from __future__ import annotations
@@ -11,52 +13,129 @@ from pathlib import Path
import pytest
from fixtures.injectors.blackout_spoof import BlackoutSpoofPlan
from fixtures.injectors.blackout_spoof import build as build_blackout_spoof
from fixtures.injectors.blackout_spoof import BlackoutSpoofPlan, BlackoutSpoofReport
from fixtures.injectors.cold_boot import ColdBootFixture
from fixtures.injectors.cold_boot import load as load_cold_boot
from fixtures.injectors.multi_segment import MultiSegmentPlan
from fixtures.injectors.multi_segment import build as build_multi_segment
from fixtures.injectors.outlier import OutlierInjectionPlan
from fixtures.injectors.outlier import build as build_outlier
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy, SpoofGpsRecord
from fixtures.injectors.multi_segment import MultiSegmentPlan, MultiSegmentReport
from fixtures.injectors.outlier import OutlierInjectionPlan, OutlierInjectionReport
def test_outlier_plan_dataclass_is_frozen() -> None:
plan = OutlierInjectionPlan(target_segment_seconds=(0.0, 5.0))
# Arrange
plan = OutlierInjectionPlan(
source_frames_dir=Path("/tmp/frames"),
tile_cache_dir=Path("/tmp/tile-cache"),
density="medium",
)
# Act / Assert
with pytest.raises(AttributeError):
plan.max_offset_m = 999.0 # type: ignore[misc]
assert plan.max_offset_m == 350.0
plan.density = "heavy" # type: ignore[misc]
assert plan.min_offset_m == 350.0
def test_outlier_build_raises_until_az408_lands() -> None:
with pytest.raises(NotImplementedError, match="AZ-408"):
build_outlier(OutlierInjectionPlan(target_segment_seconds=(0.0, 5.0)), Path("/tmp"))
def test_outlier_plan_density_literal_round_trip() -> None:
# Arrange / Act
for density in ("light", "medium", "heavy"):
plan = OutlierInjectionPlan(
source_frames_dir=Path("/tmp"),
tile_cache_dir=Path("/tmp"),
density=density, # type: ignore[arg-type]
)
# Assert
assert plan.density == density
def test_outlier_report_is_frozen_dataclass() -> None:
# Arrange
report = OutlierInjectionReport(
out_root=Path("/tmp/out"),
total_source_frames=100,
replaced_frame_count=10,
density="medium",
min_geodesic_offset_m=400.0,
max_geodesic_offset_m=900.0,
)
# Act / Assert
with pytest.raises(AttributeError):
report.replaced_frame_count = 20 # type: ignore[misc]
def test_blackout_spoof_plan_round_trip() -> None:
plan = BlackoutSpoofPlan(blackout_seconds=35.0, spoof_offset_m=120.0, spoof_bearing_deg=90.0)
# Arrange / Act
plan = BlackoutSpoofPlan(
source_frames_dir=Path("/tmp/frames"),
blackout_seconds=35.0,
spoof_offset_m=120.0,
spoof_bearing_deg=90.0,
)
# Assert
assert plan.blackout_seconds == 35.0
with pytest.raises(NotImplementedError, match="AZ-408"):
build_blackout_spoof(plan, Path("/tmp"))
assert plan.max_alignment_err_ms == 40.0 # default per AC-3
def test_blackout_spoof_report_is_frozen_dataclass() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=1000, spoof_gps=[])
# Assert that the report type is constructible (smoke check)
assert proxy.activation_report is None
def test_multi_segment_plan_defaults() -> None:
plan = MultiSegmentPlan()
# Arrange / Act
plan = MultiSegmentPlan(source_frames_dir=Path("/tmp/frames"))
# Assert
assert plan.n_segments == 3
with pytest.raises(NotImplementedError, match="AZ-408"):
build_multi_segment(plan, Path("/tmp"))
assert plan.segment_seconds == 12.0
def test_multi_segment_report_is_frozen_dataclass() -> None:
# Arrange
report = MultiSegmentReport(
out_root=Path("/tmp/out"),
segments=[],
source_duration_ms=300_000,
total_blackout_frames=300,
total_blackout_fraction=0.10,
)
# Act / Assert
with pytest.raises(AttributeError):
report.source_duration_ms = 0 # type: ignore[misc]
def test_spoof_gps_record_is_frozen_dataclass() -> None:
# Arrange
rec = SpoofGpsRecord(
monotonic_ms=1000,
lat_deg=50.1,
lon_deg=36.2,
alt_m=300.0,
fix_type=3,
hdop=1.0,
)
# Act / Assert
with pytest.raises(AttributeError):
rec.lat_deg = 0.0 # type: ignore[misc]
# Cold-boot tests are unchanged from AZ-406 — the cold-boot loader is
# still owned by AZ-419, not AZ-408.
def test_cold_boot_fixture_dataclass_is_frozen() -> None:
# Arrange
fx = ColdBootFixture(
lat_deg=50.0, lon_deg=30.0, alt_m=300.0, yaw_deg=180.0, last_valid_fix_age_s=2.5
)
# Act / Assert
with pytest.raises(AttributeError):
fx.alt_m = 999.0 # type: ignore[misc]
def test_cold_boot_load_raises_until_az419_lands(tmp_path: Path) -> None:
# Arrange
fixture_path = tmp_path / "cold_boot_fixture.json"
fixture_path.write_text("{}", encoding="utf-8")
# Act / Assert
with pytest.raises(NotImplementedError, match="AZ-419"):
load_cold_boot(fixture_path)
@@ -0,0 +1,172 @@
"""Behavioural tests for the AZ-408 multi_segment injector.
Covers AC-5 (≥3 disjoint windows, ≥30 s gaps, ≤25 % total coverage) and
AC-6 (tmpfs scratch isolation).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fixtures.injectors import multi_segment
def _build_synthetic_frames_dir(parent: Path, count: int) -> Path:
from PIL import Image # noqa: PLC0415
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
img = Image.new("RGB", (256, 256), color=(60, 60, 60))
for i in range(count):
img.save(
frames_dir / f"AD{i + 1:06d}.jpg",
format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2,
)
return frames_dir
def test_produces_three_disjoint_segments(tmp_path: Path) -> None:
"""AC-5: 3 disjoint blackout windows."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000) # 5 min @ 30 fps
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=15.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
assert len(report.segments) == 3
# Each segment is non-empty
for s in report.segments:
assert s.end_ms > s.start_ms
# Disjoint
for prev, nxt in zip(report.segments, report.segments[1:]):
assert prev.end_ms < nxt.start_ms
def test_segments_are_at_least_30_seconds_apart(tmp_path: Path) -> None:
"""AC-5: consecutive segments separated by ≥30 s of normal frames."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=12.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
for prev, nxt in zip(report.segments, report.segments[1:]):
gap_ms = nxt.start_ms - prev.end_ms
assert gap_ms >= 30_000, f"gap {gap_ms} ms < 30 s between segments"
def test_total_blackout_below_25_percent(tmp_path: Path) -> None:
"""AC-5: total blackout coverage ≤ 25 %."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=15.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
assert report.total_blackout_fraction <= 0.25
def test_rejects_overlapping_gap(tmp_path: Path) -> None:
"""Infeasible plan: too many segments inside too short a source."""
# Arrange — 30 s source can't fit 3×12 s segments with 30 s gaps
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=12.0
)
# Act / Assert
with pytest.raises(ValueError, match="gap between segment|blackout fraction"):
multi_segment.build(plan, tmp_path / "out")
def test_rejects_too_few_segments(tmp_path: Path) -> None:
"""AC-5: n_segments must be ≥3."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=2, segment_seconds=5.0
)
# Act / Assert
with pytest.raises(ValueError, match="n_segments must be ≥3"):
multi_segment.build(plan, tmp_path / "out")
def test_rejects_zero_segment_seconds(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=0.0
)
# Act / Assert
with pytest.raises(ValueError, match="segment_seconds"):
multi_segment.build(plan, tmp_path / "out")
def test_blackout_frames_are_black(tmp_path: Path) -> None:
"""Frames inside any segment are all-zero (black) on disk."""
# Arrange
from PIL import Image # noqa: PLC0415
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
out_root = tmp_path / "out"
report = multi_segment.build(plan, out_root)
# Act
for seg in report.segments[:1]: # spot-check first segment
for idx in range(seg.first_frame_idx, min(seg.first_frame_idx + 5, seg.last_frame_idx)):
name = f"AD{idx + 1:06d}.jpg"
img = Image.open(out_root / "frames" / name).convert("RGB")
r, g, b = img.getpixel((128, 128)) # type: ignore[misc]
# Assert
assert r < 5 and g < 5 and b < 5
def test_summary_json_present_with_expected_fields(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
# Act
multi_segment.build(plan, tmp_path / "out")
payload = json.loads((tmp_path / "out" / "summary.json").read_text())
# Assert
assert payload["scenario"] == "multi-segment-derkachi"
assert payload["n_segments"] == 3
assert payload["total_blackout_fraction"] <= 0.25
def test_overwrites_existing_out_root(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
out_root = tmp_path / "out"
multi_segment.build(plan, out_root)
(out_root / "stale.txt").write_text("stale")
# Act
multi_segment.build(plan, out_root)
# Assert
assert not (out_root / "stale.txt").exists()
+404
View File
@@ -0,0 +1,404 @@
"""Behavioural tests for the AZ-408 outlier injector.
Covers AC-1 (seed determinism), AC-2 (geodesic offset enforcement), and
AC-6 (tmpfs scratch isolation). Density-flag mapping is tested directly
against the ``_DENSITY_RATIO`` table.
"""
from __future__ import annotations
import csv
import io
import json
import math
from pathlib import Path
import pytest
from fixtures.injectors import outlier
from fixtures.injectors._common import (
derive_rng,
far_away_indices,
haversine_m,
iter_video_frame_indices,
read_tile_manifest,
)
# ---------------------------------------------------------------------------
# Fixture-builder helpers (synthetic tile cache + frames)
# ---------------------------------------------------------------------------
def _write_synthetic_frame(path: Path, color: tuple[int, int, int] = (40, 40, 40)) -> None:
from PIL import Image # noqa: PLC0415
img = Image.new("RGB", (256, 256), color=color)
img.save(path, format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2)
def _build_synthetic_frames_dir(parent: Path, count: int = 100) -> Path:
"""Make a fake AD*.jpg directory under ``parent/frames``."""
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
for i in range(count):
_write_synthetic_frame(frames_dir / f"AD{i + 1:06d}.jpg")
return frames_dir
def _build_synthetic_tile_cache(parent: Path, n_tiles: int = 16) -> Path:
"""Make a fake tile-cache tree under ``parent/tile-cache``.
The fake cache covers the same Derkachi bbox the real builder uses,
but with a smaller grid so the unit test stays fast. Tiles are
placed at zoom 18 with deterministic (tx, ty) offsets — the
far-away-tile check uses geodesic distance computed from the
(tx, ty) so any spread > 350 m at zoom 18 satisfies AC-2.
"""
cache_dir = parent / "tile-cache"
tiles_dir = cache_dir / "tiles" / "18"
tiles_dir.mkdir(parents=True, exist_ok=True)
rows = []
# Zoom-18 grid spread of ~10 tiles each axis covers ~1.5 km at the
# Derkachi latitude — easily > 350 m offset between corners.
base_tx = 1 << 17
base_ty = 1 << 17
for i in range(n_tiles):
tx = base_tx + (i % 4) * 4
ty = base_ty + (i // 4) * 4
tile_subdir = tiles_dir / str(tx)
tile_subdir.mkdir(parents=True, exist_ok=True)
_write_synthetic_frame(tile_subdir / f"{ty}.jpg", color=(i * 5, 90, 200 - i * 5))
rows.append(
{
"zoom_level": 18,
"tile_x": tx,
"tile_y": ty,
"capture_date": "2025-11-01",
"source": "stub",
"m_per_px": 0.5,
"jpeg_path": f"tiles/18/{tx}/{ty}.jpg",
"content_hash": "deadbeef",
"provenance": f"paired_gmaps:AD{i + 1:06d}" if i < 16 else "STUB",
}
)
manifest = cache_dir / "manifest.csv"
with manifest.open("w", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=list(rows[0].keys()), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
return cache_dir
# ---------------------------------------------------------------------------
# AC-1: density-flag determinism
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"density, expected_stride",
[("light", 100), ("medium", 10), ("heavy", 3)],
)
def test_density_ratio_maps_to_correct_stride(density: outlier.Density, expected_stride: int) -> None:
# Arrange
total = 1000
# Act
indices = list(iter_video_frame_indices(total, outlier._DENSITY_RATIO[density]))
# Assert
assert indices[0] == 0
# Stride should match the documented ratio
assert indices[1] - indices[0] == expected_stride
expected_count = (total + expected_stride - 1) // expected_stride
assert len(indices) == expected_count
def test_build_is_seed_deterministic(tmp_path: Path) -> None:
"""AC-1: same seed → identical manifest + identical replaced bytes."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=80)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=cache,
density="medium",
seed=42,
)
# Act
out_a = tmp_path / "run_a"
out_b = tmp_path / "run_b"
outlier.build(plan, out_a)
outlier.build(plan, out_b)
# Assert — manifest bit-identical
manifest_a = (out_a / "manifest.csv").read_bytes()
manifest_b = (out_b / "manifest.csv").read_bytes()
assert manifest_a == manifest_b
# Replaced frames bit-identical
rows = list(csv.DictReader(io.StringIO((out_a / "manifest.csv").read_text())))
assert rows, "manifest should have at least one replaced frame"
for row in rows:
name = row["src_jpeg_path"]
assert (out_a / "frames" / name).read_bytes() == (out_b / "frames" / name).read_bytes(), (
f"replaced frame {name} differs across runs"
)
def test_different_seeds_produce_different_replacements(tmp_path: Path) -> None:
"""Sanity: different seeds → different replacement-tile picks."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=40)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan_a = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium", seed=1
)
plan_b = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium", seed=2
)
# Act
out_a = tmp_path / "seed_a"
out_b = tmp_path / "seed_b"
outlier.build(plan_a, out_a)
outlier.build(plan_b, out_b)
# Assert — replacement-tile picks differ
rows_a = list(csv.DictReader(io.StringIO((out_a / "manifest.csv").read_text())))
rows_b = list(csv.DictReader(io.StringIO((out_b / "manifest.csv").read_text())))
assert rows_a and rows_b
pick_a = [(r["replacement_tile_x"], r["replacement_tile_y"]) for r in rows_a]
pick_b = [(r["replacement_tile_x"], r["replacement_tile_y"]) for r in rows_b]
assert pick_a != pick_b, "different seeds should produce different replacement picks"
# ---------------------------------------------------------------------------
# AC-2: every replacement crop is ≥350 m from the original frame
# ---------------------------------------------------------------------------
def test_every_replacement_exceeds_min_offset(tmp_path: Path) -> None:
"""AC-2: ≥99 % of crops are > 350 m from original; with synth cache, 100 %."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=60)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=cache,
density="medium",
seed=7,
min_offset_m=350.0,
)
# Act
report = outlier.build(plan, tmp_path / "out")
# Assert
rows = list(csv.DictReader(io.StringIO((tmp_path / "out" / "manifest.csv").read_text())))
assert rows, "should have replaced at least one frame"
offsets = [float(r["geodesic_offset_m"]) for r in rows]
assert all(o >= 350.0 for o in offsets), f"min offset {min(offsets)} < 350 m"
assert report.min_geodesic_offset_m >= 350.0
def test_far_away_indices_filters_by_distance() -> None:
"""Unit test the helper directly."""
# Arrange
from fixtures.injectors._common import TileGtRow
rows = [
TileGtRow(18, 0, 0, "", "", 0.5, "", "", "", 50.0, 30.0),
TileGtRow(18, 1, 0, "", "", 0.5, "", "", "", 50.001, 30.001), # ~140 m away
TileGtRow(18, 2, 0, "", "", 0.5, "", "", "", 50.02, 30.02), # ~2.8 km away
]
# Act
far = far_away_indices(rows, src_idx=0, min_offset_m=350.0)
# Assert
assert far == [2]
# ---------------------------------------------------------------------------
# AC-6: tmpfs scratch isolation + manifest schema
# ---------------------------------------------------------------------------
def test_build_writes_only_under_out_root(tmp_path: Path) -> None:
"""AC-6: nothing escapes the requested out_root."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=30)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="heavy"
)
out_root = tmp_path / "out"
# Act
outlier.build(plan, out_root)
# Assert — only expected files present, nothing outside out_root
expected = {
"frames",
"manifest.csv",
"summary.json",
}
actual = {p.name for p in out_root.iterdir()}
assert actual == expected
def test_build_overwrites_existing_out_root(tmp_path: Path) -> None:
"""Re-running build wipes the previous run cleanly (no stale files)."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=20)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium"
)
out_root = tmp_path / "out"
outlier.build(plan, out_root)
# Plant a stale file the next build should remove.
(out_root / "stale.txt").write_text("stale")
# Act
outlier.build(plan, out_root)
# Assert
assert not (out_root / "stale.txt").exists()
def test_summary_json_matches_report(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=50)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="light", seed=3
)
out_root = tmp_path / "out"
# Act
report = outlier.build(plan, out_root)
payload = json.loads((out_root / "summary.json").read_text())
# Assert
assert payload["scenario"] == "outlier-injection-derkachi"
assert payload["total_source_frames"] == report.total_source_frames
assert payload["replaced_frame_count"] == report.replaced_frame_count
assert payload["density"] == "light"
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
def test_missing_source_frames_raises(tmp_path: Path) -> None:
# Arrange
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=tmp_path / "does-not-exist",
tile_cache_dir=cache,
density="medium",
)
# Act / Assert
with pytest.raises(FileNotFoundError, match="source frames"):
outlier.build(plan, tmp_path / "out")
def test_missing_tile_manifest_raises(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=10)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=tmp_path / "no-cache",
density="medium",
)
# Act / Assert
with pytest.raises(FileNotFoundError, match="tile-cache manifest"):
outlier.build(plan, tmp_path / "out")
def test_read_tile_manifest_round_trips(tmp_path: Path) -> None:
# Arrange
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=8)
# Act
rows = read_tile_manifest(cache / "manifest.csv")
# Assert
assert len(rows) == 8
assert all(-90 <= r.centre_lat_deg <= 90 for r in rows)
assert all(-180 <= r.centre_lon_deg <= 180 for r in rows)
def test_derive_rng_is_stable_across_calls() -> None:
# Arrange / Act
r1 = derive_rng("outlier", 42, "medium").integers(0, 1_000_000_000)
r2 = derive_rng("outlier", 42, "medium").integers(0, 1_000_000_000)
# Assert
assert r1 == r2
def test_derive_rng_differs_across_domains() -> None:
# Arrange / Act
out = derive_rng("outlier", 42).integers(0, 1_000_000_000)
bsp = derive_rng("blackout_spoof", 42).integers(0, 1_000_000_000)
# Assert
assert out != bsp, "different domains must produce independent streams"
def test_haversine_known_distance() -> None:
"""Sanity-check the haversine helper against a known fixture."""
# Arrange
# ~1 deg of latitude ≈ 111 km
# Act
d = haversine_m(50.0, 30.0, 51.0, 30.0)
# Assert
assert 111_000 < d < 112_000
def test_iter_video_frame_indices_rejects_bad_ratio() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError):
list(iter_video_frame_indices(100, 0.0))
with pytest.raises(ValueError):
list(iter_video_frame_indices(100, 1.5))
def test_cleanup_tmpfs_removes_scratch(tmp_path: Path) -> None:
"""AC-6: ``cleanup_tmpfs`` rm-trees the scratch dir; called from fixture teardown."""
# Arrange
from fixtures.injectors._common import cleanup_tmpfs
scratch = tmp_path / "scratch"
(scratch / "deep" / "nested").mkdir(parents=True)
(scratch / "deep" / "nested" / "file.txt").write_text("x")
# Act
cleanup_tmpfs(scratch)
# Assert
assert not scratch.exists()
def test_cleanup_tmpfs_is_silent_for_missing_path(tmp_path: Path) -> None:
"""``cleanup_tmpfs`` must not raise for a non-existent path (idempotent)."""
# Arrange
from fixtures.injectors._common import cleanup_tmpfs
# Act / Assert
cleanup_tmpfs(tmp_path / "never-existed")
def test_replacement_density_meets_target(tmp_path: Path) -> None:
"""Sanity: heavy density replaces ≈ 1/3 of frames."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="heavy"
)
# Act
report = outlier.build(plan, tmp_path / "out")
# Assert
actual_ratio = report.replaced_frame_count / report.total_source_frames
assert 0.30 < actual_ratio < 0.40, f"heavy density gave {actual_ratio} (want ≈ 0.33)"
@@ -0,0 +1,312 @@
"""Unit tests for the AZ-410 anchor-pair detector (FT-P-02 logic).
Validates AC-1 (anchor-pair detection), AC-2 (visual-only drift bound),
AC-3 (IMU-fused drift bound), and AC-4 (monotonic distribution) using
synthetic FdrEstimate streams. The full-replay scenario test
(``test_ft_p_02_derkachi_drift.py``) imports this helper but is skipped
until the docker harness helpers land — these tests are the AC coverage
for the logic itself.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from runner.helpers.anchor_pair_detector import (
AnchorPair,
DEFAULT_AGE_BIN_EDGES_MS,
FdrEstimate,
aggregate,
bin_drifts,
check_monotonic,
compute_pass_fraction,
detect_anchor_pairs,
write_csv_evidence,
)
# ---------------------------------------------------------------------------
# Stream builders
# ---------------------------------------------------------------------------
def _est(
t_ms: int,
lat: float,
lon: float,
label: str,
imu_fused: bool = False,
age_ms: int = 0,
) -> FdrEstimate:
return FdrEstimate(
monotonic_ms=t_ms,
lat_deg=lat,
lon_deg=lon,
source_label=label, # type: ignore[arg-type]
imu_fused=imu_fused,
last_satellite_anchor_age_ms=age_ms,
)
# Derkachi-ish base coords.
_BASE_LAT = 50.075
_BASE_LON = 36.150
# ---------------------------------------------------------------------------
# AC-1: anchor-pair detection
# ---------------------------------------------------------------------------
def test_first_anchor_is_not_a_pair() -> None:
# Arrange — a stream that starts with an anchor must not produce a pair
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=0),
_est(100, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert pairs == [] # zero segments precede each anchor
def test_simple_visual_only_pair() -> None:
# Arrange — a→visual→visual→a, the second `a` makes one pair.
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT + 0.0002, _BASE_LON, "visual_propagated"),
_est(300, _BASE_LAT - 0.0001, _BASE_LON, "satellite_anchored", age_ms=300),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 1
p = pairs[0]
assert p.propagated_centre_ms == 200
assert p.anchor_ms == 300
assert p.last_satellite_anchor_age_ms == 300
assert not p.imu_fused_segment
assert p.drift_m > 0
def test_imu_fused_segment_classifies_pair() -> None:
# Arrange — any frame with imu_fused=True in the segment marks the pair
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated", imu_fused=True),
_est(200, _BASE_LAT + 0.0002, _BASE_LON, "visual_propagated"),
_est(300, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=300),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert pairs[0].imu_fused_segment is True
def test_dead_reckoned_in_segment_still_pair() -> None:
# Arrange
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "dead_reckoned"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 1
def test_multiple_pairs_in_one_flight() -> None:
# Arrange — 3 anchors → 2 pairs
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(50, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(100, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
_est(150, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 2
# ---------------------------------------------------------------------------
# Drift computation
# ---------------------------------------------------------------------------
def test_drift_is_geodesic_meters() -> None:
"""Drift uses pyproj/WGS84 Vincenty — ~1 deg of lat ≈ 111 km."""
# Arrange — propagate to lat+1 deg, anchor at base; expect ~111 km drift
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 1.0, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert — bracket the expected geodesic distance
assert 110_000 < pairs[0].drift_m < 112_000
# ---------------------------------------------------------------------------
# AC-2 / AC-3: pass-fraction
# ---------------------------------------------------------------------------
def test_pass_fraction_empty_returns_zero() -> None:
# Arrange / Act / Assert
assert compute_pass_fraction([], 100.0) == 0.0
def test_pass_fraction_all_pass() -> None:
# Arrange — 10 pairs all at 10 m drift, bound 100 m
pairs = [_make_pair(drift_m=10.0) for _ in range(10)]
# Act
f = compute_pass_fraction(pairs, drift_bound_m=100.0)
# Assert
assert f == 1.0
def test_pass_fraction_partial() -> None:
# Arrange — 8 of 10 under 100 m
pairs = [_make_pair(drift_m=10.0) for _ in range(8)] + [
_make_pair(drift_m=200.0) for _ in range(2)
]
# Act
f = compute_pass_fraction(pairs, drift_bound_m=100.0)
# Assert
assert f == 0.8
# ---------------------------------------------------------------------------
# AC-4: bin medians + monotonicity
# ---------------------------------------------------------------------------
def test_bin_drifts_default_edges() -> None:
# Arrange — synthetic drifts at known ages
pairs = [
_make_pair(drift_m=10.0, age_ms=500), # <1s bin
_make_pair(drift_m=20.0, age_ms=2_000), # 1-3s bin
_make_pair(drift_m=50.0, age_ms=5_000), # 3-10s bin
_make_pair(drift_m=100.0, age_ms=20_000), # 10-30s bin
_make_pair(drift_m=200.0, age_ms=60_000), # >30s bin
]
# Act
bins = bin_drifts(pairs)
# Assert — every bin has exactly one entry, in monotonic order
counts = [b.count for b in bins]
assert counts == [1, 1, 1, 1, 1]
medians = [b.median_m for b in bins]
assert medians == sorted(medians)
def test_check_monotonic_passes_for_increasing_medians() -> None:
# Arrange
pairs = [
_make_pair(drift_m=10.0, age_ms=500),
_make_pair(drift_m=15.0, age_ms=2_000),
_make_pair(drift_m=20.0, age_ms=5_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert violations == []
def test_check_monotonic_flags_regression() -> None:
# Arrange — drifts decrease with age (impossible IRL → violation)
pairs = [
_make_pair(drift_m=20.0, age_ms=500),
_make_pair(drift_m=10.0, age_ms=2_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert any("non-monotonic" in v for v in violations)
def test_check_monotonic_flags_2x_jump() -> None:
# Arrange — 100 m → 250 m is > 2x
pairs = [
_make_pair(drift_m=100.0, age_ms=500),
_make_pair(drift_m=250.0, age_ms=2_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert any(">2x" in v for v in violations)
# ---------------------------------------------------------------------------
# aggregate() integration
# ---------------------------------------------------------------------------
def test_aggregate_round_trip() -> None:
# Arrange — mix of visual-only and IMU-fused pairs
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
_est(300, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated", imu_fused=True),
_est(400, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
report = aggregate(stream)
# Assert
assert len(report.pairs) == 2
assert len(report.visual_only_pairs) == 1
assert len(report.imu_fused_pairs) == 1
# ---------------------------------------------------------------------------
# CSV evidence
# ---------------------------------------------------------------------------
def test_write_csv_evidence_round_trip(tmp_path: Path) -> None:
# Arrange
pairs = [_make_pair(drift_m=10.0, age_ms=500)]
report = aggregate(
[
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
)
csv_path = tmp_path / "ft-p-02.csv"
# Act
write_csv_evidence(report, csv_path)
text = csv_path.read_text()
# Assert
assert "drift_m" in text.splitlines()[0]
assert len(text.splitlines()) == 1 + len(report.pairs)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _make_pair(drift_m: float = 0.0, age_ms: int = 0, imu_fused: bool = False) -> AnchorPair:
return AnchorPair(
segment_first_ms=0,
propagated_centre_ms=100,
anchor_ms=200,
propagated_lat_deg=_BASE_LAT,
propagated_lon_deg=_BASE_LON,
anchor_lat_deg=_BASE_LAT,
anchor_lon_deg=_BASE_LON,
drift_m=drift_m,
last_satellite_anchor_age_ms=age_ms,
imu_fused_segment=imu_fused,
)
@@ -0,0 +1,196 @@
"""Unit tests for the AZ-411 estimate-schema validators (FT-P-03, FT-P-14).
Validates AC-1 (schema completeness), AC-2 (source-label set containment),
AC-3 (WGS84 range), and the int32 1e-7 decoder. The full single-image
push scenario in ``test_ft_p_03_14_schema_wgs84.py`` is skipped until
the upstream replay/SITL helpers land — these tests are the AC coverage
for the logic itself.
"""
from __future__ import annotations
import math
import pytest
from runner.helpers.estimate_schema import (
ALLOWED_SOURCE_LABELS,
LAT_LON_SCALE,
REQUIRED_FIELDS,
aggregate_validations,
decode_lat_lon_int32,
validate_estimate_schema,
validate_source_label,
validate_wgs84_range,
)
# ---------------------------------------------------------------------------
# AC-1: schema completeness
# ---------------------------------------------------------------------------
def _valid_record(**overrides: object) -> dict:
"""A baseline record that satisfies all four REQUIRED_FIELDS."""
return {
"lat": 50.075,
"lon": 36.150,
"cov_semi_major_m": 4.5,
"last_satellite_anchor_age_ms": 1234,
**overrides,
}
def test_valid_record_passes_schema() -> None:
# Arrange / Act
result = validate_estimate_schema(_valid_record())
# Assert
assert result.ok is True
assert result.missing_fields == []
assert result.wrong_typed_fields == []
def test_missing_field_caught() -> None:
# Arrange
rec = _valid_record()
del rec["cov_semi_major_m"]
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "cov_semi_major_m" in result.missing_fields
def test_int_typed_field_rejected_when_wrong_type() -> None:
# Arrange — last_satellite_anchor_age_ms is supposed to be int, not float
rec = _valid_record(last_satellite_anchor_age_ms=1.5)
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
def test_bool_does_not_silently_satisfy_int() -> None:
"""Python ``isinstance(True, int)`` is True; we must reject it explicitly."""
# Arrange
rec = _valid_record(last_satellite_anchor_age_ms=True)
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
def test_required_fields_table_is_what_the_spec_says() -> None:
"""Guard against accidental drift between the helper and the AZ-411 spec."""
# Arrange
names = [n for n, _ in REQUIRED_FIELDS]
# Assert
assert names == ["lat", "lon", "cov_semi_major_m", "last_satellite_anchor_age_ms"]
# ---------------------------------------------------------------------------
# AC-2: source-label set containment
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("label", sorted(ALLOWED_SOURCE_LABELS))
def test_each_allowed_label_passes(label: str) -> None:
# Arrange / Act
result = validate_source_label(label)
# Assert
assert result.ok
assert result.observed == label
def test_unknown_label_rejected() -> None:
# Arrange / Act
result = validate_source_label("imu_only")
# Assert
assert not result.ok
assert "not in" in (result.reason or "")
def test_non_string_label_rejected() -> None:
# Arrange / Act
result = validate_source_label(42)
# Assert
assert not result.ok
assert "expected str" in (result.reason or "")
# ---------------------------------------------------------------------------
# AC-3: WGS84 range + int32 decoding
# ---------------------------------------------------------------------------
def test_valid_wgs84_inside_range() -> None:
# Arrange / Act
result = validate_wgs84_range(50.075, 36.150)
# Assert
assert result.ok
def test_lat_above_90_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(91.0, 0.0).ok
def test_lon_below_minus_180_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(0.0, -181.0).ok
def test_nan_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(math.nan, 0.0).ok
def test_decode_lat_lon_int32_round_trip() -> None:
# Arrange — encode Derkachi-ish coords as int32 1e-7 then decode
lat_e7 = 500_750_000
lon_e7 = 361_500_000
# Act
lat, lon = decode_lat_lon_int32(lat_e7, lon_e7)
# Assert
assert abs(lat - 50.075) < 1e-6
assert abs(lon - 36.150) < 1e-6
assert lat == lat_e7 * LAT_LON_SCALE
def test_decode_lat_lon_int32_rejects_out_of_int32_range() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError, match="lat_e7"):
decode_lat_lon_int32(2 ** 31, 0)
with pytest.raises(ValueError, match="lon_e7"):
decode_lat_lon_int32(0, -(2 ** 31) - 1)
# ---------------------------------------------------------------------------
# aggregate_validations
# ---------------------------------------------------------------------------
def test_aggregate_validations_all_ok() -> None:
# Arrange
records = [_valid_record(), _valid_record(lat=49.9, lon=36.0)]
# Act
schemas, wgs84s = aggregate_validations(records)
# Assert
assert all(s.ok for s in schemas)
assert all(w.ok for w in wgs84s)
def test_aggregate_validations_surfaces_bad_record() -> None:
# Arrange — one good, one missing lat
bad = _valid_record()
del bad["lat"]
records = [_valid_record(), bad]
# Act
schemas, wgs84s = aggregate_validations(records)
# Assert
assert schemas[0].ok
assert not schemas[1].ok
# When lat is missing, wgs84 validator emits a missing-field result too.
assert not wgs84s[1].ok
+7
View File
@@ -41,6 +41,8 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"runner/helpers/mavproxy_tlog_reader.py",
"runner/helpers/fdr_reader.py",
"runner/helpers/geo.py",
"runner/helpers/anchor_pair_detector.py",
"runner/helpers/estimate_schema.py",
"fixtures/mock-suite-sat/Dockerfile",
"fixtures/mock-suite-sat/app.py",
"fixtures/mock-suite-sat/requirements.txt",
@@ -55,6 +57,9 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"fixtures/injectors/blackout_spoof.py",
"fixtures/injectors/multi_segment.py",
"fixtures/injectors/cold_boot.py",
"fixtures/injectors/_common.py",
"fixtures/injectors/fc_proxy.py",
"runner/helpers/injector_fixtures.py",
"fixtures/cold-boot/README.md",
"fixtures/cold-boot/cold_boot_fixture.json",
"fixtures/secrets/mavlink-test-passkey.txt",
@@ -70,6 +75,8 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"tests/security/__init__.py",
"tests/resource_limit/__init__.py",
"tests/positive/test_smoke.py",
"tests/positive/test_ft_p_02_derkachi_drift.py",
"tests/positive/test_ft_p_03_14_schema_wgs84.py",
],
)
def test_required_path_exists(relative_path: str) -> None: