Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 919f69f072 | |||
| c60f4f60c5 | |||
| 6dc388a794 | |||
| 7ca5b3732f | |||
| 7cdf56a1ac |
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
|
||||
interface UseRowProgressOptions {
|
||||
duration: number
|
||||
@@ -7,6 +7,22 @@ interface UseRowProgressOptions {
|
||||
|
||||
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const listenersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({
|
||||
move: null,
|
||||
up: null,
|
||||
})
|
||||
|
||||
const cleanupListeners = useCallback(() => {
|
||||
const { move, up } = listenersRef.current
|
||||
if (move) {
|
||||
document.removeEventListener("mousemove", move)
|
||||
listenersRef.current.move = null
|
||||
}
|
||||
if (up) {
|
||||
document.removeEventListener("mouseup", up)
|
||||
listenersRef.current.up = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -16,6 +32,7 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
@@ -24,15 +41,26 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
cleanupListeners()
|
||||
}
|
||||
|
||||
// 先清理旧的,再添加新的
|
||||
cleanupListeners()
|
||||
listenersRef.current.move = handleMove
|
||||
listenersRef.current.up = handleUp
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
},
|
||||
[duration, onSeek],
|
||||
[duration, onSeek, cleanupListeners],
|
||||
)
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupListeners()
|
||||
}
|
||||
}, [cleanupListeners])
|
||||
|
||||
return { progressRef, handleMouseDown }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import "@/pages/voice-materials/components/voice-material-card/CardActions"
|
||||
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
|
||||
import "@/pages/voice-materials/components/voice-material-card/types"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
import "@/pages/voice-materials/components/voice-material-row/useRowProgress"
|
||||
import "@/pages/voice-materials/components/voice-material-row/TagDisplay"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
import "@/pages/voice-materials/components/BatchBar"
|
||||
|
||||
@@ -569,7 +569,9 @@ class CosyVoiceService:
|
||||
清洗后的 prefix
|
||||
"""
|
||||
# 只保留字母和数字
|
||||
cleaned = "".join(c for c in name if c.isalnum())
|
||||
import re
|
||||
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
|
||||
# 最多10字符
|
||||
cleaned = cleaned[:10]
|
||||
# 如果清洗后为空,用默认值
|
||||
|
||||
Executable
+825
@@ -0,0 +1,825 @@
|
||||
"""config_schemas 模块单测.
|
||||
|
||||
覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from domain.config_schemas import (
|
||||
DEFAULT_EDIT_PLAN_CONFIG,
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG,
|
||||
BGMConfig,
|
||||
BGMSource,
|
||||
CoverConfig,
|
||||
CoverType,
|
||||
EditPlanConfigSchema,
|
||||
EditTemplateConfigSchema,
|
||||
ExportConfig,
|
||||
FilterConfig,
|
||||
ShadowConfig,
|
||||
StrokeConfig,
|
||||
SubtitleConfig,
|
||||
TextAnimation,
|
||||
TextPosition,
|
||||
TitleConfig,
|
||||
normalize_plan_config,
|
||||
normalize_template_config,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverType:
|
||||
"""CoverType 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert CoverType.AI_FRAME.value == "ai_frame"
|
||||
assert CoverType.MANUAL.value == "manual"
|
||||
assert CoverType.UPLOAD.value == "upload"
|
||||
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(CoverType.AI_FRAME, str)
|
||||
assert CoverType.AI_FRAME == "ai_frame"
|
||||
|
||||
def test_from_string(self):
|
||||
assert CoverType("ai_frame") == CoverType.AI_FRAME
|
||||
assert CoverType("manual") == CoverType.MANUAL
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
CoverType("invalid")
|
||||
|
||||
|
||||
class TestTextPosition:
|
||||
"""TextPosition 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextPosition.TOP.value == "top"
|
||||
assert TextPosition.CENTER.value == "center"
|
||||
assert TextPosition.BOTTOM.value == "bottom"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextPosition("top") == TextPosition.TOP
|
||||
assert TextPosition("bottom") == TextPosition.BOTTOM
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextPosition("left")
|
||||
|
||||
|
||||
class TestTextAnimation:
|
||||
"""TextAnimation 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextAnimation.NONE.value == "none"
|
||||
assert TextAnimation.FADE_IN.value == "fade_in"
|
||||
assert TextAnimation.SLIDE_UP.value == "slide_up"
|
||||
assert TextAnimation.SLIDE_DOWN.value == "slide_down"
|
||||
assert TextAnimation.SCALE.value == "scale"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextAnimation("fade_in") == TextAnimation.FADE_IN
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextAnimation("bounce")
|
||||
|
||||
|
||||
class TestBGMSource:
|
||||
"""BGMSource 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert BGMSource.LIBRARY.value == "library"
|
||||
assert BGMSource.UPLOAD.value == "upload"
|
||||
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
|
||||
|
||||
def test_from_string(self):
|
||||
assert BGMSource("library") == BGMSource.LIBRARY
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
BGMSource("spotify")
|
||||
|
||||
|
||||
# ── StrokeConfig / ShadowConfig ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStrokeConfig:
|
||||
"""StrokeConfig 描边配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = StrokeConfig()
|
||||
assert s.enabled is False
|
||||
assert s.color == "#000000"
|
||||
assert s.width == 1
|
||||
|
||||
def test_custom_values(self):
|
||||
s = StrokeConfig(enabled=True, color="#ff0000", width=5)
|
||||
assert s.enabled is True
|
||||
assert s.color == "#ff0000"
|
||||
assert s.width == 5
|
||||
|
||||
def test_width_min_boundary(self):
|
||||
s = StrokeConfig(width=1)
|
||||
assert s.width == 1
|
||||
|
||||
def test_width_max_boundary(self):
|
||||
s = StrokeConfig(width=10)
|
||||
assert s.width == 10
|
||||
|
||||
def test_width_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=0)
|
||||
|
||||
def test_width_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=11)
|
||||
|
||||
|
||||
class TestShadowConfig:
|
||||
"""ShadowConfig 阴影配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = ShadowConfig()
|
||||
assert s.enabled is False
|
||||
assert s.blur == 4
|
||||
assert s.offset_x == 2
|
||||
assert s.offset_y == 2
|
||||
|
||||
def test_custom_values(self):
|
||||
s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5)
|
||||
assert s.enabled is True
|
||||
assert s.blur == 10
|
||||
assert s.offset_x == 5
|
||||
assert s.offset_y == 5
|
||||
|
||||
def test_blur_min_boundary(self):
|
||||
s = ShadowConfig(blur=0)
|
||||
assert s.blur == 0
|
||||
|
||||
def test_blur_max_boundary(self):
|
||||
s = ShadowConfig(blur=20)
|
||||
assert s.blur == 20
|
||||
|
||||
def test_blur_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ShadowConfig(blur=21)
|
||||
|
||||
|
||||
# ── CoverConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverConfig:
|
||||
"""CoverConfig 封面配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = CoverConfig()
|
||||
assert c.type == CoverType.AI_FRAME
|
||||
assert c.image_url == ""
|
||||
assert c.frame_time is None
|
||||
|
||||
def test_manual_type_with_frame_time(self):
|
||||
c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5)
|
||||
assert c.type == CoverType.MANUAL
|
||||
assert c.frame_time == 5.5
|
||||
|
||||
def test_upload_type_with_image_url(self):
|
||||
c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg")
|
||||
assert c.type == CoverType.UPLOAD
|
||||
assert c.image_url == "https://example.com/cover.jpg"
|
||||
|
||||
def test_frame_time_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CoverConfig(frame_time=-1.0)
|
||||
|
||||
def test_frame_time_zero_valid(self):
|
||||
c = CoverConfig(frame_time=0.0)
|
||||
assert c.frame_time == 0.0
|
||||
|
||||
def test_from_dict_with_string_enum(self):
|
||||
c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""})
|
||||
assert c.type == CoverType.AI_REGENERATE
|
||||
|
||||
|
||||
# ── TitleConfig ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTitleConfig:
|
||||
"""TitleConfig 标题配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
t = TitleConfig()
|
||||
assert t.enabled is True
|
||||
assert t.ai_auto is True
|
||||
assert t.text == ""
|
||||
assert t.position == TextPosition.TOP
|
||||
assert t.font == "思源黑体"
|
||||
assert t.color == "#ffffff"
|
||||
assert t.size == 48
|
||||
assert t.bold is True
|
||||
assert t.italic is False
|
||||
assert isinstance(t.stroke, StrokeConfig)
|
||||
assert isinstance(t.shadow, ShadowConfig)
|
||||
|
||||
def test_custom_title(self):
|
||||
t = TitleConfig(
|
||||
enabled=True,
|
||||
ai_auto=False,
|
||||
text="我的视频标题",
|
||||
position=TextPosition.CENTER,
|
||||
font="微软雅黑",
|
||||
color="#000000",
|
||||
size=36,
|
||||
bold=False,
|
||||
italic=True,
|
||||
)
|
||||
assert t.text == "我的视频标题"
|
||||
assert t.position == TextPosition.CENTER
|
||||
assert t.size == 36
|
||||
assert t.bold is False
|
||||
assert t.italic is True
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
t = TitleConfig(size=12)
|
||||
assert t.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
t = TitleConfig(size=120)
|
||||
assert t.size == 120
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=11)
|
||||
|
||||
def test_size_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=121)
|
||||
|
||||
def test_stroke_nested_config(self):
|
||||
t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3})
|
||||
assert t.stroke.enabled is True
|
||||
assert t.stroke.color == "#ff0000"
|
||||
assert t.stroke.width == 3
|
||||
|
||||
def test_shadow_nested_config(self):
|
||||
t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3})
|
||||
assert t.shadow.enabled is True
|
||||
assert t.shadow.blur == 8
|
||||
|
||||
|
||||
# ── SubtitleConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleConfig:
|
||||
"""SubtitleConfig 字幕配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = SubtitleConfig()
|
||||
assert s.enabled is True
|
||||
assert s.position == TextPosition.BOTTOM
|
||||
assert s.font == "思源黑体"
|
||||
assert s.color == "#ffffff"
|
||||
assert s.size == 24
|
||||
assert s.animation == TextAnimation.FADE_IN
|
||||
assert s.auto_generated is False
|
||||
assert s.language == ""
|
||||
assert s.max_chars_per_line == 20
|
||||
assert s.min_chars_per_segment == 8
|
||||
|
||||
def test_custom_subtitle(self):
|
||||
s = SubtitleConfig(
|
||||
enabled=False,
|
||||
position=TextPosition.TOP,
|
||||
size=32,
|
||||
animation=TextAnimation.SLIDE_UP,
|
||||
auto_generated=True,
|
||||
language="zh",
|
||||
max_chars_per_line=30,
|
||||
min_chars_per_segment=10,
|
||||
)
|
||||
assert s.enabled is False
|
||||
assert s.position == TextPosition.TOP
|
||||
assert s.size == 32
|
||||
assert s.animation == TextAnimation.SLIDE_UP
|
||||
assert s.auto_generated is True
|
||||
assert s.language == "zh"
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
s = SubtitleConfig(size=12)
|
||||
assert s.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
s = SubtitleConfig(size=60)
|
||||
assert s.size == 60
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(size=11)
|
||||
|
||||
def test_max_chars_min_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=8)
|
||||
assert s.max_chars_per_line == 8
|
||||
|
||||
def test_max_chars_max_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=40)
|
||||
assert s.max_chars_per_line == 40
|
||||
|
||||
def test_max_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(max_chars_per_line=41)
|
||||
|
||||
def test_min_chars_min_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=2)
|
||||
assert s.min_chars_per_segment == 2
|
||||
|
||||
def test_min_chars_max_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=20)
|
||||
assert s.min_chars_per_segment == 20
|
||||
|
||||
def test_min_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(min_chars_per_segment=1)
|
||||
|
||||
|
||||
# ── BGMConfig ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGMConfig BGM配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
b = BGMConfig()
|
||||
assert b.enabled is False
|
||||
assert b.source == BGMSource.LIBRARY
|
||||
assert b.asset_id == ""
|
||||
assert b.preset_id == ""
|
||||
assert b.audio_url == ""
|
||||
assert b.volume == 0.3
|
||||
assert b.fade_in == 0.0
|
||||
assert b.fade_out == 0.0
|
||||
assert b.loop_enabled is True
|
||||
assert b.sidechain_enabled is False
|
||||
assert b.sidechain_ratio == 0.3
|
||||
assert b.sidechain_attack == 0.02
|
||||
assert b.sidechain_release == 0.5
|
||||
assert b.sidechain_threshold == -25.0
|
||||
|
||||
def test_custom_bgm(self):
|
||||
b = BGMConfig(
|
||||
enabled=True,
|
||||
source=BGMSource.UPLOAD,
|
||||
asset_id="bgm_123",
|
||||
volume=0.5,
|
||||
fade_in=2.0,
|
||||
fade_out=3.0,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
)
|
||||
assert b.enabled is True
|
||||
assert b.source == BGMSource.UPLOAD
|
||||
assert b.volume == 0.5
|
||||
assert b.sidechain_enabled is True
|
||||
assert b.sidechain_ratio == 0.5
|
||||
|
||||
def test_volume_range(self):
|
||||
b = BGMConfig(volume=0.0)
|
||||
assert b.volume == 0.0
|
||||
b = BGMConfig(volume=1.0)
|
||||
assert b.volume == 1.0
|
||||
|
||||
def test_volume_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=1.1)
|
||||
|
||||
def test_fade_in_range(self):
|
||||
b = BGMConfig(fade_in=30.0)
|
||||
assert b.fade_in == 30.0
|
||||
|
||||
def test_fade_in_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(fade_in=31.0)
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
b = BGMConfig(sidechain_attack=0.001)
|
||||
assert b.sidechain_attack == 0.001
|
||||
|
||||
def test_sidechain_attack_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_attack=0.0001)
|
||||
|
||||
def test_sidechain_threshold_range(self):
|
||||
b = BGMConfig(sidechain_threshold=-60.0)
|
||||
assert b.sidechain_threshold == -60.0
|
||||
b = BGMConfig(sidechain_threshold=0.0)
|
||||
assert b.sidechain_threshold == 0.0
|
||||
|
||||
def test_sidechain_threshold_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=-61.0)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=1.0)
|
||||
|
||||
|
||||
# ── ExportConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExportConfig:
|
||||
"""ExportConfig 导出配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
e = ExportConfig()
|
||||
assert e.resolution == "1080x1920"
|
||||
assert e.fps == 30
|
||||
assert e.video_bitrate == 8000
|
||||
assert e.audio_bitrate == 128
|
||||
assert e.format == "mp4"
|
||||
assert e.quality_preset == "balanced"
|
||||
assert e.watermark_enabled is False
|
||||
assert e.watermark_text == ""
|
||||
|
||||
def test_custom_export(self):
|
||||
e = ExportConfig(
|
||||
resolution="720x1280",
|
||||
fps=60,
|
||||
video_bitrate=5000,
|
||||
audio_bitrate=192,
|
||||
format="mov",
|
||||
quality_preset="high",
|
||||
watermark_enabled=True,
|
||||
watermark_text="我的水印",
|
||||
)
|
||||
assert e.resolution == "720x1280"
|
||||
assert e.fps == 60
|
||||
assert e.format == "mov"
|
||||
assert e.watermark_enabled is True
|
||||
|
||||
def test_fps_min_boundary(self):
|
||||
e = ExportConfig(fps=15)
|
||||
assert e.fps == 15
|
||||
|
||||
def test_fps_max_boundary(self):
|
||||
e = ExportConfig(fps=60)
|
||||
assert e.fps == 60
|
||||
|
||||
def test_fps_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=14)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=61)
|
||||
|
||||
def test_video_bitrate_range(self):
|
||||
e = ExportConfig(video_bitrate=1000)
|
||||
assert e.video_bitrate == 1000
|
||||
e = ExportConfig(video_bitrate=20000)
|
||||
assert e.video_bitrate == 20000
|
||||
|
||||
def test_video_bitrate_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=999)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=20001)
|
||||
|
||||
def test_audio_bitrate_range(self):
|
||||
e = ExportConfig(audio_bitrate=64)
|
||||
assert e.audio_bitrate == 64
|
||||
e = ExportConfig(audio_bitrate=320)
|
||||
assert e.audio_bitrate == 320
|
||||
|
||||
|
||||
# ── FilterConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterConfig:
|
||||
"""FilterConfig 滤镜配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
f = FilterConfig()
|
||||
assert f.enabled is False
|
||||
assert f.preset_id == "filter_none"
|
||||
assert f.intensity == 100
|
||||
assert f.brightness == 0.0
|
||||
assert f.contrast == 1.0
|
||||
assert f.saturation == 1.0
|
||||
assert f.warmth == 0.0
|
||||
|
||||
def test_custom_filter(self):
|
||||
f = FilterConfig(
|
||||
enabled=True,
|
||||
preset_id="vintage",
|
||||
intensity=50,
|
||||
brightness=0.3,
|
||||
contrast=1.5,
|
||||
saturation=2.0,
|
||||
warmth=-0.5,
|
||||
)
|
||||
assert f.enabled is True
|
||||
assert f.preset_id == "vintage"
|
||||
assert f.intensity == 50
|
||||
assert f.brightness == 0.3
|
||||
|
||||
def test_intensity_range(self):
|
||||
f = FilterConfig(intensity=0)
|
||||
assert f.intensity == 0
|
||||
f = FilterConfig(intensity=100)
|
||||
assert f.intensity == 100
|
||||
|
||||
def test_intensity_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=-1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=101)
|
||||
|
||||
def test_brightness_range(self):
|
||||
f = FilterConfig(brightness=-1.0)
|
||||
assert f.brightness == -1.0
|
||||
f = FilterConfig(brightness=1.0)
|
||||
assert f.brightness == 1.0
|
||||
|
||||
def test_contrast_range(self):
|
||||
f = FilterConfig(contrast=0.0)
|
||||
assert f.contrast == 0.0
|
||||
f = FilterConfig(contrast=2.0)
|
||||
assert f.contrast == 2.0
|
||||
|
||||
def test_saturation_range(self):
|
||||
f = FilterConfig(saturation=0.0)
|
||||
assert f.saturation == 0.0
|
||||
f = FilterConfig(saturation=3.0)
|
||||
assert f.saturation == 3.0
|
||||
|
||||
def test_warmth_range(self):
|
||||
f = FilterConfig(warmth=-1.0)
|
||||
assert f.warmth == -1.0
|
||||
f = FilterConfig(warmth=1.0)
|
||||
assert f.warmth == 1.0
|
||||
|
||||
def test_brightness_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=-1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=1.1)
|
||||
|
||||
|
||||
# ── 完整 Schema 模型 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditPlanConfigSchema:
|
||||
"""EditPlanConfigSchema 完整计划配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditPlanConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert isinstance(s.title, TitleConfig)
|
||||
assert isinstance(s.subtitle, SubtitleConfig)
|
||||
assert isinstance(s.bgm, BGMConfig)
|
||||
assert isinstance(s.export, ExportConfig)
|
||||
assert isinstance(s.filter, FilterConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
|
||||
def test_partial_update_via_dict(self):
|
||||
s = EditPlanConfigSchema(
|
||||
**{
|
||||
"cover": {"type": "manual", "frame_time": 10.0},
|
||||
"title": {"text": "自定义标题", "size": 60},
|
||||
"editing_mode": "template",
|
||||
}
|
||||
)
|
||||
assert s.cover.type == CoverType.MANUAL
|
||||
assert s.cover.frame_time == 10.0
|
||||
assert s.title.text == "自定义标题"
|
||||
assert s.title.size == 60
|
||||
assert s.editing_mode == "template"
|
||||
|
||||
def test_full_config_dict_roundtrip(self):
|
||||
data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
data["title"]["text"] = "测试标题"
|
||||
data["bgm"]["enabled"] = True
|
||||
s = EditPlanConfigSchema(**data)
|
||||
assert s.title.text == "测试标题"
|
||||
assert s.bgm.enabled is True
|
||||
# 默认字段保留
|
||||
assert s.subtitle.size == 24
|
||||
assert s.export.fps == 30
|
||||
|
||||
def test_invalid_subfield_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EditPlanConfigSchema(**{"title": {"size": 999}})
|
||||
|
||||
|
||||
class TestEditTemplateConfigSchema:
|
||||
"""EditTemplateConfigSchema 模板配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
assert s.transition_enabled is True
|
||||
|
||||
def test_custom_transition_enabled(self):
|
||||
s = EditTemplateConfigSchema(transition_enabled=False)
|
||||
assert s.transition_enabled is False
|
||||
|
||||
def test_has_all_plan_fields(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert hasattr(s, "cover")
|
||||
assert hasattr(s, "title")
|
||||
assert hasattr(s, "subtitle")
|
||||
assert hasattr(s, "bgm")
|
||||
assert hasattr(s, "export")
|
||||
assert hasattr(s, "filter")
|
||||
assert hasattr(s, "editing_mode")
|
||||
assert hasattr(s, "transition_enabled")
|
||||
|
||||
|
||||
# ── 默认值常量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultConfigs:
|
||||
"""默认配置常量"""
|
||||
|
||||
def test_default_plan_config_structure(self):
|
||||
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
|
||||
def test_default_template_config_extra_field(self):
|
||||
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
|
||||
|
||||
def test_template_config_inherits_plan(self):
|
||||
# 模板配置应该包含计划配置的所有字段
|
||||
for key in DEFAULT_EDIT_PLAN_CONFIG:
|
||||
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
|
||||
def test_defaults_are_valid_for_schema(self):
|
||||
# 默认值应该能通过 schema 校验
|
||||
plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG)
|
||||
assert plan.editing_mode == "one_take"
|
||||
template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert template.transition_enabled is True
|
||||
|
||||
def test_mutation_does_not_affect_original(self):
|
||||
# 修改返回的 dict 不应该影响常量
|
||||
d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
d["cover"]["type"] = "upload"
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame"
|
||||
|
||||
|
||||
# ── normalize_plan_config ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePlanConfig:
|
||||
"""normalize_plan_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_plan_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_plan_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_partial_cover_update(self):
|
||||
result = normalize_plan_config({"cover": {"type": "manual"}})
|
||||
assert result["cover"]["type"] == "manual"
|
||||
# 其他 cover 字段保留默认
|
||||
assert result["cover"]["image_url"] == ""
|
||||
assert result["cover"]["frame_time"] is None
|
||||
|
||||
def test_partial_title_update(self):
|
||||
result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}})
|
||||
assert result["title"]["text"] == "我的标题"
|
||||
assert result["title"]["size"] == 36
|
||||
assert result["title"]["font"] == "思源黑体"
|
||||
|
||||
def test_partial_subtitle_update(self):
|
||||
result = normalize_plan_config({"subtitle": {"size": 28}})
|
||||
assert result["subtitle"]["size"] == 28
|
||||
assert result["subtitle"]["position"] == "bottom"
|
||||
|
||||
def test_partial_bgm_update(self):
|
||||
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}})
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["bgm"]["volume"] == 0.5
|
||||
assert result["bgm"]["source"] == "library"
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_plan_config({"editing_mode": "template"})
|
||||
assert result["editing_mode"] == "template"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"})
|
||||
assert result["generation_task_id"] == "task_123"
|
||||
assert result["custom_field"] == "value"
|
||||
# 标准字段也保留
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_plan_config(
|
||||
{
|
||||
"cover": {"type": "upload", "image_url": "http://x.com/c.jpg"},
|
||||
"title": {"text": "标题", "size": 60},
|
||||
"bgm": {"enabled": True},
|
||||
"editing_mode": "smart",
|
||||
"extra_key": "extra_value",
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "upload"
|
||||
assert result["title"]["text"] == "标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["editing_mode"] == "smart"
|
||||
assert result["extra_key"] == "extra_value"
|
||||
|
||||
def test_non_dict_section_ignored(self):
|
||||
result = normalize_plan_config({"cover": "not_a_dict"})
|
||||
# cover 应该还是默认值
|
||||
assert result["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_editing_mode_non_string_ignored(self):
|
||||
result = normalize_plan_config({"editing_mode": 123})
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
raw = {"cover": {"type": "manual"}, "extra": "value"}
|
||||
raw_copy = copy.deepcopy(raw)
|
||||
normalize_plan_config(raw)
|
||||
assert raw == raw_copy
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
normalize_plan_config({"cover": {"type": "upload"}})
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG == original
|
||||
|
||||
|
||||
# ── normalize_template_config ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
"""normalize_template_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_template_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_template_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_partial_sections(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"title": {"text": "模板标题"},
|
||||
"bgm": {"enabled": True},
|
||||
}
|
||||
)
|
||||
assert result["title"]["text"] == "模板标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
|
||||
def test_transition_enabled_update(self):
|
||||
result = normalize_template_config({"transition_enabled": False})
|
||||
assert result["transition_enabled"] is False
|
||||
|
||||
def test_transition_enabled_non_bool_ignored(self):
|
||||
result = normalize_template_config({"transition_enabled": "yes"})
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_template_config({"editing_mode": "story"})
|
||||
assert result["editing_mode"] == "story"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_template_config({"template_version": "v2", "author": "test"})
|
||||
assert result["template_version"] == "v2"
|
||||
assert result["author"] == "test"
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"cover": {"type": "ai_regenerate"},
|
||||
"subtitle": {"size": 20},
|
||||
"transition_enabled": False,
|
||||
"editing_mode": "vlog",
|
||||
"tags": ["travel", "food"],
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "ai_regenerate"
|
||||
assert result["subtitle"]["size"] == 20
|
||||
assert result["transition_enabled"] is False
|
||||
assert result["editing_mode"] == "vlog"
|
||||
assert result["tags"] == ["travel", "food"]
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
normalize_template_config({"transition_enabled": False})
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG == original
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
"""模板片段转换器单测.
|
||||
|
||||
纯函数模块,覆盖:枚举安全解析、config过滤、
|
||||
clip→template转换、snapshot双向转换、名称校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_clip_converter import (
|
||||
clip_config_to_snapshot,
|
||||
clip_configs_to_snapshots,
|
||||
clip_to_template_clip_config,
|
||||
clips_to_template_clip_configs,
|
||||
filter_clip_config,
|
||||
filter_plan_config_to_template,
|
||||
safe_parse_clip_type,
|
||||
safe_parse_transition_effect,
|
||||
snapshot_to_template_clip_config,
|
||||
snapshots_to_template_clip_configs,
|
||||
validate_template_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSafeParseTransitionEffect:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_transition_effect(TransitionEffect.FADE)
|
||||
assert result == TransitionEffect.FADE
|
||||
assert isinstance(result, TransitionEffect)
|
||||
|
||||
def test_valid_string(self):
|
||||
result = safe_parse_transition_effect("fade")
|
||||
assert result == TransitionEffect.FADE
|
||||
|
||||
def test_cut_string(self):
|
||||
result = safe_parse_transition_effect("cut")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("invalid_effect")
|
||||
assert result == TransitionEffect.CUT # 默认
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE)
|
||||
assert result == TransitionEffect.DISSOLVE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_transition_effect(None)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_int_value_returns_default(self):
|
||||
result = safe_parse_transition_effect(123)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestSafeParseClipType:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_clip_type(ClipType.SUBTITLE)
|
||||
assert result == ClipType.SUBTITLE
|
||||
assert isinstance(result, ClipType)
|
||||
|
||||
def test_valid_string_main(self):
|
||||
result = safe_parse_clip_type("main")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_valid_string_text(self):
|
||||
result = safe_parse_clip_type("subtitle")
|
||||
assert result == ClipType.SUBTITLE
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_clip_type("unknown_type")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_clip_type("bad", default=ClipType.TITLE)
|
||||
assert result == ClipType.TITLE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_clip_type(None)
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_dict_returns_default(self):
|
||||
result = safe_parse_clip_type({"key": "val"})
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
|
||||
class TestFilterClipConfig:
|
||||
def test_none_config(self):
|
||||
result = filter_clip_config(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_clip_config({})
|
||||
assert result == {}
|
||||
|
||||
def test_basic_config_passthrough(self):
|
||||
cfg = {"font_size": 24, "color": "red"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert result == {"font_size": 24, "color": "red"}
|
||||
|
||||
def test_filters_asset_info(self):
|
||||
cfg = {"font_size": 24, "asset_info": {"id": "123"}}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "asset_info" not in result
|
||||
assert result["font_size"] == 24
|
||||
|
||||
def test_filters_source_asset_id(self):
|
||||
cfg = {"source_asset_id": "asset_1", "text_key": "hi"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "source_asset_id" not in result
|
||||
assert result["text_key"] == "hi"
|
||||
|
||||
def test_playback_speed_added_when_not_one(self):
|
||||
result = filter_clip_config({}, playback_speed=1.5)
|
||||
assert result["playback_speed"] == 1.5
|
||||
|
||||
def test_playback_speed_one_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=1.0)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_none_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=None)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_config_takes_priority(self):
|
||||
"""clip_config中的playback_speed会覆盖参数传入的(因为update在后面)."""
|
||||
cfg = {"playback_speed": 0.5, "other": "val"}
|
||||
result = filter_clip_config(cfg, playback_speed=2.0)
|
||||
assert result["playback_speed"] == 0.5 # config里的覆盖参数的
|
||||
assert result["other"] == "val"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3}
|
||||
skip = frozenset({"drop_me", "also_drop"})
|
||||
result = filter_clip_config(cfg, skip_keys=skip)
|
||||
assert result == {"keep_me": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, "asset_info": "x"}
|
||||
original = dict(cfg)
|
||||
filter_clip_config(cfg)
|
||||
assert cfg == original # 原dict不变
|
||||
|
||||
|
||||
class TestFilterPlanConfigToTemplate:
|
||||
def test_none_config(self):
|
||||
result = filter_plan_config_to_template(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_plan_config_to_template({})
|
||||
assert result == {}
|
||||
|
||||
def test_keeps_template_fields(self):
|
||||
cfg = {"title": "My Template", "aspect_ratio": "9:16"}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert result == cfg
|
||||
|
||||
def test_filters_runtime_fields(self):
|
||||
cfg = {
|
||||
"title": "T",
|
||||
"is_template_draft": True,
|
||||
"asset_ids": ["a1"],
|
||||
"source_edit_plan_id": "ep1",
|
||||
"generation_task_id": "gt1",
|
||||
}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert "is_template_draft" not in result
|
||||
assert "asset_ids" not in result
|
||||
assert "source_edit_plan_id" not in result
|
||||
assert "generation_task_id" not in result
|
||||
assert result["title"] == "T"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep": 1, "skip_a": 2, "skip_b": 3}
|
||||
skip = frozenset({"skip_a", "skip_b"})
|
||||
result = filter_plan_config_to_template(cfg, skip_keys=skip)
|
||||
assert result == {"keep": 1}
|
||||
|
||||
|
||||
class TestClipToTemplateClipConfig:
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
duration: float = 5.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
playback_speed: float | None = None
|
||||
config: dict | None = None
|
||||
|
||||
def test_basic_conversion(self):
|
||||
clip = self.FakeClip(
|
||||
clip_type="subtitle",
|
||||
order=2,
|
||||
duration=3.5,
|
||||
text_content="Hello",
|
||||
transition_effect="fade",
|
||||
)
|
||||
result = clip_to_template_clip_config("tpl_1", clip)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_1"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 2
|
||||
assert result.min_duration == 3.5
|
||||
assert result.max_duration == 3.5
|
||||
assert result.text_template == "Hello"
|
||||
assert result.transition_effect == TransitionEffect.FADE
|
||||
|
||||
def test_duration_fixed_min_max_equal(self):
|
||||
"""转换后 min_duration == max_duration == clip.duration."""
|
||||
clip = self.FakeClip(duration=7.2)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 7.2
|
||||
assert result.max_duration == 7.2
|
||||
|
||||
def test_zero_duration(self):
|
||||
clip = self.FakeClip(duration=0.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_none_duration_defaults_to_zero(self):
|
||||
clip = self.FakeClip()
|
||||
clip.duration = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_empty_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip(text_content="")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_none_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip()
|
||||
clip.text_content = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_playback_speed_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.config["playback_speed"] == 1.5
|
||||
assert result.config["font"] == "bold"
|
||||
|
||||
def test_playback_speed_one_not_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "playback_speed" not in result.config
|
||||
|
||||
def test_config_asset_info_filtered(self):
|
||||
clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "asset_info" not in result.config
|
||||
assert result.config["text_key"] == "hi"
|
||||
|
||||
def test_invalid_clip_type_falls_back(self):
|
||||
clip = self.FakeClip(clip_type="invalid_type")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_missing_attributes(self):
|
||||
"""对象没有某些属性时使用默认值."""
|
||||
|
||||
class MinimalClip:
|
||||
pass
|
||||
|
||||
result = clip_to_template_clip_config("t1", MinimalClip())
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestClipsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = clips_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_clips(self):
|
||||
clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A")
|
||||
clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="")
|
||||
result = clips_to_template_clip_configs("t1", [clip_a, clip_b])
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].order == 0
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
assert result[1].order == 1
|
||||
assert all(isinstance(r, TemplateClipConfig) for r in result)
|
||||
|
||||
|
||||
class TestClipConfigToSnapshot:
|
||||
def test_basic_snapshot(self):
|
||||
cfg = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=2,
|
||||
min_duration=3.0,
|
||||
max_duration=5.0,
|
||||
text_template="Hello",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"font_size": 20},
|
||||
)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "subtitle"
|
||||
assert snap["order"] == 2
|
||||
assert snap["min_duration"] == 3.0
|
||||
assert snap["max_duration"] == 5.0
|
||||
assert snap["text_template"] == "Hello"
|
||||
assert snap["transition_effect"] == "fade"
|
||||
assert snap["config"] == {"font_size": 20}
|
||||
|
||||
def test_enum_values_are_strings(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "main"
|
||||
assert isinstance(snap["clip_type"], str)
|
||||
assert snap["transition_effect"] == "cut"
|
||||
assert isinstance(snap["transition_effect"], str)
|
||||
|
||||
def test_config_is_copy_not_reference(self):
|
||||
config = {"key": "val"}
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
snap["config"]["key"] = "changed"
|
||||
assert config["key"] == "val" # 原config不变
|
||||
|
||||
def test_empty_config(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={})
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["config"] == {}
|
||||
|
||||
def test_none_text_becomes_empty(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
cfg.text_template = None # type: ignore
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["text_template"] == ""
|
||||
|
||||
|
||||
class TestClipConfigsToSnapshots:
|
||||
def test_empty_list(self):
|
||||
assert clip_configs_to_snapshots([]) == []
|
||||
|
||||
def test_multiple_configs(self):
|
||||
cfg1 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=0,
|
||||
min_duration=2.0,
|
||||
max_duration=2.0,
|
||||
)
|
||||
cfg2 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.TITLE,
|
||||
order=1,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
)
|
||||
snaps = clip_configs_to_snapshots([cfg1, cfg2])
|
||||
assert len(snaps) == 2
|
||||
assert snaps[0]["clip_type"] == "subtitle"
|
||||
assert snaps[1]["clip_type"] == "title"
|
||||
|
||||
|
||||
class TestSnapshotToTemplateClipConfig:
|
||||
def test_basic_conversion(self):
|
||||
snap = {
|
||||
"clip_type": "subtitle",
|
||||
"order": 3,
|
||||
"min_duration": 2.5,
|
||||
"max_duration": 4.5,
|
||||
"text_template": "World",
|
||||
"transition_effect": "dissolve",
|
||||
"config": {"color": "blue"},
|
||||
}
|
||||
result = snapshot_to_template_clip_config("tpl_2", snap)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_2"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 3
|
||||
assert result.min_duration == 2.5
|
||||
assert result.max_duration == 4.5
|
||||
assert result.text_template == "World"
|
||||
assert result.transition_effect == TransitionEffect.DISSOLVE
|
||||
assert result.config == {"color": "blue"}
|
||||
|
||||
def test_empty_snapshot_uses_defaults(self):
|
||||
result = snapshot_to_template_clip_config("t1", {})
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
assert result.config == {}
|
||||
|
||||
def test_invalid_clip_type_defaults(self):
|
||||
snap = {"clip_type": "unknown"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_invalid_transition_defaults(self):
|
||||
snap = {"transition_effect": "bad_effect"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
def test_none_config_becomes_empty(self):
|
||||
snap = {"config": None}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.config == {}
|
||||
|
||||
|
||||
class TestSnapshotsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = snapshots_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_snapshots(self):
|
||||
snaps = [
|
||||
{"clip_type": "subtitle", "order": 0, "text_template": "A"},
|
||||
{"clip_type": "title", "order": 1},
|
||||
]
|
||||
result = snapshots_to_template_clip_configs("t1", snaps)
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].text_template == "A"
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""clip → config → snapshot → config 双向转换一致性."""
|
||||
|
||||
def test_snapshot_config_round_trip(self):
|
||||
original = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=5,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
text_template="Round trip",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"key": "value"},
|
||||
)
|
||||
snap = clip_config_to_snapshot(original)
|
||||
restored = snapshot_to_template_clip_config("t1", snap)
|
||||
assert restored.clip_type == original.clip_type
|
||||
assert restored.order == original.order
|
||||
assert restored.min_duration == original.min_duration
|
||||
assert restored.max_duration == original.max_duration
|
||||
assert restored.text_template == original.text_template
|
||||
assert restored.transition_effect == original.transition_effect
|
||||
assert restored.config == original.config
|
||||
|
||||
|
||||
class TestValidateTemplateName:
|
||||
def test_valid_name(self):
|
||||
assert validate_template_name("我的模板") == "我的模板"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert validate_template_name(" Hello ") == "Hello"
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
try:
|
||||
validate_template_name("")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
try:
|
||||
validate_template_name(" ")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_none_raises(self):
|
||||
try:
|
||||
validate_template_name(None)
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
Reference in New Issue
Block a user