Compare commits

...

5 Commits

Author SHA1 Message Date
CI Bot 919f69f072 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m11s
AI Code Review / AI Code Review (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m15s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m38s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1172h25m11s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1172h34m5s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1172h41m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1172h41m1s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1172h41m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1172h47m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1172h47m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1172h47m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1172h50m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1172h50m44s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1172h50m46s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1172h53m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1172h53m54s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1172h53m56s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1173h23m43s
2026-07-29 10:31:47 +00:00
xiaoxia c60f4f60c5 test(wave182): config_schemas 配置Schema +110测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1172h58m1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1172h58m2s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1172h58m42s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1172h58m42s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1172h58m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1172h58m46s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1172h58m46s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1172h58m46s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1173h31m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1173h31m45s
2026-07-29 18:28:20 +08:00
xiaoxia 6dc388a794 fix(cosyvoice): 修复prefix过滤中文失效导致400错误 (#1138)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 11m50s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m42s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1176h18m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1176h32m22s
CI/CD Pipeline / CI Gate (push) Failing after 1176h32m24s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1176h32m24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1176h32m24s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1176h44m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1176h32m24s
CI/CD Pipeline / Frontend Lint (push) Failing after 1176h44m16s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1176h44m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1176h44m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1177h5m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1177h17m15s
修复prefix过滤中文失效导致400错误
2026-07-29 14:40:58 +08:00
xiaoxia 7ca5b3732f refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件 (#1087)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 拆分 VoiceMaterialRow 为独立子组件
- 进度条逻辑抽取到 useRowProgress hook
- 标签展示组件独立为 TagDisplay
- 代码行数从 186 行减少到 138 行 (-26%)
2026-07-29 14:35:45 +08:00
xiaoxia 7cdf56a1ac test(wave175): template_clip_converter 模板片段转换 +64测 (#1137)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 新增 template_clip_converter 模块单元测试
- 覆盖模板解析、片段转换、边界条件等场景
2026-07-29 14:34:37 +08:00
5 changed files with 1358 additions and 5 deletions
@@ -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"
+3 -1
View File
@@ -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]
# 如果清洗后为空,用默认值
+825
View File
@@ -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
View File
@@ -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")