"""VideoProcessor 纯逻辑单测 — 数据类 + 输入校验 + 解析逻辑. 通过 mock ffmpeg-python 库验证纯逻辑部分, 不实际执行 FFmpeg,确保测试轻量快速。 """ from __future__ import annotations import os from dataclasses import fields from unittest.mock import MagicMock, patch import pytest from video_processing.processor import VideoProcessor, VideoResult class TestVideoResultDataclass: """VideoResult 数据类测试.""" def test_all_fields_exist(self): """所有字段都存在.""" field_names = {f.name for f in fields(VideoResult)} expected = { "output_path", "thumbnail_path", "duration", "width", "height", "fps", "file_size", } assert expected.issubset(field_names) def test_default_construction(self): """正常构造 VideoResult.""" result = VideoResult( output_path="/tmp/out.mp4", thumbnail_path="/tmp/out.jpg", duration=10.5, width=1920, height=1080, fps=25.0, file_size=1024000, ) assert result.output_path == "/tmp/out.mp4" assert result.thumbnail_path == "/tmp/out.jpg" assert result.duration == 10.5 assert result.width == 1920 assert result.height == 1080 assert result.fps == 25.0 assert result.file_size == 1024000 def test_zero_values(self): """零值/边界值构造.""" result = VideoResult( output_path="", thumbnail_path="", duration=0.0, width=0, height=0, fps=0.0, file_size=0, ) assert result.duration == 0.0 assert result.file_size == 0 class TestVideoProcessorInit: """VideoProcessor 初始化测试.""" def test_default_temp_dir(self): """默认使用系统临时目录.""" import tempfile vp = VideoProcessor() assert vp.temp_dir == tempfile.gettempdir() def test_custom_temp_dir(self): """自定义临时目录.""" vp = VideoProcessor(temp_dir="/my/temp") assert vp.temp_dir == "/my/temp" class TestVideoProcessorConcatenateValidation: """concatenate_videos 输入校验测试.""" def test_empty_input_raises(self): """空输入列表抛出 ValueError.""" vp = VideoProcessor() with pytest.raises(ValueError, match="cannot be empty"): vp.concatenate_videos([], "/tmp/output.mp4") def test_none_input_raises(self): """None 输入抛出异常.""" vp = VideoProcessor() with pytest.raises((ValueError, TypeError)): vp.concatenate_videos(None, "/tmp/output.mp4") # type: ignore[arg-type] class TestVideoProcessorGetVideoInfoParsing: """get_video_info 解析逻辑测试(mock ffmpeg.probe).""" def _mock_probe(self, streams=None, fmt=None): """创建 ffmpeg.probe 的 mock 返回值.""" return { "streams": streams or [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1", "codec_name": "h264"}], "format": fmt or {"duration": "10.5", "bit_rate": "5000000"}, } def test_basic_info_parsing(self): """基本视频信息解析正确.""" vp = VideoProcessor() probe_data = self._mock_probe() with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["duration"] == 10.5 assert info["width"] == 1920 assert info["height"] == 1080 assert info["fps"] == 25.0 assert info["codec"] == "h264" assert info["bitrate"] == 5000000 def test_fps_fraction_parsing(self): """分数帧率解析(如 30000/1001 = 29.97).""" vp = VideoProcessor() probe_data = self._mock_probe( streams=[ { "codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "30000/1001", "codec_name": "h264", } ] ) with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["fps"] == pytest.approx(29.97, abs=0.01) def test_fps_integer_string(self): """整数字符串帧率(如 "60").""" vp = VideoProcessor() probe_data = self._mock_probe( streams=[{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "60", "codec_name": "h264"}] ) with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["fps"] == 60.0 def test_missing_r_frame_rate(self): """缺少 r_frame_rate 时使用默认值.""" vp = VideoProcessor() probe_data = self._mock_probe( streams=[{"codec_type": "video", "width": 1920, "height": 1080, "codec_name": "h264"}] ) with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["fps"] == 25.0 def test_no_video_stream(self): """没有视频流时的行为.""" vp = VideoProcessor() probe_data = { "streams": [{"codec_type": "audio", "codec_name": "aac"}], "format": {"duration": "10.0", "bit_rate": "128000"}, } with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): with pytest.raises(StopIteration): vp.get_video_info("/tmp/test.mp4") def test_float_duration(self): """浮点时长解析.""" vp = VideoProcessor() probe_data = self._mock_probe(fmt={"duration": "123.456", "bit_rate": "0"}) with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["duration"] == pytest.approx(123.456, abs=0.001) def test_bitrate_zero(self): """码率为 0 时.""" vp = VideoProcessor() probe_data = self._mock_probe(fmt={"duration": "10.0", "bit_rate": "0"}) with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data): info = vp.get_video_info("/tmp/test.mp4") assert info["bitrate"] == 0 def test_ffmpeg_probe_error_raises(self): """ffmpeg.probe 失败时抛出 RuntimeError.""" vp = VideoProcessor() import ffmpeg with patch( "video_processing.processor.ffmpeg.probe", side_effect=ffmpeg.Error([], b"", b"No such file"), ): with pytest.raises(RuntimeError, match="probe error"): vp.get_video_info("/tmp/nonexistent.mp4") class TestVideoProcessorGenerateThumbnail: """generate_thumbnail 测试.""" def _build_mock_chain(self): """构建 ffmpeg.input → .output → .overwrite_output → .run 调用链.""" mock_input_node = MagicMock() mock_output_node = MagicMock() mock_overwrite_node = MagicMock() mock_input_node.output.return_value = mock_output_node mock_output_node.overwrite_output.return_value = mock_overwrite_node return mock_input_node, mock_output_node, mock_overwrite_node def test_default_output_path(self): """默认输出路径为视频路径 + _thumb.jpg.""" vp = VideoProcessor() mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain() with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input: result = vp.generate_thumbnail("/tmp/video.mp4") assert result == "/tmp/video_thumb.jpg" mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=1.0) mock_input_node.output.assert_called_once() # 验证输出路径和参数 output_args = mock_input_node.output.call_args assert output_args[0][0] == "/tmp/video_thumb.jpg" assert output_args[1].get("vframes") == 1 assert output_args[1].get("format") == "image2" assert output_args[1].get("vcodec") == "mjpeg" def test_custom_output_path(self): """自定义输出路径.""" vp = VideoProcessor() mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain() with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input: result = vp.generate_thumbnail("/tmp/video.mp4", output_path="/custom/thumb.jpg") assert result == "/custom/thumb.jpg" def test_custom_timestamp(self): """自定义截图时间点.""" vp = VideoProcessor() mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain() with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input: vp.generate_thumbnail("/tmp/video.mp4", timestamp=3.5) # 验证 ss 参数 mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=3.5) def test_ffmpeg_error_raises_runtime(self): """FFmpeg 失败时抛出 RuntimeError.""" vp = VideoProcessor() import ffmpeg mock_input_node, mock_output, mock_overwrite = self._build_mock_chain() mock_overwrite.run.side_effect = ffmpeg.Error([], b"", b"Output file #0 does not contain any stream") with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node): with pytest.raises(RuntimeError, match="thumbnail error"): vp.generate_thumbnail("/tmp/video.mp4") class TestVideoProcessorConcatFileFormat: """concat 临时文件格式验证.""" def test_concat_file_format(self, tmp_path): """concat 临时文件格式符合 FFmpeg concat demuxer 规范.""" import os vp = VideoProcessor(temp_dir=str(tmp_path)) written_content = {} def fake_input(path, *args, **kwargs): mock_node = MagicMock() mock_output = MagicMock() mock_overwrite = MagicMock() mock_node.output.return_value = mock_output mock_output.overwrite_output.return_value = mock_overwrite if kwargs.get("format") == "concat": # 读取 concat 文件内容 with open(path) as f: written_content["concat"] = f.read() return mock_node mock_probe = MagicMock( return_value={ "streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}], "format": {"duration": "5.0", "bit_rate": "1000000"}, } ) with ( patch("video_processing.processor.ffmpeg.input", side_effect=fake_input), patch("video_processing.processor.ffmpeg.probe", mock_probe), patch("video_processing.processor.os.path.getsize", return_value=1024), ): with patch.object(VideoProcessor, "generate_thumbnail", return_value="/tmp/thumb.jpg"): vp.concatenate_videos( ["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"], str(tmp_path / "output.mp4"), ) # 验证 concat 文件格式 assert "concat" in written_content lines = written_content["concat"].strip().split("\n") assert len(lines) == 3 assert lines[0].startswith("file '") assert "a.mp4'" in lines[0] assert "b.mp4'" in lines[1] assert "c.mp4'" in lines[2] # 使用绝对路径 first_path = lines[0].replace("file '", "").rstrip("'") assert os.path.isabs(first_path) def test_concat_creates_output_directory(self, tmp_path): """输出目录不存在时自动创建.""" vp = VideoProcessor(temp_dir=str(tmp_path)) out_dir = tmp_path / "deep" / "output" out_file = out_dir / "result.mp4" mock_node = MagicMock() mock_output = MagicMock() mock_overwrite = MagicMock() mock_node.output.return_value = mock_output mock_output.overwrite_output.return_value = mock_overwrite mock_probe = MagicMock( return_value={ "streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}], "format": {"duration": "5.0", "bit_rate": "1000000"}, } ) with ( patch("video_processing.processor.ffmpeg.input", return_value=mock_node), patch("video_processing.processor.ffmpeg.probe", mock_probe), patch("video_processing.processor.os.path.getsize", return_value=1024), ): with patch.object(VideoProcessor, "generate_thumbnail", return_value=str(out_dir / "thumb.jpg")): vp.concatenate_videos(["/tmp/a.mp4"], str(out_file)) assert out_dir.exists() assert out_dir.is_dir()