""" tests/test_deep_scan.py — Unit tests for frame_extractor and deep_scan Uses synthetic in-memory videos (cv2.VideoWriter → temp file) so no real video files are required. Tests cover the pure logic, not hardware decoding. """ from __future__ import annotations import tempfile from pathlib import Path import cv2 import numpy as np import pytest from src.cv.frame_extractor import ( get_video_info, grab_frame_at, iter_frames_stepped, open_video, ) from src.cv.fingerprinting import text_safe_crop # --------------------------------------------------------------------------- # Helpers: build a tiny synthetic video on disk # --------------------------------------------------------------------------- FPS = 24 WIDTH = 320 HEIGHT = 240 SECS = 3 def _make_synthetic_video(path: Path, color_bgr: tuple[int, int, int] = (0, 128, 255)) -> Path: """Write a 3-second single-colour video to *path*.""" fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(path), fourcc, float(FPS), (WIDTH, HEIGHT)) frame = np.full((HEIGHT, WIDTH, 3), color_bgr, dtype=np.uint8) for _ in range(FPS * SECS): writer.write(frame) writer.release() return path @pytest.fixture def synthetic_video(tmp_path: Path) -> Path: return _make_synthetic_video(tmp_path / "test.mp4") # --------------------------------------------------------------------------- # open_video # --------------------------------------------------------------------------- class TestOpenVideo: def test_opens_valid_file(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: assert cap.isOpened() def test_raises_on_missing_file(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): with open_video(tmp_path / "ghost.mp4"): pass # --------------------------------------------------------------------------- # get_video_info # --------------------------------------------------------------------------- class TestGetVideoInfo: def test_returns_correct_fps(self, synthetic_video: Path) -> None: info = get_video_info(synthetic_video) assert info["fps"] == pytest.approx(FPS, rel=0.05) def test_duration_approx(self, synthetic_video: Path) -> None: info = get_video_info(synthetic_video) assert info["duration_s"] == pytest.approx(SECS, rel=0.1) def test_resolution(self, synthetic_video: Path) -> None: info = get_video_info(synthetic_video) assert info["width"] == WIDTH assert info["height"] == HEIGHT # --------------------------------------------------------------------------- # grab_frame_at # --------------------------------------------------------------------------- class TestGrabFrameAt: def test_returns_ndarray(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: frame = grab_frame_at(cap, 1.0) assert frame is not None assert isinstance(frame, np.ndarray) assert frame.shape == (HEIGHT, WIDTH, 3) def test_returns_none_past_end(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: frame = grab_frame_at(cap, 9999.0) # May return None or a repeated last frame depending on codec; # we only assert no exception is raised. assert frame is None or isinstance(frame, np.ndarray) # --------------------------------------------------------------------------- # iter_frames_stepped # --------------------------------------------------------------------------- class TestIterFramesStepped: def test_yields_correct_count(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: frames = list(iter_frames_stepped(cap, 0.0, 1.0, 0.5)) # Expect timestamps: 0.0, 0.5, 1.0 → 3 frames assert len(frames) == 3 def test_timestamps_increasing(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: frames = list(iter_frames_stepped(cap, 0.0, 2.0, 0.5)) timestamps = [t for t, _ in frames] assert timestamps == sorted(timestamps) def test_invalid_step_raises(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: with pytest.raises(ValueError, match="step_s"): list(iter_frames_stepped(cap, 0.0, 1.0, 0.0)) # --------------------------------------------------------------------------- # text_safe_crop integration (sanity: cropped height consistent) # --------------------------------------------------------------------------- class TestCropSanity: def test_crop_reduces_height(self, synthetic_video: Path) -> None: with open_video(synthetic_video) as cap: frame = grab_frame_at(cap, 0.5) assert frame is not None cropped = text_safe_crop(frame, 0.15, 0.30) assert cropped.shape[0] < frame.shape[0] assert cropped.shape[1] == frame.shape[1] # width unchanged