"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法. 通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分, 不实际执行 FFmpeg,确保测试轻量快速。 """ from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import pytest from video_processing.cover_generator import ( DEFAULT_COVER_HEIGHT, DEFAULT_COVER_QUALITY, DEFAULT_COVER_TIME, DEFAULT_COVER_WIDTH, SMART_COVER_FRAME_COUNT, CoverGenerator, ) class TestCoverGeneratorConstants: """常量默认值测试.""" def test_default_cover_time(self): """默认抽帧时间为 1.0 秒.""" assert DEFAULT_COVER_TIME == 1.0 def test_default_dimensions(self): """默认封面尺寸 1080x1920 (竖屏).""" assert DEFAULT_COVER_WIDTH == 1080 assert DEFAULT_COVER_HEIGHT == 1920 def test_default_quality(self): """默认质量为 5 (JPEG q:v, 越小越好).""" assert DEFAULT_COVER_QUALITY == 5 def test_smart_cover_frame_count(self): """智能封面默认抽 3 帧.""" assert SMART_COVER_FRAME_COUNT == 3 class TestExtractFrameCommand: """extract_frame 命令构建测试.""" def _probe_video_info_mock(self, duration=10.0): """创建 probe_video_info 的 mock.""" return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0} def test_default_params_command(self, tmp_path): """默认参数下 FFmpeg 命令正确.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): # 让 output_path 在 run_ffmpeg 后存在 def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run result = CoverGenerator.extract_frame(str(video_file), str(output_file)) assert result == Path(output_file) mock_run.assert_called_once() cmd = mock_run.call_args[0][0] # 基本结构 assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0] assert "-y" in cmd assert "-vframes" in cmd assert cmd[cmd.index("-vframes") + 1] == "1" assert "-f" in cmd assert "mjpeg" in cmd[cmd.index("-f") + 1] # 时间点 ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001) # 输入文件 i_idx = cmd.index("-i") assert cmd[i_idx + 1] == str(video_file) # 输出文件 assert cmd[-1] == str(output_file) # scale + crop 滤镜 vf_idx = cmd.index("-vf") vf_value = cmd[vf_idx + 1] assert "scale=" in vf_value assert "crop=" in vf_value assert "force_original_aspect_ratio=increase" in vf_value def test_custom_time(self, tmp_path): """自定义抽帧时间点.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(duration=30.0), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5) cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001) def test_custom_dimensions(self, tmp_path): """自定义输出尺寸.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080) cmd = mock_run.call_args[0][0] vf_idx = cmd.index("-vf") vf_value = cmd[vf_idx + 1] assert "scale=1920:1080:" in vf_value assert "crop=1920:1080" in vf_value def test_custom_quality(self, tmp_path): """自定义 JPEG 质量.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2) cmd = mock_run.call_args[0][0] q_idx = cmd.index("-q:v") assert cmd[q_idx + 1] == "2" def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path): """抽帧时间超过视频时长时,钳制到中间帧.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(duration=5.0), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0) cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") # 钳制到 duration/2 = 2.5 assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001) def test_negative_time_clamps_to_zero(self, tmp_path): """负时间钳制到 0.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(duration=10.0), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0) cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001) def test_time_equals_duration_clamps_to_midpoint(self, tmp_path): """时间点等于时长时钳制到中间帧.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(duration=10.0), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0) cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001) def test_zero_duration_video(self, tmp_path): """视频时长为 0 时的行为(不钳制,用原始时间).""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(duration=0.0), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5) cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001) def test_video_not_found_raises(self, tmp_path): """视频文件不存在时抛出 FileNotFoundError.""" output_file = tmp_path / "cover.jpg" with pytest.raises(FileNotFoundError): CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file)) def test_output_creates_parent_dir(self, tmp_path): """输出目录不存在时自动创建.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") out_dir = tmp_path / "deep" / "nested" output_file = out_dir / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(), ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_frame(str(video_file), str(output_file)) assert out_dir.exists() assert out_dir.is_dir() def test_ffmpeg_failure_propagates(self, tmp_path): """FFmpeg 失败时异常向上传递.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value=self._probe_video_info_mock(), ), patch( "video_processing.cover_generator.run_ffmpeg", side_effect=RuntimeError("FFmpeg error"), ), ): with pytest.raises(RuntimeError, match="FFmpeg error"): CoverGenerator.extract_frame(str(video_file), str(output_file)) class TestSmartCoverTimePoints: """智能封面时间点计算测试.""" def test_single_frame_falls_back_to_default(self, tmp_path): """只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值).""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value={"duration": 20.0}, ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run # frame_count=1 时退化为普通抽帧 CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1) # 只调用一次(退化路径) assert mock_run.call_count == 1 cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") # min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0 assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001) def test_zero_duration_falls_back(self, tmp_path): """视频时长为 0 时退化为普通抽帧.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with ( patch( "video_processing.cover_generator.probe_video_info", return_value={"duration": 0.0}, ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.extract_smart_cover(str(video_file), str(output_file)) # 只调用一次(退化路径) assert mock_run.call_count == 1 def test_three_frames_uniform_distribution(self, tmp_path): """3 帧均匀分布在 5%~95% 区间.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" call_times = [] with ( patch( "video_processing.cover_generator.probe_video_info", return_value={"duration": 100.0}, ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): # 记录抽帧时间 ss_idx = cmd.index("-ss") call_times.append(float(cmd[ss_idx + 1])) # 在输出路径写文件 output_arg = cmd[-1] Path(output_arg).parent.mkdir(parents=True, exist_ok=True) # 不同文件大小,让第三帧"最清晰" idx = len(call_times) - 1 size = 1000 * (idx + 1) # 递增的文件大小 Path(output_arg).write_bytes(b"x" * size) mock_run.side_effect = fake_run CoverGenerator.extract_smart_cover(str(video_file), str(output_file)) # 3 帧:5%、50%、95% assert len(call_times) == 3 assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5% assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50% assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95% def test_five_frames_distribution(self, tmp_path): """5 帧均匀分布.""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" call_times = [] with ( patch( "video_processing.cover_generator.probe_video_info", return_value={"duration": 100.0}, ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): def fake_run(cmd): ss_idx = cmd.index("-ss") call_times.append(float(cmd[ss_idx + 1])) output_arg = cmd[-1] Path(output_arg).parent.mkdir(parents=True, exist_ok=True) idx = len(call_times) - 1 Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1))) mock_run.side_effect = fake_run CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5) assert len(call_times) == 5 # step = (95-5) / (5-1) = 22.5 # times: 5, 27.5, 50, 72.5, 95 assert call_times[0] == pytest.approx(5.0, abs=0.1) assert call_times[1] == pytest.approx(27.5, abs=0.1) assert call_times[2] == pytest.approx(50.0, abs=0.1) assert call_times[3] == pytest.approx(72.5, abs=0.1) assert call_times[4] == pytest.approx(95.0, abs=0.1) def test_selects_largest_file_as_best(self, tmp_path): """选择文件最大的帧作为最佳封面(清晰度近似).""" video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" sizes = [5000, 15000, 8000] # 第二帧最大 with ( patch( "video_processing.cover_generator.probe_video_info", return_value={"duration": 100.0}, ), patch("video_processing.cover_generator.run_ffmpeg") as mock_run, ): call_idx = [0] def fake_run(cmd): output_arg = cmd[-1] Path(output_arg).parent.mkdir(parents=True, exist_ok=True) idx = call_idx[0] Path(output_arg).write_bytes(b"x" * sizes[idx]) call_idx[0] += 1 mock_run.side_effect = fake_run result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file)) # 第二帧(索引1)应该是最佳 assert result == output_file # 输出文件大小应等于第二帧大小 assert output_file.stat().st_size == 15000 class TestProcessCustomCover: """自定义封面处理测试.""" def test_custom_cover_resize_command(self, tmp_path): """自定义封面调整尺寸命令正确.""" input_file = tmp_path / "upload.jpg" input_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with patch("video_processing.cover_generator.run_ffmpeg") as mock_run: def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.process_custom_cover(str(input_file), str(output_file)) mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert "-i" in cmd assert cmd[cmd.index("-i") + 1] == str(input_file) assert cmd[-1] == str(output_file) # scale + crop vf_idx = cmd.index("-vf") vf_value = cmd[vf_idx + 1] assert "scale=" in vf_value assert "crop=" in vf_value def test_custom_cover_not_found_raises(self, tmp_path): """自定义封面文件不存在时抛出 FileNotFoundError.""" output_file = tmp_path / "cover.jpg" with pytest.raises(FileNotFoundError): CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file)) def test_custom_cover_custom_dimensions(self, tmp_path): """自定义封面自定义输出尺寸.""" input_file = tmp_path / "upload.jpg" input_file.write_bytes(b"fake") output_file = tmp_path / "cover.jpg" with patch("video_processing.cover_generator.run_ffmpeg") as mock_run: def fake_run(cmd): output_file.write_bytes(b"fake jpg") mock_run.side_effect = fake_run CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600) cmd = mock_run.call_args[0][0] vf_idx = cmd.index("-vf") vf_value = cmd[vf_idx + 1] assert "scale=800:600:" in vf_value assert "crop=800:600" in vf_value