535 lines
19 KiB
Python
Executable File
535 lines
19 KiB
Python
Executable File
"""视频拼接引擎纯逻辑单元测试."""
|
||
|
||
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
|