test(wave91): add 59 unit tests for generation_plan_builder (#945)
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
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled

This commit is contained in:
2026-07-26 18:15:48 +08:00
parent 673d18aa83
commit 4df4a937e4
3 changed files with 984 additions and 179 deletions
+648
View File
@@ -0,0 +1,648 @@
"""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"]