"""视频拼接引擎纯逻辑单元测试.""" from __future__ import annotations import pytest from video_processing.concat_engine_pure import ( build_concat_filter, build_fps_filter, build_scale_pad_filter, build_single_segment_filter_chain, calculate_scaled_size, can_use_stream_copy, count_valid_segments, estimate_total_duration, format_fps_filter, generate_concat_file_list, parse_fps, resolve_output_params, validate_concat_config, validate_video_path, ) # ───────────────────────────────────────────────────────────────────────────── # 帧率解析测试 # ───────────────────────────────────────────────────────────────────────────── class TestParseFps: """parse_fps 测试.""" def test_integer_fps(self): """整数帧率.""" assert parse_fps(30) == 30.0 def test_float_fps(self): """浮点帧率.""" assert parse_fps(29.97) == pytest.approx(29.97) def test_string_integer(self): """字符串整数.""" assert parse_fps("30") == 30.0 def test_string_fraction(self): """分数字符串(30/1).""" assert parse_fps("30/1") == 30.0 def test_fraction_24000_1001(self): """23.976 帧率.""" result = parse_fps("24000/1001") assert result == pytest.approx(23.976, rel=0.01) def test_none_input(self): """None 输入返回默认值.""" assert parse_fps(None) == 30.0 def test_empty_string(self): """空字符串返回默认值.""" assert parse_fps("") == 30.0 def test_invalid_string(self): """无效字符串.""" assert parse_fps("abc") == 30.0 def test_zero_denominator(self): """分母为 0.""" assert parse_fps("30/0") == 30.0 def test_negative_fps(self): """负帧率.""" assert parse_fps(-30) == -30.0 class TestFormatFpsFilter: """format_fps_filter 测试.""" def test_integer_fps(self): """整数帧率.""" assert format_fps_filter(30.0) == "fps=30" def test_float_fps(self): """浮点帧率.""" result = format_fps_filter(29.97) assert result.startswith("fps=") assert "29.97" in result def test_near_integer(self): """接近整数.""" assert format_fps_filter(30.0001) == "fps=30" # ───────────────────────────────────────────────────────────────────────────── # 输出参数计算测试 # ───────────────────────────────────────────────────────────────────────────── class TestResolveOutputParams: """resolve_output_params 测试.""" def test_all_specified(self): """全部显式指定.""" w, h, fps = resolve_output_params(1920, 1080, 60.0) assert w == 1920 assert h == 1080 assert fps == 60.0 def test_no_specified_use_defaults(self): """全部未指定,用默认值.""" w, h, fps = resolve_output_params(0, 0, 0) assert w == 1080 assert h == 1920 assert fps == 30.0 def test_use_first_video_info(self): """用第一段视频信息.""" info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"} w, h, fps = resolve_output_params(0, 0, 0, info) assert w == 1280 assert h == 720 assert fps == 24.0 def test_partial_specified(self): """部分指定,未指定的用探测值.""" info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"} w, h, fps = resolve_output_params(1920, 0, 0, info) assert w == 1920 # 指定的 assert h == 720 # 探测的 assert fps == 24.0 def test_zero_size_clamped(self): """零尺寸被钳制.""" w, h, fps = resolve_output_params(0, 0, 0, {}) assert w >= 1 assert h >= 1 assert fps >= 1.0 def test_custom_defaults(self): """自定义默认值.""" w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0) assert w == 640 assert h == 480 assert fps == 25.0 class TestCalculateScaledSize: """calculate_scaled_size 测试.""" def test_same_ratio(self): """比例相同.""" sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080) assert sw == 1920 assert sh == 1080 assert ox == 0 assert oy == 0 def test_wider_source(self): """源更宽,上下填黑边.""" sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920) assert sw == 1080 # 以宽度为准 assert sh < 1920 # 高度按比例 assert ox == 0 assert oy > 0 # 垂直居中 def test_taller_source(self): """源更高,左右填黑边.""" sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080) assert sh == 1080 # 以高度为准 assert sw < 1920 # 宽度按比例 assert ox > 0 # 水平居中 assert oy == 0 def test_zero_source(self): """零尺寸源.""" sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100) assert sw == 100 assert sh == 100 def test_scale_down(self): """缩小.""" sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360) assert sw == 640 assert sh == 360 assert ox == 0 assert oy == 0 def test_scale_up(self): """放大.""" sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080) assert sw == 1920 assert sh == 1080 # ───────────────────────────────────────────────────────────────────────────── # stream copy 判断测试 # ───────────────────────────────────────────────────────────────────────────── class TestCanUseStreamCopy: """can_use_stream_copy 测试.""" def test_identical_segments(self): """所有段参数相同,可以 stream copy.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True def test_force_reencode(self): """强制重编码.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False def test_different_codec(self): """编码不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False def test_different_resolution(self): """分辨率不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False def test_different_fps(self): """帧率不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"}, ] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False def test_target_differs(self): """目标参数与源不同.""" segs = [ {"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}, ] assert can_use_stream_copy(segs, 1280, 720, 30.0) is False def test_empty_segments(self): """空列表.""" assert can_use_stream_copy([], 1920, 1080, 30.0) is False def test_single_segment(self): """单段.""" segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}] assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True # ───────────────────────────────────────────────────────────────────────────── # 文件列表生成测试 # ───────────────────────────────────────────────────────────────────────────── class TestGenerateConcatFileList: """generate_concat_file_list 测试.""" def test_single_file(self): """单个文件.""" result = generate_concat_file_list(["/a.mp4"]) assert "file '/a.mp4'" in result assert result.endswith("\n") def test_multiple_files(self): """多个文件.""" result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"]) lines = result.strip().split("\n") assert len(lines) == 3 assert lines[0] == "file '/a.mp4'" assert lines[1] == "file '/b.mp4'" assert lines[2] == "file '/c.mp4'" def test_empty_list(self): """空列表.""" result = generate_concat_file_list([]) assert result == "\n" def test_path_with_single_quote(self): """路径包含单引号(转义).""" result = generate_concat_file_list(["/path/to/file's.mp4"]) # 单引号应该被转义 assert "'\\''" in result or file assert "file '" in result def test_path_with_spaces(self): """路径包含空格.""" result = generate_concat_file_list(["/path/to/my video.mp4"]) assert "my video" in result # ───────────────────────────────────────────────────────────────────────────── # 滤镜构建测试 # ───────────────────────────────────────────────────────────────────────────── class TestBuildScalePadFilter: """scale+pad 滤镜测试.""" def test_contains_scale(self): """包含 scale.""" result = build_scale_pad_filter(1920, 1080) assert "scale=" in result def test_contains_pad(self): """包含 pad.""" result = build_scale_pad_filter(1920, 1080) assert "pad=" in result assert "1920:1080" in result def test_force_original_aspect_ratio(self): """保持宽高比.""" result = build_scale_pad_filter(1920, 1080) assert "force_original_aspect_ratio=decrease" in result def test_black_padding(self): """黑边填充.""" result = build_scale_pad_filter(1920, 1080) assert ":black" in result class TestBuildFpsFilter: """fps 滤镜测试.""" def test_integer_fps(self): """整数帧率.""" assert build_fps_filter(30.0) == "fps=30" def test_float_fps(self): """浮点帧率.""" result = build_fps_filter(29.97) assert result.startswith("fps=") class TestBuildConcatFilter: """concat 滤镜测试.""" def test_two_inputs_with_audio(self): """两路输入,有音频.""" result = build_concat_filter(2, has_audio=True) assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result assert "[concat_v][concat_a]" in result def test_three_inputs_video_only(self): """三路输入,无音频.""" result = build_concat_filter(3, has_audio=False) assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result assert "[concat_v]" in result def test_single_input(self): """单路输入.""" result = build_concat_filter(1, has_audio=True) assert "[0:v][0:a]concat=n=1:v=1:a=1" in result def test_zero_inputs(self): """零输入.""" assert build_concat_filter(0) == "" class TestBuildSingleSegmentFilterChain: """单段滤镜链测试.""" def test_with_audio(self): """有音频.""" result = build_single_segment_filter_chain(1920, 1080, 30.0, 0) assert "scale=" in result assert "fps=" in result assert "setpts=PTS-STARTPTS" in result assert "asetpts=PTS-STARTPTS" in result assert "[v0]" in result assert "[a0]" in result def test_video_only(self): """无音频.""" result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False) assert "scale=" in result assert "setpts=" in result assert "asetpts" not in result assert "[v1]" in result def test_segment_index_in_labels(self): """段索引在标签中.""" result = build_single_segment_filter_chain(1920, 1080, 30.0, 5) assert "[5:v]" in result assert "[v5]" in result # ───────────────────────────────────────────────────────────────────────────── # 配置验证测试 # ───────────────────────────────────────────────────────────────────────────── class TestValidateConcatConfig: """配置验证测试.""" def test_valid_config(self): """合法配置.""" config = { "segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}], "output_width": 1920, "output_height": 1080, "output_fps": 30, } ok, errors = validate_concat_config(config) assert ok is True assert len(errors) == 0 def test_empty_segments(self): """空段列表.""" ok, errors = validate_concat_config({"segments": []}) assert ok is False assert any("至少需要" in e or "视频段" in e for e in errors) def test_missing_video_path(self): """缺少 video_path.""" config = {"segments": [{"video_path": "/a.mp4"}, {}]} ok, errors = validate_concat_config(config) assert ok is False assert any("video_path" in e for e in errors) def test_negative_width(self): """负宽度.""" config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100} ok, errors = validate_concat_config(config) assert ok is False assert any("output_width" in e for e in errors) def test_negative_height(self): """负高度.""" config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100} ok, errors = validate_concat_config(config) assert ok is False assert any("output_height" in e for e in errors) def test_negative_fps(self): """负帧率.""" config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30} ok, errors = validate_concat_config(config) assert ok is False assert any("output_fps" in e for e in errors) def test_zero_output_params_ok(self): """零输出参数合法(表示自动探测).""" config = {"segments": [{"video_path": "/a.mp4"}]} ok, errors = validate_concat_config(config) assert ok is True # ───────────────────────────────────────────────────────────────────────────── # 路径验证测试 # ───────────────────────────────────────────────────────────────────────────── class TestValidateVideoPath: """视频路径验证测试.""" def test_empty_path(self): """空路径.""" ok, msg = validate_video_path("", "/work") assert ok is False assert "不能为空" in msg def test_path_traversal(self): """路径遍历.""" ok, msg = validate_video_path("../etc/passwd", "/work") assert ok is False assert "回溯" in msg or ".." in msg def test_valid_relative_path(self): """相对路径(不检查边界).""" ok, msg = validate_video_path("video.mp4", "/work") assert ok is True def test_valid_absolute_path(self): """绝对路径在工作目录内.""" ok, msg = validate_video_path("/work/sub/video.mp4", "/work") assert ok is True def test_path_outside_work_dir(self): """路径在工作目录外.""" ok, msg = validate_video_path("/etc/passwd", "/work") assert ok is False assert "工作目录" in msg # ───────────────────────────────────────────────────────────────────────────── # 工具函数测试 # ───────────────────────────────────────────────────────────────────────────── class TestEstimateTotalDuration: """总时长估算测试.""" def test_multiple_segments(self): """多段视频.""" segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}] assert estimate_total_duration(segs) == pytest.approx(35.5) def test_empty_list(self): """空列表.""" assert estimate_total_duration([]) == 0.0 def test_invalid_duration_skipped(self): """无效时长跳过.""" segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}] assert estimate_total_duration(segs) == pytest.approx(30.0) def test_missing_duration(self): """缺 duration 字段.""" segs = [{}, {"duration": 10}] assert estimate_total_duration(segs) == pytest.approx(10.0) class TestCountValidSegments: """有效段统计测试.""" def test_all_valid(self): """全部有效.""" segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}] assert count_valid_segments(segs) == 2 def test_some_invalid(self): """部分无效.""" segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}] assert count_valid_segments(segs) == 1 def test_empty_list(self): """空列表.""" assert count_valid_segments([]) == 0