test(unit): 第65波 - intro_outro + transition + pip 引擎配置 (+82) (#861)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled

This commit was merged in pull request #861.
This commit is contained in:
2026-07-25 08:13:03 +08:00
parent 17cf970ae6
commit c192398db6
3 changed files with 550 additions and 1185 deletions
+209 -201
View File
@@ -1,301 +1,309 @@
"""
片头片尾引擎配置与纯逻辑测试.
"""片头片尾引擎单元测试 - 配置解析等纯逻辑."""
覆盖 IntroOutroConfig.from_dict / validate / has_intro / has_outro 等纯逻辑.
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
"""
from __future__ import annotations
import pytest
from video_processing.intro_outro_engine import IntroOutroConfig
class TestIntroOutroConfigDefaults:
"""默认配置测试."""
def test_default_values(self):
"""默认值正确."""
config = IntroOutroConfig()
assert config.enabled is False
assert config.intro_type == "none"
assert config.outro_type == "none"
assert config.intro_duration == 3.0
assert config.outro_duration == 3.0
assert config.transition_effect == "fade"
assert config.transition_duration == 0.5
class TestIntroOutroConfigFromDict:
"""from_dict 构造逻辑."""
"""from_dict 配置解析测试."""
def test_none_returns_default_disabled(self):
cfg = IntroOutroConfig.from_dict(None)
assert cfg.enabled is False
assert cfg.intro_type == "none"
assert cfg.outro_type == "none"
def test_none_returns_default(self):
"""None 返回默认配置."""
config = IntroOutroConfig.from_dict(None)
assert config.enabled is False
def test_empty_dict_returns_default_disabled(self):
cfg = IntroOutroConfig.from_dict({})
assert cfg.enabled is False
def test_empty_dict_returns_default(self):
"""空 dict 返回默认."""
config = IntroOutroConfig.from_dict({})
assert config.enabled is False
def test_enabled_false_returns_default_disabled(self):
cfg = IntroOutroConfig.from_dict({"enabled": False})
assert cfg.enabled is False
def test_disabled_returns_default(self):
"""enabled=False 返回默认."""
config = IntroOutroConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_enabled_with_video_intro(self):
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "video",
"video_path": "/tmp/intro.mp4",
"duration": 5.0,
},
"outro": {"type": "none"},
})
assert cfg.enabled is True
assert cfg.intro_type == "video"
assert cfg.intro_video_path == "/tmp/intro.mp4"
assert cfg.intro_duration == 5.0
def test_enabled_defaults(self):
"""启用时默认值正确."""
config = IntroOutroConfig.from_dict({"enabled": True})
assert config.enabled is True
assert config.intro_type == "none"
assert config.outro_type == "none"
def test_enabled_with_text_intro(self):
cfg = IntroOutroConfig.from_dict({
def test_text_intro(self):
"""文字片头配置."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "text",
"title": "Hello",
"subtitle": "World",
"background": "#ffffff",
"title_color": "black",
"title_size": 64,
"duration": 2.5,
},
"outro": {"type": "none"},
})
assert cfg.enabled is True
assert cfg.intro_type == "text"
assert cfg.intro_title == "Hello"
assert cfg.intro_subtitle == "World"
assert cfg.intro_background == "#ffffff"
assert cfg.intro_title_color == "black"
assert cfg.intro_title_size == 64
assert cfg.intro_duration == 2.5
def test_enabled_with_video_outro(self):
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {"type": "none"},
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 4.0,
"title": "我的片头",
"subtitle": "欢迎收看",
},
})
assert cfg.enabled is True
assert cfg.outro_type == "video"
assert cfg.outro_video_path == "/tmp/outro.mp4"
assert cfg.outro_duration == 4.0
assert config.intro_type == "text"
assert config.intro_title == "我的片头"
assert config.intro_subtitle == "欢迎收看"
def test_enabled_with_text_outro_default_values(self):
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {"type": "none"},
"outro": {"type": "text"},
})
assert cfg.outro_title == "感谢观看"
assert cfg.outro_subtitle == "点赞关注不迷路"
assert cfg.outro_title_size == 48
assert cfg.outro_duration == 3.0
def test_video_key_fallback(self):
"""video 字段作为 video_path 的 fallback."""
cfg = IntroOutroConfig.from_dict({
def test_video_intro(self):
"""视频片头配置."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "video",
"video": "/tmp/fallback.mp4",
"video_path": "/videos/intro.mp4",
"duration": 5.0,
},
"outro": {"type": "none"},
})
assert cfg.intro_video_path == "/tmp/fallback.mp4"
assert config.intro_type == "video"
assert config.intro_video_path == "/videos/intro.mp4"
assert config.intro_duration == 5.0
def test_video_intro_video_alias(self):
"""video 字段作为 video_path 别名."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "video",
"video": "/videos/intro.mp4",
},
})
assert config.intro_video_path == "/videos/intro.mp4"
def test_text_outro(self):
"""文字片尾配置."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"outro": {
"type": "text",
"title": "感谢观看",
"subtitle": "点赞关注",
},
})
assert config.outro_type == "text"
assert config.outro_title == "感谢观看"
assert config.outro_subtitle == "点赞关注"
def test_outro_default_title(self):
"""片尾默认标题."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"outro": {"type": "text"},
})
assert config.outro_title == "感谢观看"
assert config.outro_subtitle == "点赞关注不迷路"
def test_text_intro_styling(self):
"""文字片头样式配置."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "text",
"title": "测试",
"background": "#FF0000",
"title_color": "yellow",
"title_size": 64,
"subtitle_color": "white",
"subtitle_size": 32,
},
})
assert config.intro_background == "#FF0000"
assert config.intro_title_color == "yellow"
assert config.intro_title_size == 64
assert config.intro_subtitle_color == "white"
assert config.intro_subtitle_size == 32
def test_transition_config(self):
cfg = IntroOutroConfig.from_dict({
"""转场配置."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {"type": "none"},
"outro": {"type": "none"},
"transition": "fade",
"transition": "dissolve",
"transition_duration": 1.0,
})
assert cfg.transition_effect == "fade"
assert cfg.transition_duration == 1.0
assert config.transition_effect == "dissolve"
assert config.transition_duration == 1.0
def test_default_transition(self):
cfg = IntroOutroConfig.from_dict({
def test_empty_intro_dict(self):
"""空 intro dict."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {"type": "none"},
"outro": {"type": "none"},
"intro": {},
})
assert cfg.transition_effect == "fade"
assert cfg.transition_duration == 0.5
assert config.intro_type == "none"
def test_none_intro(self):
"""None intro 值."""
config = IntroOutroConfig.from_dict({
"enabled": True,
"intro": None,
})
assert config.intro_type == "none"
class TestIntroOutroConfigProperties:
"""has_intro / has_outro 属性."""
def test_has_intro_video_type(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="/tmp/a.mp4",
)
assert cfg.has_intro is True
def test_has_intro_text_type(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
)
assert cfg.has_intro is True
class TestHasIntroOutro:
"""has_intro / has_outro 属性测试."""
def test_no_intro_when_disabled(self):
cfg = IntroOutroConfig(
enabled=False,
intro_type="video",
intro_video_path="/tmp/a.mp4",
)
assert cfg.has_intro is False
"""禁用时无片头."""
config = IntroOutroConfig()
assert config.has_intro is False
assert config.has_outro is False
def test_no_intro_when_none_type(self):
cfg = IntroOutroConfig(
def test_video_intro_has_intro(self):
"""视频片头有has_intro."""
config = IntroOutroConfig(
enabled=True,
intro_type="none",
intro_type="video",
intro_video_path="/a.mp4",
)
assert cfg.has_intro is False
assert config.has_intro is True
def test_has_outro_video_type(self):
cfg = IntroOutroConfig(
def test_text_intro_has_intro(self):
"""文字片头有has_intro."""
config = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="test",
)
assert config.has_intro is True
def test_none_intro_no_intro(self):
"""none类型无片头."""
config = IntroOutroConfig(enabled=True, intro_type="none")
assert config.has_intro is False
def test_video_outro_has_outro(self):
"""视频片尾有has_outro."""
config = IntroOutroConfig(
enabled=True,
outro_type="video",
outro_video_path="/tmp/a.mp4",
outro_video_path="/a.mp4",
)
assert cfg.has_outro is True
assert config.has_outro is True
def test_has_outro_text_type(self):
cfg = IntroOutroConfig(
def test_text_outro_has_outro(self):
"""文字片尾有has_outro."""
config = IntroOutroConfig(
enabled=True,
outro_type="text",
outro_title="Bye",
outro_title="test",
)
assert cfg.has_outro is True
assert config.has_outro is True
def test_has_outro_follow_type(self):
cfg = IntroOutroConfig(
def test_follow_outro_has_outro(self):
"""follow类型片尾有has_outro."""
config = IntroOutroConfig(
enabled=True,
outro_type="follow",
outro_title="Follow me",
outro_title="test",
)
assert cfg.has_outro is True
def test_no_outro_when_disabled(self):
cfg = IntroOutroConfig(
enabled=False,
outro_type="text",
outro_title="Bye",
)
assert cfg.has_outro is False
def test_no_outro_when_none_type(self):
cfg = IntroOutroConfig(
enabled=True,
outro_type="none",
)
assert cfg.has_outro is False
assert config.has_outro is True
class TestIntroOutroConfigValidate:
"""validate 校验逻辑."""
class TestValidate:
"""validate 配置校验测试."""
def test_disabled_is_valid(self):
cfg = IntroOutroConfig(enabled=False)
ok, msg = cfg.validate()
def test_disabled_valid(self):
"""禁用配置合法."""
config = IntroOutroConfig()
ok, msg = config.validate()
assert ok is True
assert msg == ""
def test_video_intro_missing_path(self):
cfg = IntroOutroConfig(
"""视频片头缺少路径."""
config = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="",
outro_type="none",
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is False
assert "video_path" in msg
def test_text_intro_missing_title(self):
cfg = IntroOutroConfig(
"""文字片头缺少标题."""
config = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="",
outro_type="none",
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is False
assert "title" in msg
def test_video_outro_missing_path(self):
cfg = IntroOutroConfig(
"""视频片尾缺少路径."""
config = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="video",
outro_video_path="",
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is False
assert "video_path" in msg
def test_text_outro_missing_title(self):
cfg = IntroOutroConfig(
"""文字片尾缺少标题."""
config = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="text",
outro_title="",
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is False
assert "title" in msg
def test_intro_duration_zero(self):
cfg = IntroOutroConfig(
def test_zero_intro_duration_invalid(self):
"""片头时长为0无效."""
config = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_title="test",
intro_duration=0,
outro_type="none",
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is False
assert "片头时长" in msg
assert "时长" in msg
def test_intro_duration_negative(self):
cfg = IntroOutroConfig(
def test_negative_outro_duration_invalid(self):
"""片尾时长为负无效."""
config = IntroOutroConfig(
enabled=True,
outro_type="text",
outro_title="test",
outro_duration=-1.0,
)
ok, msg = config.validate()
assert ok is False
assert "时长" in msg
def test_valid_text_both(self):
"""文字片头片尾都合法."""
config = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="Hi",
intro_duration=-1.0,
outro_type="none",
)
ok, msg = cfg.validate()
assert ok is False
assert "片头时长" in msg
def test_outro_duration_zero(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="none",
outro_type="text",
outro_title="Bye",
outro_duration=0,
)
ok, msg = cfg.validate()
assert ok is False
assert "片尾时长" in msg
def test_valid_full_config(self):
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_video_path="/tmp/intro.mp4",
intro_title="片头",
intro_duration=3.0,
outro_type="text",
outro_title="Thanks",
outro_duration=2.0,
outro_title="片尾",
outro_duration=3.0,
)
ok, msg = cfg.validate()
ok, msg = config.validate()
assert ok is True
assert msg == ""
+215 -550
View File
@@ -1,597 +1,262 @@
"""画中画PiP引擎单元测试."""
"""画中画引擎单元测试 - 配置解析+校验等纯逻辑."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from video_processing.pip_engine import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_RIGHT,
POSITION_CENTER,
POSITION_TOP_LEFT,
POSITION_TOP_RIGHT,
PiPConfig,
PiPEngine,
PiPLayerConfig,
)
# ── PiPLayerConfig.validate 测试 ──────────────────────────────────────────────
from video_processing.pip_engine import PiPConfig, PiPLayerConfig
class TestPiPLayerConfigValidate:
"""PiP图层配置校验测试."""
def test_valid_config(self):
"""正常配置应该通过校验."""
layer = PiPLayerConfig(source="asset_001")
ok, err = layer.validate()
assert ok
assert err == ""
def test_empty_source(self):
"""空source应该失败."""
layer = PiPLayerConfig(source="")
ok, err = layer.validate()
assert not ok
assert "source" in err
def test_invalid_position(self):
"""无效位置应该失败."""
layer = PiPLayerConfig(source="asset_001", position="invalid_pos")
ok, err = layer.validate()
assert not ok
assert "position" in err
def test_custom_position_valid(self):
"""custom位置应该通过."""
layer = PiPLayerConfig(source="asset_001", position="custom", x=100, y=50)
ok, err = layer.validate()
assert ok
def test_opacity_out_of_range_high(self):
"""opacity超过1应该失败."""
layer = PiPLayerConfig(source="asset_001", opacity=1.5)
ok, err = layer.validate()
assert not ok
assert "opacity" in err
def test_opacity_out_of_range_low(self):
"""opacity小于0应该失败."""
layer = PiPLayerConfig(source="asset_001", opacity=-0.5)
ok, err = layer.validate()
assert not ok
assert "opacity" in err
def test_opacity_boundary_values(self):
"""opacity边界值应该通过."""
for val in [0.0, 0.5, 1.0]:
layer = PiPLayerConfig(source="asset_001", opacity=val)
ok, _ = layer.validate()
assert ok
def test_negative_corner_radius(self):
"""负圆角应该失败."""
layer = PiPLayerConfig(source="asset_001", corner_radius=-5)
ok, err = layer.validate()
assert not ok
assert "corner_radius" in err
def test_negative_start_time(self):
"""负开始时间应该失败."""
layer = PiPLayerConfig(source="asset_001", start_time=-1.0)
ok, err = layer.validate()
assert not ok
assert "start_time" in err
def test_negative_duration(self):
"""负持续时间应该失败."""
layer = PiPLayerConfig(source="asset_001", duration=-5.0)
ok, err = layer.validate()
assert not ok
assert "duration" in err
def test_invalid_animation_in(self):
"""无效入场动画应该失败."""
layer = PiPLayerConfig(source="asset_001", animation_in="spin")
ok, err = layer.validate()
assert not ok
assert "入场动画" in err
def test_all_valid_animations(self):
"""所有有效动画类型应该通过."""
for anim in [
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
]:
layer = PiPLayerConfig(source="asset_001", animation_in=anim, animation_out=anim)
ok, _ = layer.validate()
assert ok
def test_zero_duration_valid(self):
"""duration=0(全程显示)应该通过."""
layer = PiPLayerConfig(source="asset_001", duration=0.0)
ok, _ = layer.validate()
assert ok
# ── PiPConfig.from_dict 测试 ──────────────────────────────────────────────────
class TestPiPConfigFromDict:
"""PiP配置字典解析测试."""
def test_none_config(self):
"""None配置应该返回disabled."""
config = PiPConfig.from_dict(None)
assert not config.enabled
assert len(config.layers) == 0
def test_empty_config(self):
"""空字典应该返回disabled."""
config = PiPConfig.from_dict({})
assert not config.enabled
def test_enabled_false(self):
"""enabled=False应该返回disabled."""
config = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]})
assert not config.enabled
def test_single_layer(self):
"""单图层解析."""
data = {
"enabled": True,
"layers": [
{
"source": "asset_001",
"position": POSITION_TOP_RIGHT,
"width": "30%",
"opacity": 0.9,
"corner_radius": 10,
"start_time": 2.0,
"duration": 5.0,
"z_index": 2,
}
],
}
config = PiPConfig.from_dict(data)
assert config.enabled
assert len(config.layers) == 1
layer = config.layers[0]
assert layer.source == "asset_001"
assert layer.position == POSITION_TOP_RIGHT
assert layer.width == "30%"
assert layer.opacity == 0.9
assert layer.corner_radius == 10
assert layer.start_time == 2.0
assert layer.duration == 5.0
assert layer.z_index == 2
def test_multiple_layers_sorted_by_z_index(self):
"""多图层应该按z_index排序."""
data = {
"enabled": True,
"layers": [
{"source": "asset_high", "z_index": 5},
{"source": "asset_low", "z_index": 1},
{"source": "asset_mid", "z_index": 3},
],
}
config = PiPConfig.from_dict(data)
assert len(config.layers) == 3
assert config.layers[0].source == "asset_low"
assert config.layers[1].source == "asset_mid"
assert config.layers[2].source == "asset_high"
def test_invalid_layer_skipped(self):
"""无效图层应该被跳过."""
data = {
"enabled": True,
"layers": [
{"source": "asset_good"},
{"source": "", "position": "invalid"}, # 空source
{"source": "asset_good2", "opacity": 2.0}, # opacity超范围
],
}
config = PiPConfig.from_dict(data)
# 第1个有效,第2、3个无效
assert len(config.layers) == 1
assert config.layers[0].source == "asset_good"
def test_all_invalid_layers_disabled(self):
"""所有图层都无效时enabled为False."""
data = {
"enabled": True,
"layers": [
{"source": ""},
{"source": ""},
],
}
config = PiPConfig.from_dict(data)
assert not config.enabled
assert len(config.layers) == 0
class TestPiPLayerConfigDefaults:
"""PiPLayerConfig 默认配置测试."""
def test_default_values(self):
"""默认值应该正确."""
data = {
"enabled": True,
"layers": [{"source": "asset_001"}],
}
config = PiPConfig.from_dict(data)
layer = config.layers[0]
assert layer.position == POSITION_BOTTOM_RIGHT
"""默认值正确."""
layer = PiPLayerConfig()
assert layer.source == ""
assert layer.source_type == "asset_id"
assert layer.position == "bottom_right"
assert layer.margin == 20
assert layer.width == "25%"
assert layer.height == ""
assert layer.opacity == 1.0
assert layer.corner_radius == 0
assert layer.border_width == 0
assert layer.border_color == "white"
assert layer.start_time == 0.0
assert layer.duration == 0.0
assert layer.animation_in == ""
assert layer.animation_out == ""
assert layer.animation_duration == 0.5
assert layer.z_index == 1
# ── PiPEngine 位置计算测试 ────────────────────────────────────────────────────
class TestPiPLayerConfigValidate:
"""PiPLayerConfig.validate 校验测试."""
def test_valid_config(self):
"""合法配置."""
layer = PiPLayerConfig(source="asset_123")
ok, msg = layer.validate()
assert ok is True
assert msg == ""
class TestPiPEnginePosition:
"""PiP引擎位置计算测试."""
def test_empty_source_invalid(self):
"""空source非法."""
layer = PiPLayerConfig(source="")
ok, msg = layer.validate()
assert ok is False
assert "source" in msg
@pytest.fixture
def engine(self):
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
def test_invalid_position(self):
"""无效position."""
layer = PiPLayerConfig(source="asset_123", position="invalid_pos")
ok, msg = layer.validate()
assert ok is False
assert "position" in msg
def test_top_left_position(self, engine):
"""左上角位置."""
layer = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, margin=20)
x, y = engine._parse_position(layer, 480, 270)
assert x == 20
assert y == 20
def test_top_right_position(self, engine):
"""右上角位置."""
layer = PiPLayerConfig(source="a", position=POSITION_TOP_RIGHT, margin=20)
x, y = engine._parse_position(layer, 480, 270)
assert x == 1920 - 480 - 20
assert y == 20
def test_bottom_right_position(self, engine):
"""右下角位置(默认)."""
layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_RIGHT, margin=30)
x, y = engine._parse_position(layer, 480, 270)
assert x == 1920 - 480 - 30
assert y == 1080 - 270 - 30
def test_bottom_left_position(self, engine):
"""左下角位置."""
layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_LEFT, margin=15)
x, y = engine._parse_position(layer, 480, 270)
assert x == 15
assert y == 1080 - 270 - 15
def test_center_position(self, engine):
"""中心位置."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, margin=0)
x, y = engine._parse_position(layer, 480, 270)
assert x == (1920 - 480) // 2
assert y == (1080 - 270) // 2
def test_custom_position_pixel(self, engine):
"""自定义像素位置."""
layer = PiPLayerConfig(source="a", position="custom", x=100, y=200)
x, y = engine._parse_position(layer, 480, 270)
assert x == 100
assert y == 200
def test_custom_position_percentage(self, engine):
"""自定义百分比位置."""
layer = PiPLayerConfig(source="a", position="custom", x="50%", y="25%")
x, y = engine._parse_position(layer, 480, 270)
assert x == 1920 // 2
assert y == 1080 // 4
def test_top_center_position(self, engine):
"""顶部居中位置."""
layer = PiPLayerConfig(source="a", position="top_center", margin=10)
x, y = engine._parse_position(layer, 480, 270)
assert x == (1920 - 480) // 2
assert y == 10
def test_invalid_position_fallback(self, engine):
"""无效位置应该fallback到右下角."""
layer = PiPLayerConfig(source="a", position="unknown_position", margin=20)
# 直接测试_parse_position(注意:validate会拦截,但_parse_position自己也有fallback
x, y = engine._parse_position(layer, 480, 270)
assert x == 1920 - 480 - 20
assert y == 1080 - 270 - 20
# ── PiPEngine 尺寸解析测试 ────────────────────────────────────────────────────
class TestPiPEngineSize:
"""PiP引擎尺寸解析测试."""
@pytest.fixture
def engine(self):
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
def test_pixel_size_int(self, engine):
"""像素尺寸(整数)."""
assert engine._parse_size(500, 1920) == 500
def test_pixel_size_str(self, engine):
"""像素尺寸(字符串数字)."""
assert engine._parse_size("500", 1920) == 500
def test_percentage_size(self, engine):
"""百分比尺寸."""
assert engine._parse_size("50%", 1920) == 960
assert engine._parse_size("25%", 1920) == 480
def test_zero_size_default(self, engine):
"""0或无效值应该有最小值保护."""
assert engine._parse_size(0, 1920) == 1
assert engine._parse_size("", 1920) == 480 # 默认25%
def test_negative_size_default(self, engine):
"""负值应该取绝对值后至少为1."""
# _parse_size 用 max(1, value),负值会走 except 分支
result = engine._parse_size("-100", 1920)
# 会走ValueError分支,返回默认值
assert result > 0
# ── PiPEngine 滤镜构建测试 ────────────────────────────────────────────────────
class TestPiPEngineBuildFilters:
"""PiP引擎滤镜构建测试."""
@pytest.fixture
def engine(self):
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
@pytest.fixture
def fake_video(self, tmp_path):
"""创建一个假的视频文件路径."""
path = tmp_path / "test_video.mp4"
path.write_bytes(b"fake video data")
return path
def test_empty_sources(self, engine):
"""空素材列表应该返回空."""
filters, inputs, label = engine.build_pip_filters("base_label", [])
assert filters == []
assert inputs == []
assert label == "base_label"
def test_single_layer_basic(self, engine, fake_video):
"""单图层基础滤镜构建."""
def test_custom_position_valid(self):
"""custom位置合法."""
layer = PiPLayerConfig(
source="asset_001",
position=POSITION_TOP_RIGHT,
width="25%",
source="asset_123",
position="custom",
x=100,
y=100,
)
sources = [("pip_src_0", layer, fake_video)]
ok, _ = layer.validate()
assert ok is True
filters, inputs, final_label = engine.build_pip_filters("base_video", sources, base_input_idx=3)
def test_opacity_too_high(self):
"""透明度超过1."""
layer = PiPLayerConfig(source="a", opacity=1.5)
ok, msg = layer.validate()
assert ok is False
assert "opacity" in msg
# 应该有2个滤镜: 预处理 + overlay
assert len(filters) == 2
# 输入参数应该有2个(-i + path)
assert len(inputs) == 2
assert inputs[0] == "-i"
assert inputs[1] == str(fake_video)
def test_opacity_negative(self):
"""透明度为负."""
layer = PiPLayerConfig(source="a", opacity=-0.1)
ok, msg = layer.validate()
assert ok is False
assert "opacity" in msg
# 预处理滤镜应该使用正确的输入索引
assert "3:v" in filters[0]
# 应该包含scale
assert "scale=" in filters[0]
# 应该有pip_pre_0标签
assert "[pip_pre_0]" in filters[0]
def test_opacity_boundary_zero(self):
"""透明度边界值0."""
layer = PiPLayerConfig(source="a", opacity=0.0)
ok, _ = layer.validate()
assert ok is True
# overlay滤镜
assert "overlay=" in filters[1]
assert "[base_video][pip_pre_0]" in filters[1]
def test_opacity_boundary_one(self):
"""透明度边界值1."""
layer = PiPLayerConfig(source="a", opacity=1.0)
ok, _ = layer.validate()
assert ok is True
def test_single_layer_final_label(self, engine, fake_video):
"""最终输出标签应该正确."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER)
sources = [("s0", layer, fake_video)]
def test_negative_corner_radius(self):
"""负圆角."""
layer = PiPLayerConfig(source="a", corner_radius=-5)
ok, msg = layer.validate()
assert ok is False
assert "corner_radius" in msg
_, _, final_label = engine.build_pip_filters("main_v", sources)
assert final_label == "pip_combined_0"
def test_negative_start_time(self):
"""负开始时间."""
layer = PiPLayerConfig(source="a", start_time=-1.0)
ok, msg = layer.validate()
assert ok is False
assert "start_time" in msg
def test_multiple_layers(self, engine, fake_video):
"""多图层叠加."""
layer1 = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, z_index=1)
layer2 = PiPLayerConfig(source="b", position=POSITION_BOTTOM_RIGHT, z_index=2)
sources = [
("s0", layer1, fake_video),
("s1", layer2, fake_video),
]
def test_negative_duration(self):
"""负时长."""
layer = PiPLayerConfig(source="a", duration=-2.0)
ok, msg = layer.validate()
assert ok is False
assert "duration" in msg
filters, inputs, final_label = engine.build_pip_filters("base", sources, base_input_idx=0)
def test_zero_duration_valid(self):
"""零时长(全程显示)合法."""
layer = PiPLayerConfig(source="a", duration=0.0)
ok, _ = layer.validate()
assert ok is True
# 2层 × 2个滤镜(预处理+overlay)= 4个滤镜
assert len(filters) == 4
# 2个输入文件
assert len(inputs) == 4 # 2 × (-i + path)
def test_invalid_animation_in(self):
"""无效入场动画."""
layer = PiPLayerConfig(source="a", animation_in="invalid_anim")
ok, msg = layer.validate()
assert ok is False
assert "入场动画" in msg
# 输入索引应该连续
assert "0:v" in filters[0]
assert "1:v" in filters[2]
def test_invalid_animation_out(self):
"""无效出场动画."""
layer = PiPLayerConfig(source="a", animation_out="invalid_anim")
ok, msg = layer.validate()
assert ok is False
assert "出场动画" in msg
# 最终标签应该是第二个overlay的输出
assert final_label == "pip_combined_1"
def test_with_opacity(self, engine, fake_video):
"""透明度应该在滤镜中体现."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=0.5)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "colorchannelmixer=aa=0.5" in pre_filter
assert "yuva420p" in pre_filter
def test_with_corner_radius(self, engine, fake_video):
"""圆角裁剪应该在滤镜中体现."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=20)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "geq=" in pre_filter
def test_with_border(self, engine, fake_video):
"""边框应该在滤镜中体现."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, border_width=3, border_color="red")
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "pad=" in pre_filter
assert "red" in pre_filter
def test_timing_start_time_and_duration(self, engine, fake_video):
"""时间控制应该生成enable表达式."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=5.0, duration=10.0)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
overlay_filter = filters[1]
assert "enable=" in overlay_filter
assert "between(t,5.0,15.0)" in overlay_filter
def test_timing_start_time_only(self, engine, fake_video):
"""只有开始时间(全程显示到结束)."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=3.0, duration=0.0)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
overlay_filter = filters[1]
assert "enable=" in overlay_filter
assert "gte(t,3.0)" in overlay_filter
def test_no_timing_no_enable(self, engine, fake_video):
"""无时间限制时不应该有enable表达式."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=0.0, duration=0.0)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
overlay_filter = filters[1]
assert "enable=" not in overlay_filter
def test_fade_animation(self, engine, fake_video):
"""淡入淡出动画."""
layer = PiPLayerConfig(
source="a",
position=POSITION_CENTER,
animation_in=ANIMATION_FADE,
animation_out=ANIMATION_FADE,
duration=10.0,
animation_duration=0.8,
)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "fade=t=in" in pre_filter
assert "fade=t=out" in pre_filter
assert "alpha=1" in pre_filter
def test_slide_animation_in(self, engine, fake_video):
"""滑入动画应该在overlay表达式中."""
layer = PiPLayerConfig(
source="a",
position=POSITION_CENTER,
animation_in=ANIMATION_SLIDE_LEFT,
animation_duration=0.5,
)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
overlay_filter = filters[1]
# x表达式应该包含动态变化
assert "overlay=" in overlay_filter
def test_full_opacity_no_alpha(self, engine, fake_video):
"""opacity=1时不应该有colorchannelmixer."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=1.0)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "colorchannelmixer" not in pre_filter
def test_zero_corner_radius_no_geq(self, engine, fake_video):
"""corner_radius=0时不应该有geq滤镜."""
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=0)
sources = [("s0", layer, fake_video)]
filters, _, _ = engine.build_pip_filters("base", sources)
pre_filter = filters[0]
assert "geq=" not in pre_filter
def test_negative_animation_duration(self):
"""负动画时长."""
layer = PiPLayerConfig(source="a", animation_duration=-0.5)
ok, msg = layer.validate()
assert ok is False
assert "animation_duration" in msg
# ── PiPEngine 素材验证(降级策略)测试 ────────────────────────────────────────
class TestPiPConfigDefaults:
"""PiPConfig 默认配置测试."""
def test_default_values(self):
"""默认值正确."""
config = PiPConfig()
assert config.enabled is False
assert config.layers == []
class TestPiPEngineValidateSource:
"""PiP引擎素材验证与降级测试."""
class TestPiPConfigFromDict:
"""PiPConfig.from_dict 解析测试."""
@pytest.fixture
def engine(self):
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
def test_none_returns_disabled(self):
"""None 返回禁用配置."""
config = PiPConfig.from_dict(None)
assert config.enabled is False
assert config.layers == []
def test_asset_id_in_map(self, engine, tmp_path):
"""asset_id在map中应该返回路径."""
asset_path = tmp_path / "test.mp4"
asset_path.write_bytes(b"data")
asset_map = {"asset_001": asset_path}
def test_empty_dict_returns_disabled(self):
"""空 dict 返回禁用."""
config = PiPConfig.from_dict({})
assert config.enabled is False
layer = PiPLayerConfig(source="asset_001", source_type="asset_id")
result = engine.validate_layer_source(layer, asset_map)
assert result == asset_path
def test_disabled_returns_disabled(self):
"""enabled=False 返回禁用."""
config = PiPConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_asset_id_not_in_map(self, engine):
"""asset_id不在map中应该返回None(降级)."""
layer = PiPLayerConfig(source="nonexistent", source_type="asset_id")
result = engine.validate_layer_source(layer, {})
assert result is None
def test_enabled_no_layers(self):
"""启用但无图层,disabled."""
config = PiPConfig.from_dict({"enabled": True, "layers": []})
assert config.enabled is False
assert config.layers == []
def test_local_path_exists(self, engine, tmp_path):
"""本地路径存在应该返回."""
path = tmp_path / "video.mp4"
path.write_bytes(b"data")
def test_single_layer(self):
"""单个图层."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "asset_001", "position": "top_left"},
],
})
assert config.enabled is True
assert len(config.layers) == 1
assert config.layers[0].source == "asset_001"
assert config.layers[0].position == "top_left"
layer = PiPLayerConfig(source=str(path), source_type="local_path")
result = engine.validate_layer_source(layer, {})
assert result == path
def test_multiple_layers_sorted_by_z_index(self):
"""多个图层按z_index排序."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "a", "z_index": 3},
{"source": "b", "z_index": 1},
{"source": "c", "z_index": 2},
],
})
assert len(config.layers) == 3
assert config.layers[0].z_index == 1
assert config.layers[1].z_index == 2
assert config.layers[2].z_index == 3
def test_local_path_not_exists(self, engine):
"""本地路径不存在应该返回None(降级)."""
layer = PiPLayerConfig(source="/nonexistent/path.mp4", source_type="local_path")
result = engine.validate_layer_source(layer, {})
assert result is None
def test_invalid_layer_skipped(self):
"""无效图层跳过."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "valid_asset"},
{"source": ""}, # 无效,空source
],
})
assert len(config.layers) == 1
assert config.layers[0].source == "valid_asset"
def test_url_type_not_supported(self, engine):
"""URL类型暂时不支持,返回None."""
layer = PiPLayerConfig(source="http://example.com/video.mp4", source_type="url")
result = engine.validate_layer_source(layer, {})
assert result is None
def test_all_invalid_layers_disabled(self):
"""全部无效则disabled."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": ""},
{"source": ""},
],
})
assert config.enabled is False
assert config.layers == []
def test_exception_handling(self, engine):
"""异常情况应该返回None(不阻断)."""
layer = PiPLayerConfig(source=None, source_type="local_path") # type: ignore
# 模拟异常情况
result = engine.validate_layer_source(layer, {})
assert result is None
def test_layer_full_config(self):
"""完整图层配置."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{
"source": "https://example.com/video.mp4",
"source_type": "url",
"position": "bottom_right",
"width": "30%",
"opacity": 0.8,
"corner_radius": 10,
"border_width": 2,
"border_color": "red",
"start_time": 5.0,
"duration": 10.0,
"z_index": 5,
},
],
})
assert len(config.layers) == 1
layer = config.layers[0]
assert layer.source == "https://example.com/video.mp4"
assert layer.source_type == "url"
assert layer.width == "30%"
assert layer.opacity == 0.8
assert layer.corner_radius == 10
assert layer.border_width == 2
assert layer.border_color == "red"
assert layer.start_time == 5.0
assert layer.duration == 10.0
assert layer.z_index == 5
+126 -434
View File
@@ -1,484 +1,176 @@
"""转场特效引擎单测 — Phase 8 智能增强."""
"""转场引擎单元测试 - 配置解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.transition_engine import (
CUT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
MAX_TRANSITION_DURATION,
MIN_TRANSITION_DURATION,
TransitionConfig,
TransitionEngine,
TransitionType,
_normalize_transition_name,
)
# ── TransitionType 枚举测试 ──────────────────────────────────────────────────
class TestTransitionConstants:
"""常量测试."""
def test_duration_ranges(self):
"""时长范围合理."""
assert MIN_TRANSITION_DURATION == 0.3
assert MAX_TRANSITION_DURATION == 2.0
assert DEFAULT_TRANSITION_DURATION == 0.5
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
def test_cut_transition_value(self):
"""cut转场值."""
assert CUT_TRANSITION == "cut"
class TestTransitionType:
"""TransitionType 枚举测试."""
def test_all_supported_count(self):
"""支持的转场类型数量(不含cut."""
supported = TransitionType.all_supported()
# 至少 8 种:fade, dissolve, slide*4, zoom, wipe*4, circlecrop, rectcrop
assert len(supported) >= 8
assert "fade" in supported
assert "dissolve" in supported
assert "zoom" in supported
assert "circlecrop" in supported
assert "rectcrop" in supported
def test_supports_fade(self):
"""支持fade."""
assert TransitionType.is_supported("fade") is True
def test_slide_directions(self):
"""四个方向的滑入转场都支持."""
assert TransitionType.is_supported("slideleft")
assert TransitionType.is_supported("slideright")
assert TransitionType.is_supported("slideup")
assert TransitionType.is_supported("slidedown")
def test_supports_cut(self):
"""cut也在TransitionType枚举中."""
assert "cut" in [t.value for t in TransitionType]
def test_wipe_directions(self):
"""四个方向的擦除转场都支持."""
assert TransitionType.is_supported("wipeleft")
assert TransitionType.is_supported("wiperight")
assert TransitionType.is_supported("wipeup")
assert TransitionType.is_supported("wipedown")
def test_unsupported_effect(self):
"""不支持的效果."""
assert TransitionType.is_supported("nonexistent_effect_xyz") is False
def test_is_supported_case_insensitive(self):
"""大小写不敏感."""
assert TransitionType.is_supported("FADE")
assert TransitionType.is_supported("Fade")
assert TransitionType.is_supported("fade")
"""是否大小写不敏感(看实现)."""
# 直接测试几个已知的
assert TransitionType.is_supported("fade") is True
assert TransitionType.is_supported("dissolve") is True
def test_is_supported_with_underscores(self):
"""下划线不影响判断."""
assert TransitionType.is_supported("slide_left")
assert TransitionType.is_supported("slide-left")
def test_is_supported_aliases(self):
"""别名支持."""
assert TransitionType.is_supported("crossfade")
assert TransitionType.is_supported("dissolve")
assert TransitionType.is_supported("zoomin")
assert TransitionType.is_supported("wipe")
def test_unsupported_transition(self):
"""不支持的转场返回 False."""
assert not TransitionType.is_supported("nonexistent_effect")
assert not TransitionType.is_supported("random_stuff")
assert not TransitionType.is_supported("")
def test_cut_not_in_supported(self):
"""硬切不在"支持的转场效果"列表中(它不是特效)."""
supported = TransitionType.all_supported()
assert "cut" not in supported
def test_all_types_have_value(self):
"""所有枚举都有有效值."""
for t in TransitionType:
assert isinstance(t.value, str)
assert len(t.value) > 0
# ── 名称标准化测试 ────────────────────────────────────────────────────────────
class TestTransitionConfigParse:
"""TransitionConfig.parse 解析测试."""
def test_no_args_default(self):
"""无参数默认配置."""
config = TransitionConfig.parse()
assert config.effect == CUT_TRANSITION
assert config.duration == DEFAULT_TRANSITION_DURATION
class TestNormalizeTransitionName:
"""名称标准化函数测试."""
def test_none_effect_default(self):
"""None effect默认为cut."""
config = TransitionConfig.parse(effect=None)
assert config.effect == CUT_TRANSITION
def test_lowercase(self):
"""大写转小写."""
assert _normalize_transition_name("FADE") == "fade"
assert _normalize_transition_name("Fade") == "fade"
def test_empty_effect_default(self):
"""空字符串effect默认为cut."""
config = TransitionConfig.parse(effect="")
assert config.effect == CUT_TRANSITION
def test_remove_underscores(self):
"""移除下划线."""
assert _normalize_transition_name("slide_left") == "slideleft"
assert _normalize_transition_name("slide_up") == "slideup"
def test_remove_hyphens(self):
"""移除连字符."""
assert _normalize_transition_name("slide-left") == "slideleft"
def test_mixed(self):
"""混合情况."""
assert _normalize_transition_name("Slide_Left") == "slideleft"
assert _normalize_transition_name("FADE-IN") == "fadein"
# ── TransitionConfig 测试 ────────────────────────────────────────────────────
class TestTransitionConfig:
"""TransitionConfig 配置解析测试."""
# ── 默认值 ──
def test_default_config(self):
"""默认配置是硬切."""
cfg = TransitionConfig.parse()
assert cfg.effect == CUT_TRANSITION
assert cfg.duration == DEFAULT_TRANSITION_DURATION
assert cfg.is_cut is True
def test_none_effect(self):
"""None effect 降级为 cut."""
cfg = TransitionConfig.parse(effect=None)
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_empty_effect(self):
"""空字符串 effect 降级为 cut."""
cfg = TransitionConfig.parse(effect="")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
# ── 有效转场类型 ──
def test_whitespace_effect_default(self):
"""空白effect默认为cut."""
config = TransitionConfig.parse(effect=" ")
assert config.effect == CUT_TRANSITION
def test_fade_effect(self):
"""fade 转场."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.effect == "fade"
assert cfg.is_cut is False
assert cfg.ffmpeg_transition == "fade"
"""fade效果."""
config = TransitionConfig.parse(effect="fade")
assert config.effect == "fade"
def test_dissolve_effect(self):
"""dissolve 转场."""
cfg = TransitionConfig.parse(effect="dissolve")
assert cfg.effect == "dissolve"
assert cfg.ffmpeg_transition == "dissolve"
def test_unsupported_effect_falls_back_to_cut(self):
"""不支持的效果降级到cut."""
config = TransitionConfig.parse(effect="super_cool_effect")
assert config.effect == CUT_TRANSITION
def test_zoom_effect(self):
"""zoom 转场 → FFmpeg zoomin."""
cfg = TransitionConfig.parse(effect="zoom")
assert cfg.effect == "zoom"
assert cfg.ffmpeg_transition == "zoomin"
def test_cut_effect(self):
"""显式cut效果."""
config = TransitionConfig.parse(effect="cut")
assert config.effect == CUT_TRANSITION
def test_slide_left_alias(self):
"""slide_left 别名."""
cfg = TransitionConfig.parse(effect="slide_left")
assert cfg.effect == "slideleft"
assert cfg.ffmpeg_transition == "slideleft"
def test_wipe_alias(self):
"""wipe 别名 → 默认向左擦."""
cfg = TransitionConfig.parse(effect="wipe")
assert cfg.effect == "wipeleft"
assert cfg.ffmpeg_transition == "wipeleft"
def test_circlecrop_effect(self):
"""圆形扩散转场."""
cfg = TransitionConfig.parse(effect="circlecrop")
assert cfg.effect == "circlecrop"
assert cfg.ffmpeg_transition == "circlecrop"
def test_rectcrop_effect(self):
"""矩形扩散转场."""
cfg = TransitionConfig.parse(effect="rectcrop")
assert cfg.effect == "rectcrop"
assert cfg.ffmpeg_transition == "rectcrop"
# ── 降级策略 ──
def test_unsupported_fallback_to_cut(self):
"""不支持的转场自动降级为硬切,不阻断渲染."""
cfg = TransitionConfig.parse(effect="nonexistent_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_unsupported_whitespace_fallback(self):
"""带空格的不支持转场也降级."""
cfg = TransitionConfig.parse(effect=" bad effect ")
assert cfg.effect == CUT_TRANSITION
# ── 时长边界校验 ──
def test_default_duration(self):
"""默认时长 0.5s."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.duration == 0.5
def test_duration_within_range(self):
"""正常范围内的时长."""
cfg = TransitionConfig.parse(effect="fade", duration=1.0)
assert cfg.duration == 1.0
def test_duration_min_boundary(self):
"""最小值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MIN_TRANSITION_DURATION)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_max_boundary(self):
"""最大值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MAX_TRANSITION_DURATION)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_custom_duration(self):
"""自定义时长."""
config = TransitionConfig.parse(duration=1.0)
assert config.duration == 1.0
def test_duration_below_min_clamped(self):
"""低于最小值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=0.1)
assert cfg.duration == MIN_TRANSITION_DURATION
assert cfg.duration >= MIN_TRANSITION_DURATION
"""时长低于最小值钳制."""
config = TransitionConfig.parse(duration=0.1)
assert config.duration == MIN_TRANSITION_DURATION
def test_duration_above_max_clamped(self):
"""高于最大值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=5.0)
assert cfg.duration == MAX_TRANSITION_DURATION
assert cfg.duration <= MAX_TRANSITION_DURATION
"""时长高于最大值钳制."""
config = TransitionConfig.parse(duration=5.0)
assert config.duration == MAX_TRANSITION_DURATION
def test_duration_zero_default_for_effect(self):
"""有转场效果但 duration=0 时使用默认值."""
# 0.0 会被当作小于最小值钳制到 0.3
cfg = TransitionConfig.parse(effect="fade", duration=0.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_at_min(self):
"""时长边界最小值."""
config = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
assert config.duration == MIN_TRANSITION_DURATION
def test_duration_negative_clamped(self):
"""时长被钳制到最小值."""
cfg = TransitionConfig.parse(effect="fade", duration=-1.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_at_max(self):
"""时长边界最大值."""
config = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
assert config.duration == MAX_TRANSITION_DURATION
def test_duration_none_uses_default(self):
"""None duration 使用默认."""
cfg = TransitionConfig.parse(effect="fade", duration=None)
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_invalid_duration_falls_back(self):
"""无效时长回退到默认."""
config = TransitionConfig.parse(duration="not_a_number")
assert config.duration == DEFAULT_TRANSITION_DURATION
def test_duration_invalid_type(self):
"""无效类型的时长使用默认值."""
cfg = TransitionConfig.parse(effect="fade", duration="abc") # type: ignore
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_none_duration_default(self):
"""None时长用默认值."""
config = TransitionConfig.parse(duration=None)
assert config.duration == DEFAULT_TRANSITION_DURATION
# ── cut 的 ffmpeg_transition ──
def test_cut_ffmpeg_transition_empty(self):
"""硬切没有对应的 FFmpeg xfade transition."""
cfg = TransitionConfig.parse(effect="cut")
assert cfg.ffmpeg_transition == ""
def test_effect_and_duration(self):
"""同时指定效果和时长."""
config = TransitionConfig.parse(effect="fade", duration=1.0)
assert config.effect == "fade"
assert config.duration == 1.0
# ── TransitionEngine 测试 ────────────────────────────────────────────────────
class TestIsCut:
"""is_cut 属性测试."""
def test_cut_is_cut(self):
"""cut是硬切."""
config = TransitionConfig(effect=CUT_TRANSITION, duration=0.5)
assert config.is_cut is True
def test_fade_not_cut(self):
"""fade不是硬切."""
config = TransitionConfig(effect="fade", duration=0.5)
assert config.is_cut is False
class TestTransitionEngine:
"""TransitionEngine 转场引擎测试."""
class TestFfmpegTransition:
"""ffmpeg_transition 属性测试."""
def test_default_engine(self):
"""默认引擎初始化."""
engine = TransitionEngine()
assert engine is not None
def test_cut_returns_empty(self):
"""cut返回空字符串."""
config = TransitionConfig(effect=CUT_TRANSITION, duration=0.5)
assert config.ffmpeg_transition == ""
def test_custom_default_duration(self):
"""自定义默认时长."""
engine = TransitionEngine(default_duration=1.0)
cfg = engine.resolve_config(effect="fade")
assert cfg.duration == 1.0
def test_fade_returns_fade(self):
"""fade返回fade."""
config = TransitionConfig(effect="fade", duration=0.5)
result = config.ffmpeg_transition
assert isinstance(result, str)
assert len(result) > 0
def test_resolve_config_fade(self):
"""解析 fade 配置."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=0.8)
assert cfg.effect == "fade"
assert cfg.duration == 0.8
def test_resolve_config_fallback(self):
"""不支持的转场降级."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="unknown_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_resolve_config_duration_clamp(self):
"""时长边界钳制."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=3.0)
assert cfg.duration == MAX_TRANSITION_DURATION
# ── 批量解析 ──
def test_resolve_clip_transitions_all_valid(self):
"""批量解析全部有效转场."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["cut", "fade", "dissolve", "slideleft"])
assert len(configs) == 4
assert configs[0].effect == "cut"
assert configs[0].is_cut is True
assert configs[1].effect == "fade"
assert configs[2].effect == "dissolve"
assert configs[3].effect == "slideleft"
def test_resolve_clip_transitions_with_fallback(self):
"""批量解析包含不支持的转场,自动降级."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["fade", "bad_effect", "dissolve", "worse_effect"])
assert len(configs) == 4
assert configs[0].effect == "fade"
assert configs[1].effect == "cut" # 降级
assert configs[2].effect == "dissolve"
assert configs[3].effect == "cut" # 降级
def test_resolve_clip_transitions_with_durations(self):
"""带时长校验的批量解析(转场时长不超过片段时长的一半)."""
engine = TransitionEngine(default_duration=1.0)
# 片段只有 1.0s,转场时长被限制在 0.5s
configs = engine.resolve_clip_transitions(
["fade", "dissolve"],
clip_durations=[1.0, 1.0],
)
assert len(configs) == 2
# 1.0s 默认值超过了片段时长的一半 (0.5s),所以被钳制
assert configs[0].duration <= 0.5
assert configs[1].duration <= 0.5
def test_resolve_clip_transitions_short_clip_min_bound(self):
"""超短片段的转场时长至少为最小值."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(
["fade"],
clip_durations=[0.1], # 极短片段
)
assert len(configs) == 1
# 0.1 * 0.5 = 0.05 < MIN_TRANSITION_DURATION,所以用最小值
assert configs[0].duration == MIN_TRANSITION_DURATION
# ── xfade 滤镜链构建 ──
def test_build_xfade_single_clip(self):
"""单 clip 直接 copy."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[5.0],
clip_video_labels=["v0"],
transitions=["cut"],
output_label="outv",
)
assert "copy" in filter_str
assert "[outv]" in filter_str
assert total_dur == pytest.approx(5.0, abs=0.01)
def test_build_xfade_two_clips_fade(self):
"""两个 clip 之间 fade 转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
# 总时长 = 3 + 4 - transition_duration (0.5) = 6.5
assert total_dur == pytest.approx(6.5, abs=0.1)
def test_build_xfade_three_clips_mixed(self):
"""三个 clip 混合转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "dissolve"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
assert "transition=dissolve" in filter_str
# 总时长 ≈ 3 + 4 + 5 - 2 * 0.5 = 11.0
assert total_dur == pytest.approx(11.0, abs=0.2)
def test_build_xfade_with_custom_duration(self):
"""自定义转场时长."""
engine = TransitionEngine(default_duration=0.5)
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=1.0,
output_label="outv",
)
assert "xfade" in filter_str
# 总时长 = 3 + 4 - 1.0 = 6.0
assert total_dur == pytest.approx(6.0, abs=0.1)
def test_build_xfade_zoom_transition(self):
"""zoom 转场滤镜构建."""
engine = TransitionEngine()
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "zoom"],
)
assert "xfade" in filter_str
assert "transition=zoomin" in filter_str # zoom → zoomin
def test_build_xfade_slide_directions(self):
"""四个方向的滑入转场."""
engine = TransitionEngine()
for direction in ["slideleft", "slideright", "slideup", "slidedown"]:
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", direction],
)
assert f"transition={direction}" in filter_str
def test_build_xfade_fallback_transition(self):
"""不支持的转场降级后构建(降级为cut,等效于极短fade)."""
engine = TransitionEngine()
# bad_effect 降级为 cutcut 使用极短转场
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "bad_effect"],
)
# 降级后是 cutcut 会被 xfade 层映射为 fade(因为 cut 不在 map 里)
# 但时长会很短,所以仍然有 xfade
assert "xfade" in filter_str
# ── 支持的转场列表 ──
def test_supported_transitions_list(self):
"""获取支持的转场列表(给 API 用)."""
transitions = TransitionEngine.supported_transitions()
assert len(transitions) >= 10 # cut + 至少 9 种特效
# 检查结构
for t in transitions:
assert "name" in t
assert "display_name" in t
assert "category" in t
# 检查分类
names = [t["name"] for t in transitions]
assert "cut" in names
assert "fade" in names
assert "zoom" in names
assert "circlecrop" in names
# ── 集成测试:与 UnifiedRenderService 协作 ────────────────────────────────────
class TestTransitionIntegration:
"""转场引擎与统一渲染服务的集成测试."""
def test_unified_render_service_has_transition_engine(self):
"""UnifiedRenderService 内部有 TransitionEngine 实例."""
from pathlib import Path
from video_processing.unified_render_service import UnifiedRenderService
# 构造最小化的服务实例
service = UnifiedRenderService(
plan=None,
clips=[],
asset_path_map={},
work_dir=Path("/tmp"),
)
assert hasattr(service, "_transition_engine")
assert isinstance(service._transition_engine, TransitionEngine)
def test_resolved_clip_has_transition_duration(self):
"""ResolvedClip 有 transition_duration 字段."""
from video_processing.unified_render_service import ResolvedClip
rc = ResolvedClip(
clip_id="test",
asset_id="asset1",
local_path=__file__, # 随便一个存在的路径
clip_type="main",
order=0,
transition_effect="fade",
transition_duration=0.8,
)
assert rc.transition_duration == 0.8
assert rc.transition_effect == "fade"
def test_valid_effect_has_ffmpeg_name(self):
"""所有非cut的支持效果都有对应的ffmpeg名称."""
for t in TransitionType:
if t.value == CUT_TRANSITION:
continue # cut返回空是正常的
config = TransitionConfig(effect=t.value, duration=0.5)
assert config.ffmpeg_transition != ""