Files
xiaoxia-saas/tests/unit/domain/test_xfade_builder.py
xiaoxia 14fefc7b1a
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (push) Failing after 4m57s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Frontend Lint (push) Successful in 31s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m11s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m20s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
test(wave141): xfade_builder domain 层 +63 单测 (#1061)
PR #1061
- xfade_builder domain 层单测补全
- 新增 tests/unit/domain/test_xfade_builder.py +579行
2026-07-28 11:34:47 +08:00

580 lines
22 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""xfade_builder 单测.
domain 层 XFade 转场滤镜构建纯逻辑模块,0 FFmpeg 依赖。
覆盖:转场名称映射、滤镜链串联、xfade 滤镜链构建(含 duration 钳制)。
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from packages.domain.xfade_builder import (
DEFAULT_TRANSITION_DURATION,
SUPPORTED_TRANSITIONS,
XFADE_TRANSITION_MAP,
XFade_TRANSITION_NAMES,
build_xfade_filter_chain,
chain_filters,
resolve_xfade_transition,
)
# ═══════════════════════════════════════════════════════════════════════════════
# 常量测试
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstants:
"""常量测试."""
def test_default_duration(self):
"""默认转场时长 0.5s."""
assert DEFAULT_TRANSITION_DURATION == 0.5
def test_transition_map_not_empty(self):
"""转场映射非空."""
assert len(XFADE_TRANSITION_MAP) > 10
def test_supported_transitions(self):
"""支持的转场数量与映射键一致."""
assert len(SUPPORTED_TRANSITIONS) == len(XFADE_TRANSITION_MAP)
def test_output_names_subset(self):
"""输出名称是映射值的集合."""
assert XFade_TRANSITION_NAMES == set(XFADE_TRANSITION_MAP.values())
def test_fade_in_map(self):
"""fade 是基础转场."""
assert "fade" in XFADE_TRANSITION_MAP
assert XFADE_TRANSITION_MAP["fade"] == "fade"
def test_dissolve_aliases(self):
"""dissolve 有多个别名."""
assert XFADE_TRANSITION_MAP["dissolve"] == "dissolve"
assert XFADE_TRANSITION_MAP["crossfade"] == "dissolve"
assert XFADE_TRANSITION_MAP["crossdissolve"] == "dissolve"
def test_slide_directions(self):
"""4 方向滑动都有映射."""
assert XFADE_TRANSITION_MAP["slideleft"] == "slideleft"
assert XFADE_TRANSITION_MAP["slideright"] == "slideright"
assert XFADE_TRANSITION_MAP["slideup"] == "slideup"
assert XFADE_TRANSITION_MAP["slidedown"] == "slidedown"
def test_slide_underscore_aliases(self):
"""下划线别名也支持."""
assert XFADE_TRANSITION_MAP["slide_left"] == "slideleft"
assert XFADE_TRANSITION_MAP["slide_right"] == "slideright"
assert XFADE_TRANSITION_MAP["slide_up"] == "slideup"
assert XFADE_TRANSITION_MAP["slide_down"] == "slidedown"
def test_slide_default_direction(self):
"""slide 默认向左滑."""
assert XFADE_TRANSITION_MAP["slide"] == "slideleft"
def test_wipe_directions(self):
"""4 方向擦除."""
assert XFADE_TRANSITION_MAP["wipeleft"] == "wipeleft"
assert XFADE_TRANSITION_MAP["wiperight"] == "wiperight"
assert XFADE_TRANSITION_MAP["wipeup"] == "wipeup"
assert XFADE_TRANSITION_MAP["wipedown"] == "wipedown"
def test_wipe_default(self):
"""wipe 默认向左擦."""
assert XFADE_TRANSITION_MAP["wipe"] == "wipeleft"
def test_zoom(self):
"""缩放转场."""
assert XFADE_TRANSITION_MAP["zoom"] == "zoomin"
assert XFADE_TRANSITION_MAP["zoomin"] == "zoomin"
assert XFADE_TRANSITION_MAP["zoomout"] == "zoomout"
def test_circle_rect(self):
"""圆形/矩形裁剪."""
assert XFADE_TRANSITION_MAP["circle"] == "circlecrop"
assert XFADE_TRANSITION_MAP["circlecrop"] == "circlecrop"
assert XFADE_TRANSITION_MAP["rect"] == "rectcrop"
assert XFADE_TRANSITION_MAP["rectcrop"] == "rectcrop"
# ═══════════════════════════════════════════════════════════════════════════════
# chain_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestChainFilters:
"""滤镜链串联测试."""
def test_single_filter(self):
"""单个滤镜."""
result = chain_filters(["scale=1280:720"], "v0")
assert result == "[0:v]scale=1280:720[v0]"
def test_multiple_filters(self):
"""多个滤镜用逗号连接."""
result = chain_filters(["scale=1280:720", "fps=25", "format=yuv420p"], "out")
assert "scale=1280:720,fps=25,format=yuv420p" in result
def test_empty_filters(self):
"""空滤镜列表."""
result = chain_filters([], "out")
assert result == "[0:v][out]"
def test_custom_input_label(self):
"""自定义输入标签."""
result = chain_filters(["fps=30"], "v1", input_label="2:v")
assert result.startswith("[2:v]")
def test_custom_output_label(self):
"""自定义输出标签."""
result = chain_filters(["scale=640:480"], "my_output")
assert result.endswith("[my_output]")
def test_preserves_filter_order(self):
"""保持滤镜顺序."""
filters = ["a", "b", "c", "d"]
result = chain_filters(filters, "out")
idx_a = result.index("a")
idx_b = result.index("b")
idx_c = result.index("c")
idx_d = result.index("d")
assert idx_a < idx_b < idx_c < idx_d
# ═══════════════════════════════════════════════════════════════════════════════
# resolve_xfade_transition
# ═══════════════════════════════════════════════════════════════════════════════
class TestResolveXfadeTransition:
"""转场名称解析测试."""
def test_fade(self):
"""fade → fade."""
assert resolve_xfade_transition("fade") == "fade"
def test_dissolve(self):
"""dissolve → dissolve."""
assert resolve_xfade_transition("dissolve") == "dissolve"
def test_slide_left_alias(self):
"""slide_left 别名."""
assert resolve_xfade_transition("slide_left") == "slideleft"
def test_slide_default(self):
"""slide 默认向左."""
assert resolve_xfade_transition("slide") == "slideleft"
def test_zoom_default(self):
"""zoom 默认 zoomin."""
assert resolve_xfade_transition("zoom") == "zoomin"
def test_wipe_default(self):
"""wipe 默认向左擦."""
assert resolve_xfade_transition("wipe") == "wipeleft"
def test_unknown_falls_back_to_fade(self):
"""未知转场回退到 fade."""
assert resolve_xfade_transition("unknown_effect") == "fade"
def test_empty_string_fades(self):
"""空字符串回退到 fade."""
assert resolve_xfade_transition("") == "fade"
def test_enum_value(self):
"""支持枚举(有 .value 属性)."""
mock_enum = MagicMock()
mock_enum.value = "dissolve"
assert resolve_xfade_transition(mock_enum) == "dissolve"
def test_enum_unknown_value_fades(self):
"""枚举值未知时回退到 fade."""
mock_enum = MagicMock()
mock_enum.value = "not_a_real_effect"
assert resolve_xfade_transition(mock_enum) == "fade"
def test_circle_alias(self):
"""circle 别名."""
assert resolve_xfade_transition("circle") == "circlecrop"
def test_rect_alias(self):
"""rect 别名."""
assert resolve_xfade_transition("rect") == "rectcrop"
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 基础结构
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainBasic:
"""xfade 滤镜链基础结构测试."""
def test_empty_clips(self):
"""空片段返回空."""
result, duration = build_xfade_filter_chain([], [], [])
assert result == ""
assert duration == 0.0
def test_single_clip_copy(self):
"""单个片段用 copy."""
result, duration = build_xfade_filter_chain([5.0], ["v0"], [])
assert "[v0]copy[outv]" in result
assert duration == 5.0
def test_two_clips_fade(self):
"""两个片段 + fade 转场."""
result, total = build_xfade_filter_chain([5.0, 3.0], ["v0", "v1"], ["cut", "fade"])
assert "xfade=transition=fade" in result
assert ":duration=0.500" in result
assert "[outv]" in result
# 总时长 = 5 + 3 - 0.5 = 7.5
assert abs(total - 7.5) < 0.01
def test_three_clips(self):
"""三个片段有 2 个 xfade."""
result, total = build_xfade_filter_chain(
[4.0, 3.0, 5.0],
["v0", "v1", "v2"],
["cut", "fade", "dissolve"],
)
assert result.count("xfade=") == 2
assert "xf1" in result # 中间标签
# 总时长 = 4 + 3 + 5 - 0.5*2 = 11.0
assert abs(total - 11.0) < 0.01
def test_output_label_custom(self):
"""自定义输出标签."""
result, _ = build_xfade_filter_chain(
[5.0, 3.0],
["v0", "v1"],
["cut", "fade"],
output_label="final",
)
assert result.endswith("[final]")
assert "[outv]" not in result
def test_custom_transition_duration(self):
"""自定义转场时长."""
result, total = build_xfade_filter_chain(
[5.0, 3.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
assert ":duration=1.000" in result
assert abs(total - 7.0) < 0.01 # 5+3-1 = 7
def test_intermediate_labels(self):
"""多片段使用中间 xf 标签."""
result, _ = build_xfade_filter_chain(
[1.0, 1.0, 1.0, 1.0, 1.0],
["v0", "v1", "v2", "v3", "v4"],
["cut"] * 5,
)
# 5 个片段 = 4 个 xfade,中间标签 xf1, xf2, xf3
assert "[xf1]" in result
assert "[xf2]" in result
assert "[xf3]" in result
def test_video_labels_used(self):
"""使用传入的视频标签."""
result, _ = build_xfade_filter_chain(
[2.0, 2.0],
["clip_a", "clip_b"],
["cut", "fade"],
)
assert "[clip_a]" in result
assert "[clip_b]" in result
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — offset 计算
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainOffset:
"""xfade offset 计算测试."""
def test_two_clips_offset(self):
"""两片段 offset = dur0 - td."""
result, _ = build_xfade_filter_chain(
[10.0, 5.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# offset = 10 - 1*1 = 9
assert ":offset=9.000" in result
def test_three_clips_second_offset(self):
"""三片段第二个 offset."""
result, _ = build_xfade_filter_chain(
[5.0, 4.0, 3.0],
["v0", "v1", "v2"],
["cut", "fade", "fade"],
transition_duration=0.5,
)
# 第一个 xfade offset = 5 - 0.5*1 = 4.5
# 第二个:cumulative = 5+4 = 9, offset = 9 - 0.5*2 = 8
assert ":offset=4.500" in result
assert ":offset=8.000" in result
def test_offset_never_negative(self):
"""offset 不为负."""
result, _ = build_xfade_filter_chain(
[0.1, 0.1],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# 找 offset 的值
import re
offsets = re.findall(r"offset=([\d.]+)", result)
for off in offsets:
assert float(off) >= 0.0
def test_short_first_clip_clamps_td(self):
"""第一个片段很短时,转场时长被钳制."""
result, _ = build_xfade_filter_chain(
[0.3, 2.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# 第一片段只有 0.3soffset ≈ 0, available ≈ 0.3, td 被钳制
import re
durations = re.findall(r"duration=([\d.]+)", result)
# 第一个 duration 是 xfade 的 duration
xfade_dur = float(durations[0])
assert xfade_dur <= 0.3 # 不能超过第一个片段时长
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — duration 钳制(防 exit 234
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainClamping:
"""duration 钳制逻辑测试(防 FFmpeg exit 234."""
def test_td_not_exceed_first_input(self):
"""转场时长不超过第一个输入的可用时长."""
# 第一个片段 1s,转场 2s → 被钳制
result, total = build_xfade_filter_chain(
[1.0, 3.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=2.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
assert float(durations[0]) <= 1.0
# 总时长不会比 1+3 = 4 还大(钳制后 td < 2
assert total < 4.0
def test_td_not_exceed_second_input(self):
"""转场时长不超过第二个片段时长."""
result, _ = build_xfade_filter_chain(
[3.0, 0.2],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
assert float(durations[0]) <= 0.2
def test_td_minimum_1ms(self):
"""td 至少 1ms."""
result, _ = build_xfade_filter_chain(
[0.0001, 0.0001],
["v0", "v1"],
["cut", "fade"],
transition_duration=0.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
if durations:
assert float(durations[0]) >= 0.001
def test_many_short_clips(self):
"""多个极短片段."""
n = 5
durations = [0.2] * n
labels = [f"v{i}" for i in range(n)]
result, total = build_xfade_filter_chain(
durations,
labels,
["cut"] * n,
transition_duration=0.5,
)
# 4 个转场
assert result.count("xfade=") == 4
# 总时长合理:sum = 1.0,减去被钳制的转场
assert total > 0
assert total <= sum(durations)
def test_second_xfade_first_input_is_accumulated(self):
"""第二个 xfade 的第一个输入时长是累积值(考虑之前的转场扣减)."""
# 三个片段,转场比较长,验证第二步钳制
result, total = build_xfade_filter_chain(
[2.0, 2.0, 2.0],
["v0", "v1", "v2"],
["cut", "fade", "fade"],
transition_duration=1.0,
)
# 第一个 xfade: first_input_dur = 2.0, td = min(1.0, 2.0-offset)
# offset = 2 - 1*1 = 1.0, available = 2.0 - 1.0 = 1.0, td = 1.0
# 第二个 xfade: first_input_dur = (2+2) - 1.0 = 3.0(累积 - 已用转场)
# offset = 4 - 1*2 = 2.0, available = 3.0 - 2.0 = 1.0, td = min(1.0, 1.0, 2.0) = 1.0
import re
dur_match = re.findall(r":duration=([\d.]+)", result)
# 两个 xfade,每个 duration 都是 1.0(正常情况)
assert len(dur_match) == 2
assert float(dur_match[0]) == 1.0
assert float(dur_match[1]) == 1.0
def test_total_duration_positive(self):
"""总时长不为负."""
_, total = build_xfade_filter_chain(
[0.1, 0.1],
["v0", "v1"],
["cut", "fade"],
transition_duration=10.0,
)
assert total >= 0.0
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 转场类型
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainTransitions:
"""不同转场类型测试."""
def test_slideleft_transition(self):
"""slideleft 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slideleft"])
assert "xfade=transition=slideleft" in result
def test_slide_left_alias_resolved(self):
"""slide_left 别名解析正确."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slide_left"])
assert "xfade=transition=slideleft" in result
def test_dissolve_transition(self):
"""dissolve 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "dissolve"])
assert "xfade=transition=dissolve" in result
def test_zoom_transition(self):
"""zoom 转场 → zoomin."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "zoom"])
assert "xfade=transition=zoomin" in result
def test_wipe_transition(self):
"""wipe 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "wipeup"])
assert "xfade=transition=wipeup" in result
def test_unknown_transition_fade(self):
"""未知转场回退到 fade."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "nonexistent"])
assert "xfade=transition=fade" in result
def test_cut_resolved_as_fade(self):
"""cut 也回退到 fade(调用方应对 cut 做特殊处理,但这里也能工作)."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "cut"])
# cut 不在映射里,回退到 fade
assert "xfade=transition=fade" in result
def test_transitions_shorter_than_clips(self):
"""transitions 比片段少时,超出部分用 cut/fade."""
result, _ = build_xfade_filter_chain(
[1.0, 1.0, 1.0, 1.0],
["v0", "v1", "v2", "v3"],
["cut", "fade"], # 只有 2 个转场
)
# 4 个片段 = 3 个 xfade,第三个用默认(cut→fade
assert result.count("xfade=") == 3
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 总时长验证
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainTotalDuration:
"""总时长计算验证."""
def test_two_equal_clips_default_td(self):
"""两个等长片段 + 默认 0.5s 转场."""
_, total = build_xfade_filter_chain(
[5.0, 5.0],
["v0", "v1"],
["cut", "fade"],
)
assert abs(total - 9.5) < 0.01 # 5+5-0.5
def test_three_clips_two_transitions(self):
"""三个片段两个转场."""
_, total = build_xfade_filter_chain(
[3.0, 4.0, 3.0],
["v0", "v1", "v2"],
["cut"] * 3,
transition_duration=0.5,
)
# 10 - 1.0 = 9.0
assert abs(total - 9.0) < 0.01
def test_single_clip_no_transition_loss(self):
"""单个片段无转场扣减."""
_, total = build_xfade_filter_chain([10.0], ["v0"], [])
assert total == 10.0
def test_zero_duration_clips(self):
"""0 时长片段不崩溃."""
result, total = build_xfade_filter_chain(
[0.0, 0.0],
["v0", "v1"],
["cut", "fade"],
)
assert total >= 0.0
assert "xfade=" in result # 仍然生成转场(td 被钳制到最小)
def test_very_long_transition_clamped(self):
"""极长转场被钳制,总时长仍为正."""
_, total = build_xfade_filter_chain(
[2.0, 2.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=100.0,
)
# 总时长 > 0
assert total > 0.0
# 且 < sum(durations) = 4(因为被钳制但还有重叠)
assert total < 4.0
def test_many_clips_linear_total(self):
"""多片段总时长近似线性增长."""
n = 10
durations = [1.0] * n
labels = [f"v{i}" for i in range(n)]
_, total = build_xfade_filter_chain(
durations,
labels,
["cut"] * n,
transition_duration=0.1,
)
# 10 - 9*0.1 = 9.1
assert abs(total - 9.1) < 0.05