Compare commits

...

7 Commits

Author SHA1 Message Date
xiaoxia 3c84c565af test(wave191): 补全direct_construction测试用例,增加完整构造场景
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
AI Code Review / AI Code Review (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m47s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 34s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m28s
- 拆分test_direct_construction为minimal和full两个用例
- minimal用例验证默认值行为
- full用例验证所有字段均可独立赋值
- 新增EditingMode导入
2026-07-30 01:02:28 +08:00
CI Bot af06773934 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m12s
AI Code Review / AI Code Review (pull_request) Failing after 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m13s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
2026-07-29 11:05:44 +00:00
xiaoxia 51ab129b23 test(wave191): edit_template 剪辑模板实体 +35测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
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 / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (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 / ACR Image Cleanup (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-07-29 18:55:33 +08:00
xiaoxia faeed6f014 refactor(editing-planner): 拆分 SubtitleStylePanel 为子组件(190→146行, -23%) (#1139)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m4s
CI/CD Pipeline / Unit Tests (push) Successful in 12m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m36s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m37s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-29 17:30:42 +08:00
xiaoxia 6dc388a794 fix(cosyvoice): 修复prefix过滤中文失效导致400错误 (#1138)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m18s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
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) Has been skipped
修复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
10 changed files with 915 additions and 63 deletions
+14 -58
View File
@@ -1,18 +1,16 @@
/**
* 字幕样式配置面板 — Drawer 形式
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
*/
import React from "react"
import { Drawer, Slider, ColorPicker, Select } from "antd"
import type { Color } from "antd/es/color-picker"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
ASR_LANGUAGE_OPTIONS,
} from "@/pages/editing-planner/constants/subtitleStyle"
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
interface SubtitleStylePanelProps {
open: boolean
@@ -40,7 +38,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
onClose={onClose}
className="subtitle-style-drawer"
>
{/* ── 字幕开关 ── */}
<div className="sub-field">
<div className="sub-toggle-row">
<span className="sub-label"></span>
@@ -55,26 +52,11 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
{config.enabled && (
<>
{/* ── 模式切换 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
onClick={() => update({ mode: "manual" })}
>
</button>
<button
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
onClick={() => update({ mode: "asr" })}
>
🤖 ASR
</button>
</div>
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
@@ -88,7 +70,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
@@ -101,7 +82,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
@@ -113,7 +93,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -125,46 +104,24 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 字幕位置 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
onClick={() =>
update({
position: opt.value as SubtitleStyleConfig["position"],
})
}
>
{opt.label}
</button>
))}
</div>
<SubtitlePositionSelector
position={config.position}
onPositionChange={(position) => update({ position })}
/>
</div>
{/* ── 描边 / 阴影 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
onClick={() => update({ stroke: !config.stroke })}
>
S
</button>
<button
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
onClick={() => update({ shadow: !config.shadow })}
>
</button>
</div>
<SubtitleEffectButtons
stroke={config.stroke}
shadow={config.shadow}
onStrokeChange={(stroke) => update({ stroke })}
onShadowChange={(shadow) => update({ shadow })}
/>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -179,7 +136,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 预览 ── */}
<SubtitlePreview config={config} />
</>
)}
@@ -0,0 +1,32 @@
import React from "react"
interface SubtitleEffectButtonsProps {
stroke: boolean
shadow: boolean
onStrokeChange: (enabled: boolean) => void
onShadowChange: (enabled: boolean) => void
}
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
stroke,
shadow,
onStrokeChange,
onShadowChange,
}) => {
return (
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${stroke ? " active" : ""}`}
onClick={() => onStrokeChange(!stroke)}
>
S
</button>
<button
className={`sub-effect-btn${shadow ? " active" : ""}`}
onClick={() => onShadowChange(!shadow)}
>
</button>
</div>
)
}
@@ -0,0 +1,25 @@
import React from "react"
interface SubtitleModeSwitchProps {
mode: "manual" | "asr"
onModeChange: (mode: "manual" | "asr") => void
}
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
return (
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
onClick={() => onModeChange("manual")}
>
</button>
<button
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
onClick={() => onModeChange("asr")}
>
🤖 ASR
</button>
</div>
)
}
@@ -0,0 +1,27 @@
import React from "react"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
interface SubtitlePositionSelectorProps {
position: SubtitleStyleConfig["position"]
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
}
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
position,
onPositionChange,
}) => {
return (
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
>
{opt.label}
</button>
))}
</div>
)
}
@@ -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 }
}
@@ -65,6 +65,9 @@ import "@/pages/editing-planner/components/filter/FilterPresetGrid"
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
import "@/pages/editing-planner/components/tts/VoiceSelector"
import "@/pages/editing-planner/components/tts/TtsSlider"
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
@@ -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]
# 如果清洗后为空,用默认值
+281
View File
@@ -0,0 +1,281 @@
"""edit_template 剪辑模板实体单测."""
from datetime import datetime, timezone
import pytest
from domain.edit_template import EditTemplate, EditTemplateStatus
from domain.editing_mode import EditingMode
# ── EditTemplateStatus 枚举 ──────────────────────────────────────────────────
class TestEditTemplateStatus:
"""EditTemplateStatus 枚举"""
def test_enum_values(self):
assert EditTemplateStatus.ACTIVE.value == "active"
assert EditTemplateStatus.INACTIVE.value == "inactive"
def test_is_str_enum(self):
assert isinstance(EditTemplateStatus.ACTIVE, str)
assert EditTemplateStatus.ACTIVE == "active"
def test_from_string(self):
assert EditTemplateStatus("active") == EditTemplateStatus.ACTIVE
assert EditTemplateStatus("inactive") == EditTemplateStatus.INACTIVE
def test_invalid_raises(self):
with pytest.raises(ValueError):
EditTemplateStatus("deleted")
# ── EditTemplate.create 工厂方法 ────────────────────────────────────────────
class TestEditTemplateCreate:
"""EditTemplate.create 工厂方法"""
def test_minimal_create(self):
t = EditTemplate.create("测试模板")
assert t.id is not None
assert len(t.id) == 32 # uuid4 hex
assert t.name == "测试模板"
assert t.description == ""
assert t.template_type == "default"
assert t.editing_mode == "one_take"
assert t.config == {}
assert t.preview_url == ""
assert t.sort_weight == 0
assert t.status == EditTemplateStatus.ACTIVE
assert t.version == 1
def test_unique_ids(self):
t1 = EditTemplate.create("模板A")
t2 = EditTemplate.create("模板B")
assert t1.id != t2.id
def test_custom_fields(self):
t = EditTemplate.create(
"自定义模板",
description="这是一个自定义模板",
template_type="story",
editing_mode="one_take",
config={"key": "value"},
preview_url="https://example.com/preview.mp4",
sort_weight=100,
status=EditTemplateStatus.INACTIVE,
version=2,
)
assert t.name == "自定义模板"
assert t.description == "这是一个自定义模板"
assert t.template_type == "story"
assert t.editing_mode == "one_take"
assert t.config == {"key": "value"}
assert t.preview_url == "https://example.com/preview.mp4"
assert t.sort_weight == 100
assert t.status == EditTemplateStatus.INACTIVE
assert t.version == 2
def test_name_stripped(self):
t = EditTemplate.create(" 带空格的模板 ")
assert t.name == "带空格的模板"
def test_empty_name_raises(self):
with pytest.raises(ValueError, match="名称"):
EditTemplate.create("")
def test_whitespace_only_name_raises(self):
with pytest.raises(ValueError):
EditTemplate.create(" ")
def test_invalid_editing_mode_raises(self):
with pytest.raises(ValueError, match="editing_mode"):
EditTemplate.create("测试", editing_mode="invalid_mode")
def test_empty_editing_mode_falls_back_to_default(self):
t = EditTemplate.create("测试", editing_mode="")
assert t.editing_mode == "one_take"
def test_whitespace_editing_mode_falls_back(self):
t = EditTemplate.create("测试", editing_mode=" ")
assert t.editing_mode == "one_take"
def test_editing_mode_stripped(self):
t = EditTemplate.create("测试", editing_mode=" one_take ")
assert t.editing_mode == "one_take"
def test_description_stripped(self):
t = EditTemplate.create("测试", description=" 描述 ")
assert t.description == "描述"
def test_template_type_stripped(self):
t = EditTemplate.create("测试", template_type=" vlog ")
assert t.template_type == "vlog"
def test_empty_template_type_falls_back(self):
t = EditTemplate.create("测试", template_type="")
assert t.template_type == "default"
def test_none_config_becomes_empty_dict(self):
t = EditTemplate.create("测试", config=None)
assert t.config == {}
assert isinstance(t.config, dict)
def test_preview_url_stripped(self):
t = EditTemplate.create("测试", preview_url=" https://x.com/a.mp4 ")
assert t.preview_url == "https://x.com/a.mp4"
def test_timestamps_are_utc(self):
t = EditTemplate.create("测试")
assert t.created_at.tzinfo is not None
assert t.updated_at.tzinfo is not None
def test_created_at_equals_updated_at_on_create(self):
t = EditTemplate.create("测试")
# 创建时两个时间应该非常接近
diff = abs((t.updated_at - t.created_at).total_seconds())
assert diff < 1.0
# ── 状态操作 ─────────────────────────────────────────────────────────────────
class TestEditTemplateStatusOperations:
"""EditTemplate 状态操作"""
def test_activate_sets_active(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
t.activate()
assert t.status == EditTemplateStatus.ACTIVE
assert t.is_active is True
def test_deactivate_sets_inactive(self):
t = EditTemplate.create("测试")
t.deactivate()
assert t.status == EditTemplateStatus.INACTIVE
assert t.is_active is False
def test_is_active_true(self):
t = EditTemplate.create("测试")
assert t.is_active is True
def test_is_active_false(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
assert t.is_active is False
def test_activate_updates_updated_at(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
old_updated = t.updated_at
t.activate()
assert t.updated_at >= old_updated
def test_deactivate_updates_updated_at(self):
t = EditTemplate.create("测试")
old_updated = t.updated_at
t.deactivate()
assert t.updated_at >= old_updated
# ── 版本操作 ─────────────────────────────────────────────────────────────────
class TestEditTemplateVersion:
"""EditTemplate 版本操作"""
def test_bump_version_increments(self):
t = EditTemplate.create("测试")
assert t.version == 1
t.bump_version()
assert t.version == 2
def test_bump_version_multiple(self):
t = EditTemplate.create("测试", version=5)
t.bump_version()
t.bump_version()
t.bump_version()
assert t.version == 8
def test_bump_version_updates_updated_at(self):
t = EditTemplate.create("测试")
old_updated = t.updated_at
t.bump_version()
assert t.updated_at >= old_updated
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
class TestEditTemplateBasics:
"""EditTemplate 基础特性"""
def test_slots_no_extra_attrs(self):
t = EditTemplate.create("测试")
with pytest.raises(AttributeError):
t.nonexistent_field = "value"
def test_direct_construction_minimal(self):
# 最小构造:仅必填字段 + 状态,其余走默认值
t = EditTemplate(
id="custom_id",
name="直接构造",
status=EditTemplateStatus.ACTIVE,
)
assert t.id == "custom_id"
assert t.name == "直接构造"
assert t.status == EditTemplateStatus.ACTIVE
# 默认值检查
assert t.description == ""
assert t.config == {}
assert t.version == 1
assert t.editing_mode == EditingMode.ONE_TAKE.value
assert isinstance(t.created_at, datetime)
assert isinstance(t.updated_at, datetime)
def test_direct_construction_full(self):
# 完整构造:所有字段都传
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
t = EditTemplate(
id="full_id",
name="完整构造",
description="测试描述",
template_type="custom",
editing_mode=EditingMode.PIP.value,
config={"key": "value"},
preview_url="https://example.com/preview.jpg",
sort_weight=100,
status=EditTemplateStatus.INACTIVE,
version=3,
created_at=now,
updated_at=now,
)
assert t.id == "full_id"
assert t.name == "完整构造"
assert t.description == "测试描述"
assert t.template_type == "custom"
assert t.editing_mode == EditingMode.PIP.value
assert t.config == {"key": "value"}
assert t.preview_url == "https://example.com/preview.jpg"
assert t.sort_weight == 100
assert t.status == EditTemplateStatus.INACTIVE
assert t.version == 3
assert t.created_at == now
assert t.updated_at == now
def test_config_is_independent(self):
# 不同实例的 config 应该是独立的 dict
t1 = EditTemplate.create("模板1")
t2 = EditTemplate.create("模板2")
t1.config["key"] = "value"
assert "key" not in t2.config
def test_equality(self):
# 两个不同实例即使内容相同也不等(id不同)
t1 = EditTemplate.create("同名模板")
t2 = EditTemplate.create("同名模板")
assert t1 != t2
def test_same_id_equal(self):
now = datetime.now(timezone.utc)
t1 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
t2 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
assert t1 == t2
+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")