"""Unit tests for generation_plan_builder.py — pure logic utilities. 覆盖: - VirtualPlan / VirtualClip 数据类 - extract_intro_outro_from_clip_configs - apply_template_clip_effects - build_clips_by_mode (4种模式) - build_error_info """ from __future__ import annotations from dataclasses import dataclass from typing import Any from unittest.mock import patch import pytest from worker_app.tasks.generation_plan_builder import ( VirtualClip, VirtualPlan, apply_template_clip_effects, build_clips_by_mode, build_error_info, extract_intro_outro_from_clip_configs, ) # ── 辅助:模拟 clip_config 对象 ────────────────────────────────────────────── @dataclass class MockClipType: """模拟 Enum 类型的 clip_type。""" value: str @dataclass class MockTransition: """模拟 Enum 类型的 transition_effect。""" value: str @dataclass class MockClipConfig: """模拟 TemplateClipConfig 对象。""" clip_type: Any transition_effect: Any = "cut" default_duration: float = 3.0 text_template: str = "" config: dict | None = None def _make_config( clip_type: str = "main", transition: str = "cut", duration: float = 3.0, text: str = "", config: dict | None = None, use_enum: bool = True, ) -> MockClipConfig: """创建一个模拟 clip_config。""" ct = MockClipType(clip_type) if use_enum else clip_type tr = MockTransition(transition) if use_enum and transition != "cut" else transition return MockClipConfig( clip_type=ct, transition_effect=tr, default_duration=duration, text_template=text, config=config or {}, ) # ── VirtualPlan / VirtualClip ─────────────────────────────────────────────── class TestVirtualPlan: def test_default_values(self): plan = VirtualPlan(id="plan_001") assert plan.id == "plan_001" assert plan.name == "" assert plan.config == {} def test_full_init(self): plan = VirtualPlan(id="p1", name="My Plan", config={"key": "value"}) assert plan.id == "p1" assert plan.name == "My Plan" assert plan.config == {"key": "value"} def test_mutable_config(self): plan = VirtualPlan(id="p1") plan.config["new_key"] = "new_val" assert plan.config == {"new_key": "new_val"} class TestVirtualClip: def test_default_values(self): clip = VirtualClip(id="c001") assert clip.id == "c001" assert clip.plan_id == "" assert clip.clip_type == "main" assert clip.order == 0 assert clip.asset_id == "" assert clip.duration == 0.0 assert clip.transition_effect == "cut" assert clip.transition_duration == 0.0 assert clip.playback_speed == 1.0 assert clip.status == "ready" assert clip.config == {} def test_full_init(self): clip = VirtualClip( id="c001", plan_id="p1", clip_type="overlay", order=1, asset_id="asset_001", duration=5.5, transition_effect="fade", transition_duration=0.5, playback_speed=1.5, config={"role": "b_roll"}, ) assert clip.clip_type == "overlay" assert clip.duration == 5.5 assert clip.playback_speed == 1.5 def test_mutable_config(self): clip = VirtualClip(id="c001") clip.config["effect"] = "vintage" assert clip.config == {"effect": "vintage"} # ── extract_intro_outro_from_clip_configs ─────────────────────────────────── class TestExtractIntroOutro: def test_empty_configs(self): result = extract_intro_outro_from_clip_configs([]) assert result == {} def test_no_intro_no_outro(self): configs = [_make_config("main"), _make_config("showcase")] result = extract_intro_outro_from_clip_configs(configs) assert result == {} def test_intro_only_basic(self): configs = [_make_config("intro", text="Hello", duration=2.5)] result = extract_intro_outro_from_clip_configs(configs) assert result["has_intro"] is True assert result["intro_type"] == "text" assert result["intro_duration"] == 2.5 assert result["intro_text"] == "Hello" assert "has_outro" not in result def test_outro_only_basic(self): configs = [_make_config("outro", text="Bye", duration=3.0)] result = extract_intro_outro_from_clip_configs(configs) assert result["has_outro"] is True assert result["outro_type"] == "text" assert result["outro_duration"] == 3.0 assert result["outro_text"] == "Bye" def test_both_intro_and_outro(self): configs = [ _make_config("intro", text="Start"), _make_config("main"), _make_config("outro", text="End"), ] result = extract_intro_outro_from_clip_configs(configs) assert result["has_intro"] is True assert result["intro_text"] == "Start" assert result["has_outro"] is True assert result["outro_text"] == "End" def test_intro_extra_config_pass_through(self): configs = [ _make_config( "intro", config={ "intro_text_color": "#ffffff", "intro_bg_color": "#000000", "intro_font_size": 32, "intro_video_url": "https://example.com/intro.mp4", "intro_video_path": "/tmp/intro.mp4", "random_key": "should_not_appear", }, ) ] result = extract_intro_outro_from_clip_configs(configs) assert result["intro_text_color"] == "#ffffff" assert result["intro_bg_color"] == "#000000" assert result["intro_font_size"] == 32 assert result["intro_video_url"] == "https://example.com/intro.mp4" assert "random_key" not in result def test_outro_extra_config_pass_through(self): configs = [ _make_config( "outro", config={ "outro_text_color": "#ff0000", "outro_bg_color": "#00ff00", "outro_font_size": 24, "outro_follow_text": "关注我们", }, ) ] result = extract_intro_outro_from_clip_configs(configs) assert result["outro_text_color"] == "#ff0000" assert result["outro_follow_text"] == "关注我们" def test_intro_type_from_config(self): configs = [_make_config("intro", config={"intro_type": "video"})] result = extract_intro_outro_from_clip_configs(configs) assert result["intro_type"] == "video" def test_default_duration_when_zero(self): configs = [_make_config("intro", duration=0.0)] result = extract_intro_outro_from_clip_configs(configs) assert result["intro_duration"] == 3.0 def test_empty_text_not_included(self): configs = [_make_config("intro", text="")] result = extract_intro_outro_from_clip_configs(configs) assert "intro_text" not in result def test_with_string_clip_type_no_enum(self): configs = [_make_config("intro", text="Hi", use_enum=False)] result = extract_intro_outro_from_clip_configs(configs) assert result["has_intro"] is True assert result["intro_text"] == "Hi" def test_first_intro_used_when_multiple(self): configs = [ _make_config("intro", text="First"), _make_config("intro", text="Second"), ] result = extract_intro_outro_from_clip_configs(configs) assert result["intro_text"] == "First" def test_none_config_handled(self): cfg = _make_config("intro") cfg.config = None result = extract_intro_outro_from_clip_configs([cfg]) assert result["has_intro"] is True # ── apply_template_clip_effects ───────────────────────────────────────────── class TestApplyTemplateClipEffects: def test_empty_clips(self): clips: list[VirtualClip] = [] configs = [_make_config("main", transition="fade")] apply_template_clip_effects(clips, configs, "one_take") assert clips == [] def test_empty_configs(self): clips = [VirtualClip(id="c1", clip_type="main")] apply_template_clip_effects(clips, [], "one_take") assert clips[0].transition_effect == "cut" def test_no_main_configs(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("intro"), _make_config("outro")] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_effect == "cut" def test_transition_effect_applied(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", transition="fade")] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_effect == "fade" def test_transition_duration_applied(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", transition="fade", config={"transition_duration": 0.8})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_duration == 0.8 def test_transition_duration_invalid_ignored(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", transition="fade", config={"transition_duration": "abc"})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_duration == 0.0 def test_cut_transition_not_applied(self): clips = [VirtualClip(id="c1", clip_type="main", transition_effect="dissolve")] configs = [_make_config("main", transition="cut")] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_effect == "dissolve" # 保留原值 def test_color_grade_applied(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"color_grade": "vintage"})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].config["color_grade"] == "vintage" def test_multiple_effect_keys_applied(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [ _make_config( "main", config={ "color_grade": "warm", "playback_speed": 1.5, "reverse": True, "chroma_key": {"color": "green"}, "filter": "黑白", }, ) ] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].config["color_grade"] == "warm" assert clips[0].config["playback_speed"] == 1.5 assert clips[0].config["reverse"] is True assert clips[0].config["chroma_key"] == {"color": "green"} def test_playback_speed_top_level_updated(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"playback_speed": 2.0})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].playback_speed == 2.0 assert clips[0].config["playback_speed"] == 2.0 def test_speed_fallback_sets_playback_speed(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"speed": 0.8})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].playback_speed == 0.8 def test_playback_speed_takes_priority_over_speed(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"speed": 0.5, "playback_speed": 2.0})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].playback_speed == 2.0 def test_existing_config_preserved(self): clips = [VirtualClip(id="c1", clip_type="main", config={"role": "b_roll", "original": "value"})] configs = [_make_config("main", config={"color_grade": "cool"})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].config["role"] == "b_roll" assert clips[0].config["original"] == "value" assert clips[0].config["color_grade"] == "cool" def test_corner_voice_skipped(self): clips = [ VirtualClip(id="c1", clip_type="main"), VirtualClip(id="c2", clip_type="corner_voice"), ] configs = [_make_config("main", transition="fade")] apply_template_clip_effects(clips, configs, "voice_pip") assert clips[0].transition_effect == "fade" assert clips[1].transition_effect == "cut" # 不应用效果 def test_cyclic_matching_more_clips_than_configs(self): clips = [ VirtualClip(id="c1", clip_type="main"), VirtualClip(id="c2", clip_type="main"), VirtualClip(id="c3", clip_type="main"), ] configs = [ _make_config("main", transition="fade"), _make_config("main", transition="dissolve"), ] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_effect == "fade" assert clips[1].transition_effect == "dissolve" assert clips[2].transition_effect == "dissolve" # 循环用最后一个 def test_pip_mode_main_and_overlay_both_effected(self): clips = [ VirtualClip(id="c1", clip_type="main"), VirtualClip(id="c2", clip_type="overlay"), ] configs = [_make_config("main", transition="fade")] apply_template_clip_effects(clips, configs, "pip") assert clips[0].transition_effect == "fade" assert clips[1].transition_effect == "fade" def test_showcase_and_b_roll_count_as_template_source(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [ _make_config("showcase", transition="zoom_in"), _make_config("b_roll", transition="slide_left"), ] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].transition_effect == "zoom_in" # 用第一个匹配的 def test_string_transition_no_enum(self): clips = [VirtualClip(id="c1", clip_type="main")] cfg = _make_config("main") cfg.transition_effect = "wipe" # 直接字符串 apply_template_clip_effects(clips, [cfg], "one_take") assert clips[0].transition_effect == "wipe" def test_zero_speed_ignored(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"playback_speed": 0})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].playback_speed == 1.0 # 保持默认 def test_negative_speed_ignored(self): clips = [VirtualClip(id="c1", clip_type="main")] configs = [_make_config("main", config={"playback_speed": -1.0})] apply_template_clip_effects(clips, configs, "one_take") assert clips[0].playback_speed == 1.0 # ── build_clips_by_mode ──────────────────────────────────────────────────── class TestBuildClipsByMode: def test_one_take_single_asset(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": "a1", "duration": 10.0}], mode="one_take", ) assert len(clips) == 1 assert clips[0].clip_type == "main" assert clips[0].asset_id == "a1" assert clips[0].duration == 10.0 assert clips[0].order == 0 assert clips[0].plan_id == "p1" def test_one_take_multiple_assets(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[ {"asset_id": "a1", "duration": 5.0}, {"asset_id": "a2", "duration": 10.0}, {"asset_id": "a3", "duration": 7.0}, ], mode="one_take", ) assert len(clips) == 3 assert all(c.clip_type == "main" for c in clips) assert [c.order for c in clips] == [0, 1, 2] assert [c.duration for c in clips] == [5.0, 10.0, 7.0] def test_pip_mode_main_and_overlay(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[ {"asset_id": "a1", "duration": 10.0}, {"asset_id": "a2", "duration": 5.0}, {"asset_id": "a3", "duration": 3.0}, ], mode="pip", ) assert len(clips) == 3 assert clips[0].clip_type == "main" assert clips[1].clip_type == "overlay" assert clips[2].clip_type == "overlay" def test_pip_single_asset_is_main(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": "a1", "duration": 10.0}], mode="pip", ) assert len(clips) == 1 assert clips[0].clip_type == "main" def test_voice_over_all_main_with_role(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[ {"asset_id": "a1", "duration": 5.0}, {"asset_id": "a2", "duration": 8.0}, ], mode="voice_over", ) assert len(clips) == 2 assert all(c.clip_type == "main" for c in clips) assert all(c.config.get("role") == "b_roll" for c in clips) def test_voice_pip_three_layers(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[ {"asset_id": "a1", "duration": 10.0}, {"asset_id": "a2", "duration": 5.0}, {"asset_id": "a3", "duration": 3.0}, {"asset_id": "a4", "duration": 4.0}, ], mode="voice_pip", ) assert len(clips) == 4 assert clips[0].clip_type == "background" assert clips[1].clip_type == "corner_voice" assert clips[2].clip_type == "b_roll" assert clips[3].clip_type == "b_roll" def test_voice_pip_two_assets(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[ {"asset_id": "a1", "duration": 10.0}, {"asset_id": "a2", "duration": 5.0}, ], mode="voice_pip", ) assert len(clips) == 2 assert clips[0].clip_type == "background" assert clips[1].clip_type == "corner_voice" def test_voice_pip_single_asset(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": "a1", "duration": 10.0}], mode="voice_pip", ) assert len(clips) == 1 assert clips[0].clip_type == "background" def test_default_mode_is_one_take(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": "a1", "duration": 10.0}], mode="unknown_mode", ) assert len(clips) == 1 assert clips[0].clip_type == "main" def test_empty_assets(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[], mode="one_take", ) assert clips == [] def test_default_asset_id_when_missing(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"duration": 5.0}], mode="one_take", ) assert clips[0].asset_id == "asset_000" def test_default_duration_when_missing(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": "a1"}], mode="one_take", ) assert clips[0].duration == 0.0 def test_clip_ids_sequential(self): clips = build_clips_by_mode( plan_id="p1", asset_infos=[{"asset_id": f"a{i}"} for i in range(5)], mode="one_take", ) assert [c.id for c in clips] == ["vc_000", "vc_001", "vc_002", "vc_003", "vc_004"] def test_plan_id_propagated(self): clips = build_clips_by_mode( plan_id="my_plan_123", asset_infos=[{"asset_id": "a1"}, {"asset_id": "a2"}], mode="pip", ) assert all(c.plan_id == "my_plan_123" for c in clips) # ── build_error_info ─────────────────────────────────────────────────────── class TestBuildErrorInfo: def test_basic_structure(self): try: raise ValueError("test error") except ValueError as e: info = build_error_info(e, stage="render") assert info["error_type"] == "ValueError" assert info["message"] == "test error" assert info["stage"] == "render" assert "stack_trace" in info assert "failed_at" in info assert "ValueError" in info["stack_trace"] assert "test error" in info["stack_trace"] def test_default_stage(self): try: raise RuntimeError("oops") except RuntimeError as e: info = build_error_info(e) assert info["stage"] == "render" def test_custom_stage(self): try: raise TypeError("bad type") except TypeError as e: info = build_error_info(e, stage="download") assert info["stage"] == "download" def test_failed_at_is_iso_format(self): try: raise ValueError("x") except ValueError as e: info = build_error_info(e) # ISO 格式检查:包含 T 和 + assert "T" in info["failed_at"] @patch("worker_app.tasks.generation_plan_builder.traceback.format_exc") def test_long_stack_trace_truncated(self, mock_format): # 构造30行堆栈 lines = [f' File "file{i}.py", line {i}, in func{i}' for i in range(28)] lines.append("ValueError: deep error") mock_format.return_value = "\n".join(lines) try: raise ValueError("deep") except ValueError as e: info = build_error_info(e) assert "truncated" in info["stack_trace"] assert "total 29 lines" in info["stack_trace"] # 确认只保留了前20行 assert "func19" in info["stack_trace"] assert "func20" not in info["stack_trace"] @patch("worker_app.tasks.generation_plan_builder.traceback.format_exc") def test_short_stack_trace_not_truncated(self, mock_format): # 构造5行堆栈(小于20) mock_format.return_value = ( "Traceback (most recent call last):\n" ' File "test.py", line 10, in foo\n' ' raise ValueError("simple")\n' "ValueError: simple\n" ) try: raise ValueError("simple") except ValueError as e: info = build_error_info(e) assert "truncated" not in info["stack_trace"] assert "ValueError: simple" in info["stack_trace"]