7f3c462617
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m51s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 1h12m17s
650 lines
24 KiB
Python
Executable File
650 lines
24 KiB
Python
Executable File
"""plan_generator_utils 纯逻辑单测 — 第90波.
|
||
|
||
测试素材分配、clip_type映射、默认clip生成、配置转clip等纯函数。
|
||
不依赖 DB,使用领域对象直接构造。
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
from packages.domain.editing_mode import EditingMode
|
||
from packages.domain.plan_generator_utils import (
|
||
DEFAULT_CLIP_DURATION,
|
||
create_clips_from_configs,
|
||
distribute_assets,
|
||
generate_default_clips,
|
||
map_clip_types_for_mode,
|
||
)
|
||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_main_clip(plan_id: str = "plan1", order: int = 0) -> EditPlanClip:
|
||
"""创建一个 MAIN 类型的 clip."""
|
||
return EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=ClipType.MAIN.value,
|
||
order=order,
|
||
duration=5.0,
|
||
)
|
||
|
||
|
||
def _make_clips(n: int, clip_type: str = "main") -> list[EditPlanClip]:
|
||
"""创建 n 个指定类型的 clip."""
|
||
return [
|
||
EditPlanClip.create(
|
||
plan_id="plan1",
|
||
clip_type=clip_type,
|
||
order=i,
|
||
duration=5.0,
|
||
)
|
||
for i in range(n)
|
||
]
|
||
|
||
|
||
def _collect_asset_ids(clips: list[EditPlanClip]) -> list[str]:
|
||
"""按顺序收集 clips 的 asset_id(空的跳过)."""
|
||
return [c.asset_id for c in clips if c.asset_id]
|
||
|
||
|
||
# ── distribute_assets: ONE_TAKE ─────────────────────────────────────
|
||
|
||
|
||
class TestDistributeOneTake:
|
||
"""ONE_TAKE 模式素材分配."""
|
||
|
||
def test_equal_count(self):
|
||
"""素材数 == clip 数:一一对应."""
|
||
clips = _make_clips(3)
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == "a2"
|
||
assert clips[2].asset_id == "a3"
|
||
|
||
def test_more_assets_than_clips(self):
|
||
"""素材多于 clip:多余的不用."""
|
||
clips = _make_clips(2)
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == "a2"
|
||
|
||
def test_fewer_assets_than_clips(self):
|
||
"""素材少于 clip:后面的 clip 没素材."""
|
||
clips = _make_clips(5)
|
||
distribute_assets(clips, ["a1", "a2"], EditingMode.ONE_TAKE.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == "a2"
|
||
assert clips[2].asset_id == ""
|
||
assert clips[3].asset_id == ""
|
||
assert clips[4].asset_id == ""
|
||
|
||
def test_empty_assets(self):
|
||
"""空素材列表:无分配."""
|
||
clips = _make_clips(3)
|
||
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
|
||
for c in clips:
|
||
assert c.asset_id == ""
|
||
|
||
def test_empty_clips(self):
|
||
"""空 clip 列表:不报错."""
|
||
distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)
|
||
|
||
def test_only_main_clips_get_assigned(self):
|
||
"""只分配给 MAIN 类型 clip,其他类型不受影响."""
|
||
clips = _make_clips(2) + _make_clips(2, "intro") + _make_clips(2, "outro")
|
||
distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.ONE_TAKE.value)
|
||
mains = [c for c in clips if c.clip_type == "main"]
|
||
others = [c for c in clips if c.clip_type != "main"]
|
||
assert mains[0].asset_id == "a1"
|
||
assert mains[1].asset_id == "a2"
|
||
for c in others:
|
||
assert c.asset_id == ""
|
||
|
||
|
||
# ── distribute_assets: PIP ──────────────────────────────────────────
|
||
|
||
|
||
class TestDistributePip:
|
||
"""PIP 模式素材分配."""
|
||
|
||
def test_basic_pip_distribution(self):
|
||
"""第1个素材给 main,其余给 overlay."""
|
||
clips = _make_clips(1) + _make_clips(3, "overlay")
|
||
distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.PIP.value)
|
||
mains = [c for c in clips if c.clip_type == "main"]
|
||
overlays = [c for c in clips if c.clip_type == "overlay"]
|
||
assert mains[0].asset_id == "a1"
|
||
assert overlays[0].asset_id == "a2"
|
||
assert overlays[1].asset_id == "a3"
|
||
assert overlays[2].asset_id == "a4"
|
||
|
||
def test_single_asset_only_main(self):
|
||
"""只有1个素材:只分配给 main,overlay 没素材."""
|
||
clips = _make_clips(1) + _make_clips(2, "overlay")
|
||
distribute_assets(clips, ["a1"], EditingMode.PIP.value)
|
||
mains = [c for c in clips if c.clip_type == "main"]
|
||
overlays = [c for c in clips if c.clip_type == "overlay"]
|
||
assert mains[0].asset_id == "a1"
|
||
assert overlays[0].asset_id == ""
|
||
assert overlays[1].asset_id == ""
|
||
|
||
def test_more_overlays_than_assets(self):
|
||
"""overlay 多于剩余素材:后面的 overlay 没素材."""
|
||
clips = _make_clips(1) + _make_clips(5, "overlay")
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
|
||
overlays = [c for c in clips if c.clip_type == "overlay"]
|
||
assert overlays[0].asset_id == "a2"
|
||
assert overlays[1].asset_id == "a3"
|
||
assert overlays[2].asset_id == ""
|
||
assert overlays[3].asset_id == ""
|
||
assert overlays[4].asset_id == ""
|
||
|
||
def test_no_main_clip(self):
|
||
"""没有 main clip:第1个素材没人拿,overlay 从第2个素材开始."""
|
||
clips = _make_clips(3, "overlay")
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
|
||
overlays = [c for c in clips if c.clip_type == "overlay"]
|
||
# PIP 逻辑:先给 main 分配第1个素材(没有 main 则跳过),
|
||
# 剩余从第2个开始分配给 overlay
|
||
assert overlays[0].asset_id == "a2"
|
||
assert overlays[1].asset_id == "a3"
|
||
assert overlays[2].asset_id == ""
|
||
|
||
|
||
# ── distribute_assets: VOICE_OVER ───────────────────────────────────
|
||
|
||
|
||
class TestDistributeVoiceOver:
|
||
"""VOICE_OVER 模式素材分配."""
|
||
|
||
def test_voice_over_same_as_one_take(self):
|
||
"""VOICE_OVER 和 ONE_TAKE 分配策略相同:按顺序给 main."""
|
||
clips = _make_clips(3)
|
||
assets = ["a1", "a2", "a3"]
|
||
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == "a2"
|
||
assert clips[2].asset_id == "a3"
|
||
|
||
def test_voice_over_fewer_assets(self):
|
||
"""素材不足时,后面的 main clip 没素材."""
|
||
clips = _make_clips(5)
|
||
distribute_assets(clips, ["a1"], EditingMode.VOICE_OVER.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == ""
|
||
|
||
|
||
# ── distribute_assets: VOICE_PIP ────────────────────────────────────
|
||
|
||
|
||
class TestDistributeVoicePip:
|
||
"""VOICE_PIP 模式素材分配."""
|
||
|
||
def test_three_assets_full_distribution(self):
|
||
"""3个素材:background + corner_voice + b_roll 各一个."""
|
||
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(1, "b_roll")
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
|
||
bgs = [c for c in clips if c.clip_type == "background"]
|
||
voices = [c for c in clips if c.clip_type == "corner_voice"]
|
||
brolls = [c for c in clips if c.clip_type == "b_roll"]
|
||
assert bgs[0].asset_id == "a1"
|
||
assert voices[0].asset_id == "a2"
|
||
assert brolls[0].asset_id == "a3"
|
||
|
||
def test_single_asset_only_background(self):
|
||
"""1个素材:只分配给 background."""
|
||
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
|
||
distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value)
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == ""
|
||
assert clips[2].asset_id == ""
|
||
assert clips[3].asset_id == ""
|
||
|
||
def test_two_assets_bg_and_voice(self):
|
||
"""2个素材:background + corner_voice."""
|
||
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
|
||
distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value)
|
||
bgs = [c for c in clips if c.clip_type == "background"]
|
||
voices = [c for c in clips if c.clip_type == "corner_voice"]
|
||
brolls = [c for c in clips if c.clip_type == "b_roll"]
|
||
assert bgs[0].asset_id == "a1"
|
||
assert voices[0].asset_id == "a2"
|
||
assert brolls[0].asset_id == ""
|
||
|
||
def test_many_broll_clips(self):
|
||
"""多个 b_roll clip:按顺序分配剩余素材."""
|
||
clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(5, "b_roll")
|
||
distribute_assets(
|
||
clips,
|
||
["a1", "a2", "a3", "a4", "a5"],
|
||
EditingMode.VOICE_PIP.value,
|
||
)
|
||
brolls = [c for c in clips if c.clip_type == "b_roll"]
|
||
assert brolls[0].asset_id == "a3"
|
||
assert brolls[1].asset_id == "a4"
|
||
assert brolls[2].asset_id == "a5"
|
||
assert brolls[3].asset_id == ""
|
||
assert brolls[4].asset_id == ""
|
||
|
||
def test_missing_some_layer_clips(self):
|
||
"""缺少某些层的 clip 不影响其他层."""
|
||
# 没有 corner_voice,素材应该按顺序:bg 拿 a1,b_roll 从 a2 开始
|
||
clips = _make_clips(1, "background") + _make_clips(3, "b_roll")
|
||
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
|
||
bgs = [c for c in clips if c.clip_type == "background"]
|
||
brolls = [c for c in clips if c.clip_type == "b_roll"]
|
||
assert bgs[0].asset_id == "a1"
|
||
# 没有 corner_voice,b_roll 从第2个素材开始
|
||
assert brolls[0].asset_id == "a2"
|
||
assert brolls[1].asset_id == "a3"
|
||
|
||
|
||
# ── distribute_assets: 边缘情况 ─────────────────────────────────────
|
||
|
||
|
||
class TestDistributeEdgeCases:
|
||
"""素材分配边缘情况."""
|
||
|
||
def test_unknown_mode_falls_back_to_one_take(self):
|
||
"""未知模式退化为 ONE_TAKE."""
|
||
clips = _make_clips(3)
|
||
distribute_assets(clips, ["a1", "a2", "a3"], "unknown_mode")
|
||
assert clips[0].asset_id == "a1"
|
||
assert clips[1].asset_id == "a2"
|
||
assert clips[2].asset_id == "a3"
|
||
|
||
def test_both_empty(self):
|
||
"""两边都空:不报错."""
|
||
distribute_assets([], [], EditingMode.ONE_TAKE.value)
|
||
|
||
|
||
# ── map_clip_types_for_mode ─────────────────────────────────────────
|
||
|
||
|
||
class TestMapClipTypesForMode:
|
||
"""clip_type 按模式映射."""
|
||
|
||
def test_one_take_unchanged(self):
|
||
"""ONE_TAKE 模式:main 保持 main."""
|
||
clips = _make_clips(5)
|
||
map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
|
||
for c in clips:
|
||
assert c.clip_type == "main"
|
||
|
||
def test_voice_over_unchanged(self):
|
||
"""VOICE_OVER 模式:main 保持 main."""
|
||
clips = _make_clips(5)
|
||
map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
|
||
for c in clips:
|
||
assert c.clip_type == "main"
|
||
|
||
def test_pip_first_main_stays_rest_become_overlay(self):
|
||
"""PIP 模式:第1个 main 保持,其余变 overlay."""
|
||
clips = _make_clips(5)
|
||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||
assert clips[0].clip_type == "main"
|
||
assert clips[1].clip_type == "overlay"
|
||
assert clips[2].clip_type == "overlay"
|
||
assert clips[3].clip_type == "overlay"
|
||
assert clips[4].clip_type == "overlay"
|
||
|
||
def test_pip_single_main_unchanged(self):
|
||
"""PIP 模式只有1个 main:保持 main."""
|
||
clips = _make_clips(1)
|
||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||
assert clips[0].clip_type == "main"
|
||
|
||
def test_voice_pip_three_types(self):
|
||
"""VOICE_PIP 模式:background + corner_voice + b_roll."""
|
||
clips = _make_clips(5)
|
||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||
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"
|
||
assert clips[4].clip_type == "b_roll"
|
||
|
||
def test_voice_pip_one_main(self):
|
||
"""VOICE_PIP 只有1个 main:变成 background."""
|
||
clips = _make_clips(1)
|
||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||
assert clips[0].clip_type == "background"
|
||
|
||
def test_voice_pip_two_mains(self):
|
||
"""VOICE_PIP 2个 main:background + corner_voice."""
|
||
clips = _make_clips(2)
|
||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||
assert clips[0].clip_type == "background"
|
||
assert clips[1].clip_type == "corner_voice"
|
||
|
||
def test_non_main_clips_unchanged(self):
|
||
"""非 MAIN 类型 clip 不受影响."""
|
||
clips = _make_clips(1, "intro") + _make_clips(3) + _make_clips(1, "outro") # main
|
||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||
assert clips[0].clip_type == "intro"
|
||
assert clips[1].clip_type == "main" # 第1个 main
|
||
assert clips[2].clip_type == "overlay" # 第2个 main → overlay
|
||
assert clips[3].clip_type == "overlay" # 第3个 main → overlay
|
||
assert clips[4].clip_type == "outro"
|
||
|
||
def test_no_main_clips_noop(self):
|
||
"""没有 main clip:什么都不做."""
|
||
clips = _make_clips(3, "intro")
|
||
original_types = [c.clip_type for c in clips]
|
||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||
assert [c.clip_type for c in clips] == original_types
|
||
|
||
def test_empty_clips_noop(self):
|
||
"""空列表:不报错."""
|
||
map_clip_types_for_mode([], EditingMode.PIP.value)
|
||
|
||
|
||
# ── generate_default_clips ──────────────────────────────────────────
|
||
|
||
|
||
class TestGenerateDefaultClips:
|
||
"""默认 clip 生成."""
|
||
|
||
def test_one_take_normal(self):
|
||
"""ONE_TAKE:N 个 main clip."""
|
||
clips = generate_default_clips("plan1", EditingMode.ONE_TAKE.value, 5)
|
||
assert len(clips) == 5
|
||
for c in clips:
|
||
assert c.clip_type == "main"
|
||
assert c.plan_id == "plan1"
|
||
assert c.duration == DEFAULT_CLIP_DURATION
|
||
# order 递增
|
||
for i in range(5):
|
||
assert clips[i].order == i
|
||
|
||
def test_pip_structure(self):
|
||
"""PIP:1个 main + (N-1)个 overlay."""
|
||
clips = generate_default_clips("plan1", EditingMode.PIP.value, 4)
|
||
assert len(clips) == 4
|
||
assert clips[0].clip_type == "main"
|
||
assert clips[1].clip_type == "overlay"
|
||
assert clips[2].clip_type == "overlay"
|
||
assert clips[3].clip_type == "overlay"
|
||
assert clips[0].order == 0
|
||
assert clips[3].order == 3
|
||
|
||
def test_pip_single_asset(self):
|
||
"""PIP 只有1个素材:1个 main,没有 overlay."""
|
||
clips = generate_default_clips("plan1", EditingMode.PIP.value, 1)
|
||
assert len(clips) == 1
|
||
assert clips[0].clip_type == "main"
|
||
|
||
def test_voice_over_structure(self):
|
||
"""VOICE_OVER:N 个 main clip,带 b_roll 标记."""
|
||
clips = generate_default_clips("plan1", EditingMode.VOICE_OVER.value, 3)
|
||
assert len(clips) == 3
|
||
for c in clips:
|
||
assert c.clip_type == "main"
|
||
assert c.config.get("role") == "b_roll"
|
||
|
||
def test_voice_pip_three_layers(self):
|
||
"""VOICE_PIP:background + corner_voice + b_roll."""
|
||
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 5)
|
||
assert len(clips) == 5
|
||
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"
|
||
assert clips[4].clip_type == "b_roll"
|
||
|
||
def test_voice_pip_single_asset(self):
|
||
"""VOICE_PIP 1个素材:只有 background."""
|
||
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 1)
|
||
assert len(clips) == 1
|
||
assert clips[0].clip_type == "background"
|
||
|
||
def test_voice_pip_two_assets(self):
|
||
"""VOICE_PIP 2个素材:background + corner_voice."""
|
||
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 2)
|
||
assert len(clips) == 2
|
||
assert clips[0].clip_type == "background"
|
||
assert clips[1].clip_type == "corner_voice"
|
||
|
||
def test_zero_assets_at_least_one(self):
|
||
"""0 个素材:至少生成 1 个 clip."""
|
||
for mode in [
|
||
EditingMode.ONE_TAKE.value,
|
||
EditingMode.PIP.value,
|
||
EditingMode.VOICE_OVER.value,
|
||
EditingMode.VOICE_PIP.value,
|
||
]:
|
||
clips = generate_default_clips("plan1", mode, 0)
|
||
assert len(clips) >= 1
|
||
|
||
def test_unknown_mode_falls_back(self):
|
||
"""未知模式退化为 ONE_TAKE 风格."""
|
||
clips = generate_default_clips("plan1", "unknown", 3)
|
||
assert len(clips) == 3
|
||
for c in clips:
|
||
assert c.clip_type == "main"
|
||
|
||
def test_order_is_sequential(self):
|
||
"""所有模式下 order 都是从 0 开始连续递增."""
|
||
for mode in [
|
||
EditingMode.ONE_TAKE.value,
|
||
EditingMode.PIP.value,
|
||
EditingMode.VOICE_OVER.value,
|
||
EditingMode.VOICE_PIP.value,
|
||
]:
|
||
clips = generate_default_clips("plan1", mode, 5)
|
||
for i, c in enumerate(clips):
|
||
assert c.order == i
|
||
|
||
|
||
# ── create_clips_from_configs ───────────────────────────────────────
|
||
|
||
|
||
class TestCreateClipsFromConfigs:
|
||
"""从模板配置创建 clips."""
|
||
|
||
def test_basic_creation(self):
|
||
"""基本创建:按 order 排序,属性正确传递."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=1,
|
||
min_duration=3.0,
|
||
max_duration=7.0,
|
||
transition_effect="fade",
|
||
),
|
||
TemplateClipConfig(
|
||
id="cfg2",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.INTRO,
|
||
order=0,
|
||
min_duration=2.0,
|
||
max_duration=4.0,
|
||
transition_effect="cut",
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert len(clips) == 2
|
||
# 按 order 排序:intro(order=0) 在前,main(order=1) 在后
|
||
assert clips[0].clip_type == "intro"
|
||
assert clips[1].clip_type == "main"
|
||
assert clips[0].order == 0
|
||
assert clips[1].order == 1
|
||
assert clips[0].template_clip_config_id == "cfg2"
|
||
assert clips[1].template_clip_config_id == "cfg1"
|
||
|
||
def test_duration_average_of_min_max(self):
|
||
"""min_duration 和 max_duration 都有时,取平均值."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=4.0,
|
||
max_duration=6.0,
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].duration == 5.0 # (4+6)/2
|
||
|
||
def test_duration_only_min(self):
|
||
"""只有 min_duration 时,用 min_duration."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3.5,
|
||
max_duration=0,
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].duration == 3.5
|
||
|
||
def test_duration_only_max(self):
|
||
"""只有 max_duration 时,用 max_duration."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=0,
|
||
max_duration=8.0,
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].duration == 8.0
|
||
|
||
def test_duration_default_when_both_zero(self):
|
||
"""都为 0 时用默认时长."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=0,
|
||
max_duration=0,
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].duration == DEFAULT_CLIP_DURATION
|
||
|
||
def test_empty_configs_returns_empty(self):
|
||
"""空配置列表返回空列表."""
|
||
clips = create_clips_from_configs("plan1", [])
|
||
assert clips == []
|
||
|
||
def test_plan_id_passed_through(self):
|
||
"""plan_id 正确传递给所有 clip."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id=f"cfg{i}",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=i,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
)
|
||
for i in range(3)
|
||
]
|
||
clips = create_clips_from_configs("my_plan", configs)
|
||
for c in clips:
|
||
assert c.plan_id == "my_plan"
|
||
|
||
def test_playback_speed_from_config(self):
|
||
"""playback_speed 从 config.playback_speed 读取."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
config={"playback_speed": 1.5},
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].playback_speed == 1.5
|
||
|
||
def test_playback_speed_fallback_to_speed_ratio(self):
|
||
"""playback_speed 不存在时回退到 speed_ratio."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
config={"speed_ratio": 0.8},
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].playback_speed == 0.8
|
||
|
||
def test_playback_speed_default_1(self):
|
||
"""没有 speed 配置时默认为 1.0."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
config={},
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].playback_speed == 1.0
|
||
|
||
def test_playback_speed_none_falls_back(self):
|
||
"""playback_speed 为 None 时回退到 1.0."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
config={"playback_speed": None},
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].playback_speed == 1.0
|
||
|
||
def test_transition_default_cut(self):
|
||
"""transition_effect 为空时默认为 cut."""
|
||
configs = [
|
||
TemplateClipConfig(
|
||
id="cfg1",
|
||
template_id="tpl1",
|
||
clip_type=ClipType.MAIN,
|
||
order=0,
|
||
min_duration=3,
|
||
max_duration=5,
|
||
transition_effect=None,
|
||
),
|
||
]
|
||
clips = create_clips_from_configs("plan1", configs)
|
||
assert clips[0].transition_effect == "cut"
|
||
|
||
|
||
# ── 常量导出 ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConstants:
|
||
"""常量导出验证."""
|
||
|
||
def test_default_duration_value(self):
|
||
"""默认片段时长应为 5 秒."""
|
||
assert DEFAULT_CLIP_DURATION == 5.0
|