d2c067e9bd
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m55s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Unit Tests (push) Successful in 9m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m13s
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 12m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m7s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m44s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
372 lines
15 KiB
Python
Executable File
372 lines
15 KiB
Python
Executable File
"""XFade 转场滤镜构建领域模型单元测试."""
|
||
|
||
from __future__ import annotations
|
||
|
||
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_transition_duration(self):
|
||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||
|
||
def test_xfade_transition_map_not_empty(self):
|
||
assert len(XFADE_TRANSITION_MAP) > 0
|
||
|
||
def test_supported_transitions(self):
|
||
assert "fade" in SUPPORTED_TRANSITIONS
|
||
assert "dissolve" in SUPPORTED_TRANSITIONS
|
||
|
||
def test_xfade_transition_names(self):
|
||
assert "fade" in XFade_TRANSITION_NAMES
|
||
assert "dissolve" in XFade_TRANSITION_NAMES
|
||
|
||
|
||
# ── 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"], "v0")
|
||
assert result == "[0:v]scale=1280:720,fps=25[v0]"
|
||
|
||
def test_empty_filters(self):
|
||
result = chain_filters([], "out")
|
||
assert result == "[0:v][out]"
|
||
|
||
def test_custom_input_label(self):
|
||
result = chain_filters(["scale=640:480"], "out", input_label="1:v")
|
||
assert result == "[1:v]scale=640:480[out]"
|
||
|
||
def test_three_filters(self):
|
||
result = chain_filters(["trim=0:5", "setpts=PTS-STARTPTS", "fps=30"], "v1")
|
||
assert result == "[0:v]trim=0:5,setpts=PTS-STARTPTS,fps=30[v1]"
|
||
|
||
|
||
# ── resolve_xfade_transition 测试 ───────────────────────────────────────────
|
||
|
||
|
||
class TestResolveXfadeTransition:
|
||
def test_fade(self):
|
||
assert resolve_xfade_transition("fade") == "fade"
|
||
|
||
def test_dissolve(self):
|
||
assert resolve_xfade_transition("dissolve") == "dissolve"
|
||
|
||
def test_crossfade_maps_to_dissolve(self):
|
||
assert resolve_xfade_transition("crossfade") == "dissolve"
|
||
|
||
def test_slideleft(self):
|
||
assert resolve_xfade_transition("slideleft") == "slideleft"
|
||
|
||
def test_slide_left_maps_to_slideleft(self):
|
||
assert resolve_xfade_transition("slide_left") == "slideleft"
|
||
|
||
def test_slide_default_left(self):
|
||
assert resolve_xfade_transition("slide") == "slideleft"
|
||
|
||
def test_slideup(self):
|
||
assert resolve_xfade_transition("slideup") == "slideup"
|
||
|
||
def test_zoom_maps_to_zoomin(self):
|
||
assert resolve_xfade_transition("zoom") == "zoomin"
|
||
|
||
def test_zoomin(self):
|
||
assert resolve_xfade_transition("zoomin") == "zoomin"
|
||
|
||
def test_wipe_default_left(self):
|
||
assert resolve_xfade_transition("wipe") == "wipeleft"
|
||
|
||
def test_wipeup(self):
|
||
assert resolve_xfade_transition("wipeup") == "wipeup"
|
||
|
||
def test_circle_maps_to_circlecrop(self):
|
||
assert resolve_xfade_transition("circle") == "circlecrop"
|
||
|
||
def test_rect_maps_to_rectcrop(self):
|
||
assert resolve_xfade_transition("rect") == "rectcrop"
|
||
|
||
def test_unknown_falls_back_to_fade(self):
|
||
assert resolve_xfade_transition("nonexistent_effect") == "fade"
|
||
|
||
def test_empty_string_falls_back_to_fade(self):
|
||
assert resolve_xfade_transition("") == "fade"
|
||
|
||
def test_enum_with_value_attribute(self):
|
||
"""测试带 .value 属性的枚举对象."""
|
||
|
||
class FakeEnum:
|
||
def __init__(self, val):
|
||
self.value = val
|
||
|
||
assert resolve_xfade_transition(FakeEnum("fade")) == "fade"
|
||
assert resolve_xfade_transition(FakeEnum("slideleft")) == "slideleft"
|
||
assert resolve_xfade_transition(FakeEnum("unknown")) == "fade"
|
||
|
||
|
||
# ── build_xfade_filter_chain 测试 ───────────────────────────────────────────
|
||
|
||
|
||
class TestBuildXfadeFilterChain:
|
||
# ── 边界情况 ──────────────────────────────────────────────────────
|
||
|
||
def test_empty_clips(self):
|
||
result, duration = build_xfade_filter_chain([], [], [])
|
||
assert result == ""
|
||
assert duration == 0.0
|
||
|
||
def test_single_clip(self):
|
||
result, duration = build_xfade_filter_chain([10.0], ["v0"], ["none"])
|
||
assert "copy" in result
|
||
assert "[v0]copy[outv]" in result
|
||
assert duration == 10.0
|
||
|
||
def test_single_clip_custom_output_label(self):
|
||
result, duration = build_xfade_filter_chain([5.0], ["a0"], ["none"], output_label="final")
|
||
assert "[a0]copy[final]" in result
|
||
assert duration == 5.0
|
||
|
||
# ── 两片段基础测试 ────────────────────────────────────────────────
|
||
|
||
def test_two_clips_basic(self):
|
||
result, duration = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "xfade=transition=fade" in result
|
||
assert "[v0][v1]" in result
|
||
assert "[outv]" in result
|
||
# 总时长 = 10 + 10 - 0.5 = 19.5
|
||
assert abs(duration - 19.5) < 0.01
|
||
|
||
def test_two_clips_custom_duration(self):
|
||
result, duration = build_xfade_filter_chain(
|
||
[10.0, 10.0],
|
||
["v0", "v1"],
|
||
["none", "fade"],
|
||
transition_duration=1.0,
|
||
)
|
||
assert "duration=1.000" in result
|
||
# 总时长 = 10 + 10 - 1.0 = 19.0
|
||
assert abs(duration - 19.0) < 0.01
|
||
|
||
def test_two_clips_offset(self):
|
||
"""两片段时 offset 应该为 0(cumulative - td * 1 = 10 - 0.5 = 9.5?不对)。
|
||
|
||
对于两个片段:
|
||
- cumulative = clip_durations[0] = 10.0
|
||
- offset = max(0, cumulative - td * i) = max(0, 10.0 - 0.5 * 1) = 9.5
|
||
- duration=0.5, offset=9.5
|
||
"""
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "offset=9.500" in result
|
||
|
||
# ── 多片段测试 ────────────────────────────────────────────────────
|
||
|
||
def test_three_clips(self):
|
||
result, duration = build_xfade_filter_chain(
|
||
[5.0, 5.0, 5.0],
|
||
["v0", "v1", "v2"],
|
||
["none", "fade", "dissolve"],
|
||
)
|
||
# 应该有两个 xfade
|
||
assert result.count("xfade=") == 2
|
||
# 第一个 xfade 输出标签 xf1,第二个 xfade 输出 outv
|
||
assert "xf1" in result
|
||
assert "[outv]" in result
|
||
# 总时长 ≈ 5 + 5 + 5 - 0.5 - 0.5 = 14.0
|
||
assert abs(duration - 14.0) < 0.1
|
||
|
||
def test_five_clips(self):
|
||
result, duration = build_xfade_filter_chain(
|
||
[3.0, 3.0, 3.0, 3.0, 3.0],
|
||
["v0", "v1", "v2", "v3", "v4"],
|
||
["none", "fade", "fade", "fade", "fade"],
|
||
)
|
||
assert result.count("xfade=") == 4
|
||
# 总时长 ≈ 15 - 4 * 0.5 = 13.0
|
||
assert abs(duration - 13.0) < 0.2
|
||
|
||
# ── 转场效果测试 ──────────────────────────────────────────────────
|
||
|
||
def test_dissolve_transition(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "dissolve"])
|
||
assert "transition=dissolve" in result
|
||
|
||
def test_slideleft_transition(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "slideleft"])
|
||
assert "transition=slideleft" in result
|
||
|
||
def test_cut_uses_fade(self):
|
||
"""cut 转场效果应该回退到 fade."""
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "cut"])
|
||
# cut 不是 XFADE_TRANSITION_MAP 的键,所以会回退到 fade
|
||
assert "transition=fade" in result
|
||
|
||
def test_transitions_shorter_than_clips(self):
|
||
"""如果 transitions 列表比 clips 短,剩余的用 'cut'(→ fade)."""
|
||
result, _ = build_xfade_filter_chain(
|
||
[5.0, 5.0, 5.0],
|
||
["v0", "v1", "v2"],
|
||
["none"], # 只有一个
|
||
)
|
||
# 第二个转场(index 2)会回退到 cut → fade
|
||
assert result.count("transition=fade") == 2
|
||
|
||
# ── 时长钳制测试 ──────────────────────────────────────────────────
|
||
|
||
def test_short_first_clip_truncates_transition(self):
|
||
"""第一个片段非常短,转场时长应该被钳制."""
|
||
result, duration = build_xfade_filter_chain(
|
||
[0.3, 10.0],
|
||
["v0", "v1"],
|
||
["none", "fade"],
|
||
transition_duration=1.0,
|
||
)
|
||
# offset = max(0, 0.3 - 1.0 * 1) = 0.0
|
||
# available = max(0, 0.3 - 0.0) = 0.3
|
||
# safe_td = min(1.0, 0.3, 剩余 10.0, clip_durations[1] 10.0) = 0.3
|
||
assert "duration=0.300" in result
|
||
assert abs(duration - 10.0) < 0.01 # 0.3 + 10.0 - 0.3 = 10.0
|
||
|
||
def test_zero_duration_clips(self):
|
||
"""零时长片段的边界情况."""
|
||
result, duration = build_xfade_filter_chain([0.0, 5.0], ["v0", "v1"], ["none", "fade"])
|
||
# 第一个片段 0 时长,转场时长应该被钳制到最小值 0.001
|
||
# offset = max(0, 0 - 0.5) = 0
|
||
# available = max(0, 0 - 0) = 0
|
||
# safe_td = min(0.5, 0, ...) = min(0.5, 0, 5.0, 5.0) = 0 → max(0.001, 0) = 0.001
|
||
assert "duration=0.001" in result
|
||
|
||
def test_very_long_transition_duration(self):
|
||
"""转场时长超过所有片段时长."""
|
||
result, duration = build_xfade_filter_chain(
|
||
[2.0, 2.0],
|
||
["v0", "v1"],
|
||
["none", "fade"],
|
||
transition_duration=5.0,
|
||
)
|
||
# offset = max(0, 2.0 - 5.0) = 0
|
||
# available = max(0, 2.0 - 0) = 2.0
|
||
# safe_td = min(5.0, 2.0, 剩余 2.0, 2.0) = 2.0
|
||
assert "duration=2.000" in result
|
||
assert abs(duration - 2.0) < 0.01 # 2 + 2 - 2 = 2
|
||
|
||
# ── 标签测试 ──────────────────────────────────────────────────────
|
||
|
||
def test_custom_labels(self):
|
||
result, _ = build_xfade_filter_chain(
|
||
[10.0, 10.0],
|
||
["clip_a", "clip_b"],
|
||
["none", "fade"],
|
||
output_label="final_v",
|
||
)
|
||
assert "[clip_a][clip_b]" in result
|
||
assert "[final_v]" in result
|
||
|
||
def test_intermediate_labels_three_clips(self):
|
||
result, _ = build_xfade_filter_chain([5.0, 5.0, 5.0], ["v0", "v1", "v2"], ["none", "fade", "fade"])
|
||
# 第一个 xfade 输出 xf1
|
||
assert "[xf1][v2]" in result or result.count("[xf1]") >= 1
|
||
|
||
# ── 总时长计算验证 ────────────────────────────────────────────────
|
||
|
||
def test_total_duration_two_equal_clips(self):
|
||
_, duration = build_xfade_filter_chain([8.0, 8.0], ["v0", "v1"], ["none", "fade"])
|
||
# 8 + 8 - 0.5 = 15.5
|
||
assert abs(duration - 15.5) < 0.01
|
||
|
||
def test_total_duration_no_transition_impossible(self):
|
||
"""即使 transition_duration=0,也有最小 0.001 的钳制."""
|
||
_, duration = build_xfade_filter_chain(
|
||
[10.0, 10.0],
|
||
["v0", "v1"],
|
||
["none", "fade"],
|
||
transition_duration=0.0,
|
||
)
|
||
# transition_duration=0,但 safe_td 有下限 0.001
|
||
assert duration < 20.0 # 应该小于 20(有重叠)
|
||
assert duration > 19.9 # 但接近 20
|
||
|
||
# ── 滤镜字符串格式验证 ────────────────────────────────────────────
|
||
|
||
def test_filter_format_contains_xfade_keyword(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "xfade=" in result
|
||
|
||
def test_filter_uses_semicolon_separator(self):
|
||
"""多步 xfade 之间用分号分隔."""
|
||
result, _ = build_xfade_filter_chain([5.0, 5.0, 5.0], ["v0", "v1", "v2"], ["none", "fade", "fade"])
|
||
assert ";" in result
|
||
# 3个片段 → 2个xfade → 1个分号
|
||
assert result.count("xfade=") == 2
|
||
assert result.count(";") == 1
|
||
|
||
def test_filter_has_transition_param(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "transition=fade" in result
|
||
|
||
def test_filter_has_duration_param(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "duration=" in result
|
||
|
||
def test_filter_has_offset_param(self):
|
||
result, _ = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", "fade"])
|
||
assert "offset=" in result
|
||
|
||
# ── 各种转场效果遍历测试 ──────────────────────────────────────────
|
||
|
||
@pytest.mark.parametrize(
|
||
"transition_name",
|
||
list(XFADE_TRANSITION_MAP.keys()),
|
||
)
|
||
def test_all_supported_transitions(self, transition_name):
|
||
"""所有支持的转场效果都应该能正确生成滤镜."""
|
||
result, duration = build_xfade_filter_chain([10.0, 10.0], ["v0", "v1"], ["none", transition_name])
|
||
expected = XFADE_TRANSITION_MAP[transition_name]
|
||
assert f"transition={expected}" in result
|
||
assert duration > 0
|
||
|
||
# ── 四片段复杂场景 ────────────────────────────────────────────────
|
||
|
||
def test_four_clips_different_durations(self):
|
||
durations = [3.0, 5.0, 2.0, 7.0]
|
||
result, duration = build_xfade_filter_chain(
|
||
durations,
|
||
["v0", "v1", "v2", "v3"],
|
||
["none", "fade", "dissolve", "slideleft"],
|
||
)
|
||
assert result.count("xfade=") == 3
|
||
# 总时长 = sum(durations) - 3 * 0.5 ≈ 17 - 1.5 = 15.5
|
||
assert abs(duration - 15.5) < 0.2
|
||
|
||
# ── transition_duration = 0 的边界 ───────────────────────────────
|
||
|
||
def test_zero_transition_duration_minimum_clamped(self):
|
||
result, _ = build_xfade_filter_chain(
|
||
[10.0, 10.0],
|
||
["v0", "v1"],
|
||
["none", "fade"],
|
||
transition_duration=0.0,
|
||
)
|
||
# 至少 0.001
|
||
assert "duration=0.001" in result
|
||
|
||
# ── 单片段自定义输出标签 ─────────────────────────────────────────
|
||
|
||
def test_single_clip_output_label(self):
|
||
result, _ = build_xfade_filter_chain([5.0], ["v0"], ["none"], output_label="result")
|
||
assert "[v0]copy[result]" in result
|