Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 6f0a5d63c7 test(wave166): plan_generator_utils计划生成工具 +54测
CI/CD Pipeline / Check if frontend-only change (pull_request) Waiting to run
CI/CD Pipeline / Validate - Code Quality (pull_request) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Waiting to run
CI/CD Pipeline / Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
PR Automation / Auto Approve on CI Green (pull_request) Waiting to run
PR Automation / Auto Merge on CI Green + Approved (pull_request) Waiting to run
Preview Deploy / Deploy Preview Environment (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) CI runner不可用,手动设置
AI Code Review / AI Code Review (pull_request) CI runner不可用,手动设置
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
- distribute_assets素材分配:
  ONE_TAKE:等数/多clip少素材/多素材少clip/空素材/空clip/仅main分配
  PIP:第一素材main/单素材/无overlay
  VOICE_OVER:全分配main/多clip少素材
  VOICE_PIP:三素材三角色/单素材/双素材
  边界:未知模式回退/空列表不崩溃
- map_clip_types_for_mode类型映射:
  ONE_TAKE不变/VOICE_OVER不变/PIP首main其余overlay/PIP单clip
  VOICE_PIP:5clip/2clip/1clip
  非main不变/空clip/无main
- generate_default_clips默认片段:
  ONE_TAKE:基本/顺序/0素材/负素材/默认时长
  PIP:1素材/3素材/0素材
  VOICE_OVER:基本/b_roll标记
  VOICE_PIP:1/2/5素材/0素材
  未知模式回退
- create_clips_from_configs模板创建:
  空列表/单配置/min+max/min_only/max_only/无边界默认
  按order排序/clip_type保留/playback_speed/speed_ratio兼容
  默认speed/transition_effect保留/返回类型
2026-07-28 16:06:31 +08:00
9 changed files with 642 additions and 152 deletions
+101 -16
View File
@@ -1,9 +1,9 @@
/**
* Step 6 封面设置组件
*/
import React from "react"
import type { CoverConfig } from "../../editing-planner/types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -28,6 +28,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{/* 启用开关 */}
<div className="xx-cover-header">
<span className="xx-cover-header-label">启用自定义封面</span>
<label className="xx-switch">
@@ -42,14 +43,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
{coverSettings.enabled && (
<>
{/* 模式选择 */}
<div className="xx-section-title">封面来源</div>
<CoverModeSelector
mode={coverSettings.mode}
onModeChange={setMode}
modeLabels={COVER_MODE_LABELS}
modeIcons={COVER_MODE_ICONS}
/>
<div className="xx-cover-mode-tabs">
{(["auto", "frame", "upload"] as const).map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
onClick={() => setMode(m)}
>
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
</button>
))}
</div>
{/* 智能封面 */}
{coverSettings.mode === "auto" && (
<div className="xx-cover-auto">
<div className="xx-cover-auto-desc">
@@ -62,19 +71,95 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
)}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && (
<FrameCoverPicker
frameTime={coverSettings.frame_time}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={setFrameTime}
/>
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">
{formatTime(coverSettings.frame_time)}
</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">
{formatTime(coverSettings.frame_time)}
</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={coverSettings.frame_time}
onChange={(e) => setFrameTime(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="xx-cover-quick-btn"
onClick={() => setFrameTime(t)}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{coverSettings.mode === "upload" && (
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
<div className="xx-cover-upload">
<div
className="xx-cover-upload-area"
onClick={() => {
const input = document.getElementById("cover-upload-input")
input?.click()
}}
>
{coverSettings.upload_url ? (
<div className="xx-cover-upload-preview">
<img src={coverSettings.upload_url} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
}}
/>
</div>
</div>
)}
{/* 封面预览 */}
<div className="xx-section-title">封面预览</div>
<div className="xx-cover-preview-box">
{coverSettings.upload_url ? (
@@ -1,32 +0,0 @@
import React from "react"
import type { CoverMode } from "../../../editing-planner/types"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
@@ -1,58 +0,0 @@
import React from "react"
interface FrameCoverPickerProps {
frameTime: number
totalDuration: number
formatTime: (seconds: number) => string
onFrameTimeChange: (time: number) => void
}
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
frameTime,
totalDuration,
formatTime,
onFrameTimeChange,
}) => {
const quickRatios = [0, 0.25, 0.5, 0.75]
return (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={frameTime}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{quickRatios.map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
}
@@ -1,46 +0,0 @@
import React from "react"
interface UploadCoverPickerProps {
uploadUrl: string
onUpload: (file: File) => void
}
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
const handleClick = () => {
const input = document.getElementById("cover-upload-input")
input?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="xx-cover-upload">
<div className="xx-cover-upload-area" onClick={handleClick}>
{uploadUrl ? (
<div className="xx-cover-upload-preview">
<img src={uploadUrl} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
}
@@ -6,6 +6,7 @@
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@
from __future__ import annotations
from packages.domain.speed_config import (
DEFAULT_SPEED,
SpeedConfig,
+537
View File
@@ -0,0 +1,537 @@
"""plan_generator_utils 单元测试 - wave166
覆盖:
- distribute_assets 素材分配(4种模式 + 边界)
- map_clip_types_for_mode clip类型映射(4种模式)
- generate_default_clips 默认片段生成(4种模式 + 边界)
- create_clips_from_configs 从模板配置创建
"""
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, order: int = 0) -> EditPlanClip:
return EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=5.0,
)
def _make_clips(plan_id: str, count: int, clip_type: str = "main") -> list[EditPlanClip]:
return [
EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=i,
duration=5.0,
)
for i in range(count)
]
# ============================================================
# distribute_assets - ONE_TAKE
# ============================================================
class TestDistributeOneTake:
def test_equal_count(self):
clips = _make_clips("p1", 3)
assets = ["a1", "a2", "a3"]
distribute_assets(clips, assets, 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_clips_than_assets(self):
clips = _make_clips("p1", 5)
assets = ["a1", "a2"]
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[2].asset_id == "" # 没分配到
def test_more_assets_than_clips(self):
clips = _make_clips("p1", 2)
assets = ["a1", "a2", "a3"]
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
def test_empty_assets(self):
clips = _make_clips("p1", 3)
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
for c in clips:
assert c.asset_id == ""
def test_empty_clips(self):
# 不报错即可
distribute_assets([], ["a1", "a2"], EditingMode.ONE_TAKE.value)
def test_only_main_clips_get_assigned(self):
# intro/outro 不应该被分配
clips = []
clips.append(EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0))
clips.append(_make_main_clip("p1", order=1))
clips.append(EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0))
assets = ["a1"]
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "" # intro 无
assert clips[1].asset_id == "a1" # main 有
assert clips[2].asset_id == "" # outro 无
# ============================================================
# distribute_assets - PIP
# ============================================================
class TestDistributePip:
def test_first_asset_to_main(self):
clips = _make_clips("p1", 3)
# 第一个main是背景,其余改为overlay
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assets = ["a1", "a2", "a3"]
distribute_assets(clips, assets, EditingMode.PIP.value)
assert clips[0].asset_id == "a1" # main → 背景
assert clips[1].asset_id == "a2" # overlay
assert clips[2].asset_id == "a3" # overlay
def test_single_asset(self):
clips = _make_clips("p1", 1)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assets = ["a1"]
distribute_assets(clips, assets, EditingMode.PIP.value)
assert clips[0].asset_id == "a1"
def test_only_main_clip_with_no_overlays(self):
clips = _make_clips("p1", 1)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assets = ["a1", "a2", "a3"] # 多余素材
distribute_assets(clips, assets, EditingMode.PIP.value)
assert clips[0].asset_id == "a1"
# ============================================================
# distribute_assets - VOICE_OVER
# ============================================================
class TestDistributeVoiceOver:
def test_assets_to_main_clips(self):
clips = _make_clips("p1", 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_more_clips_than_assets(self):
clips = _make_clips("p1", 5)
assets = ["a1", "a2"]
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 == ""
# ============================================================
# distribute_assets - VOICE_PIP
# ============================================================
class TestDistributeVoicePip:
def test_three_assets_three_roles(self):
clips = _make_clips("p1", 3)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assets = ["a1", "a2", "a3"]
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
assert clips[0].asset_id == "a1"
assert clips[1].clip_type == "corner_voice"
assert clips[1].asset_id == "a2"
assert clips[2].clip_type == "b_roll"
assert clips[2].asset_id == "a3"
def test_single_asset(self):
clips = _make_clips("p1", 1)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assets = ["a1"]
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
assert clips[0].asset_id == "a1"
def test_two_assets(self):
clips = _make_clips("p1", 2)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assets = ["a1", "a2"]
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
# ============================================================
# distribute_assets - 边界情况
# ============================================================
class TestDistributeEdgeCases:
def test_unknown_mode_falls_back_to_one_take(self):
clips = _make_clips("p1", 2)
assets = ["a1", "a2"]
distribute_assets(clips, assets, "unknown_mode")
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
def test_none_clips_no_crash(self):
# 空列表
distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)
def test_none_assets_no_crash(self):
clips = _make_clips("p1", 2)
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
for c in clips:
assert c.asset_id == ""
# ============================================================
# map_clip_types_for_mode
# ============================================================
class TestMapClipTypesForMode:
def test_one_take_unchanged(self):
clips = _make_clips("p1", 3)
original_types = [c.clip_type for c in clips]
map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
assert [c.clip_type for c in clips] == original_types
def test_voice_over_unchanged(self):
clips = _make_clips("p1", 3)
map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
for c in clips:
assert c.clip_type == ClipType.MAIN.value
def test_pip_first_stays_main_rest_overlay(self):
clips = _make_clips("p1", 4)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == ClipType.MAIN.value
assert clips[1].clip_type == "overlay"
assert clips[2].clip_type == "overlay"
assert clips[3].clip_type == "overlay"
def test_pip_single_clip_stays_main(self):
clips = _make_clips("p1", 1)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == ClipType.MAIN.value
def test_voice_pip_mapping(self):
clips = _make_clips("p1", 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_two_clips(self):
clips = _make_clips("p1", 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_voice_pip_single_clip(self):
clips = _make_clips("p1", 1)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
def test_non_main_clips_unchanged(self):
clips = [
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
_make_main_clip("p1", order=1),
EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0),
]
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "intro"
assert clips[1].clip_type == "background" # main 被改了
assert clips[2].clip_type == "outro"
def test_empty_clips_no_error(self):
map_clip_types_for_mode([], EditingMode.PIP.value)
def test_no_main_clips_no_error(self):
clips = [
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
]
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == "intro"
# ============================================================
# generate_default_clips
# ============================================================
class TestGenerateDefaultClipsOneTake:
def test_basic(self):
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 3)
assert len(clips) == 3
for c in clips:
assert c.clip_type == ClipType.MAIN.value
assert c.plan_id == "p1"
def test_order_sequential(self):
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 5)
for i, c in enumerate(clips):
assert c.order == i
def test_zero_assets_at_least_one(self):
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 0)
assert len(clips) == 1
def test_negative_assets_at_least_one(self):
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, -5)
assert len(clips) == 1
def test_default_duration(self):
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 1)
assert clips[0].duration == DEFAULT_CLIP_DURATION
class TestGenerateDefaultClipsPip:
def test_one_asset(self):
clips = generate_default_clips("p1", EditingMode.PIP.value, 1)
assert len(clips) == 1
assert clips[0].clip_type == ClipType.MAIN.value
def test_three_assets(self):
clips = generate_default_clips("p1", EditingMode.PIP.value, 3)
assert len(clips) == 3
assert clips[0].clip_type == ClipType.MAIN.value
assert clips[1].clip_type == "overlay"
assert clips[2].clip_type == "overlay"
def test_zero_assets(self):
clips = generate_default_clips("p1", EditingMode.PIP.value, 0)
assert len(clips) >= 1
assert clips[0].clip_type == ClipType.MAIN.value
class TestGenerateDefaultClipsVoiceOver:
def test_basic(self):
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 3)
assert len(clips) == 3
for c in clips:
assert c.clip_type == ClipType.MAIN.value
def test_has_b_roll_config(self):
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 2)
# VOICE_OVER 标记 role=b_roll
assert clips[0].config.get("role") == "b_roll"
class TestGenerateDefaultClipsVoicePip:
def test_one_asset(self):
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 1)
assert len(clips) == 1
assert clips[0].clip_type == "background"
def test_two_assets(self):
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 2)
assert len(clips) == 2
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
def test_five_assets(self):
clips = generate_default_clips("p1", 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_zero_assets(self):
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 0)
assert len(clips) >= 1
assert clips[0].clip_type == "background"
class TestGenerateDefaultClipsUnknownMode:
def test_falls_back_to_one_take(self):
clips = generate_default_clips("p1", "unknown_mode", 3)
assert len(clips) == 3
for c in clips:
assert c.clip_type == ClipType.MAIN.value
# ============================================================
# create_clips_from_configs
# ============================================================
def _make_template_config(
cfg_id: str,
order: int,
clip_type: ClipType = ClipType.MAIN,
min_dur: float = 0,
max_dur: float = 0,
) -> TemplateClipConfig:
return TemplateClipConfig(
id=cfg_id,
template_id="t1",
clip_type=clip_type,
order=order,
min_duration=min_dur,
max_duration=max_dur,
transition_effect="cut",
config={},
)
class TestCreateClipsFromConfigs:
def test_empty_configs(self):
result = create_clips_from_configs("p1", [])
assert result == []
def test_single_config(self):
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=7.0)]
result = create_clips_from_configs("p1", configs)
assert len(result) == 1
assert result[0].plan_id == "p1"
assert result[0].template_clip_config_id == "c1"
# 平均时长 = (3+7)/2 = 5.0
assert result[0].duration == pytest.approx(5.0)
def test_duration_min_only(self):
configs = [_make_template_config("c1", 0, min_dur=4.0)]
result = create_clips_from_configs("p1", configs)
assert result[0].duration == 4.0
def test_duration_max_only(self):
configs = [_make_template_config("c1", 0, max_dur=6.0)]
result = create_clips_from_configs("p1", configs)
assert result[0].duration == 6.0
def test_duration_default_when_no_bounds(self):
configs = [_make_template_config("c1", 0)]
result = create_clips_from_configs("p1", configs)
assert result[0].duration == DEFAULT_CLIP_DURATION
def test_sorted_by_order(self):
configs = [
_make_template_config("c_third", 2),
_make_template_config("c_first", 0),
_make_template_config("c_second", 1),
]
result = create_clips_from_configs("p1", configs)
assert len(result) == 3
assert result[0].template_clip_config_id == "c_first"
assert result[1].template_clip_config_id == "c_second"
assert result[2].template_clip_config_id == "c_third"
assert result[0].order == 0
assert result[1].order == 1
assert result[2].order == 2
def test_clip_type_preserved(self):
configs = [
TemplateClipConfig(
id="c_intro",
template_id="t1",
clip_type=ClipType.INTRO,
order=0,
min_duration=3.0,
max_duration=3.0,
transition_effect="cut",
config={},
),
TemplateClipConfig(
id="c_main",
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=5.0,
max_duration=5.0,
transition_effect="cut",
config={},
),
]
result = create_clips_from_configs("p1", configs)
assert result[0].clip_type == ClipType.INTRO.value
assert result[1].clip_type == ClipType.MAIN.value
def test_playback_speed_from_config(self):
configs = [
TemplateClipConfig(
id="c1",
template_id="t1",
clip_type=ClipType.MAIN,
order=0,
min_duration=5.0,
max_duration=5.0,
transition_effect="cut",
config={"playback_speed": 1.5},
)
]
result = create_clips_from_configs("p1", configs)
assert result[0].playback_speed == pytest.approx(1.5)
def test_speed_ratio_fallback(self):
# 兼容 speed_ratio 字段名
configs = [
TemplateClipConfig(
id="c1",
template_id="t1",
clip_type=ClipType.MAIN,
order=0,
min_duration=5.0,
max_duration=5.0,
transition_effect="cut",
config={"speed_ratio": 0.8},
)
]
result = create_clips_from_configs("p1", configs)
assert result[0].playback_speed == pytest.approx(0.8)
def test_default_playback_speed(self):
configs = [_make_template_config("c1", 0, min_dur=5.0, max_dur=5.0)]
result = create_clips_from_configs("p1", configs)
assert result[0].playback_speed == pytest.approx(1.0)
def test_transition_effect_preserved(self):
configs = [
TemplateClipConfig(
id="c1",
template_id="t1",
clip_type=ClipType.MAIN,
order=0,
min_duration=5.0,
max_duration=5.0,
transition_effect="fade",
config={},
)
]
result = create_clips_from_configs("p1", configs)
assert result[0].transition_effect == "fade"
def test_returns_edit_plan_clip_objects(self):
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=5.0)]
result = create_clips_from_configs("p1", configs)
assert isinstance(result[0], EditPlanClip)
@@ -6,6 +6,7 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
+1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import dataclasses
import pytest
from packages.domain.transition_presets import (