"""AZ-398 — :class:`FrameSource` Protocol conformance + concrete strategy ACs.""" from __future__ import annotations from pathlib import Path import cv2 import numpy as np import pytest from gps_denied_onboard.clock.wall_clock import WallClock from gps_denied_onboard.frame_source import ( FrameSource, FrameSourceConfigError, ) from gps_denied_onboard.frame_source.live_camera import LiveCameraFrameSource from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource # --------------------------------------------------------------------------- # Helpers. def _make_synthetic_video(path: Path, n_frames: int, fps: int = 30) -> None: """Write an ``n_frames``-frame 64×48 BGR MP4V at ``path``.""" fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(path), fourcc, fps, (64, 48)) if not writer.isOpened(): raise RuntimeError(f"OpenCV could not open writer at {path!s}") try: for i in range(n_frames): frame = np.full((48, 64, 3), i % 256, dtype=np.uint8) writer.write(frame) finally: writer.release() @pytest.fixture def video_path_60(tmp_path: Path) -> Path: """A synthetic 60-frame .mp4 file for AC-2.""" path = tmp_path / "az398_synthetic.mp4" _make_synthetic_video(path, n_frames=60) return path @pytest.fixture def enable_video_flag(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BUILD_VIDEO_FILE_FRAME_SOURCE", "ON") @pytest.fixture def disable_video_flag(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BUILD_VIDEO_FILE_FRAME_SOURCE", "OFF") @pytest.fixture def disable_live_flag(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BUILD_LIVE_CAMERA_FRAME_SOURCE", "OFF") # --------------------------------------------------------------------------- # AC-1 — Protocol conformance. def test_video_file_frame_source_satisfies_frame_source_protocol( enable_video_flag: None, video_path_60: Path ) -> None: # Arrange + Act source = VideoFileFrameSource( path=video_path_60, camera_calibration_id="az398-synth", clock=WallClock(), ) try: # Assert assert isinstance(source, FrameSource) finally: source.close() # --------------------------------------------------------------------------- # AC-2 — VideoFileFrameSource produces 60 ordered frames + idempotent EOS. def test_video_file_frame_source_emits_60_frames_then_none( enable_video_flag: None, video_path_60: Path ) -> None: # Arrange source = VideoFileFrameSource( path=video_path_60, camera_calibration_id="az398-synth", clock=WallClock(), ) monotonics: list[int] = [] try: # Act for _ in range(60): frame = source.next_frame() assert frame is not None monotonics.append(frame.metadata["monotonic_ns"]) # AC-2: 61st call → None; subsequent calls also None eos_first = source.next_frame() eos_second = source.next_frame() finally: source.close() # Assert assert eos_first is None assert eos_second is None assert len(monotonics) == 60 # Non-decreasing monotonic_ns ordering (Invariant 3 / AC-2) assert all(b >= a for a, b in zip(monotonics, monotonics[1:], strict=False)) def test_video_file_frame_source_emits_frame_id_counter_and_metadata( enable_video_flag: None, video_path_60: Path ) -> None: # Arrange source = VideoFileFrameSource( path=video_path_60, camera_calibration_id="az398-synth", clock=WallClock(), ) try: # Act first = source.next_frame() second = source.next_frame() finally: source.close() # Assert assert first is not None and second is not None assert first.frame_id == 0 assert second.frame_id == 1 assert first.camera_calibration_id == "az398-synth" assert first.metadata["source"] == "video_file" assert "monotonic_ns" in first.metadata assert "source_pts_ns" in first.metadata # --------------------------------------------------------------------------- # AC-7 — corrupt video file raises FrameSourceConfigError on construction. def test_video_file_frame_source_rejects_corrupt_file( enable_video_flag: None, tmp_path: Path ) -> None: # Arrange corrupt = tmp_path / "garbage.mp4" corrupt.write_bytes(b"not actually mp4 content" * 256) # Act + Assert with pytest.raises(FrameSourceConfigError): VideoFileFrameSource( path=corrupt, camera_calibration_id="az398-corrupt", clock=WallClock(), ) def test_video_file_frame_source_rejects_missing_path( enable_video_flag: None, tmp_path: Path ) -> None: # Act + Assert with pytest.raises(FrameSourceConfigError, match="does not exist"): VideoFileFrameSource( path=tmp_path / "missing.mp4", camera_calibration_id="az398-missing", clock=WallClock(), ) # --------------------------------------------------------------------------- # AC-8 — Build-flag gating. def test_video_file_frame_source_refuses_when_build_flag_off( disable_video_flag: None, tmp_path: Path ) -> None: # Arrange — create a real file so the gate is exercised before path checks valid = tmp_path / "any.mp4" valid.write_bytes(b"") # Act + Assert with pytest.raises( FrameSourceConfigError, match="BUILD_VIDEO_FILE_FRAME_SOURCE is OFF" ): VideoFileFrameSource( path=valid, camera_calibration_id="az398-gate", clock=WallClock(), ) def test_live_camera_frame_source_refuses_when_build_flag_off( disable_live_flag: None, ) -> None: # Act + Assert with pytest.raises( FrameSourceConfigError, match="BUILD_LIVE_CAMERA_FRAME_SOURCE is OFF" ): LiveCameraFrameSource( device_index=0, camera_calibration_id="az398-live-gate", clock=WallClock(), ) # --------------------------------------------------------------------------- # AC-9 — Public API re-exports. def test_frame_source_public_module_only_exposes_protocol_and_errors() -> None: # Arrange from gps_denied_onboard import frame_source as module # Assert — concrete strategies MUST NOT appear in __all__ per AC-9 assert "FrameSource" in module.__all__ assert "FrameSourceError" in module.__all__ assert "FrameSourceConfigError" in module.__all__ assert "LiveCameraFrameSource" not in module.__all__ assert "VideoFileFrameSource" not in module.__all__ # --------------------------------------------------------------------------- # AC-10 — close is idempotent. def test_video_file_frame_source_close_is_idempotent( enable_video_flag: None, video_path_60: Path ) -> None: # Arrange source = VideoFileFrameSource( path=video_path_60, camera_calibration_id="az398-synth", clock=WallClock(), ) # Act — closing twice must not raise (AC-10) source.close() source.close() # Assert — next_frame after close returns None, not an exception assert source.next_frame() is None # --------------------------------------------------------------------------- # Factory. def test_build_frame_source_video_file_returns_video_file_source( enable_video_flag: None, video_path_60: Path ) -> None: from gps_denied_onboard.runtime_root.frame_source_factory import ( build_frame_source, ) source = build_frame_source( kind="video_file", camera_calibration_id="az398-factory", clock=WallClock(), video_path=video_path_60, ) try: # Assert assert isinstance(source, VideoFileFrameSource) finally: source.close() def test_build_frame_source_rejects_unknown_kind() -> None: from gps_denied_onboard.runtime_root.frame_source_factory import ( build_frame_source, ) with pytest.raises(FrameSourceConfigError, match="unknown kind"): build_frame_source( kind="invalid", # type: ignore[arg-type] camera_calibration_id="x", clock=WallClock(), ) def test_build_frame_source_live_rejects_video_path(enable_video_flag: None) -> None: from gps_denied_onboard.runtime_root.frame_source_factory import ( build_frame_source, ) with pytest.raises(FrameSourceConfigError, match="video_path must be None"): build_frame_source( kind="live", camera_calibration_id="x", clock=WallClock(), device_index=0, video_path="/tmp/whatever.mp4", ) def test_build_frame_source_video_file_requires_video_path() -> None: from gps_denied_onboard.runtime_root.frame_source_factory import ( build_frame_source, ) with pytest.raises(FrameSourceConfigError, match="video_path is required"): build_frame_source( kind="video_file", camera_calibration_id="x", clock=WallClock(), )