"""Tests for unified cover frame extraction pipeline. 统一封面管道测试: - extract_first_frame: 从已渲染视频抽取封面帧 - 封面天然带标题(ASS 字幕已烧录到视频中) """ from __future__ import annotations import tempfile import unittest from pathlib import Path from unittest.mock import patch class TestExtractFirstFrame(unittest.TestCase): """extract_first_frame 单元测试.""" @patch("video_processing.ffmpeg_utils.run_ffmpeg") @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_extracts_frame_at_default_ratio(self, mock_probe, mock_run): """默认在视频 15% 处抽帧.""" from video_processing.thumbnail_generator import extract_first_frame # Mock run_ffmpeg 创建输出文件(ffmpeg 真实行为) mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake") with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") result = extract_first_frame(video.name) self.assertTrue(Path(result).exists()) # 验证 ffmpeg 被调用 mock_run.assert_called() cmd = mock_run.call_args[0][0] self.assertIn("-vframes", cmd) self.assertIn("1", cmd) Path(result).unlink(missing_ok=True) @patch("video_processing.ffmpeg_utils.run_ffmpeg") @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_custom_seek_ratio(self, mock_probe, mock_run): """自定义抽帧位置.""" from video_processing.thumbnail_generator import extract_first_frame mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake") with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") result = extract_first_frame(video.name, seek_ratio=0.5) self.assertTrue(Path(result).exists()) # 50% of 10s = 5s cmd = mock_run.call_args[0][0] ss_idx = cmd.index("-ss") + 1 seek_val = cmd[ss_idx] # Should be around 5 seconds self.assertIn("05", seek_val) Path(result).unlink(missing_ok=True) @patch("video_processing.ffmpeg_utils.run_ffmpeg") @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_output_path_parameter(self, mock_probe, mock_run): """指定输出路径.""" from video_processing.thumbnail_generator import extract_first_frame with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as out: pass # just get a path # Create the file so ffmpeg "succeeds" mock_run.side_effect = lambda *a, **k: Path(out.name).write_bytes(b"fake image") result = extract_first_frame(video.name, output_path=out.name) self.assertEqual(result, out.name) Path(out.name).unlink(missing_ok=True) @patch("video_processing.ffmpeg_utils.run_ffmpeg") @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_keeps_original_resolution_by_default(self, mock_probe, mock_run): """默认保持原始分辨率(width=-1, height=-1).""" from video_processing.thumbnail_generator import extract_first_frame mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake") with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") result = extract_first_frame(video.name) cmd = mock_run.call_args[0][0] vf_idx = cmd.index("-vf") + 1 vf_filter = cmd[vf_idx] # Should NOT have scale filter (only format) self.assertNotIn("scale", vf_filter) self.assertIn("format", vf_filter) Path(result).unlink(missing_ok=True) @patch("video_processing.ffmpeg_utils.run_ffmpeg") @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_custom_width_triggers_scale(self, mock_probe, mock_run): """指定宽度时添加 scale 滤镜.""" from video_processing.thumbnail_generator import extract_first_frame mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake") with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") result = extract_first_frame(video.name, width=640) cmd = mock_run.call_args[0][0] vf_idx = cmd.index("-vf") + 1 vf_filter = cmd[vf_idx] self.assertIn("scale=640", vf_filter) Path(result).unlink(missing_ok=True) @patch("video_processing.ffmpeg_utils.run_ffmpeg", side_effect=RuntimeError("fail")) @patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0) def test_cleanup_temp_file_on_failure(self, mock_probe, mock_run): """失败时清理临时文件.""" from video_processing.thumbnail_generator import extract_first_frame with tempfile.NamedTemporaryFile(suffix=".mp4") as video: Path(video.name).write_bytes(b"fake video") with self.assertRaises(RuntimeError): extract_first_frame(video.name) import pytest pytest.skip("RenderAdapterResult.cover_url 已被 cover_candidates 替代,测试待更新", allow_module_level=True) class TestRenderAdapterCoverUrl(unittest.TestCase): """RenderAdapterResult.cover_url 字段测试.""" def test_result_has_cover_url_field(self): """RenderAdapterResult 包含 cover_url 字段.""" from video_processing.render_adapter import RenderAdapterResult result = RenderAdapterResult(success=True, cover_url="https://example.com/cover.jpg") self.assertEqual(result.cover_url, "https://example.com/cover.jpg") def test_result_cover_url_defaults_empty(self): """cover_url 默认为空字符串.""" from video_processing.render_adapter import RenderAdapterResult result = RenderAdapterResult(success=True) self.assertEqual(result.cover_url, "") if __name__ == "__main__": unittest.main()