mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:31:13 +00:00
[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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user