"""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()