"""Unit tests for content.extraction.clipper.""" from __future__ import annotations import asyncio from unittest.mock import AsyncMock, patch import pytest from content.extraction.clipper import ( ClipResult, _build_ffmpeg_cmd, _ffmpeg_available, extract_clip, extract_clips, ) # ── _ffmpeg_available ───────────────────────────────────────────────────────── class TestFfmpegAvailable: def test_returns_bool(self): result = _ffmpeg_available() assert isinstance(result, bool) def test_false_when_shutil_finds_nothing(self): with patch("content.extraction.clipper.shutil.which", return_value=None): assert _ffmpeg_available() is False def test_true_when_shutil_finds_ffmpeg(self): with patch("content.extraction.clipper.shutil.which", return_value="/usr/bin/ffmpeg"): assert _ffmpeg_available() is True # ── _build_ffmpeg_cmd ───────────────────────────────────────────────────────── class TestBuildFfmpegCmd: def test_returns_list(self): cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4") assert isinstance(cmd, list) def test_starts_with_ffmpeg(self): cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4") assert cmd[0] == "ffmpeg" def test_contains_source_path(self): cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4") assert "/src.mp4" in cmd def test_contains_output_path(self): cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4") assert "/out.mp4" in cmd def test_duration_is_end_minus_start(self): cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4") t_idx = cmd.index("-t") assert float(cmd[t_idx + 1]) == pytest.approx(20.0) def test_start_time_in_seek(self): cmd = _build_ffmpeg_cmd("/src.mp4", 5.5, 15.5, "/out.mp4") ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(5.5) def test_overwrite_flag_present(self): cmd = _build_ffmpeg_cmd("/src.mp4", 0, 10, "/out.mp4") assert "-y" in cmd # ── extract_clip ────────────────────────────────────────────────────────────── class TestExtractClip: @pytest.mark.asyncio async def test_returns_failure_when_ffmpeg_missing(self): with patch("content.extraction.clipper._ffmpeg_available", return_value=False): result = await extract_clip( {"highlight_id": "h1", "source_path": "/a.mp4", "start_time": 0, "end_time": 5} ) assert result.success is False assert "ffmpeg" in result.error.lower() @pytest.mark.asyncio async def test_returns_failure_when_source_missing(self, tmp_path): with patch("content.extraction.clipper._ffmpeg_available", return_value=True): result = await extract_clip( { "highlight_id": "h1", "source_path": str(tmp_path / "nonexistent.mp4"), "start_time": 0, "end_time": 5, } ) assert result.success is False assert "source_path" in result.error @pytest.mark.asyncio async def test_returns_failure_when_invalid_time_range(self, tmp_path): src = tmp_path / "src.mp4" src.write_bytes(b"fake") with patch("content.extraction.clipper._ffmpeg_available", return_value=True): result = await extract_clip( { "highlight_id": "h1", "source_path": str(src), "start_time": 30, "end_time": 10, # end < start } ) assert result.success is False assert "invalid time range" in result.error @pytest.mark.asyncio async def test_successful_extraction(self, tmp_path): src = tmp_path / "src.mp4" src.write_bytes(b"fake video") mock_proc = AsyncMock() mock_proc.returncode = 0 mock_proc.communicate = AsyncMock(return_value=(b"", b"")) with ( patch("content.extraction.clipper._ffmpeg_available", return_value=True), patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ), ): result = await extract_clip( { "highlight_id": "h1", "source_path": str(src), "start_time": 0, "end_time": 10, }, output_dir=str(tmp_path), ) assert result.success is True assert result.highlight_id == "h1" assert result.duration == pytest.approx(10.0) @pytest.mark.asyncio async def test_ffmpeg_nonzero_exit_returns_failure(self, tmp_path): src = tmp_path / "src.mp4" src.write_bytes(b"fake") mock_proc = AsyncMock() mock_proc.returncode = 1 mock_proc.communicate = AsyncMock(return_value=(b"", b"encoding error")) with ( patch("content.extraction.clipper._ffmpeg_available", return_value=True), patch("asyncio.create_subprocess_exec", return_value=mock_proc), ): result = await extract_clip( { "highlight_id": "h2", "source_path": str(src), "start_time": 0, "end_time": 5, }, output_dir=str(tmp_path), ) assert result.success is False assert result.error @pytest.mark.asyncio async def test_timeout_returns_failure(self, tmp_path): src = tmp_path / "src.mp4" src.write_bytes(b"fake") async def _slow_communicate(): await asyncio.sleep(1000) mock_proc = AsyncMock() mock_proc.returncode = None mock_proc.communicate = _slow_communicate with ( patch("content.extraction.clipper._ffmpeg_available", return_value=True), patch("asyncio.create_subprocess_exec", return_value=mock_proc), patch("asyncio.wait_for", side_effect=TimeoutError), ): result = await extract_clip( { "highlight_id": "h3", "source_path": str(src), "start_time": 0, "end_time": 5, }, output_dir=str(tmp_path), ) assert result.success is False assert "timed out" in result.error @pytest.mark.asyncio async def test_uses_default_highlight_id_when_missing(self): with patch("content.extraction.clipper._ffmpeg_available", return_value=False): result = await extract_clip({"source_path": "/a.mp4", "start_time": 0, "end_time": 5}) assert result.highlight_id == "unknown" # ── extract_clips ───────────────────────────────────────────────────────────── class TestExtractClips: @pytest.mark.asyncio async def test_returns_list_of_results(self): async def _fake_extract(h, output_dir=None): return ClipResult(highlight_id=h["highlight_id"], success=True, duration=5.0) with patch("content.extraction.clipper.extract_clip", side_effect=_fake_extract): results = await extract_clips( [ {"highlight_id": "a", "source_path": "", "start_time": 0, "end_time": 5}, {"highlight_id": "b", "source_path": "", "start_time": 5, "end_time": 10}, ] ) assert len(results) == 2 assert results[0].highlight_id == "a" assert results[1].highlight_id == "b" @pytest.mark.asyncio async def test_empty_list_returns_empty(self): results = await extract_clips([]) assert results == []