mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 23:01:13 +00:00
702a0c0ff3
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>
230 lines
7.9 KiB
Python
230 lines
7.9 KiB
Python
"""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()
|