"""PiP Engine 纯逻辑单测. 测试 pip_engine_pure.py 中的所有纯函数, 0 FFmpeg 依赖,快速轻量。 """ from __future__ import annotations from pathlib import Path import pytest from video_processing.pip_engine_pure import ( build_animation_filters, build_enable_expr, build_overlay_expr, build_pip_filters, build_pip_pre_filter, compute_pip_position, compute_pip_size, count_visible_layers, sort_layers_by_z_index, validate_pip_layer, ) from packages.domain.pip_config import ( ANIMATION_FADE, ANIMATION_SLIDE_BOTTOM, ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_TOP, PiPLayerConfig, ) # ── 常量与工具 ──────────────────────────────────────────────────────────────── OUTPUT_W = 1080 OUTPUT_H = 1920 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 # ═══════════════════════════════════════════════════════════════════════════════ 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 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 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_full_width_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 # 按比例后高度不超过输出 def test_zero_width_minimum(self): """极小尺寸钳制到至少 1 像素.""" layer = _make_layer(width="0%") w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H) 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 # ═══════════════════════════════════════════════════════════════════════════════ # compute_pip_position # ═══════════════════════════════════════════════════════════════════════════════ class TestComputePipPosition: """位置计算测试.""" def test_bottom_right(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 def test_top_left(self): """左上角.""" layer = _make_layer(position="top_left", margin=10) x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H) 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): """正中心.""" 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 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) 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) assert x >= 0 assert x <= OUTPUT_W - 200 assert y >= 0 assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部 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 # ═══════════════════════════════════════════════════════════════════════════════ # build_pip_pre_filter # ═══════════════════════════════════════════════════════════════════════════════ class TestBuildPipPreFilter: """预处理滤镜构建测试.""" def test_basic_scale_setsar(self): """基础:scale + setsar.""" layer = _make_layer() 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): """圆角裁剪滤镜.""" layer = _make_layer(corner_radius=20) result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") 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 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): """边框滤镜.""" layer = _make_layer(border_width=5, border_color="red") result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") assert "pad=210:160:5:5:red" in result def test_zero_border_no_pad(self): """border_width=0 时不加 pad.""" layer = _make_layer(border_width=0) result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") assert "pad=" not in result def test_opacity_filter(self): """透明度滤镜.""" layer = _make_layer(opacity=0.5) result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") assert "colorchannelmixer=aa=0.5" in result assert "format=yuva420p" in result def test_full_opacity_no_alpha(self): """opacity=1.0 时不加透明度滤镜.""" layer = _make_layer(opacity=1.0) result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") 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.""" layer = _make_layer(opacity=-0.5) result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre") 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): """淡入 + 淡出.""" layer = _make_layer( animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE, animation_duration=0.5, duration=3.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) 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 时不加.""" 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.""" 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, # 比动画时长短 ) 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] # ═══════════════════════════════════════════════════════════════════════════════ # 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" def test_slide_in_from_left(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 不变 def test_slide_in_from_right(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" 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_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_out_to_left(self): """向左滑出.""" layer = _make_layer( animation_out=ANIMATION_SLIDE_LEFT, animation_duration=0.5, duration=3.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" def test_slide_out_to_right(self): """向右滑出.""" layer = _make_layer( animation_out=ANIMATION_SLIDE_RIGHT, animation_duration=0.5, duration=3.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 def test_slide_out_to_top(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 def test_slide_out_to_bottom(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 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_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 # ═══════════════════════════════════════════════════════════════════════════════ # build_enable_expr # ═══════════════════════════════════════════════════════════════════════════════ class TestBuildEnableExpr: """enable 表达式构建测试.""" def test_no_time_restriction(self): """无时间限制返回空.""" layer = _make_layer() assert build_enable_expr(layer) == "" def test_start_time_only(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 def test_none_duration(self): """duration=None 视为无限.""" layer = _make_layer(start_time=3.0, duration=None) result = build_enable_expr(layer) assert "gte(t,3.0)" in result assert "between" not in result # ═══════════════════════════════════════════════════════════════════════════════ # 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" 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, ) # 2 个滤镜片段:预处理 + overlay assert len(filters) == 2 # 1 个输入 assert inputs == ["-i", str(path)] # 最终标签 assert label == "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"), ] 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, ) # 每个图层 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_base_input_idx_offset(self): """base_input_idx 偏移.""" layer = _make_layer(width="20%") filters, inputs, label = build_pip_filters( "base", [layer], [Path("/tmp/x.mp4")], output_width=OUTPUT_W, output_height=OUTPUT_H, base_input_idx=5, ) # 预处理滤镜引用 [5:v] assert "[5:v]" in filters[0] 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( "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, ) # overlay 滤镜中包含滑动表达式 overlay_filter = filters[1] assert "overlay=" in overlay_filter assert str(OUTPUT_H) in overlay_filter # 从底部滑入 def test_with_enable_time(self): """带时间控制的图层.""" layer = _make_layer(width="20%", start_time=2.0, duration=5.0) filters, _, _ = build_pip_filters( "v0", [layer], [Path("/tmp/a.mp4")], output_width=OUTPUT_W, output_height=OUTPUT_H, ) 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 # ═══════════════════════════════════════════════════════════════════════════════ # validate_pip_layer # ═══════════════════════════════════════════════════════════════════════════════ class TestValidatePipLayer: """配置验证测试.""" def test_valid_layer(self): """合法配置.""" layer = _make_layer() ok, err = validate_pip_layer(layer) assert ok 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 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 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 assert "source" in err def test_invalid_position(self): """不支持的 position.""" layer = _make_layer(position="middle") ok, err = validate_pip_layer(layer) assert ok 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 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 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 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 assert "border_width" 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 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 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 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 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_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_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 # ═══════════════════════════════════════════════════════════════════════════════ # count_visible_layers # ═══════════════════════════════════════════════════════════════════════════════ class TestCountVisibleLayers: """可见图层统计测试.""" def test_all_visible(self): """全部可见.""" 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)] 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), ] assert count_visible_layers(layers) == 2 def test_empty_list(self): """空列表.""" assert count_visible_layers([]) == 0 # ═══════════════════════════════════════════════════════════════════════════════ # 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] 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" 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]