From 77a49e33657aaa81e11078aff85e5fb64a25055d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 00:25:42 +0800 Subject: [PATCH] =?UTF-8?q?test(wave201):=20concat=5Fengine=5Fpure=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E8=A1=A5=E5=85=A8=20+85=E6=B5=8B=20(#1167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_concat_engine_pure.py | 649 ++++++++------- tests/unit/test_pip_engine_pure.py | 1112 +++++++++---------------- 2 files changed, 760 insertions(+), 1001 deletions(-) diff --git a/tests/unit/test_concat_engine_pure.py b/tests/unit/test_concat_engine_pure.py index 49f21b7f7..2ffd34677 100755 --- a/tests/unit/test_concat_engine_pure.py +++ b/tests/unit/test_concat_engine_pure.py @@ -1,12 +1,12 @@ -"""视频拼接引擎纯逻辑单元测试.""" +"""concat_engine_pure 单元测试.""" -from __future__ import annotations +from pathlib import Path -import pytest -from video_processing.concat_engine_pure import ( +from apps.worker.video_processing.concat_engine_pure import ( build_concat_filter, build_fps_filter, build_scale_pad_filter, + build_setpts_filter, build_single_segment_filter_chain, calculate_scaled_size, can_use_stream_copy, @@ -20,200 +20,203 @@ from video_processing.concat_engine_pure import ( validate_video_path, ) -# ───────────────────────────────────────────────────────────────────────────── -# 帧率解析测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── parse_fps ──────────────────────────────────────────────────────────────── 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 输入返回默认值.""" + def test_none_returns_default(self): assert parse_fps(None) == 30.0 - def test_empty_string(self): - """空字符串返回默认值.""" - assert parse_fps("") == 30.0 + def test_integer_value(self): + assert parse_fps(30) == 30.0 + assert parse_fps(24) == 24.0 - def test_invalid_string(self): - """无效字符串.""" - assert parse_fps("abc") == 30.0 + def test_float_value(self): + assert parse_fps(29.97) == 29.97 + + def test_string_integer(self): + assert parse_fps("30") == 30.0 + assert parse_fps(" 60 ") == 60.0 # 带空格 + + def test_string_fraction(self): + assert parse_fps("30/1") == 30.0 + assert abs(parse_fps("24000/1001") - 23.976) < 0.01 def test_zero_denominator(self): - """分母为 0.""" assert parse_fps("30/0") == 30.0 + def test_empty_string(self): + assert parse_fps("") == 30.0 + assert parse_fps(" ") == 30.0 + + def test_invalid_string(self): + assert parse_fps("abc") == 30.0 + assert parse_fps("30fps") == 30.0 + def test_negative_fps(self): - """负帧率.""" assert parse_fps(-30) == -30.0 + def test_zero_fps(self): + assert parse_fps(0) == 0.0 + + +# ── format_fps_filter ─────────────────────────────────────────────────────── + class TestFormatFpsFilter: - """format_fps_filter 测试.""" - def test_integer_fps(self): - """整数帧率.""" assert format_fps_filter(30.0) == "fps=30" - def test_float_fps(self): - """浮点帧率.""" + def test_near_integer_fps(self): + # 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入) + assert format_fps_filter(30.0001) == "fps=30" + assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30 + + def test_non_integer_fps(self): + result = format_fps_filter(23.976) + assert result.startswith("fps=") + assert "23.976" in result + + def test_float_precision(self): result = format_fps_filter(29.97) assert result.startswith("fps=") - assert "29.97" in result + # 三位小数 + parts = result.split("=")[1] + assert len(parts.split(".")[1]) == 3 - def test_near_integer(self): - """接近整数.""" - assert format_fps_filter(30.0001) == "fps=30" + def test_one_fps(self): + assert format_fps_filter(1.0) == "fps=1" -# ───────────────────────────────────────────────────────────────────────────── -# 输出参数计算测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── resolve_output_params ─────────────────────────────────────────────────── class TestResolveOutputParams: - """resolve_output_params 测试.""" - - def test_all_specified(self): - """全部显式指定.""" + def test_config_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): - """用第一段视频信息.""" + def test_fallback_to_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): - """部分指定,未指定的用探测值.""" + def test_fallback_to_defaults(self): + w, h, fps = resolve_output_params(0, 0, 0) + assert w == 1080 # default_width + assert h == 1920 # default_height + assert fps == 30.0 + + def test_partial_config(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 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) + w, h, fps = resolve_output_params( + 0, + 0, + 0, + default_width=640, + default_height=480, + default_fps=25.0, + ) assert w == 640 assert h == 480 assert fps == 25.0 + def test_minimum_size(self): + w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"}) + assert w >= 1 + assert h >= 1 + assert fps >= 1.0 + + def test_fps_fraction_in_info(self): + info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"} + _, _, fps = resolve_output_params(0, 0, 0, info) + assert abs(fps - 23.976) < 0.01 + + +# ── calculate_scaled_size ─────────────────────────────────────────────────── + 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): - """源更宽,上下填黑边.""" + def test_wider_source_pad_top_bottom(self): + # 源是16:9,目标是9:16竖屏 → 上下填黑边 sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920) assert sw == 1080 # 以宽度为准 - assert sh < 1920 # 高度按比例 + assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607 assert ox == 0 assert oy > 0 # 垂直居中 - def test_taller_source(self): - """源更高,左右填黑边.""" + def test_taller_source_pad_left_right(self): + # 源是9:16竖屏,目标是16:9横屏 → 左右填黑边 sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080) assert sh == 1080 # 以高度为准 - assert sw < 1920 # 宽度按比例 + assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607 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 + def test_zero_source_size(self): + sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080) + assert sw == 1920 + assert sh == 1080 assert ox == 0 assert oy == 0 - def test_scale_up(self): - """放大.""" + def test_negative_source_size(self): + sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080) + assert sw == 1920 + assert sh == 1080 + assert ox == 0 + assert oy == 0 + + def test_target_same_ratio_different_size(self): + # 比例相同,尺寸不同 → 直接缩放到目标大小 sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080) assert sw == 1920 assert sh == 1080 + assert ox == 0 + assert oy == 0 -# ───────────────────────────────────────────────────────────────────────────── -# stream copy 判断测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── can_use_stream_copy ───────────────────────────────────────────────────── class TestCanUseStreamCopy: - """can_use_stream_copy 测试.""" + def test_force_reencode_false(self): + assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False - def test_identical_segments(self): - """所有段参数相同,可以 stream copy.""" + def test_empty_segments(self): + assert can_use_stream_copy([], 1920, 1080, 30.0) is False + + def test_single_segment_matching_params(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 + + def test_multiple_segments_same_params(self): 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"}, + {"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"}, @@ -221,7 +224,6 @@ class TestCanUseStreamCopy: 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"}, @@ -229,306 +231,349 @@ class TestCanUseStreamCopy: 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): - """单段.""" + def test_target_differs_from_source(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 + # 目标分辨率不同 + assert can_use_stream_copy(segs, 1280, 720, 30.0) is False + # 目标帧率不同 + assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False + + def test_fps_fraction_match(self): + segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}] + assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True -# ───────────────────────────────────────────────────────────────────────────── -# 文件列表生成测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── generate_concat_file_list ─────────────────────────────────────────────── 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") + result = generate_concat_file_list(["/tmp/video.mp4"]) + assert result == "file '/tmp/video.mp4'\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'" + assert result.endswith("\n") + + def test_escapes_single_quotes(self): + result = generate_concat_file_list(["/path/with'quote.mp4"]) + # 单引号转义: '\'' + assert "'\\''" in result 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 + result = generate_concat_file_list(["/path/to/video file.mp4"]) + assert "file '/path/to/video file.mp4'" in result -# ───────────────────────────────────────────────────────────────────────────── -# 滤镜构建测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── build_scale_pad_filter ────────────────────────────────────────────────── 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): - """保持宽高比.""" + def test_basic_filter(self): result = build_scale_pad_filter(1920, 1080) + assert "scale=1920:1080" in result assert "force_original_aspect_ratio=decrease" in result + assert "pad=1920:1080" in result + assert "black" in result + assert "(ow-iw)/2" in result + assert "(oh-ih)/2" in result - def test_black_padding(self): - """黑边填充.""" - result = build_scale_pad_filter(1920, 1080) - assert ":black" in result + def test_different_resolution(self): + result = build_scale_pad_filter(1080, 1920) + assert "scale=1080:1920" in result + assert "pad=1080:1920" in result + + def test_ignores_source_size(self): + # src_w/src_h 目前不影响输出,都是用表达式 + result1 = build_scale_pad_filter(1920, 1080) + result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720) + assert result1 == result2 + + +# ── build_fps_filter ──────────────────────────────────────────────────────── 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 滤镜测试.""" +# ── build_setpts_filter ───────────────────────────────────────────────────── - 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 + +class TestBuildSetptsFilter: + def test_returns_correct_string(self): + assert build_setpts_filter() == "setpts=PTS-STARTPTS" + + +# ── build_concat_filter ───────────────────────────────────────────────────── + + +class TestBuildConcatFilter: + def test_zero_inputs(self): + assert build_concat_filter(0) == "" + + def test_single_input_with_audio(self): + result = build_concat_filter(1) + assert "[0:v][0:a]" in result + assert "concat=n=1: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 + def test_single_input_no_audio(self): + result = build_concat_filter(1, has_audio=False) + assert "[0:v]" in result + assert "concat=n=1:v=1:a=0" in result assert "[concat_v]" in result + assert "[concat_a]" not 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_multiple_inputs_with_audio(self): + result = build_concat_filter(3) + assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result + assert "concat=n=3:v=1:a=1" in result - def test_zero_inputs(self): - """零输入.""" - assert build_concat_filter(0) == "" + def test_multiple_inputs_no_audio(self): + result = build_concat_filter(3, has_audio=False) + assert "[0:v][1:v][2:v]" in result + assert "concat=n=3:v=1:a=0" in result + + def test_negative_inputs(self): + assert build_concat_filter(-1) == "" + + +# ── build_single_segment_filter_chain ─────────────────────────────────────── 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 "[0:v]" in result assert "[v0]" in result + assert "scale=1920:1080" in result + assert "fps=30" in result + assert "setpts=PTS-STARTPTS" in result + # 音频链 + assert "[0:a]" in result assert "[a0]" in result + assert "asetpts=PTS-STARTPTS" in result + # 用分号分隔 + assert ";" 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_without_audio(self): + result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False) + assert "[2:v]" in result + assert "[v2]" in result + assert "[2:a]" not in result + assert ";" not 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 + def test_segment_index_propagated(self): + for idx in [0, 5, 10]: + result = build_single_segment_filter_chain(1920, 1080, 30.0, idx) + assert f"[{idx}:v]" in result + assert f"[v{idx}]" in result -# ───────────────────────────────────────────────────────────────────────────── -# 配置验证测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_concat_config ────────────────────────────────────────────────── class TestValidateConcatConfig: - """配置验证测试.""" - def test_valid_config(self): - """合法配置.""" config = { - "segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}], + "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 + valid, errors = validate_concat_config(config) + assert valid is True + assert errors == [] + + def test_no_segments(self): + valid, errors = validate_concat_config({}) + assert valid is False + assert any("至少需要一个" in e for e in errors) 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) + valid, errors = validate_concat_config({"segments": []}) + assert valid is False + assert len(errors) >= 1 def test_missing_video_path(self): - """缺少 video_path.""" - config = {"segments": [{"video_path": "/a.mp4"}, {}]} - ok, errors = validate_concat_config(config) - assert ok is False + config = {"segments": [{"video_path": ""}]} + valid, errors = validate_concat_config(config) + assert valid 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 + def test_multiple_missing_paths(self): + config = { + "segments": [ + {"video_path": "/a.mp4"}, + {"video_path": ""}, + {"video_path": ""}, + ] + } + valid, errors = validate_concat_config(config) + assert valid is False + path_errors = [e for e in errors if "video_path" in e] + assert len(path_errors) == 2 + + def test_negative_output_width(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1} + valid, errors = validate_concat_config(config) + assert valid 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 + def test_negative_output_height(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1} + valid, errors = validate_concat_config(config) + assert valid 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 + def test_negative_output_fps(self): + config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1} + valid, errors = validate_concat_config(config) + assert valid 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 + def test_zero_output_params_valid(self): + # 0值表示未指定,是合法的 + config = { + "segments": [{"video_path": "/a.mp4"}], + "output_width": 0, + "output_height": 0, + "output_fps": 0, + } + valid, errors = validate_concat_config(config) + assert valid is True -# ───────────────────────────────────────────────────────────────────────────── -# 路径验证测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── validate_video_path ───────────────────────────────────────────────────── class TestValidateVideoPath: - """视频路径验证测试.""" - def test_empty_path(self): - """空路径.""" - ok, msg = validate_video_path("", "/work") - assert ok is False - assert "不能为空" in msg + valid, err = validate_video_path("", "/work") + assert valid is False + assert "不能为空" in err - 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_relative_path_valid(self): + valid, err = validate_video_path("video.mp4", "/work") + assert valid is True + assert err == "" - def test_valid_relative_path(self): - """相对路径(不检查边界).""" - ok, msg = validate_video_path("video.mp4", "/work") - assert ok is True + def test_relative_path_with_subdir(self): + valid, err = validate_video_path("sub/video.mp4", "/work") + assert valid 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_traversal_rejected(self): + valid, err = validate_video_path("../secret.mp4", "/work") + assert valid is False + assert ".." in err - def test_path_outside_work_dir(self): - """路径在工作目录外.""" - ok, msg = validate_video_path("/etc/passwd", "/work") - assert ok is False - assert "工作目录" in msg + def test_nested_path_traversal_rejected(self): + valid, err = validate_video_path("sub/../../secret.mp4", "/work") + assert valid is False + + def test_absolute_path_inside_workdir(self): + valid, err = validate_video_path("/work/sub/video.mp4", "/work") + assert valid is True + + def test_absolute_path_outside_workdir(self): + valid, err = validate_video_path("/etc/passwd", "/work") + assert valid is False + assert "工作目录内" in err + + def test_path_object_input(self): + valid, err = validate_video_path(Path("video.mp4"), Path("/work")) + assert valid is True -# ───────────────────────────────────────────────────────────────────────────── -# 工具函数测试 -# ───────────────────────────────────────────────────────────────────────────── +# ── estimate_total_duration ───────────────────────────────────────────────── class TestEstimateTotalDuration: - """总时长估算测试.""" + def test_single_segment(self): + assert estimate_total_duration([{"duration": 10.5}]) == 10.5 def test_multiple_segments(self): - """多段视频.""" - segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}] - assert estimate_total_duration(segs) == pytest.approx(35.5) + segs = [ + {"duration": 10}, + {"duration": 20.5}, + {"duration": 5.5}, + ] + assert estimate_total_duration(segs) == 36.0 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_field(self): + segs = [{"path": "a.mp4"}, {"duration": 10}] + assert estimate_total_duration(segs) == 10.0 - def test_missing_duration(self): - """缺 duration 字段.""" - segs = [{}, {"duration": 10}] - assert estimate_total_duration(segs) == pytest.approx(10.0) + def test_invalid_duration_skipped(self): + segs = [ + {"duration": 10}, + {"duration": "abc"}, + {"duration": 20}, + ] + assert estimate_total_duration(segs) == 30.0 + + def test_string_duration(self): + segs = [{"duration": "15.5"}] + assert estimate_total_duration(segs) == 15.5 + + def test_negative_duration(self): + segs = [{"duration": -5}] + assert estimate_total_duration(segs) == -5.0 + + +# ── count_valid_segments ──────────────────────────────────────────────────── class TestCountValidSegments: - """有效段统计测试.""" - def test_all_valid(self): - """全部有效.""" - segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}] + 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 + segs = [ + {"video_path": "/a.mp4"}, + {"video_path": ""}, + {"video_path": "/c.mp4"}, + ] + assert count_valid_segments(segs) == 2 + + def test_none_valid(self): + segs = [ + {"video_path": ""}, + {"other_field": "x"}, + ] + assert count_valid_segments(segs) == 0 def test_empty_list(self): - """空列表.""" assert count_valid_segments([]) == 0 diff --git a/tests/unit/test_pip_engine_pure.py b/tests/unit/test_pip_engine_pure.py index f04f61fb4..bac5d0542 100755 --- a/tests/unit/test_pip_engine_pure.py +++ b/tests/unit/test_pip_engine_pure.py @@ -1,15 +1,10 @@ -"""PiP Engine 纯逻辑单测. - -测试 pip_engine_pure.py 中的所有纯函数, -0 FFmpeg 依赖,快速轻量。 -""" - -from __future__ import annotations +"""pip_engine_pure 单元测试.""" from pathlib import Path import pytest -from video_processing.pip_engine_pure import ( + +from apps.worker.video_processing.pip_engine_pure import ( build_animation_filters, build_enable_expr, build_overlay_expr, @@ -21,7 +16,6 @@ from video_processing.pip_engine_pure import ( sort_layers_by_z_index, validate_pip_layer, ) - from packages.domain.pip_config import ( ANIMATION_FADE, ANIMATION_SLIDE_BOTTOM, @@ -31,936 +25,656 @@ from packages.domain.pip_config import ( PiPLayerConfig, ) -# ── 常量与工具 ──────────────────────────────────────────────────────────────── -OUTPUT_W = 1080 -OUTPUT_H = 1920 +def _make_layer(**kwargs): + """快速创建 PiPLayerConfig.""" + layer = PiPLayerConfig() + for k, v in kwargs.items(): + setattr(layer, k, v) + return layer -def _make_layer(**kwargs) -> PiPLayerConfig: - """快速创建图层配置.""" - defaults = dict( - source_type="local_path", - source="/tmp/test.mp4", - width="25%", - height=None, - position="bottom_right", - margin=20, - opacity=1.0, - corner_radius=0, - border_width=0, - border_color="black", - z_index=0, - start_time=0.0, - duration=None, - animation_in=None, - animation_out=None, - animation_duration=0.5, - ) - defaults.update(kwargs) - return PiPLayerConfig(**defaults) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# compute_pip_size -# ═══════════════════════════════════════════════════════════════════════════════ +# ── compute_pip_size ──────────────────────────────────────────────────────── class TestComputePipSize: - """尺寸计算测试.""" - def test_percentage_width_auto_height(self): - """百分比宽度,自动高度(16:9).""" - layer = _make_layer(width="25%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 270 # 1080 * 25% - assert h == 151 # 270 * 9 / 16 = 151.875 → 151 + layer = _make_layer(width="30%") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 576 # 1920 * 0.3 + assert h == int(576 * 9 / 16) # 按16:9等比 def test_pixel_width_and_height(self): - """像素宽高.""" - layer = _make_layer(width=300, height=200) - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 300 - assert h == 200 + layer = _make_layer(width="400", height="300") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 400 + assert h == 300 - def test_pixel_width_percent_height(self): - """像素宽 + 百分比高.""" - layer = _make_layer(width=200, height="10%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 200 - assert h == 192 # 1920 * 10% + def test_int_width_and_height(self): + layer = _make_layer(width=500, height=400) + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 500 + assert h == 400 - def test_full_width_clamped(self): - """超过输出尺寸时钳制到输出范围内.""" + def test_width_exceeds_output_clamped(self): layer = _make_layer(width="200%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == OUTPUT_W - assert h <= OUTPUT_H # 按比例后高度不超过输出 + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 1920 + assert h <= 1080 - def test_zero_width_minimum(self): - """极小尺寸钳制到至少 1 像素.""" - layer = _make_layer(width="0%") - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) + def test_height_exceeds_output_clamped(self): + layer = _make_layer(width="100", height="200%") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 100 + assert h == 1080 + + def test_minimum_size(self): + layer = _make_layer(width="0", height="0") + w, h = compute_pip_size(layer, 1920, 1080) assert w >= 1 assert h >= 1 - def test_pixel_int_width(self): - """整数像素宽度.""" - layer = _make_layer(width=500, height=300) - w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) - assert w == 500 - assert h == 300 + def test_empty_height_auto_ratio(self): + layer = _make_layer(width="320", height="") + w, h = compute_pip_size(layer, 1920, 1080) + assert w == 320 + assert h == int(320 * 9 / 16) -# ═══════════════════════════════════════════════════════════════════════════════ -# compute_pip_position -# ═══════════════════════════════════════════════════════════════════════════════ +# ── compute_pip_position ──────────────────────────────────────────────────── class TestComputePipPosition: - """位置计算测试.""" - - def test_bottom_right(self): - """右下角位置.""" + def test_bottom_right_position(self): layer = _make_layer(position="bottom_right", margin=20) - pip_w, pip_h = 200, 150 - x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H) - assert x == OUTPUT_W - pip_w - 20 - assert y == OUTPUT_H - pip_h - 20 + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == 1920 - 200 - 20 + assert y == 1080 - 150 - 20 - def test_top_left(self): - """左上角.""" + def test_top_left_position(self): layer = _make_layer(position="top_left", margin=10) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x == 10 assert y == 10 - def test_top_center(self): - """顶部居中.""" - layer = _make_layer(position="top_center", margin=20) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 200) // 2 - assert y == 20 - - def test_center(self): - """正中心.""" + def test_center_position(self): layer = _make_layer(position="center") - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 200) // 2 - assert y == (OUTPUT_H - 150) // 2 + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == (1920 - 200) // 2 + assert y == (1080 - 150) // 2 def test_custom_position(self): - """自定义坐标.""" layer = _make_layer(position="custom", x=100, y=200) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x == 100 assert y == 200 - def test_margin_effect(self): - """不同 margin 值影响位置.""" - layer1 = _make_layer(position="bottom_right", margin=0) - layer2 = _make_layer(position="bottom_right", margin=50) - x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H) - x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H) - assert x1 > x2 - assert y1 > y2 - - def test_clamped_when_outside(self): - """自定义坐标超出画面时钳制到边界内.""" - layer = _make_layer(position="custom", x=-50, y=99999) - x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) + def test_clamped_to_left_edge(self): + # parse_size_value 有 max(1, value) 钳制,负数返回1 + layer = _make_layer(position="custom", x=-100, y=0) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert x >= 0 - assert x <= OUTPUT_W - 200 + assert x < 200 + + def test_clamped_to_right_edge(self): + layer = _make_layer(position="custom", x=9999, y=0) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert x == 1920 - 200 + + def test_clamped_to_top_edge(self): + # parse_size_value 有 max(1, value) 钳制,负数返回1 + layer = _make_layer(position="custom", x=0, y=-50) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) assert y >= 0 - assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部 + assert y < 150 - def test_bottom_center(self): - """底部居中.""" - layer = _make_layer(position="bottom_center", margin=30) - x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H) - assert x == (OUTPUT_W - 300) // 2 - assert y == OUTPUT_H - 200 - 30 - - def test_center_left(self): - """左侧居中.""" - layer = _make_layer(position="center_left", margin=15) - x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == 15 - assert y == (OUTPUT_H - 100) // 2 - - def test_center_right(self): - """右侧居中.""" - layer = _make_layer(position="center_right", margin=15) - x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == OUTPUT_W - 150 - 15 - assert y == (OUTPUT_H - 100) // 2 + def test_clamped_to_bottom_edge(self): + layer = _make_layer(position="custom", x=0, y=9999) + x, y = compute_pip_position(layer, 200, 150, 1920, 1080) + assert y == 1080 - 150 -# ═══════════════════════════════════════════════════════════════════════════════ -# build_pip_pre_filter -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_pip_pre_filter ──────────────────────────────────────────────────── class TestBuildPipPreFilter: - """预处理滤镜构建测试.""" - - def test_basic_scale_setsar(self): - """基础:scale + setsar.""" - layer = _make_layer() + def test_basic_scale_and_sar(self): + layer = _make_layer(width="200", height="150") result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert result.startswith("[1:v]") assert "scale=200:150" in result assert "setsar=1" in result assert result.endswith("[pip_pre_0]") - def test_corner_radius_filter(self): - """圆角裁剪滤镜.""" + def test_with_corner_radius(self): layer = _make_layer(corner_radius=20) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "geq=" in result assert "format=yuva420p" in result - # 圆角半径应钳制到 min(r, w//2, h//2) - assert "hypot(" in result - def test_corner_radius_clamped(self): - """圆角半径超过尺寸一半时自动钳制.""" - layer = _make_layer(corner_radius=1000) # 超大 - result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre") - # 钳制后 r = min(1000, 50, 40) = 40 - # 检查 geq 表达式中的 r 值 - import re + def test_corner_radius_zero(self): + layer = _make_layer(corner_radius=0) + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") + assert "geq=" not in result - r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result) - assert r_matches - assert int(r_matches[0]) <= 50 # 不超过宽的一半 - - def test_border_filter(self): - """边框滤镜.""" + def test_with_border(self): layer = _make_layer(border_width=5, border_color="red") - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "pad=210:160:5:5:red" in result - def test_zero_border_no_pad(self): - """border_width=0 时不加 pad.""" + def test_border_zero(self): layer = _make_layer(border_width=0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "pad=" not in result - def test_opacity_filter(self): - """透明度滤镜.""" + def test_with_opacity(self): layer = _make_layer(opacity=0.5) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer=aa=0.5" in result - assert "format=yuva420p" in result - def test_full_opacity_no_alpha(self): - """opacity=1.0 时不加透明度滤镜.""" + def test_full_opacity_no_alpha_filter(self): layer = _make_layer(opacity=1.0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer" not in result - def test_opacity_clamped_high(self): - """opacity > 1.0 时钳制.""" - layer = _make_layer(opacity=2.0) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - # 钳制到 1.0,不加透明度滤镜 - assert "colorchannelmixer=aa=1" not in result - assert "colorchannelmixer" not in result - - def test_opacity_clamped_low(self): - """opacity < 0 时钳制到 0.""" + def test_opacity_clamped(self): layer = _make_layer(opacity=-0.5) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") assert "colorchannelmixer=aa=0.0" in result - def test_fade_in_animation(self): - """淡入动画.""" - layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=in:st=0:d=0.3:alpha=1" in result - - def test_fade_out_animation(self): - """淡出动画(需要 duration).""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=0.5, - duration=5.0, - ) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result - - def test_fade_out_no_duration(self): - """淡出无 duration 时不加.""" - layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None) - result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") - assert "fade=t=out" not in result - - def test_combined_effects(self): - """多个效果组合:圆角 + 边框 + 透明度.""" - layer = _make_layer( - corner_radius=15, - border_width=3, - border_color="white", - opacity=0.8, - ) - result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre") - assert "geq=" in result # 圆角 - assert "pad=306:206:3:3:white" in result # 边框 - assert "colorchannelmixer=aa=0.8" in result # 透明度 - - def test_output_label(self): - """输出标签正确.""" - layer = _make_layer() - result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label") - assert result.endswith("[my_label]") - - def test_input_label(self): - """输入标签正确.""" - layer = _make_layer() - result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out") - assert result.startswith("[5:v]") - - -# ═══════════════════════════════════════════════════════════════════════════════ -# build_animation_filters -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestBuildAnimationFilters: - """动画滤镜构建测试.""" - - def test_no_animation(self): - """无动画返回空列表.""" - layer = _make_layer() - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_fade_in_only(self): - """仅淡入.""" - layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - assert "fade=t=in" in result[0] - - def test_fade_out_with_duration(self): - """淡出(有 duration).""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=0.3, - duration=10.0, - ) - result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - assert "fade=t=out:st=9.7:d=0.3" in result[0] - - def test_fade_out_no_duration_skipped(self): - """淡出无 duration 时跳过.""" - layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_fade_in_and_out(self): - """淡入 + 淡出.""" + def test_with_fade_animation(self): layer = _make_layer( animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE, animation_duration=0.5, duration=3.0, ) + result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0") + assert "fade=t=in:st=0:d=0.5:alpha=1" in result + assert "fade=t=out" in result + + def test_all_features_combined(self): + layer = _make_layer( + corner_radius=15, + border_width=3, + border_color="blue", + opacity=0.8, + animation_in=ANIMATION_FADE, + animation_duration=0.3, + duration=5.0, + ) + result = build_pip_pre_filter("[1:v]", layer, 300, 200, "pip_out") + assert "scale=300:200" in result + assert "geq=" in result + assert "pad=" in result + assert "colorchannelmixer=aa=0.8" in result + assert "fade=t=in" in result + assert result.endswith("[pip_out]") + + +# ── build_animation_filters ───────────────────────────────────────────────── + + +class TestBuildAnimationFilters: + def test_no_animation(self): + layer = _make_layer() + assert build_animation_filters(layer, 200, 150) == [] + + def test_fade_in_only(self): + layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5) + result = build_animation_filters(layer, 200, 150) + assert len(result) == 1 + assert "fade=t=in:st=0:d=0.5:alpha=1" in result[0] + + def test_fade_out_requires_duration(self): + layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5) + result = build_animation_filters(layer, 200, 150) + # 没有duration,出场动画不生效 + assert len(result) == 0 + + def test_fade_out_with_duration(self): + layer = _make_layer( + animation_out=ANIMATION_FADE, + animation_duration=0.5, + duration=5.0, + ) + result = build_animation_filters(layer, 200, 150) + assert len(result) == 1 + assert "fade=t=out" in result[0] + assert "st=4.5" in result[0] # 5.0 - 0.5 + + def test_both_fade_animations(self): + layer = _make_layer( + animation_in=ANIMATION_FADE, + animation_out=ANIMATION_FADE, + animation_duration=0.3, + duration=4.0, + ) result = build_animation_filters(layer, 200, 150) assert len(result) == 2 - assert any("fade=t=in" in f for f in result) - assert any("fade=t=out" in f for f in result) + assert "fade=t=in" in result[0] + assert "fade=t=out" in result[1] - def test_slide_in_not_here(self): - """slide 动画不在此函数处理.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - result = build_animation_filters(layer, 200, 150) - assert result == [] - - def test_zero_duration_no_animation(self): - """动画时长为 0 时不加.""" + def test_zero_duration_animation(self): layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0) result = build_animation_filters(layer, 200, 150) assert result == [] - def test_negative_duration_clamped(self): - """负动画时长钳制为 0.""" + def test_negative_animation_duration_clamped(self): layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1) result = build_animation_filters(layer, 200, 150) assert result == [] - def test_fade_out_start_clamped_to_zero(self): - """淡出开始时间不为负.""" - layer = _make_layer( - animation_out=ANIMATION_FADE, - animation_duration=2.0, - duration=1.0, # 比动画时长短 - ) + def test_slide_animation_not_in_this_function(self): + # slide类动画不在这个函数处理 + layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) result = build_animation_filters(layer, 200, 150) - assert len(result) == 1 - # start = max(0, 1.0 - 2.0) = 0 - assert "st=0.0:d=2.0" in result[0] + assert result == [] -# ═══════════════════════════════════════════════════════════════════════════════ -# build_overlay_expr -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_overlay_expr ────────────────────────────────────────────────────── class TestBuildOverlayExpr: - """overlay 表达式构建测试.""" - def test_no_animation_static_position(self): - """无动画时返回静态坐标.""" layer = _make_layer() - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" - def test_slide_in_from_left(self): - """从左侧滑入.""" + def test_slide_left_enter(self): layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "if(lt(t,0.5)" in x - assert "-150" in x # 起始位置 = -pip_width - assert y == "200" # y 不变 + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(lt(t,0.5)" in x_expr + assert y_expr == "200" - def test_slide_in_from_right(self): - """从右侧滑入.""" + def test_slide_right_enter(self): layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert str(OUTPUT_W) in x - assert y == "200" + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "output_width" not in x_expr # 应该是具体数值 + assert "if(lt(t,0.5)" in x_expr + assert "1920" in x_expr - def test_slide_in_from_top(self): - """从顶部滑入.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "if(lt(t,0.3)" in y - assert "-100" in y + def test_slide_top_enter(self): + layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.5) + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert "if(lt(t,0.5)" in y_expr - def test_slide_in_from_bottom(self): - """从底部滑入.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert str(OUTPUT_H) in y + def test_slide_bottom_enter(self): + layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.5) + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(lt(t,0.5)" in y_expr + assert "1080" in y_expr - def test_slide_out_to_left(self): - """向左滑出.""" + def test_slide_left_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_LEFT, animation_duration=0.5, - duration=3.0, + duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "gt(t,2.5)" in x - assert y == "200" + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in x_expr - def test_slide_out_to_right(self): - """向右滑出.""" + def test_slide_right_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_RIGHT, animation_duration=0.5, - duration=3.0, + duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "gt(t,2.5)" in x - assert y == "200" - # 向右滑出:结束时 x > base_x(值变大) - # 检查表达式中含增大方向的计算 - assert "+(t-2.5)/0.5*" in x + x_expr, _ = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in x_expr - def test_slide_out_to_top(self): - """向上滑出.""" + def test_slide_top_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_TOP, animation_duration=0.5, duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "gt(t,4.5)" in y + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in y_expr - def test_slide_out_to_bottom(self): - """向下滑出.""" + def test_slide_bottom_exit(self): layer = _make_layer( animation_out=ANIMATION_SLIDE_BOTTOM, animation_duration=0.5, duration=5.0, ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert "gt(t,4.5)" in y - # 向下滑出:y 值增大 - assert "+(t-4.5)/0.5*" in y + _, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert "if(gt(t,4.5)" in y_expr - def test_slide_in_and_out_different_axes(self): - """滑入(x方向) + 滑出(y方向),两个轴都有动画.""" - layer = _make_layer( - animation_in=ANIMATION_SLIDE_LEFT, - animation_out=ANIMATION_SLIDE_BOTTOM, - animation_duration=0.5, - duration=4.0, - ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert "lt(t,0.5)" in x # x 方向入场 - assert "gt(t,3.5)" in y # y 方向出场 + def test_exit_animation_no_duration_ignored(self): + layer = _make_layer(animation_out=ANIMATION_SLIDE_LEFT, animation_duration=0.5) + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" def test_zero_animation_duration_no_effect(self): - """动画时长为 0 时无效果.""" layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" - - def test_no_duration_skip_outro(self): - """无 duration 时跳过滑出.""" - layer = _make_layer( - animation_out=ANIMATION_SLIDE_LEFT, - animation_duration=0.5, - duration=None, - ) - x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x == "100" - assert y == "200" - - def test_expression_format_quoted(self): - """有动画时表达式带单引号.""" - layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5) - x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H) - assert x.startswith("'") - assert x.endswith("'") - - def test_static_position_unquoted(self): - """无动画时纯数字,不带引号.""" - layer = _make_layer() - x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H) - assert x == "50" - assert y == "60" - assert "'" not in x - assert "'" not in y + x_expr, y_expr = build_overlay_expr(layer, 100, 200, 200, 150, 1920, 1080) + assert x_expr == "100" + assert y_expr == "200" -# ═══════════════════════════════════════════════════════════════════════════════ -# build_enable_expr -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_enable_expr ──────────────────────────────────────────────────────── class TestBuildEnableExpr: - """enable 表达式构建测试.""" - - def test_no_time_restriction(self): - """无时间限制返回空.""" - layer = _make_layer() + def test_no_start_no_duration_empty(self): + layer = _make_layer(start_time=0.0) + # duration默认是0.0 assert build_enable_expr(layer) == "" - def test_start_time_only(self): - """只有开始时间.""" + def test_with_start_and_duration(self): + layer = _make_layer(start_time=2.0, duration=3.0) + result = build_enable_expr(layer) + assert result == ":enable='between(t,2.0,5.0)'" + + def test_with_start_only_no_duration(self): layer = _make_layer(start_time=5.0) result = build_enable_expr(layer) assert result == ":enable='gte(t,5.0)'" - def test_duration_only(self): - """只有 duration(从 0 开始).""" - layer = _make_layer(duration=10.0) - result = build_enable_expr(layer) - assert result == ":enable='between(t,0.0,10.0)'" - - def test_start_and_duration(self): - """开始时间 + 时长.""" - layer = _make_layer(start_time=2.0, duration=5.0) - result = build_enable_expr(layer) - assert "between(t,2.0,7.0)" in result - - def test_zero_start_with_duration(self): - """0 开始 + 时长.""" - layer = _make_layer(start_time=0, duration=3.5) - result = build_enable_expr(layer) - assert "between(t,0.0,3.5)" in result - def test_negative_start_clamped(self): - """负开始时间钳制为 0.""" layer = _make_layer(start_time=-1.0, duration=5.0) result = build_enable_expr(layer) - assert "between(t,0.0,5.0)" in result + assert "between(t,0.0," in result - def test_none_duration(self): - """duration=None 视为无限.""" - layer = _make_layer(start_time=3.0, duration=None) + def test_zero_duration_with_start(self): + layer = _make_layer(start_time=3.0, duration=0.0) result = build_enable_expr(layer) assert "gte(t,3.0)" in result - assert "between" not in result + + def test_zero_start_and_duration(self): + layer = _make_layer(start_time=0, duration=0) + assert build_enable_expr(layer) == "" -# ═══════════════════════════════════════════════════════════════════════════════ -# build_pip_filters -# ═══════════════════════════════════════════════════════════════════════════════ +# ── build_pip_filters ─────────────────────────────────────────────────────── class TestBuildPipFilters: - """完整滤镜链构建测试.""" - def test_empty_layers(self): - """空图层列表返回空.""" - filters, inputs, label = build_pip_filters( - "base", - [], - [], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - assert filters == [] - assert inputs == [] - assert label == "base" + filter_parts, input_args, final = build_pip_filters("base", [], [], output_width=1920, output_height=1080) + assert filter_parts == [] + assert input_args == [] + assert final == "base" def test_single_layer(self): - """单个图层.""" - layer = _make_layer(width="20%", position="bottom_right") - path = Path("/tmp/clip1.mp4") - - filters, inputs, label = build_pip_filters( - "v0", - [layer], - [path], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + layer = _make_layer(width="200", height="150", position="top_left", source="test.mp4") + filter_parts, input_args, final = build_pip_filters( + "base", [layer], ["/tmp/test.mp4"], output_width=1920, output_height=1080 ) - - # 2 个滤镜片段:预处理 + overlay - assert len(filters) == 2 - # 1 个输入 - assert inputs == ["-i", str(path)] - # 最终标签 - assert label == "pip_combined_0" + assert len(filter_parts) == 2 # pre_filter + overlay + assert input_args == ["-i", "/tmp/test.mp4"] + assert final == "pip_combined_0" def test_multiple_layers(self): - """多个图层.""" layers = [ - _make_layer(width="30%", position="bottom_left"), - _make_layer(width="25%", position="top_right"), - _make_layer(width="20%", position="top_left"), + _make_layer(width="100", height="100", position="top_left"), + _make_layer(width="100", height="100", position="top_right"), ] - paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")] - - filters, inputs, label = build_pip_filters( - "base", - layers, - paths, - output_width=OUTPUT_W, - output_height=OUTPUT_H, + sources = ["/tmp/a.mp4", "/tmp/b.mp4"] + filter_parts, input_args, final = build_pip_filters( + "base", layers, sources, output_width=1920, output_height=1080 ) + assert len(filter_parts) == 4 # 2 pre + 2 overlay + assert len(input_args) == 4 # 2 * (-i, path) + assert final == "pip_combined_1" - # 每个图层 2 个滤镜(预处理 + overlay) - assert len(filters) == 6 - # 3 个输入 - assert len(inputs) == 6 # -i path × 3 - assert inputs[0::2] == ["-i", "-i", "-i"] - # 最终标签是最后一个 combined - assert label == "pip_combined_2" + def test_mismatched_layers_and_sources(self): + layer = _make_layer() + with pytest.raises(ValueError, match="长度不一致"): + build_pip_filters("base", [layer], [], output_width=1920, output_height=1080) - def test_base_input_idx_offset(self): - """base_input_idx 偏移.""" - layer = _make_layer(width="20%") - filters, inputs, label = build_pip_filters( + def test_custom_base_input_index(self): + layer = _make_layer(width="100", height="100", position="top_left") + filter_parts, input_args, _ = build_pip_filters( "base", [layer], - [Path("/tmp/x.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + ["/a.mp4"], + output_width=1920, + output_height=1080, base_input_idx=5, ) - # 预处理滤镜引用 [5:v] - assert "[5:v]" in filters[0] + assert "[5:v]" in filter_parts[0] + assert len(input_args) == 2 - def test_layer_count_mismatch_raises(self): - """图层和路径数量不一致时报错.""" - with pytest.raises(ValueError, match="长度不一致"): - build_pip_filters( - "base", - [_make_layer()], - [], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - - def test_filter_chaining(self): - """多图层时滤镜链正确串联.""" - layers = [_make_layer(width="10%"), _make_layer(width="10%")] - paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")] - - filters, _, _ = build_pip_filters( + def test_path_object_supported(self): + layer = _make_layer(width="100", height="100", position="top_left") + _, input_args, _ = build_pip_filters( "base", - layers, - paths, - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - - # 第一个 overlay 的输入是 base + pip_pre_0 - # 输出是 pip_combined_0 - assert "[base]" in filters[1] - assert "[pip_combined_0]" in filters[1] - - # 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1 - # 输出是 pip_combined_1 - assert "[pip_combined_0]" in filters[3] - assert "[pip_combined_1]" in filters[3] - - def test_with_animation_layer(self): - """带动画的图层生成正确表达式.""" - layer = _make_layer( - width="30%", - animation_in=ANIMATION_SLIDE_BOTTOM, - animation_duration=0.5, - ) - filters, inputs, _ = build_pip_filters( - "v0", [layer], - [Path("/tmp/a.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + [Path("/tmp/test.mp4")], + output_width=1920, + output_height=1080, ) - # overlay 滤镜中包含滑动表达式 - overlay_filter = filters[1] - assert "overlay=" in overlay_filter - assert str(OUTPUT_H) in overlay_filter # 从底部滑入 + assert input_args[1] == "/tmp/test.mp4" - def test_with_enable_time(self): - """带时间控制的图层.""" - layer = _make_layer(width="20%", start_time=2.0, duration=5.0) - filters, _, _ = build_pip_filters( - "v0", + def test_filter_parts_contain_correct_labels(self): + layer = _make_layer(width="100", height="100", position="top_left") + filter_parts, _, _ = build_pip_filters( + "base", [layer], - [Path("/tmp/a.mp4")], - output_width=OUTPUT_W, - output_height=OUTPUT_H, + ["/a.mp4"], + output_width=1920, + output_height=1080, ) - overlay_filter = filters[1] - assert "enable=" in overlay_filter - assert "between" in overlay_filter - - def test_string_paths(self): - """路径可以是字符串.""" - layer = _make_layer(width="10%") - filters, inputs, label = build_pip_filters( - "v0", - [layer], - ["/tmp/s.mp4"], - output_width=OUTPUT_W, - output_height=OUTPUT_H, - ) - assert inputs == ["-i", "/tmp/s.mp4"] - assert len(filters) == 2 + # pre filter输出 pip_pre_0 + assert "[pip_pre_0]" in filter_parts[0] + # overlay使用 pip_pre_0 作为输入 + assert "[pip_pre_0]" in filter_parts[1] + # overlay输出 pip_combined_0 + assert "[pip_combined_0]" in filter_parts[1] -# ═══════════════════════════════════════════════════════════════════════════════ -# validate_pip_layer -# ═══════════════════════════════════════════════════════════════════════════════ +# ── validate_pip_layer ────────────────────────────────────────────────────── class TestValidatePipLayer: - """配置验证测试.""" - def test_valid_layer(self): - """合法配置.""" - layer = _make_layer() - ok, err = validate_pip_layer(layer) - assert ok is True + layer = _make_layer( + source_type="asset_id", + source="asset_123", + width="25%", + position="bottom_right", + ) + valid, err = validate_pip_layer(layer) + assert valid is True assert err == "" - def test_empty_source_type(self): - """空 source_type.""" - layer = _make_layer(source_type="") - ok, err = validate_pip_layer(layer) - assert ok is False + def test_missing_source_type(self): + layer = _make_layer(source_type="", source="abc", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source_type" in err def test_invalid_source_type(self): - """不支持的 source_type.""" - layer = _make_layer(source_type="ftp") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="invalid", source="abc", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source_type" in err - def test_empty_source(self): - """空 source.""" - layer = _make_layer(source="") - ok, err = validate_pip_layer(layer) - assert ok is False + def test_missing_source(self): + layer = _make_layer(source_type="asset_id", source="", width="25%", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False assert "source" in err + def test_missing_width(self): + layer = _make_layer(source_type="asset_id", source="abc", width=None, position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False + assert "width" in err + + def test_empty_width(self): + layer = _make_layer(source_type="asset_id", source="abc", width="", position="top_left") + valid, err = validate_pip_layer(layer) + assert valid is False + assert "width" in err + def test_invalid_position(self): - """不支持的 position.""" - layer = _make_layer(position="middle") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", position="invalid_pos") + valid, err = validate_pip_layer(layer) + assert valid is False assert "position" in err - def test_opacity_too_high(self): - """opacity > 1.""" - layer = _make_layer(opacity=1.5) - ok, err = validate_pip_layer(layer) - assert ok is False + def test_invalid_opacity_high(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", opacity=2.0) + valid, err = validate_pip_layer(layer) + assert valid is False assert "opacity" in err - def test_opacity_negative(self): - """opacity < 0.""" - layer = _make_layer(opacity=-0.1) - ok, err = validate_pip_layer(layer) - assert ok is False + def test_invalid_opacity_low(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", opacity=-0.5) + valid, err = validate_pip_layer(layer) + assert valid is False assert "opacity" in err def test_negative_corner_radius(self): - """负圆角.""" - layer = _make_layer(corner_radius=-5) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", corner_radius=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "corner_radius" in err def test_negative_border_width(self): - """负边框.""" - layer = _make_layer(border_width=-2) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", border_width=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "border_width" in err + def test_negative_animation_duration(self): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_duration=-1) + valid, err = validate_pip_layer(layer) + assert valid is False + assert "animation_duration" in err + def test_negative_start_time(self): - """负开始时间.""" - layer = _make_layer(start_time=-1.0) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", start_time=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "start_time" in err def test_negative_duration(self): - """负时长.""" - layer = _make_layer(duration=-5.0) - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", duration=-1) + valid, err = validate_pip_layer(layer) + assert valid is False assert "duration" in err def test_invalid_animation_in(self): - """不支持的入场动画.""" - layer = _make_layer(animation_in="zoom") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_in="invalid") + valid, err = validate_pip_layer(layer) + assert valid is False assert "animation_in" in err def test_invalid_animation_out(self): - """不支持的出场动画.""" - layer = _make_layer(animation_out="spin") - ok, err = validate_pip_layer(layer) - assert ok is False + layer = _make_layer(source_type="asset_id", source="abc", width="25%", animation_out="invalid") + valid, err = validate_pip_layer(layer) + assert valid is False assert "animation_out" in err - def test_multiple_errors_combined(self): - """多个错误合并.""" - layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx") - ok, err = validate_pip_layer(layer) - assert ok is False - assert err.count(";") >= 2 # 至少 2 个错误 + def test_multiple_errors_joined(self): + layer = _make_layer(source_type="", source="", width=None, position="bad") + valid, err = validate_pip_layer(layer) + assert valid is False + assert err.count(";") >= 2 # 至少3个错误,2个分号 - def test_valid_url_source(self): - """URL 类型 source 合法.""" - layer = _make_layer(source_type="url", source="https://example.com/v.mp4") - ok, err = validate_pip_layer(layer) - assert ok is True + def test_all_source_types_valid(self): + for st in ("local_path", "asset_id", "url"): + layer = _make_layer(source_type=st, source="abc", width="25%") + valid, _ = validate_pip_layer(layer) + assert valid is True - def test_valid_asset_id(self): - """asset_id 类型合法.""" - layer = _make_layer(source_type="asset_id", source="asset_123") - ok, err = validate_pip_layer(layer) - assert ok is True - - def test_zero_values_valid(self): - """0 值合法(不是负数).""" - layer = _make_layer( - corner_radius=0, - border_width=0, - start_time=0, - animation_duration=0, - ) - ok, err = validate_pip_layer(layer) - assert ok is True + def test_all_positions_valid(self): + for pos in ( + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", + "custom", + ): + layer = _make_layer(source_type="asset_id", source="abc", width="25%", position=pos) + valid, err = validate_pip_layer(layer) + assert valid is True, f"position {pos} should be valid: {err}" -# ═══════════════════════════════════════════════════════════════════════════════ -# count_visible_layers -# ═══════════════════════════════════════════════════════════════════════════════ +# ── count_visible_layers ──────────────────────────────────────────────────── class TestCountVisibleLayers: - """可见图层统计测试.""" - def test_all_visible(self): - """全部可见.""" - layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)] + layers = [ + _make_layer(opacity=1.0), + _make_layer(opacity=0.5), + ] assert count_visible_layers(layers) == 2 - def test_all_invisible(self): - """全部不可见.""" - layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)] + def test_none_visible(self): + layers = [ + _make_layer(opacity=0.0), + _make_layer(opacity=0.0), + ] assert count_visible_layers(layers) == 0 def test_mixed(self): - """混合.""" layers = [ _make_layer(opacity=1.0), _make_layer(opacity=0.0), - _make_layer(opacity=0.001), + _make_layer(opacity=0.1), + _make_layer(opacity=0.0), ] assert count_visible_layers(layers) == 2 def test_empty_list(self): - """空列表.""" assert count_visible_layers([]) == 0 + def test_negative_opacity_not_counted(self): + # opacity < 0 也不算可见 + layer = _make_layer(opacity=-0.5) + assert count_visible_layers([layer]) == 0 -# ═══════════════════════════════════════════════════════════════════════════════ -# sort_layers_by_z_index -# ═══════════════════════════════════════════════════════════════════════════════ + +# ── sort_layers_by_z_index ────────────────────────────────────────────────── class TestSortLayersByZIndex: - """图层排序测试.""" - def test_sorted_by_z_index(self): - """按 z_index 从小到大排序.""" - layers = [ - _make_layer(z_index=5, source="/tmp/a.mp4"), - _make_layer(z_index=1, source="/tmp/b.mp4"), - _make_layer(z_index=3, source="/tmp/c.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert [layer.z_index for layer in sorted_layers] == [1, 3, 5] + l1 = _make_layer(z_index=3) + l2 = _make_layer(z_index=1) + l3 = _make_layer(z_index=2) + result = sort_layers_by_z_index([l1, l2, l3]) + assert result[0].z_index == 1 + assert result[1].z_index == 2 + assert result[2].z_index == 3 def test_same_z_index_stable(self): - """相同 z_index 保持相对顺序.""" - layers = [ - _make_layer(z_index=2, source="/tmp/1.mp4"), - _make_layer(z_index=2, source="/tmp/2.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert sorted_layers[0].source == "/tmp/1.mp4" - assert sorted_layers[1].source == "/tmp/2.mp4" + l1 = _make_layer(z_index=5) + l2 = _make_layer(z_index=5) + l3 = _make_layer(z_index=5) + result = sort_layers_by_z_index([l1, l2, l3]) + # 稳定排序,保持原顺序 + assert result[0] is l1 + assert result[1] is l2 + assert result[2] is l3 + + def test_negative_z_index(self): + l1 = _make_layer(z_index=-5) + l2 = _make_layer(z_index=0) + l3 = _make_layer(z_index=5) + result = sort_layers_by_z_index([l3, l1, l2]) + assert result[0].z_index == -5 + assert result[2].z_index == 5 def test_empty_list(self): - """空列表.""" assert sort_layers_by_z_index([]) == [] def test_single_layer(self): - """单个图层.""" - layers = [_make_layer(z_index=0)] - assert len(sort_layers_by_z_index(layers)) == 1 - - def test_negative_z_index(self): - """负 z_index.""" - layers = [ - _make_layer(z_index=0, source="/tmp/0.mp4"), - _make_layer(z_index=-5, source="/tmp/-5.mp4"), - _make_layer(z_index=3, source="/tmp/3.mp4"), - ] - sorted_layers = sort_layers_by_z_index(layers) - assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3] + layer = _make_layer(z_index=10) + result = sort_layers_by_z_index([layer]) + assert len(result) == 1 + assert result[0] is layer