Files
xiaoxia-saas/tests/unit/test_template_effect_mapper.py
T
CI Bot ae4fad67c3
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m14s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m15s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 18s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m3s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1243h44m21s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1243h46m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1243h47m0s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1243h47m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1243h55m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1243h55m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1243h55m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1243h55m44s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1244h31m10s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1244h31m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1244h31m14s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1244h19m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1244h28m21s
test(wave102): 抽取template_effect_mapper纯逻辑模块 + 45个单测
从 worker_app/tasks/generation.py 抽离模板效果映射纯逻辑到 packages/domain/template_effect_mapper.py:
- VirtualClip / VirtualPlan 数据类
- extract_intro_outro_from_clip_configs: intro/outro 配置提取
- apply_template_clip_effects: 效果层映射(转场/滤镜/调速/色度键)
- _clip_type_value: Enum/字符串兼容工具

generation.py 保留向后兼容别名(_VirtualClip/_VirtualPlan + 两个函数)

测试:+45 个单测
- 数据类默认值/可变性/config独立性
- extract_intro_outro: 空/无intro/基本字段/text_template/默认时长/配置透传/
  outro/同时有intro+outro/多个intro用第一个/Enum类型/无关字段忽略
- apply_template_clip_effects: 空clips/空configs/无main类型/
  转场效果/转场时长/无效时长/零时长/cut跳过/color_grade/
  playback_speed/speed key/优先级/无效speed/零speed/
  chroma_key+filter/reverse/已有config保留/corner_voice排除/
  showcase+b_roll作为模板/循环匹配/Enum转场/config=None/
  就地修改/三对三匹配
- 端到端集成测试

generation.py: 1538→1414行(-124行,-8%)
新模块: 179行
2026-07-26 18:23:31 +08:00

701 lines
26 KiB
Python
Executable File

"""template_effect_mapper 单元测试 — 模板效果映射纯逻辑层。
覆盖:
- VirtualClip / VirtualPlan 数据类
- extract_intro_outro_from_clip_configs: intro/outro 配置提取
- apply_template_clip_effects: 效果层映射
"""
from __future__ import annotations
import unittest
from dataclasses import dataclass, field
from typing import Any
from packages.domain.template_effect_mapper import (
VirtualClip,
VirtualPlan,
apply_template_clip_effects,
extract_intro_outro_from_clip_configs,
)
# ── Mock ClipConfig ──────────────────────────────────────────────────────────
@dataclass
class _MockClipConfig:
"""模拟 TemplateClipConfig 对象。"""
clip_type: Any = "main"
config: dict[str, Any] | None = None
default_duration: float = 3.0
text_template: str | None = None
transition_effect: Any = "cut"
# ── VirtualClip / VirtualPlan 数据类测试 ────────────────────────────────────
class TestVirtualClip(unittest.TestCase):
"""VirtualClip 数据类测试。"""
def test_default_values(self):
"""默认值正确。"""
clip = VirtualClip(id="c1")
self.assertEqual(clip.id, "c1")
self.assertEqual(clip.plan_id, "")
self.assertEqual(clip.clip_type, "main")
self.assertEqual(clip.order, 0)
self.assertEqual(clip.asset_id, "")
self.assertEqual(clip.text_content, "")
self.assertEqual(clip.start_time, 0.0)
self.assertEqual(clip.duration, 0.0)
self.assertEqual(clip.transition_effect, "cut")
self.assertEqual(clip.transition_duration, 0.0)
self.assertEqual(clip.playback_speed, 1.0)
self.assertEqual(clip.status, "ready")
self.assertEqual(clip.config, {})
def test_mutable(self):
"""VirtualClip 是可变 dataclass(非 frozen)。"""
clip = VirtualClip(id="c1", duration=5.0)
clip.duration = 10.0
self.assertEqual(clip.duration, 10.0)
def test_config_independent(self):
"""每个 clip 的 config 是独立的 dict。"""
clip1 = VirtualClip(id="c1")
clip2 = VirtualClip(id="c2")
clip1.config["key"] = "value"
self.assertNotIn("key", clip2.config)
class TestVirtualPlan(unittest.TestCase):
"""VirtualPlan 数据类测试。"""
def test_default_values(self):
"""默认值正确。"""
plan = VirtualPlan(id="p1")
self.assertEqual(plan.id, "p1")
self.assertEqual(plan.name, "")
self.assertEqual(plan.config, {})
def test_config_independent(self):
"""每个 plan 的 config 是独立的 dict。"""
plan1 = VirtualPlan(id="p1")
plan2 = VirtualPlan(id="p2")
plan1.config["key"] = "value"
self.assertNotIn("key", plan2.config)
# ── extract_intro_outro_from_clip_configs 测试 ──────────────────────────────
class TestExtractIntroOutro(unittest.TestCase):
"""extract_intro_outro_from_clip_configs 测试。"""
def test_empty_list(self):
"""空列表返回空 dict。"""
result = extract_intro_outro_from_clip_configs([])
self.assertEqual(result, {})
def test_no_intro_no_outro(self):
"""只有 main 类型,没有 intro/outro。"""
configs = [
_MockClipConfig(clip_type="main"),
_MockClipConfig(clip_type="showcase"),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result, {})
def test_intro_only_basic(self):
"""只有 intro:基本字段。"""
configs = [
_MockClipConfig(clip_type="intro", default_duration=2.5),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(result["has_intro"])
self.assertEqual(result["intro_type"], "text") # 默认
self.assertEqual(result["intro_duration"], 2.5)
self.assertNotIn("intro_text", result)
self.assertNotIn("has_outro", result)
def test_intro_with_text_template(self):
"""intro 带 text_template。"""
configs = [
_MockClipConfig(
clip_type="intro",
text_template="欢迎观看",
),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["intro_text"], "欢迎观看")
def test_intro_default_duration_zero(self):
"""intro default_duration 为 0 时使用默认 3.0。"""
configs = [
_MockClipConfig(clip_type="intro", default_duration=0),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["intro_duration"], 3.0)
def test_intro_with_config_extra_fields(self):
"""intro config 中的额外字段被透传。"""
configs = [
_MockClipConfig(
clip_type="intro",
config={
"intro_type": "video",
"intro_text_color": "#ffffff",
"intro_bg_color": "#000000",
"intro_font_size": 48,
"intro_video_url": "https://example.com/intro.mp4",
"intro_video_path": "/tmp/intro.mp4",
},
),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["intro_type"], "video")
self.assertEqual(result["intro_text_color"], "#ffffff")
self.assertEqual(result["intro_bg_color"], "#000000")
self.assertEqual(result["intro_font_size"], 48)
self.assertEqual(result["intro_video_url"], "https://example.com/intro.mp4")
self.assertEqual(result["intro_video_path"], "/tmp/intro.mp4")
def test_outro_only_basic(self):
"""只有 outro:基本字段。"""
configs = [
_MockClipConfig(clip_type="outro", default_duration=4.0),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(result["has_outro"])
self.assertEqual(result["outro_type"], "text")
self.assertEqual(result["outro_duration"], 4.0)
self.assertNotIn("has_intro", result)
def test_outro_with_text_template(self):
"""outro 带 text_template。"""
configs = [
_MockClipConfig(
clip_type="outro",
text_template="感谢观看",
),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["outro_text"], "感谢观看")
def test_outro_with_config_extra_fields(self):
"""outro config 中的额外字段被透传。"""
configs = [
_MockClipConfig(
clip_type="outro",
config={
"outro_type": "subscribe",
"outro_text_color": "#ffffff",
"outro_bg_color": "#333333",
"outro_font_size": 36,
"outro_follow_text": "关注我们",
},
),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["outro_type"], "subscribe")
self.assertEqual(result["outro_text_color"], "#ffffff")
self.assertEqual(result["outro_bg_color"], "#333333")
self.assertEqual(result["outro_font_size"], 36)
self.assertEqual(result["outro_follow_text"], "关注我们")
def test_both_intro_and_outro(self):
"""同时有 intro 和 outro。"""
configs = [
_MockClipConfig(clip_type="intro", default_duration=2.0),
_MockClipConfig(clip_type="main"),
_MockClipConfig(clip_type="outro", default_duration=3.0),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(result["has_intro"])
self.assertEqual(result["intro_duration"], 2.0)
self.assertTrue(result["has_outro"])
self.assertEqual(result["outro_duration"], 3.0)
def test_multiple_intros_uses_first(self):
"""多个 intro 只用第一个。"""
configs = [
_MockClipConfig(clip_type="intro", default_duration=2.0, text_template="第一"),
_MockClipConfig(clip_type="intro", default_duration=5.0, text_template="第二"),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["intro_duration"], 2.0)
self.assertEqual(result["intro_text"], "第一")
def test_multiple_outros_uses_first(self):
"""多个 outro 只用第一个。"""
configs = [
_MockClipConfig(clip_type="outro", default_duration=3.0, text_template="end1"),
_MockClipConfig(clip_type="outro", default_duration=6.0, text_template="end2"),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertEqual(result["outro_duration"], 3.0)
self.assertEqual(result["outro_text"], "end1")
def test_intro_config_none(self):
"""intro.config 为 None 时正常工作。"""
configs = [
_MockClipConfig(clip_type="intro", config=None),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(result["has_intro"])
self.assertEqual(result["intro_type"], "text") # 默认
def test_clip_type_enum_value(self):
"""clip_type 是 Enum 时通过 .value 获取。"""
class _EnumType:
value = "intro"
configs = [
_MockClipConfig(clip_type=_EnumType(), default_duration=2.0),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(result["has_intro"])
self.assertEqual(result["intro_duration"], 2.0)
def test_irrelevant_config_fields_ignored(self):
"""intro/outro config 中非预期字段不被透传。"""
configs = [
_MockClipConfig(
clip_type="intro",
config={"random_key": "should_not_appear", "intro_type": "text"},
),
]
result = extract_intro_outro_from_clip_configs(configs)
self.assertNotIn("random_key", result)
# ── apply_template_clip_effects 测试 ────────────────────────────────────────
class TestApplyTemplateClipEffects(unittest.TestCase):
"""apply_template_clip_effects 效果层映射测试。"""
def test_empty_clips(self):
"""空 clips 列表:不报错、不修改。"""
configs = [_MockClipConfig(clip_type="main")]
clips: list[VirtualClip] = []
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips, [])
def test_empty_configs(self):
"""空 configs 列表:不修改 clips。"""
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, [], "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "cut")
self.assertEqual(clips[0].playback_speed, 1.0)
self.assertEqual(clips[0].config, {})
def test_no_main_configs(self):
"""没有 main/showcase/b_roll 类型的 config:不修改 clips。"""
configs = [
_MockClipConfig(
clip_type="intro",
config={"color_grade": "warm"},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "cut")
self.assertEqual(clips[0].config, {})
def test_transition_effect_applied(self):
"""转场效果正确映射。"""
configs = [
_MockClipConfig(clip_type="main", transition_effect="fade"),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "fade")
def test_transition_duration_applied(self):
"""转场时长从 config 中读取并映射。"""
configs = [
_MockClipConfig(
clip_type="main",
transition_effect="dissolve",
config={"transition_duration": 0.8},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "dissolve")
self.assertEqual(clips[0].transition_duration, 0.8)
def test_transition_duration_invalid_string(self):
"""转场时长是无效字符串时忽略。"""
configs = [
_MockClipConfig(
clip_type="main",
transition_effect="fade",
config={"transition_duration": "abc"},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_duration, 0.0)
def test_transition_duration_zero_ignored(self):
"""转场时长 <= 0 时忽略。"""
configs = [
_MockClipConfig(
clip_type="main",
transition_effect="fade",
config={"transition_duration": 0},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_duration, 0.0)
def test_cut_transition_skipped(self):
"""cut 转场不覆盖默认值。"""
configs = [
_MockClipConfig(clip_type="main", transition_effect="cut"),
]
clips = [VirtualClip(id="c1", duration=5.0, transition_effect="fade")]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "fade") # 保持原值
def test_color_grade_applied(self):
"""color_grade 效果层映射。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"color_grade": "vivid"},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["color_grade"], "vivid")
def test_playback_speed_applied_to_config_and_field(self):
"""playback_speed 同时映射到 config 和顶级字段。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"playback_speed": 1.5},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["playback_speed"], 1.5)
self.assertEqual(clips[0].playback_speed, 1.5)
def test_speed_key_also_applies(self):
"""speed key 也能设置 playback_speed 顶级字段。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"speed": 2.0},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].playback_speed, 2.0)
self.assertEqual(clips[0].config["speed"], 2.0)
def test_playback_speed_takes_priority_over_speed(self):
"""playback_speed 比 speed 优先级高。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"playback_speed": 1.2, "speed": 2.0},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].playback_speed, 1.2)
def test_speed_invalid_ignored(self):
"""无效 speed 值忽略。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"speed": "fast"},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].playback_speed, 1.0)
def test_speed_zero_ignored(self):
"""speed <= 0 时忽略。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"speed": 0},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].playback_speed, 1.0)
def test_chroma_key_and_filter_applied(self):
"""chroma_key 和 filter 效果层映射。"""
configs = [
_MockClipConfig(
clip_type="main",
config={
"chroma_key": {"color": "green", "tolerance": 0.1},
"filter": "vintage",
},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["chroma_key"]["color"], "green")
self.assertEqual(clips[0].config["filter"], "vintage")
def test_reverse_effect_applied(self):
"""reverse 效果层映射。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"reverse": True},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertTrue(clips[0].config["reverse"])
def test_existing_config_preserved(self):
"""已有 config 字段被保留,效果层合并。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"color_grade": "warm", "speed": 1.5},
),
]
clips = [
VirtualClip(
id="c1",
duration=5.0,
config={"role": "b_roll", "existing_key": "value"},
)
]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["role"], "b_roll")
self.assertEqual(clips[0].config["existing_key"], "value")
self.assertEqual(clips[0].config["color_grade"], "warm")
self.assertEqual(clips[0].config["speed"], 1.5)
def test_corner_voice_excluded(self):
"""corner_voice 类型的 clip 不应用效果映射。"""
configs = [
_MockClipConfig(
clip_type="main",
transition_effect="fade",
config={"color_grade": "warm"},
),
]
clips = [
VirtualClip(id="c1", clip_type="main", duration=5.0),
VirtualClip(id="c2", clip_type="corner_voice", duration=3.0),
]
apply_template_clip_effects(clips, configs, "VOICE_PIP")
# main 类型被修改
self.assertEqual(clips[0].transition_effect, "fade")
self.assertEqual(clips[0].config.get("color_grade"), "warm")
# corner_voice 不被修改
self.assertEqual(clips[1].transition_effect, "cut")
self.assertEqual(clips[1].config, {})
def test_showcase_and_b_roll_clip_types_used(self):
"""showcase 和 b_roll 类型的 clip_config 也作为模板池。"""
configs = [
_MockClipConfig(
clip_type="showcase",
transition_effect="slideleft",
),
_MockClipConfig(
clip_type="b_roll",
transition_effect="dissolve",
),
]
clips = [
VirtualClip(id="c1", duration=5.0),
VirtualClip(id="c2", duration=5.0),
]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "slideleft")
self.assertEqual(clips[1].transition_effect, "dissolve")
def test_cyclic_matching_more_clips_than_configs(self):
"""素材比模板多时,最后一个模板复用。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"color_grade": "warm"},
),
_MockClipConfig(
clip_type="main",
config={"color_grade": "cool"},
),
]
clips = [
VirtualClip(id="c1", duration=5.0),
VirtualClip(id="c2", duration=5.0),
VirtualClip(id="c3", duration=5.0),
VirtualClip(id="c4", duration=5.0),
]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["color_grade"], "warm")
self.assertEqual(clips[1].config["color_grade"], "cool")
# 素材3和4都复用最后一个模板(cool)
self.assertEqual(clips[2].config["color_grade"], "cool")
self.assertEqual(clips[3].config["color_grade"], "cool")
def test_enum_transition_effect(self):
"""transition_effect 是 Enum 时正确处理。"""
class _EnumEffect:
value = "fade"
configs = [
_MockClipConfig(clip_type="main", transition_effect=_EnumEffect()),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "fade")
def test_config_none_handled(self):
"""clip_config.config 为 None 时正常处理。"""
configs = [
_MockClipConfig(clip_type="main", transition_effect="fade", config=None),
]
clips = [VirtualClip(id="c1", duration=5.0)]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].transition_effect, "fade")
# config 为 None 时不应有 config 写入
self.assertEqual(clips[0].config, {})
def test_clips_modified_in_place(self):
"""clips 是就地修改的。"""
configs = [
_MockClipConfig(
clip_type="main",
transition_effect="dissolve",
config={"color_grade": "warm"},
),
]
clips = [VirtualClip(id="c1", duration=5.0)]
result = apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertIsNone(result)
self.assertEqual(clips[0].transition_effect, "dissolve")
def test_three_clips_three_configs_matching(self):
"""三素材三模板一一对应。"""
configs = [
_MockClipConfig(
clip_type="main",
config={"color_grade": "warm"},
transition_effect="fade",
),
_MockClipConfig(
clip_type="main",
config={"color_grade": "cool"},
transition_effect="dissolve",
),
_MockClipConfig(
clip_type="main",
config={"color_grade": "vintage"},
transition_effect="wipeleft",
),
]
clips = [
VirtualClip(id="c1", duration=5.0),
VirtualClip(id="c2", duration=5.0),
VirtualClip(id="c3", duration=5.0),
]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
self.assertEqual(clips[0].config["color_grade"], "warm")
self.assertEqual(clips[0].transition_effect, "fade")
self.assertEqual(clips[1].config["color_grade"], "cool")
self.assertEqual(clips[1].transition_effect, "dissolve")
self.assertEqual(clips[2].config["color_grade"], "vintage")
self.assertEqual(clips[2].transition_effect, "wipeleft")
# ── 集成测试 ────────────────────────────────────────────────────────────────
class TestEndToEndMapping(unittest.TestCase):
"""端到端集成测试:intro_outro 提取 + 效果映射完整流程。"""
def test_full_template_pipeline(self):
"""完整模板流水线:intro + main + outro,效果映射正确。"""
configs = [
_MockClipConfig(
clip_type="intro",
default_duration=2.0,
text_template="精彩开始",
config={"intro_type": "text", "intro_text_color": "#ffffff"},
),
_MockClipConfig(
clip_type="main",
transition_effect="fade",
config={
"color_grade": "warm",
"playback_speed": 1.2,
"transition_duration": 0.6,
},
),
_MockClipConfig(
clip_type="b_roll",
transition_effect="dissolve",
config={"color_grade": "vivid", "speed": 1.5},
),
_MockClipConfig(
clip_type="outro",
default_duration=3.0,
text_template="谢谢观看",
config={"outro_type": "subscribe", "outro_follow_text": "关注+点赞"},
),
]
# 效果映射
clips = [
VirtualClip(id="c1", clip_type="main", duration=5.0),
VirtualClip(id="c2", clip_type="main", duration=4.0),
VirtualClip(id="c3", clip_type="main", duration=3.0),
]
apply_template_clip_effects(clips, configs, "ONE_TAKE")
# 第一个片段用第一个 main 模板
self.assertEqual(clips[0].transition_effect, "fade")
self.assertEqual(clips[0].transition_duration, 0.6)
self.assertEqual(clips[0].playback_speed, 1.2)
self.assertEqual(clips[0].config["color_grade"], "warm")
# 第二个片段用第二个 b_roll 模板
self.assertEqual(clips[1].transition_effect, "dissolve")
self.assertEqual(clips[1].playback_speed, 1.5)
self.assertEqual(clips[1].config["color_grade"], "vivid")
# 第三个片段复用最后一个模板
self.assertEqual(clips[2].transition_effect, "dissolve")
self.assertEqual(clips[2].playback_speed, 1.5)
# intro/outro 提取
intro_outro = extract_intro_outro_from_clip_configs(configs)
self.assertTrue(intro_outro["has_intro"])
self.assertEqual(intro_outro["intro_duration"], 2.0)
self.assertEqual(intro_outro["intro_text"], "精彩开始")
self.assertEqual(intro_outro["intro_text_color"], "#ffffff")
self.assertTrue(intro_outro["has_outro"])
self.assertEqual(intro_outro["outro_duration"], 3.0)
self.assertEqual(intro_outro["outro_text"], "谢谢观看")
self.assertEqual(intro_outro["outro_follow_text"], "关注+点赞")
if __name__ == "__main__":
unittest.main()