"""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()