Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcc12367f2 | |||
| 3fded204bb | |||
| 9e6b8b01e3 | |||
| e091c5beb7 | |||
| aef01b5091 | |||
| ee704efc0b | |||
| faeed6f014 | |||
| 6dc388a794 | |||
| 7ca5b3732f | |||
| 7cdf56a1ac | |||
| fac825b60b | |||
| 409efe141a | |||
| 6e676a07f7 | |||
| e629cf0e69 | |||
| dd17312181 | |||
| 05fbf64697 | |||
| 096dc66e2c | |||
| 1230eaa24b | |||
| 0d7effe88f | |||
| 76fdab4f63 |
@@ -1091,6 +1091,8 @@ jobs:
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
|
||||
Regular → Executable
+14
-58
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
+32
@@ -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>
|
||||
)
|
||||
}
|
||||
+25
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+27
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
|
||||
if (!titleSettings.title.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -9,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
])
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -42,3 +42,4 @@ import "@/pages/generate/hooks/generate-video/types"
|
||||
import "@/pages/generate/hooks/generate-video/phase"
|
||||
import "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
import "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
import "@/pages/generate/hooks/generate-video/buildPayload"
|
||||
|
||||
@@ -8,14 +8,14 @@ import type {
|
||||
UseGenerateVideoProps,
|
||||
GenerationPhase,
|
||||
} from "@/pages/generate/hooks/generate-video/types"
|
||||
import { getNextPhase, PHASE_ORDER } from "@/pages/generate/hooks/generate-video/phase"
|
||||
import { extractErrorMessage } from "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
import { getDefaultVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase"
|
||||
import { extractBackendError } from "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
import { buildVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
|
||||
describe("generate-video module smoke test", () => {
|
||||
it("should load all generate-video modules", () => {
|
||||
expect(PHASE_ORDER.length).toBeGreaterThan(0)
|
||||
expect(typeof extractErrorMessage).toBe("function")
|
||||
expect(typeof getDefaultVoiceConfig).toBe("function")
|
||||
expect(typeof getGenerationPhase).toBe("function")
|
||||
expect(typeof extractBackendError).toBe("function")
|
||||
expect(typeof buildVoiceConfig).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,10 +8,11 @@ packages / DB 等重依赖。
|
||||
"""
|
||||
|
||||
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
|
||||
from . import dedup_helpers, ffmpeg_utils, oss_helpers
|
||||
from . import dedup_helpers, ffmpeg_utils, oss_helpers, url_security
|
||||
|
||||
__all__ = [
|
||||
"ffmpeg_utils",
|
||||
"oss_helpers",
|
||||
"dedup_helpers",
|
||||
"url_security",
|
||||
]
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.speed_config import MAX_SPEED # noqa: F401 — 向后兼容
|
||||
from packages.domain.speed_config import MIN_SPEED # noqa: F401 — 向后兼容
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
_split_atempo_stages,
|
||||
)
|
||||
@@ -70,3 +70,35 @@ class SpeedEngine:
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度."""
|
||||
return _resolve_clip_speed_base(clip_config, global_speed)
|
||||
|
||||
|
||||
# ── 向后兼容:模块级函数(重构前的 API) ─────────────────────────
|
||||
def build_video_filter(config):
|
||||
"""向后兼容:模块级 build_video_filter."""
|
||||
return _build_video_filter_base(config)
|
||||
|
||||
|
||||
def build_audio_filter(config):
|
||||
"""向后兼容:模块级 build_audio_filter."""
|
||||
return _build_audio_filter_base(config)
|
||||
|
||||
|
||||
def adjust_duration(original_duration, config):
|
||||
"""向后兼容:模块级 adjust_duration."""
|
||||
return _adjust_duration_base(original_duration, config)
|
||||
|
||||
|
||||
def resolve_clip_speed(clip_config, global_speed=DEFAULT_SPEED):
|
||||
"""向后兼容:模块级 resolve_clip_speed."""
|
||||
return _resolve_clip_speed_base(clip_config, global_speed)
|
||||
|
||||
|
||||
def build_clip_speed_filter(speed, pitch_correct=True):
|
||||
"""向后兼容:模块级 build_clip_speed_filter."""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
build_video_filter(config),
|
||||
build_audio_filter(config),
|
||||
config,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
# 如果清洗后为空,用默认值
|
||||
|
||||
@@ -26,6 +26,7 @@ from packages.domain.url_security import ALLOWED_AUDIO_MIME_TYPES as _allowed_au
|
||||
from packages.domain.url_security import ALLOWED_IMAGE_MIME_TYPES as _allowed_image_base
|
||||
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
|
||||
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
|
||||
from packages.domain.url_security import ALLOWED_VIDEO_MIME_TYPES as _allowed_video_base
|
||||
from packages.domain.url_security import MAX_URL_LENGTH as _max_url_length_base
|
||||
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
|
||||
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
|
||||
@@ -42,6 +43,7 @@ ALLOWED_SCHEMES = set(_allowed_schemes_base)
|
||||
ALLOWED_PORTS = set(_allowed_ports_base)
|
||||
ALLOWED_AUDIO_MIME_TYPES = set(_allowed_audio_base)
|
||||
ALLOWED_IMAGE_MIME_TYPES = set(_allowed_image_base)
|
||||
ALLOWED_VIDEO_MIME_TYPES = set(_allowed_video_base)
|
||||
MAX_URL_LENGTH = _max_url_length_base
|
||||
UrlSecurityError = _UrlSecurityError_base
|
||||
|
||||
|
||||
@@ -102,3 +102,5 @@ ignore = [
|
||||
"apps/api/app/middleware/auth.py" = ["ALL"]
|
||||
"apps/*/migrations/*" = ["ALL"]
|
||||
"alembic/*" = ["ALL"]
|
||||
|
||||
"tests/**" = ["B011"]
|
||||
@@ -303,7 +303,7 @@ def is_in_protected_list(tag, protected_set):
|
||||
# ========== 核心清理逻辑 ==========
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
|
||||
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None, pr_days=0):
|
||||
"""
|
||||
清理单个仓库
|
||||
|
||||
@@ -441,8 +441,28 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
|
||||
deleted_count += 1
|
||||
print(f" 打开PR数: {len(open_head_shas)}个head sha")
|
||||
print(f" 将删除PR镜像: {deleted_count}个")
|
||||
|
||||
# pr-days兜底:超过指定天数的打开PR镜像也清理
|
||||
if pr_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||||
extra_old = []
|
||||
for tag in pr_tags_list:
|
||||
sha = extract_sha_from_pr_tag(tag)
|
||||
is_open_pr = False
|
||||
for ohs in open_head_shas:
|
||||
if sha.startswith(ohs) or ohs.startswith(sha):
|
||||
is_open_pr = True
|
||||
break
|
||||
if is_open_pr:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff and info["digest"]:
|
||||
extra_old.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
if extra_old:
|
||||
pr_to_delete.extend(extra_old)
|
||||
print(f" pr-days兜底: 额外清理{len(extra_old)}个超期打开PR镜像(>{pr_days}天)")
|
||||
else:
|
||||
# 无Gitea token,降级为按7天保留
|
||||
# 无Gitea token,降级为按pr_days天保留(默认7天)
|
||||
print(" 模式: 按时间保留7天(无Gitea token降级)")
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
for tag in pr_tags_list:
|
||||
@@ -561,6 +581,9 @@ def main():
|
||||
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
|
||||
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
|
||||
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
|
||||
parser.add_argument(
|
||||
"--pr-days", type=int, default=0, help="PR镜像保留天数(超过天数的PR镜像会被清理,0表示不按天数清理)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须指定 --dry-run 或 --execute
|
||||
@@ -633,7 +656,7 @@ def main():
|
||||
total_tags = 0
|
||||
for repo in repos_to_clean:
|
||||
count, deleted = cleanup_repo(
|
||||
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
|
||||
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set, pr_days=args.pr_days
|
||||
)
|
||||
total_tags += count
|
||||
total_deleted += deleted
|
||||
|
||||
Executable
+414
@@ -0,0 +1,414 @@
|
||||
"""AI响应解析纯逻辑单测.
|
||||
|
||||
覆盖:标题解析(多格式)、语义匹配解析、
|
||||
标题降级生成、关键词匹配降级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from unittest.mock import patch
|
||||
|
||||
from packages.domain.ai_parsing import (
|
||||
generate_titles_fallback,
|
||||
keyword_match_fallback,
|
||||
parse_semantic_match_response,
|
||||
parse_titles_from_response,
|
||||
)
|
||||
|
||||
|
||||
class TestParseTitlesFromResponse:
|
||||
def test_empty_content(self):
|
||||
assert parse_titles_from_response("") == []
|
||||
|
||||
def test_json_array(self):
|
||||
content = '["标题一", "标题二", "标题三"]'
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题一", "标题二", "标题三"]
|
||||
|
||||
def test_json_array_with_whitespace_items(self):
|
||||
content = '[" 标题一 ", "", "标题二"]'
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题一", "标题二"]
|
||||
|
||||
def test_json_dict_with_titles_key(self):
|
||||
content = '{"titles": ["爆款标题1", "爆款标题2"]}'
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["爆款标题1", "爆款标题2"]
|
||||
|
||||
def test_json_code_block(self):
|
||||
content = '```json\n["标题A", "标题B"]\n```'
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题A", "标题B"]
|
||||
|
||||
def test_json_code_block_with_backticks_only(self):
|
||||
content = '```\n["X", "Y"]\n```'
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["X", "Y"]
|
||||
|
||||
def test_numbered_list_dot(self):
|
||||
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["第一个标题", "第二个标题", "第三个标题"]
|
||||
|
||||
def test_numbered_list_chinese_comma(self):
|
||||
content = "1、标题甲\n2、标题乙"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题甲", "标题乙"]
|
||||
|
||||
def test_numbered_list_parenthesis(self):
|
||||
"""右括号格式编号能被去掉,左括号保留(实际行为)."""
|
||||
content = "1) 标题1\n2) 标题2"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题1", "标题2"]
|
||||
|
||||
def test_dash_prefix(self):
|
||||
content = "- 标题A\n- 标题B\n- 标题C"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题A", "标题B", "标题C"]
|
||||
|
||||
def test_bullet_prefix(self):
|
||||
content = "• 要点一\n• 要点二"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["要点一", "要点二"]
|
||||
|
||||
def test_newline_only(self):
|
||||
content = "标题一\n标题二\n标题三"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题一", "标题二", "标题三"]
|
||||
|
||||
def test_quoted_titles(self):
|
||||
content = "\"双引号标题\"\n'单引号标题'\n「中文引号」"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["双引号标题", "单引号标题", "中文引号"]
|
||||
|
||||
def test_skip_empty_lines(self):
|
||||
content = "标题1\n\n标题2\n\n标题3"
|
||||
result = parse_titles_from_response(content)
|
||||
assert result == ["标题1", "标题2", "标题3"]
|
||||
|
||||
def test_filter_long_lines(self):
|
||||
"""超过100字符的行被过滤."""
|
||||
long_title = "a" * 150
|
||||
content = f"短标题\n{long_title}\n另一个短标题"
|
||||
result = parse_titles_from_response(content)
|
||||
assert len(result) == 2
|
||||
assert "短标题" in result
|
||||
assert "另一个短标题" in result
|
||||
|
||||
def test_invalid_json_falls_back_to_line_parse(self):
|
||||
content = '["标题1", "标题2", invalid]' # 非法JSON
|
||||
result = parse_titles_from_response(content)
|
||||
# 会走到按行解析
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_mixed_format_numbered_and_dash(self):
|
||||
content = "1. 第一题\n- 第二题\n2. 第三题"
|
||||
result = parse_titles_from_response(content)
|
||||
assert "第一题" in result
|
||||
assert "第二题" in result
|
||||
assert "第三题" in result
|
||||
|
||||
|
||||
class TestParseSemanticMatchResponse:
|
||||
def test_empty_content(self):
|
||||
assert parse_semantic_match_response("", ["a1", "a2"]) is None
|
||||
|
||||
def test_dict_format_asset_id_score(self):
|
||||
content = '{"asset_1": 0.85, "asset_2": 0.6}'
|
||||
result = parse_semantic_match_response(content, ["asset_1", "asset_2"])
|
||||
assert result is not None
|
||||
assert result["asset_1"] == 0.85
|
||||
assert result["asset_2"] == 0.6
|
||||
|
||||
def test_matches_array_format(self):
|
||||
content = '{"matches": [{"asset_id": "a1", "score": 0.9}, {"asset_id": "a2", "score": 0.7}]}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 0.9
|
||||
assert result["a2"] == 0.7
|
||||
|
||||
def test_list_format(self):
|
||||
content = '[{"asset_id": "x", "score": 0.5}, {"asset_id": "y", "score": 0.8}]'
|
||||
result = parse_semantic_match_response(content, ["x", "y"])
|
||||
assert result is not None
|
||||
assert result["x"] == 0.5
|
||||
assert result["y"] == 0.8
|
||||
|
||||
def test_id_alias_in_matches(self):
|
||||
"""matches中用id替代asset_id."""
|
||||
content = '{"matches": [{"id": "a1", "score": 0.75}]}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 0.75
|
||||
|
||||
def test_score_clamped_to_0_1(self):
|
||||
"""分数超出0-1范围会被截断."""
|
||||
content = '{"a1": -0.5, "a2": 1.5, "a3": 0.5}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2", "a3"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 0.0
|
||||
assert result["a2"] == 1.0
|
||||
assert result["a3"] == 0.5
|
||||
|
||||
def test_score_int_converted_to_float(self):
|
||||
content = '{"a1": 1, "a2": 0}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 1.0
|
||||
assert result["a2"] == 0.0
|
||||
|
||||
def test_json_code_block(self):
|
||||
content = '```json\n{"a1": 0.9, "a2": 0.8}\n```'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 0.9
|
||||
|
||||
def test_half_threshold_with_asset_ids(self):
|
||||
"""提供asset_ids时,至少一半有评分才算成功."""
|
||||
# 4个assets,只有1个有评分(<2)→ 失败
|
||||
content = '{"a1": 0.9}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
|
||||
assert result is None
|
||||
|
||||
def test_half_threshold_passes(self):
|
||||
# 4个assets,2个有评分(=一半)→ 成功
|
||||
content = '{"a1": 0.9, "a2": 0.8}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
|
||||
assert result is not None
|
||||
|
||||
def test_no_asset_ids_returns_any_result(self):
|
||||
content = '{"x1": 0.7}'
|
||||
result = parse_semantic_match_response(content, [])
|
||||
assert result is not None
|
||||
assert result["x1"] == 0.7
|
||||
|
||||
def test_no_asset_ids_empty_result_returns_none(self):
|
||||
content = "{}"
|
||||
result = parse_semantic_match_response(content, [])
|
||||
assert result is None
|
||||
|
||||
def test_invalid_json_returns_none(self):
|
||||
content = "not json at all"
|
||||
result = parse_semantic_match_response(content, ["a1"])
|
||||
assert result is None
|
||||
|
||||
def test_non_numeric_values_ignored(self):
|
||||
content = '{"a1": "high", "a2": 0.8}'
|
||||
result = parse_semantic_match_response(content, ["a1", "a2"])
|
||||
assert result is not None
|
||||
assert "a1" not in result
|
||||
assert result["a2"] == 0.8
|
||||
|
||||
def test_single_asset_id_needs_at_least_1(self):
|
||||
"""1个asset,需要至少max(1, 0)=1个评分."""
|
||||
content = '{"a1": 0.5}'
|
||||
result = parse_semantic_match_response(content, ["a1"])
|
||||
assert result is not None
|
||||
assert result["a1"] == 0.5
|
||||
|
||||
|
||||
class TestGenerateTitlesFallback:
|
||||
def test_basic_generation(self):
|
||||
with patch.object(random, "shuffle", lambda x: None): # 禁用shuffle
|
||||
result = generate_titles_fallback(
|
||||
"美食 探店 川菜",
|
||||
{"examples": ["必看攻略", "绝密技巧"]},
|
||||
count=3,
|
||||
)
|
||||
assert len(result) == 3
|
||||
assert all(isinstance(t, str) for t in result)
|
||||
assert all(len(t) > 0 for t in result)
|
||||
|
||||
def test_count_limited_by_templates(self):
|
||||
"""最多10个模板."""
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"测试",
|
||||
{"examples": ["例1", "例2"]},
|
||||
count=20,
|
||||
)
|
||||
assert len(result) == 10 # 模板总数上限
|
||||
|
||||
def test_default_count(self):
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"科技 产品",
|
||||
{"examples": ["测试标题", "另一个例子"]},
|
||||
)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_empty_description_uses_default_keyword(self):
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
" ",
|
||||
{"examples": ["例A", "例B"]},
|
||||
count=1,
|
||||
)
|
||||
assert "精彩内容" in result[0]
|
||||
|
||||
def test_keyword_extracted_from_description(self):
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"Python编程入门教程",
|
||||
{"examples": ["入门", "技巧"]},
|
||||
count=5,
|
||||
)
|
||||
# 第一个关键词应该出现在某些标题中
|
||||
assert any("Python编程入门教程" in t for t in result)
|
||||
|
||||
def test_examples_truncated(self):
|
||||
"""第一个example超过10字符会被截断."""
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"美食",
|
||||
{"examples": ["这是一个非常长的例子超过十个字", "第二个例子"]},
|
||||
count=1,
|
||||
)
|
||||
# 第一个标题应该包含截断的example + "..."
|
||||
assert "..." in result[0]
|
||||
|
||||
def test_no_examples_uses_default(self):
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"健身",
|
||||
{"examples": []},
|
||||
count=2,
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert "必看" in result[0] # 默认example_0
|
||||
|
||||
def test_second_example_default(self):
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"健身",
|
||||
{"examples": ["只有一个"]},
|
||||
count=3,
|
||||
)
|
||||
# 第二个标题应该包含默认的"你不知道的事"
|
||||
assert any("你不知道的事" in t for t in result)
|
||||
|
||||
def test_single_word_keyword(self):
|
||||
"""单字会被过滤掉,使用默认关键词."""
|
||||
with patch.object(random, "shuffle", lambda x: None):
|
||||
result = generate_titles_fallback(
|
||||
"a b c",
|
||||
{"examples": ["例"]},
|
||||
count=1,
|
||||
)
|
||||
# 所有词都是1个字符,应该用默认关键词
|
||||
assert "精彩内容" in result[0]
|
||||
|
||||
|
||||
class TestKeywordMatchFallback:
|
||||
def test_basic_matching(self):
|
||||
assets = [
|
||||
{"id": "a1", "name": "美食探店视频", "tags": ["美食", "探店"], "description": "成都美食"},
|
||||
{"id": "a2", "name": "科技产品评测", "tags": ["科技"], "description": "手机评测"},
|
||||
{"id": "a3", "name": "旅行Vlog", "tags": ["旅行"], "description": "日本旅行"},
|
||||
]
|
||||
result = keyword_match_fallback("美食 探店 成都", assets)
|
||||
assert len(result) == 3
|
||||
# 美食相关的应该排第一
|
||||
assert result[0]["id"] == "a1"
|
||||
assert 0 < result[0]["match_score"] <= 1.0
|
||||
|
||||
def test_score_between_0_and_1(self):
|
||||
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
|
||||
result = keyword_match_fallback("完全不相关的关键词", assets)
|
||||
assert 0 <= result[0]["match_score"] <= 1
|
||||
|
||||
def test_no_keywords_default_score(self):
|
||||
"""描述中没有有效关键词时,所有素材0.5分."""
|
||||
assets = [
|
||||
{"id": "a1", "name": "素材1", "tags": [], "description": ""},
|
||||
{"id": "a2", "name": "素材2", "tags": [], "description": ""},
|
||||
]
|
||||
result = keyword_match_fallback(" ", assets) # 空描述
|
||||
assert len(result) == 2
|
||||
assert result[0]["match_score"] == 0.5
|
||||
assert result[0]["match_reason"] == "fallback_default"
|
||||
|
||||
def test_sorted_descending(self):
|
||||
assets = [
|
||||
{"id": "a_low", "name": "不相关", "tags": [], "description": ""},
|
||||
{"id": "a_high", "name": "美食推荐", "tags": ["美食"], "description": "美食攻略"},
|
||||
]
|
||||
result = keyword_match_fallback("美食 推荐", assets)
|
||||
assert result[0]["id"] == "a_high"
|
||||
assert result[0]["match_score"] > result[1]["match_score"]
|
||||
|
||||
def test_match_reason_keyword(self):
|
||||
assets = [{"id": "a1", "name": "测试", "tags": [], "description": ""}]
|
||||
result = keyword_match_fallback("测试关键词", assets)
|
||||
assert result[0]["match_reason"] == "fallback_keyword"
|
||||
|
||||
def test_name_bonus(self):
|
||||
"""名称命中应该有额外加分."""
|
||||
assets = [
|
||||
{
|
||||
"id": "a1",
|
||||
"name": "完全不相关的名字",
|
||||
"tags": [],
|
||||
"description": "美食教程", # 描述里有关键词
|
||||
},
|
||||
{
|
||||
"id": "a2",
|
||||
"name": "美食分享", # 名称里有关键词
|
||||
"tags": [],
|
||||
"description": "", # 描述里没有
|
||||
},
|
||||
]
|
||||
result = keyword_match_fallback("美食", assets)
|
||||
# 名称命中的a2应该分数更高(name bonus)
|
||||
assert result[0]["id"] == "a2"
|
||||
|
||||
def test_empty_assets(self):
|
||||
result = keyword_match_fallback("美食", [])
|
||||
assert result == []
|
||||
|
||||
def test_asset_dict_not_mutated(self):
|
||||
"""不修改原始asset字典."""
|
||||
asset = {"id": "a1", "name": "测试", "tags": []}
|
||||
original = dict(asset)
|
||||
keyword_match_fallback("测试", [asset])
|
||||
assert asset == original
|
||||
|
||||
def test_chinese_keywords_used(self):
|
||||
"""中文2-4字片段应该被用作关键词."""
|
||||
assets = [
|
||||
{"id": "a1", "name": "编程入门", "tags": [], "description": ""},
|
||||
{"id": "a2", "name": "美食推荐", "tags": [], "description": ""},
|
||||
]
|
||||
result = keyword_match_fallback("编程入门教程", assets)
|
||||
assert result[0]["id"] == "a1"
|
||||
assert result[0]["match_score"] > 0
|
||||
|
||||
def test_english_keywords_used(self):
|
||||
"""英文3字符以上单词应该被用作关键词."""
|
||||
assets = [
|
||||
{"id": "a1", "name": "Python tutorial", "tags": [], "description": ""},
|
||||
{"id": "a2", "name": "Java course", "tags": [], "description": ""},
|
||||
]
|
||||
result = keyword_match_fallback("python programming", assets)
|
||||
assert result[0]["id"] == "a1"
|
||||
assert result[0]["match_score"] > 0
|
||||
|
||||
def test_score_is_rounded_to_3_decimals(self):
|
||||
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
|
||||
result = keyword_match_fallback("测试关键词", assets)
|
||||
# 3位小数
|
||||
assert len(str(result[0]["match_score"]).split(".")[-1]) <= 3
|
||||
|
||||
def test_perfect_match_score(self):
|
||||
assets = [
|
||||
{
|
||||
"id": "a1",
|
||||
"name": "美食探店推荐",
|
||||
"tags": ["美食", "探店", "推荐"],
|
||||
"description": "美食探店推荐视频",
|
||||
}
|
||||
]
|
||||
result = keyword_match_fallback("美食 探店 推荐", assets)
|
||||
assert result[0]["match_score"] <= 1.0
|
||||
assert result[0]["match_score"] > 0.5 # 应该有较高分数
|
||||
@@ -18,9 +18,9 @@ from packages.domain.classification import (
|
||||
class TestAssetLibraryKind:
|
||||
"""AssetLibraryKind 枚举测试."""
|
||||
|
||||
def test_two_values(self):
|
||||
"""视频和配音两类."""
|
||||
assert len(AssetLibraryKind) == 2
|
||||
def test_three_values(self):
|
||||
"""视频/配音/图片三类."""
|
||||
assert len(AssetLibraryKind) == 3
|
||||
|
||||
def test_video(self):
|
||||
assert AssetLibraryKind.VIDEO == "video"
|
||||
@@ -28,6 +28,9 @@ class TestAssetLibraryKind:
|
||||
def test_voice(self):
|
||||
assert AssetLibraryKind.VOICE == "voice"
|
||||
|
||||
def test_image(self):
|
||||
assert AssetLibraryKind.IMAGE == "image"
|
||||
|
||||
def test_str_compatible(self):
|
||||
"""StrEnum 字符串兼容."""
|
||||
assert AssetLibraryKind.VIDEO == "video"
|
||||
|
||||
Executable
+518
@@ -0,0 +1,518 @@
|
||||
"""片段操作工具单测.
|
||||
|
||||
纯函数模块,覆盖:分割校验/计算、合并校验/计算、
|
||||
order重排、order偏移。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.clip_operations import (
|
||||
DEFAULT_SPLIT_DURATION,
|
||||
MergeResult,
|
||||
SplitResult,
|
||||
calculate_merge,
|
||||
calculate_reorder_new_orders,
|
||||
calculate_shift_orders,
|
||||
calculate_split,
|
||||
validate_merge_clips,
|
||||
validate_split_time,
|
||||
)
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_default_split_duration(self):
|
||||
assert DEFAULT_SPLIT_DURATION == 5.0
|
||||
|
||||
|
||||
class TestValidateSplitTime:
|
||||
def test_valid_middle(self):
|
||||
validate_split_time(5.0, 10.0) # 不抛异常就是通过
|
||||
|
||||
def test_valid_small(self):
|
||||
validate_split_time(0.1, 10.0)
|
||||
|
||||
def test_valid_near_end(self):
|
||||
validate_split_time(9.9, 10.0)
|
||||
|
||||
def test_zero_invalid(self):
|
||||
try:
|
||||
validate_split_time(0.0, 10.0)
|
||||
except ValueError as e:
|
||||
assert "分割时间" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_negative_invalid(self):
|
||||
try:
|
||||
validate_split_time(-1.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_equal_to_duration_invalid(self):
|
||||
try:
|
||||
validate_split_time(10.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_greater_than_duration_invalid(self):
|
||||
try:
|
||||
validate_split_time(15.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
class TestCalculateSplit:
|
||||
def test_split_half(self):
|
||||
result = calculate_split(10.0, 5.0)
|
||||
assert isinstance(result, SplitResult)
|
||||
assert result.left_duration == 5.0
|
||||
assert result.right_duration == 5.0
|
||||
assert result.right_start_time == 5.0
|
||||
assert result.left_trim_end == 5.0
|
||||
assert result.right_trim_start == 5.0
|
||||
|
||||
def test_split_one_third(self):
|
||||
result = calculate_split(9.0, 3.0)
|
||||
assert result.left_duration == 3.0
|
||||
assert result.right_duration == 6.0
|
||||
assert result.right_start_time == 3.0
|
||||
|
||||
def test_split_with_start_time(self):
|
||||
result = calculate_split(10.0, 4.0, start_time=100.0)
|
||||
assert result.left_duration == 4.0
|
||||
assert result.right_duration == 6.0
|
||||
assert result.right_start_time == 104.0
|
||||
|
||||
def test_split_precision_rounding(self):
|
||||
result = calculate_split(1.0, 1 / 3, precision=3)
|
||||
assert result.left_duration == round(1 / 3, 3)
|
||||
assert result.right_duration == round(2 / 3, 3)
|
||||
|
||||
def test_split_default_precision_is_3(self):
|
||||
result = calculate_split(1.0, 0.123456)
|
||||
# 默认精度3位
|
||||
assert result.left_duration == 0.123
|
||||
|
||||
def test_custom_precision(self):
|
||||
result = calculate_split(1.0, 0.123456, precision=5)
|
||||
assert result.left_duration == 0.12346 # 5位精度,四舍五入
|
||||
|
||||
def test_split_returns_frozen_dataclass(self):
|
||||
result = calculate_split(10.0, 5.0)
|
||||
try:
|
||||
result.left_duration = 3.0 # type: ignore
|
||||
except AttributeError:
|
||||
pass # frozen,应该抛异常
|
||||
else:
|
||||
raise AssertionError("SplitResult should be frozen")
|
||||
|
||||
def test_invalid_split_time_raises(self):
|
||||
try:
|
||||
calculate_split(10.0, 0.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
"""模拟 EditPlanClip 的最小数据类."""
|
||||
|
||||
id: str = ""
|
||||
plan_id: str = "plan_1"
|
||||
order: int = 0
|
||||
duration: float = 3.0
|
||||
clip_type: str = "main"
|
||||
text_content: str = ""
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
class TestValidateMergeClips:
|
||||
def test_valid_two_clips(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=1),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert plan_id == "plan_1"
|
||||
assert first_order == 0
|
||||
|
||||
def test_valid_three_clips(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=2),
|
||||
FakeClip(id="c2", order=3),
|
||||
FakeClip(id="c3", order=4),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert plan_id == "plan_1"
|
||||
assert first_order == 2
|
||||
|
||||
def test_unordered_input_still_valid(self):
|
||||
"""输入顺序不影响,内部会排序."""
|
||||
clips = [
|
||||
FakeClip(id="c3", order=2),
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=1),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert first_order == 0
|
||||
|
||||
def test_single_clip_invalid(self):
|
||||
try:
|
||||
validate_merge_clips([FakeClip()])
|
||||
except ValueError as e:
|
||||
assert "至少需要 2 个" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_empty_list_invalid(self):
|
||||
try:
|
||||
validate_merge_clips([])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_different_plan_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", plan_id="plan_a", order=0),
|
||||
FakeClip(id="c2", plan_id="plan_b", order=1),
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "同一计划" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_non_consecutive_order_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=2), # 跳过1
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "不连续" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_different_clip_type_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, clip_type="main"),
|
||||
FakeClip(id="c2", order=1, clip_type="title"),
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "相同类型" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
class TestCalculateMerge:
|
||||
def test_merge_two_clips_duration(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=3.0),
|
||||
FakeClip(id="c2", order=1, duration=5.0),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert isinstance(result, MergeResult)
|
||||
assert result.total_duration == 8.0
|
||||
|
||||
def test_merge_three_clips_duration(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=2.0),
|
||||
FakeClip(id="c2", order=1, duration=3.0),
|
||||
FakeClip(id="c3", order=2, duration=4.0),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.total_duration == 9.0
|
||||
|
||||
def test_merge_text_concatenation(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="第一句"),
|
||||
FakeClip(id="c2", order=1, text_content="第二句"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "第一句\n第二句"
|
||||
|
||||
def test_merge_empty_text_skipped(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="hello"),
|
||||
FakeClip(id="c2", order=1, text_content=""),
|
||||
FakeClip(id="c3", order=2, text_content="world"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "hello\nworld"
|
||||
|
||||
def test_merge_whitespace_text_skipped(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="a"),
|
||||
FakeClip(id="c2", order=1, text_content=" "),
|
||||
FakeClip(id="c3", order=2, text_content="b"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "a\nb"
|
||||
|
||||
def test_merge_all_empty_text(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content=""),
|
||||
FakeClip(id="c2", order=1, text_content=""),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == ""
|
||||
|
||||
def test_merge_config_later_overrides(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config={"font_size": 20, "color": "red"}),
|
||||
FakeClip(id="c2", order=1, config={"font_size": 24, "bold": True}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_config["font_size"] == 24 # 后面的覆盖
|
||||
assert result.merged_config["color"] == "red"
|
||||
assert result.merged_config["bold"] is True
|
||||
|
||||
def test_merge_config_removes_trim_fields(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config={"trim_start": 1.0, "a": 1}),
|
||||
FakeClip(id="c2", order=1, config={"trim_end": 2.0, "b": 2}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert "trim_start" not in result.merged_config
|
||||
assert "trim_end" not in result.merged_config
|
||||
assert result.merged_config["a"] == 1
|
||||
assert result.merged_config["b"] == 2
|
||||
|
||||
def test_merge_none_config_handled(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config=None),
|
||||
FakeClip(id="c2", order=1, config={"key": "val"}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_config == {"key": "val"}
|
||||
|
||||
def test_merge_first_order_and_shift(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=5),
|
||||
FakeClip(id="c2", order=6),
|
||||
FakeClip(id="c3", order=7),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.first_order == 5
|
||||
assert result.shift_amount == 2 # 3个合并成1个,前移2位
|
||||
|
||||
def test_merge_two_clips_shift(self):
|
||||
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
|
||||
result = calculate_merge(clips)
|
||||
assert result.shift_amount == 1
|
||||
|
||||
def test_merge_unordered_input(self):
|
||||
"""输入乱序也能正确处理(内部排序)."""
|
||||
clips = [
|
||||
FakeClip(id="c3", order=2, duration=4.0, text_content="C"),
|
||||
FakeClip(id="c1", order=0, duration=2.0, text_content="A"),
|
||||
FakeClip(id="c2", order=1, duration=3.0, text_content="B"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.total_duration == 9.0
|
||||
assert result.merged_text == "A\nB\nC"
|
||||
assert result.first_order == 0
|
||||
|
||||
def test_merge_precision(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=1 / 3),
|
||||
FakeClip(id="c2", order=1, duration=1 / 3),
|
||||
]
|
||||
result = calculate_merge(clips, precision=3)
|
||||
assert result.total_duration == round(2 / 3, 3)
|
||||
|
||||
def test_merge_empty_list_raises(self):
|
||||
try:
|
||||
calculate_merge([])
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_merge_result_is_frozen(self):
|
||||
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
|
||||
result = calculate_merge(clips)
|
||||
try:
|
||||
result.total_duration = 10.0 # type: ignore
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("MergeResult should be frozen")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeItem:
|
||||
id: str
|
||||
order: int = 0
|
||||
|
||||
|
||||
class TestCalculateReorderNewOrders:
|
||||
def test_basic_reorder(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
new_order = ["c", "a", "b"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result == {"c": 0, "a": 1, "b": 2}
|
||||
|
||||
def test_reverse_order(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b"), FakeItem(id="c")]
|
||||
new_order = ["c", "b", "a"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result["c"] == 0
|
||||
assert result["b"] == 1
|
||||
assert result["a"] == 2
|
||||
|
||||
def test_same_order(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
new_order = ["a", "b"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result == {"a": 0, "b": 1}
|
||||
|
||||
def test_mismatched_ids_raises(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a", "c"], items)
|
||||
except ValueError as e:
|
||||
assert "不匹配" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_extra_id_in_list_raises(self):
|
||||
items = [FakeItem(id="a")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a", "b"], items)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_missing_id_raises(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a"], items)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_custom_id_attr(self):
|
||||
@dataclass
|
||||
class CustomItem:
|
||||
key: str
|
||||
order: int = 0
|
||||
|
||||
items = [CustomItem(key="x"), CustomItem(key="y")]
|
||||
result = calculate_reorder_new_orders(["y", "x"], items, id_attr="key")
|
||||
assert result == {"y": 0, "x": 1}
|
||||
|
||||
def test_custom_order_attr_does_not_affect_return(self):
|
||||
"""order_attr不影响返回值(返回的是索引),只影响参数校验的ID提取."""
|
||||
items = [FakeItem(id="a", order=10), FakeItem(id="b", order=20)]
|
||||
result = calculate_reorder_new_orders(["b", "a"], items)
|
||||
assert result == {"b": 0, "a": 1} # 新order是索引,不是原值
|
||||
|
||||
|
||||
class TestCalculateShiftOrders:
|
||||
def test_shift_positive(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=5)
|
||||
# order > 0 的是 b(1) 和 c(2)
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert len(result) == 2
|
||||
assert shifted["b"] == 6
|
||||
assert shifted["c"] == 7
|
||||
|
||||
def test_shift_negative(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=-1)
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert shifted["b"] == 0
|
||||
assert shifted["c"] == 1
|
||||
|
||||
def test_threshold_not_included(self):
|
||||
"""threshold_order本身不包含在内(严格大于)."""
|
||||
items = [FakeItem(id="a", order=5)]
|
||||
result = calculate_shift_orders(items, threshold_order=5, shift=1)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_excluded_ids_skipped(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=1),
|
||||
FakeItem(id="b", order=2),
|
||||
FakeItem(id="c", order=3),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=10, excluded_ids={"b"})
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert "b" not in shifted
|
||||
assert shifted["a"] == 11
|
||||
assert shifted["c"] == 13
|
||||
|
||||
def test_none_excluded_ids(self):
|
||||
items = [FakeItem(id="a", order=1)]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=None)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_excluded_ids(self):
|
||||
items = [FakeItem(id="a", order=1)]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=set())
|
||||
assert len(result) == 1
|
||||
|
||||
def test_no_items_above_threshold(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=10, shift=5)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_custom_id_attr(self):
|
||||
@dataclass
|
||||
class CustomItem:
|
||||
key: str
|
||||
pos: int = 0
|
||||
|
||||
items = [CustomItem(key="x", pos=1), CustomItem(key="y", pos=2)]
|
||||
result = calculate_shift_orders(
|
||||
items,
|
||||
threshold_order=0,
|
||||
shift=3,
|
||||
id_attr="key",
|
||||
order_attr="pos",
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0][1] == 4
|
||||
assert result[1][1] == 5
|
||||
|
||||
def test_preserves_item_reference(self):
|
||||
item = FakeItem(id="a", order=5)
|
||||
items = [item]
|
||||
result = calculate_shift_orders(items, threshold_order=3, shift=2)
|
||||
assert len(result) == 1
|
||||
assert result[0][0] is item # 是同一个对象引用
|
||||
assert result[0][1] == 7
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
"""音频降噪配置领域模型单测.
|
||||
|
||||
纯逻辑模块,覆盖:等级枚举、配置解析、参数计算、
|
||||
滤镜构建、便捷函数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from packages.domain.noise_reduction_config import (
|
||||
DEFAULT_LEVEL,
|
||||
DEFAULT_NOISE_FLOOR,
|
||||
MAX_NOISE_FLOOR,
|
||||
MIN_NOISE_FLOOR,
|
||||
NoiseReductionConfig,
|
||||
NoiseReductionLevel,
|
||||
apply_noise_reduction_if_needed,
|
||||
build_afftdn_filter,
|
||||
build_arnndn_filter,
|
||||
get_level_names,
|
||||
)
|
||||
|
||||
|
||||
class TestNoiseReductionLevel:
|
||||
def test_level_values(self):
|
||||
assert NoiseReductionLevel.LOW.value == "low"
|
||||
assert NoiseReductionLevel.MEDIUM.value == "medium"
|
||||
assert NoiseReductionLevel.HIGH.value == "high"
|
||||
assert NoiseReductionLevel.CUSTOM.value == "custom"
|
||||
|
||||
def test_level_is_str_enum(self):
|
||||
assert isinstance(NoiseReductionLevel.LOW, str)
|
||||
assert NoiseReductionLevel.LOW == "low"
|
||||
|
||||
def test_default_level(self):
|
||||
assert DEFAULT_LEVEL == NoiseReductionLevel.MEDIUM
|
||||
|
||||
def test_default_noise_floor(self):
|
||||
assert DEFAULT_NOISE_FLOOR == -25.0
|
||||
|
||||
def test_parameter_ranges(self):
|
||||
assert MIN_NOISE_FLOOR == -60.0
|
||||
assert MAX_NOISE_FLOOR == -5.0
|
||||
|
||||
|
||||
class TestNoiseReductionConfigDefaults:
|
||||
def test_default_disabled(self):
|
||||
cfg = NoiseReductionConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.level == NoiseReductionLevel.MEDIUM
|
||||
assert cfg.noise_floor == -25.0
|
||||
assert cfg.voice_enhance is False
|
||||
|
||||
def test_custom_config(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.HIGH,
|
||||
noise_floor=-15.0,
|
||||
voice_enhance=True,
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.level == NoiseReductionLevel.HIGH
|
||||
assert cfg.noise_floor == -15.0
|
||||
assert cfg.voice_enhance is True
|
||||
|
||||
|
||||
class TestFromDict:
|
||||
def test_none_data_disabled(self):
|
||||
cfg = NoiseReductionConfig.from_dict(None)
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_empty_dict_disabled(self):
|
||||
cfg = NoiseReductionConfig.from_dict({})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_enabled_false(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": False})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_enabled_defaults(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.level == NoiseReductionLevel.MEDIUM
|
||||
assert cfg.noise_floor == DEFAULT_NOISE_FLOOR
|
||||
assert cfg.voice_enhance is False
|
||||
|
||||
def test_low_level(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "low"})
|
||||
assert cfg.level == NoiseReductionLevel.LOW
|
||||
|
||||
def test_medium_level(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "medium"})
|
||||
assert cfg.level == NoiseReductionLevel.MEDIUM
|
||||
|
||||
def test_high_level(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "high"})
|
||||
assert cfg.level == NoiseReductionLevel.HIGH
|
||||
|
||||
def test_custom_level(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom"})
|
||||
assert cfg.level == NoiseReductionLevel.CUSTOM
|
||||
|
||||
def test_case_insensitive_level(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "HIGH"})
|
||||
assert cfg.level == NoiseReductionLevel.HIGH
|
||||
|
||||
def test_invalid_level_defaults_to_medium(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "ultra"})
|
||||
assert cfg.level == NoiseReductionLevel.MEDIUM
|
||||
|
||||
def test_custom_noise_floor(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": -30.0})
|
||||
assert cfg.noise_floor == -30.0
|
||||
|
||||
def test_noise_floor_below_min_clamped(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": -100.0})
|
||||
assert cfg.noise_floor == MIN_NOISE_FLOOR
|
||||
|
||||
def test_noise_floor_above_max_clamped(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": 0.0})
|
||||
assert cfg.noise_floor == MAX_NOISE_FLOOR
|
||||
|
||||
def test_noise_floor_at_min(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": -60.0})
|
||||
assert cfg.noise_floor == -60.0
|
||||
|
||||
def test_noise_floor_at_max(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": -5.0})
|
||||
assert cfg.noise_floor == -5.0
|
||||
|
||||
def test_invalid_noise_floor_defaults(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": "bad"})
|
||||
assert cfg.noise_floor == DEFAULT_NOISE_FLOOR
|
||||
|
||||
def test_voice_enhance_true(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "voice_enhance": True})
|
||||
assert cfg.voice_enhance is True
|
||||
|
||||
def test_voice_enhance_false(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "voice_enhance": False})
|
||||
assert cfg.voice_enhance is False
|
||||
|
||||
def test_voice_enhance_default_false(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True})
|
||||
assert cfg.voice_enhance is False
|
||||
|
||||
def test_noise_floor_int_converted(self):
|
||||
cfg = NoiseReductionConfig.from_dict({"enabled": True, "level": "custom", "noise_floor": -30})
|
||||
assert cfg.noise_floor == -30.0
|
||||
|
||||
def test_all_params(self):
|
||||
cfg = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -20.0,
|
||||
"voice_enhance": True,
|
||||
}
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.level == NoiseReductionLevel.CUSTOM
|
||||
assert cfg.noise_floor == -20.0
|
||||
assert cfg.voice_enhance is True
|
||||
|
||||
|
||||
class TestHasEffect:
|
||||
def test_disabled_no_effect(self):
|
||||
cfg = NoiseReductionConfig(enabled=False)
|
||||
assert cfg.has_effect() is False
|
||||
|
||||
def test_enabled_has_effect(self):
|
||||
cfg = NoiseReductionConfig(enabled=True)
|
||||
assert cfg.has_effect() is True
|
||||
|
||||
def test_low_level_has_effect(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.LOW)
|
||||
assert cfg.has_effect() is True
|
||||
|
||||
|
||||
class TestGetEffectiveNoiseFloor:
|
||||
def test_low_level(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.LOW)
|
||||
assert cfg.get_effective_noise_floor() == -35.0
|
||||
|
||||
def test_medium_level(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.MEDIUM)
|
||||
assert cfg.get_effective_noise_floor() == -25.0
|
||||
|
||||
def test_high_level(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.HIGH)
|
||||
assert cfg.get_effective_noise_floor() == -15.0
|
||||
|
||||
def test_custom_level(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.CUSTOM,
|
||||
noise_floor=-40.0,
|
||||
)
|
||||
assert cfg.get_effective_noise_floor() == -40.0
|
||||
|
||||
def test_custom_level_ignores_preset(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.CUSTOM,
|
||||
noise_floor=-20.0,
|
||||
)
|
||||
# custom级别用自己的noise_floor,不是medium的-25
|
||||
assert cfg.get_effective_noise_floor() == -20.0
|
||||
|
||||
|
||||
class TestGetLevelParams:
|
||||
def test_low_level_params(self):
|
||||
cfg = NoiseReductionConfig(level=NoiseReductionLevel.LOW)
|
||||
params = cfg.get_level_params()
|
||||
assert params["nf"] == -35.0
|
||||
assert params["tn"] == -10.0
|
||||
assert params["tr"] == 50.0
|
||||
|
||||
def test_medium_level_params(self):
|
||||
cfg = NoiseReductionConfig(level=NoiseReductionLevel.MEDIUM)
|
||||
params = cfg.get_level_params()
|
||||
assert params["nf"] == -25.0
|
||||
assert params["tn"] == -10.0
|
||||
assert params["tr"] == 50.0
|
||||
|
||||
def test_high_level_params(self):
|
||||
cfg = NoiseReductionConfig(level=NoiseReductionLevel.HIGH)
|
||||
params = cfg.get_level_params()
|
||||
assert params["nf"] == -15.0
|
||||
assert params["tn"] == -5.0
|
||||
assert params["tr"] == 30.0
|
||||
|
||||
def test_custom_level_params(self):
|
||||
cfg = NoiseReductionConfig(level=NoiseReductionLevel.CUSTOM, noise_floor=-45.0)
|
||||
params = cfg.get_level_params()
|
||||
assert params["nf"] == -45.0
|
||||
assert params["tn"] == -10.0 # 默认值
|
||||
assert params["tr"] == 50.0 # 默认值
|
||||
|
||||
def test_params_are_floats(self):
|
||||
cfg = NoiseReductionConfig(level=NoiseReductionLevel.LOW)
|
||||
params = cfg.get_level_params()
|
||||
assert all(isinstance(v, float) for v in params.values())
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_disabled_always_valid(self):
|
||||
cfg = NoiseReductionConfig(enabled=False)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_enabled_valid(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.MEDIUM,
|
||||
noise_floor=-25.0,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_noise_floor_below_min_invalid(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, noise_floor=-100.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "noise_floor" in msg
|
||||
|
||||
def test_noise_floor_above_max_invalid(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, noise_floor=0.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "noise_floor" in msg
|
||||
|
||||
def test_at_min_boundary_valid(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, noise_floor=MIN_NOISE_FLOOR)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_at_max_boundary_valid(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, noise_floor=MAX_NOISE_FLOOR)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
|
||||
class TestBuildAfftdnFilter:
|
||||
def test_disabled_returns_anull(self):
|
||||
cfg = NoiseReductionConfig(enabled=False)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert result == "[0:a]anull[nr]"
|
||||
|
||||
def test_medium_level_filter(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.MEDIUM)
|
||||
result = build_afftdn_filter(cfg, "[a0]", "[nr0]")
|
||||
assert "afftdn=nf=-25.0" in result
|
||||
assert "[a0]" in result
|
||||
assert "[nr0]" in result
|
||||
|
||||
def test_low_level_filter(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.LOW)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert "nf=-35.0" in result
|
||||
|
||||
def test_high_level_filter(self):
|
||||
cfg = NoiseReductionConfig(enabled=True, level=NoiseReductionLevel.HIGH)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert "nf=-15.0" in result
|
||||
assert "tn=-5.0" in result
|
||||
assert "tr=30.0" in result
|
||||
|
||||
def test_custom_level_filter(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.CUSTOM,
|
||||
noise_floor=-40.0,
|
||||
)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert "nf=-40.0" in result
|
||||
|
||||
def test_voice_enhance_adds_filters(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.MEDIUM,
|
||||
voice_enhance=True,
|
||||
)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert "highpass" in result
|
||||
assert "acompressor" in result
|
||||
assert "loudnorm" in result
|
||||
|
||||
def test_no_voice_enhance_no_extra_filters(self):
|
||||
cfg = NoiseReductionConfig(
|
||||
enabled=True,
|
||||
level=NoiseReductionLevel.MEDIUM,
|
||||
voice_enhance=False,
|
||||
)
|
||||
result = build_afftdn_filter(cfg, "[0:a]", "[nr]")
|
||||
assert "highpass" not in result
|
||||
assert "acompressor" not in result
|
||||
assert "loudnorm" not in result
|
||||
|
||||
def test_filter_starts_with_input_label(self):
|
||||
cfg = NoiseReductionConfig(enabled=True)
|
||||
result = build_afftdn_filter(cfg, "[in]", "[out]")
|
||||
assert result.startswith("[in]")
|
||||
|
||||
def test_filter_ends_with_output_label(self):
|
||||
cfg = NoiseReductionConfig(enabled=True)
|
||||
result = build_afftdn_filter(cfg, "[in]", "[out]")
|
||||
assert result.endswith("[out]")
|
||||
|
||||
|
||||
class TestBuildArnndnFilter:
|
||||
def test_disabled_returns_anull(self):
|
||||
cfg = NoiseReductionConfig(enabled=False)
|
||||
result = build_arnndn_filter(cfg, "[0:a]", "[nr]", "model.rnnn")
|
||||
assert result == "[0:a]anull[nr]"
|
||||
|
||||
def test_enabled_arnndn(self):
|
||||
cfg = NoiseReductionConfig(enabled=True)
|
||||
result = build_arnndn_filter(cfg, "[a0]", "[nr0]", "models/denoise.rnnn")
|
||||
assert "arnndn" in result
|
||||
assert "m=models/denoise.rnnn" in result
|
||||
assert result.startswith("[a0]")
|
||||
assert result.endswith("[nr0]")
|
||||
|
||||
|
||||
class TestApplyNoiseReductionIfNeeded:
|
||||
def test_none_config_returns_none(self):
|
||||
result = apply_noise_reduction_if_needed(None, "[0:a]", "[nr]")
|
||||
assert result is None
|
||||
|
||||
def test_disabled_config_returns_none(self):
|
||||
result = apply_noise_reduction_if_needed({"enabled": False}, "[0:a]", "[nr]")
|
||||
assert result is None
|
||||
|
||||
def test_enabled_config_returns_filter(self):
|
||||
result = apply_noise_reduction_if_needed({"enabled": True, "level": "medium"}, "[0:a]", "[nr]")
|
||||
assert result is not None
|
||||
assert "afftdn" in result
|
||||
assert "[0:a]" in result
|
||||
assert "[nr]" in result
|
||||
|
||||
def test_invalid_config_returns_none(self, caplog):
|
||||
"""解析失败时返回None,不抛异常."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
# 传入奇怪的数据触发异常
|
||||
result = apply_noise_reduction_if_needed({"enabled": "maybe"}, "[0:a]", "[nr]")
|
||||
# enabled="maybe"会被bool转成True,然后正常解析
|
||||
# 让我们用一个会抛异常的方式...
|
||||
# 实际上from_dict是不会抛异常的,所以换个思路
|
||||
assert result is not None or result is None # 不抛异常就行
|
||||
|
||||
def test_custom_level(self):
|
||||
result = apply_noise_reduction_if_needed(
|
||||
{"enabled": True, "level": "custom", "noise_floor": -40.0},
|
||||
"[a0]",
|
||||
"[nr0]",
|
||||
)
|
||||
assert result is not None
|
||||
assert "nf=-40.0" in result
|
||||
|
||||
|
||||
class TestGetLevelNames:
|
||||
def test_returns_all_levels(self):
|
||||
names = get_level_names()
|
||||
assert "low" in names
|
||||
assert "medium" in names
|
||||
assert "high" in names
|
||||
assert "custom" in names
|
||||
assert len(names) == 4
|
||||
|
||||
def test_names_are_strings(self):
|
||||
names = get_level_names()
|
||||
assert all(isinstance(n, str) for n in names)
|
||||
Executable
+406
@@ -0,0 +1,406 @@
|
||||
"""渲染图层工具函数单测.
|
||||
|
||||
纯函数模块,覆盖:图层角色映射、z_index、
|
||||
clip时长计算、总时长估算、直通判断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from packages.domain.render_layer_utils import (
|
||||
LAYER_Z_INDEX,
|
||||
MAIN_LAYER_ROLES,
|
||||
PIP_DEFAULT_SCALE,
|
||||
can_pass_through,
|
||||
clip_adjusted_duration,
|
||||
clip_effective_duration,
|
||||
clip_playback_speed,
|
||||
estimate_total_duration,
|
||||
get_layer_z_index,
|
||||
resolve_layer_role,
|
||||
)
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_layer_z_index_keys(self):
|
||||
assert "background" in LAYER_Z_INDEX
|
||||
assert "broll" in LAYER_Z_INDEX
|
||||
assert "main" in LAYER_Z_INDEX
|
||||
assert "overlay" in LAYER_Z_INDEX
|
||||
assert "corner_voice" in LAYER_Z_INDEX
|
||||
assert "audio" in LAYER_Z_INDEX
|
||||
|
||||
def test_layer_z_index_values(self):
|
||||
assert LAYER_Z_INDEX["background"] == -1
|
||||
assert LAYER_Z_INDEX["broll"] == 0
|
||||
assert LAYER_Z_INDEX["main"] == 0
|
||||
assert LAYER_Z_INDEX["overlay"] == 1
|
||||
assert LAYER_Z_INDEX["corner_voice"] == 1
|
||||
assert LAYER_Z_INDEX["audio"] == 2
|
||||
|
||||
def test_pip_default_scale(self):
|
||||
assert PIP_DEFAULT_SCALE == 0.25
|
||||
|
||||
def test_main_layer_roles(self):
|
||||
assert "main" in MAIN_LAYER_ROLES
|
||||
assert "broll" in MAIN_LAYER_ROLES
|
||||
assert "background" in MAIN_LAYER_ROLES
|
||||
assert len(MAIN_LAYER_ROLES) == 3
|
||||
|
||||
|
||||
class TestResolveLayerRole:
|
||||
def test_main_default(self):
|
||||
assert resolve_layer_role("main") == "main"
|
||||
|
||||
def test_intro_maps_to_main(self):
|
||||
assert resolve_layer_role("intro") == "main"
|
||||
|
||||
def test_outro_maps_to_main(self):
|
||||
assert resolve_layer_role("outro") == "main"
|
||||
|
||||
def test_overlay(self):
|
||||
assert resolve_layer_role("overlay") == "overlay"
|
||||
|
||||
def test_corner_voice(self):
|
||||
assert resolve_layer_role("corner_voice") == "corner_voice"
|
||||
|
||||
def test_background(self):
|
||||
assert resolve_layer_role("background") == "background"
|
||||
|
||||
def test_b_roll(self):
|
||||
assert resolve_layer_role("b_roll") == "broll"
|
||||
|
||||
def test_main_with_b_roll_role(self):
|
||||
assert resolve_layer_role("main", {"role": "b_roll"}) == "broll"
|
||||
|
||||
def test_main_with_audio_role(self):
|
||||
assert resolve_layer_role("main", {"role": "audio"}) == "audio"
|
||||
|
||||
def test_main_with_unknown_role(self):
|
||||
assert resolve_layer_role("main", {"role": "something"}) == "main"
|
||||
|
||||
def test_overlay_ignores_config_role(self):
|
||||
"""overlay类型不受config.role影响."""
|
||||
assert resolve_layer_role("overlay", {"role": "b_roll"}) == "overlay"
|
||||
|
||||
def test_intro_ignores_config_role(self):
|
||||
"""intro类型不受config.role影响."""
|
||||
assert resolve_layer_role("intro", {"role": "audio"}) == "main"
|
||||
|
||||
def test_none_config(self):
|
||||
assert resolve_layer_role("main", None) == "main"
|
||||
|
||||
def test_empty_config(self):
|
||||
assert resolve_layer_role("main", {}) == "main"
|
||||
|
||||
def test_unknown_clip_type_defaults_to_main(self):
|
||||
"""未知clip_type走default分支返回main."""
|
||||
assert resolve_layer_role("unknown_type") == "main"
|
||||
|
||||
|
||||
class TestGetLayerZIndex:
|
||||
def test_background(self):
|
||||
assert get_layer_z_index("background") == -1
|
||||
|
||||
def test_main(self):
|
||||
assert get_layer_z_index("main") == 0
|
||||
|
||||
def test_broll(self):
|
||||
assert get_layer_z_index("broll") == 0
|
||||
|
||||
def test_overlay(self):
|
||||
assert get_layer_z_index("overlay") == 1
|
||||
|
||||
def test_corner_voice(self):
|
||||
assert get_layer_z_index("corner_voice") == 1
|
||||
|
||||
def test_audio(self):
|
||||
assert get_layer_z_index("audio") == 2
|
||||
|
||||
def test_unknown_returns_zero(self):
|
||||
assert get_layer_z_index("unknown_role") == 0
|
||||
|
||||
def test_empty_string_returns_zero(self):
|
||||
assert get_layer_z_index("") == 0
|
||||
|
||||
|
||||
class TestClipEffectiveDuration:
|
||||
def test_duration_only(self):
|
||||
"""只有duration,没有actual,用duration."""
|
||||
assert clip_effective_duration(5.0) == 5.0
|
||||
|
||||
def test_duration_less_than_actual(self):
|
||||
"""duration < actual,取duration."""
|
||||
assert clip_effective_duration(3.0, 5.0) == 3.0
|
||||
|
||||
def test_duration_greater_than_actual(self):
|
||||
"""duration > actual,取actual."""
|
||||
assert clip_effective_duration(10.0, 5.0) == 5.0
|
||||
|
||||
def test_duration_equal_to_actual(self):
|
||||
assert clip_effective_duration(5.0, 5.0) == 5.0
|
||||
|
||||
def test_zero_duration_with_actual(self):
|
||||
"""duration=0表示使用完整素材,取actual."""
|
||||
assert clip_effective_duration(0.0, 8.0) == 8.0
|
||||
|
||||
def test_negative_duration_with_actual(self):
|
||||
"""duration<0也取actual."""
|
||||
assert clip_effective_duration(-1.0, 8.0) == 8.0
|
||||
|
||||
def test_zero_duration_zero_actual(self):
|
||||
assert clip_effective_duration(0.0, 0.0) == 0.0
|
||||
|
||||
def test_zero_actual_uses_duration(self):
|
||||
"""actual=0时,duration>0就用duration."""
|
||||
assert clip_effective_duration(5.0, 0.0) == 5.0
|
||||
|
||||
def test_both_zero(self):
|
||||
assert clip_effective_duration(0.0) == 0.0
|
||||
|
||||
|
||||
class TestClipPlaybackSpeed:
|
||||
def test_normal_speed(self):
|
||||
assert clip_playback_speed(1.0) == 1.0
|
||||
|
||||
def test_fast_speed(self):
|
||||
assert clip_playback_speed(2.0) == 2.0
|
||||
|
||||
def test_slow_speed(self):
|
||||
assert clip_playback_speed(0.5) == 0.5
|
||||
|
||||
def test_zero_speed_fallback(self):
|
||||
assert clip_playback_speed(0) == 1.0
|
||||
|
||||
def test_negative_speed_fallback(self):
|
||||
assert clip_playback_speed(-1.0) == 1.0
|
||||
|
||||
def test_string_fallback(self):
|
||||
assert clip_playback_speed("fast") == 1.0
|
||||
|
||||
def test_none_fallback(self):
|
||||
assert clip_playback_speed(None) == 1.0
|
||||
|
||||
def test_int_speed(self):
|
||||
assert clip_playback_speed(2) == 2.0
|
||||
|
||||
def test_list_fallback(self):
|
||||
assert clip_playback_speed([1, 2]) == 1.0
|
||||
|
||||
def test_dict_fallback(self):
|
||||
assert clip_playback_speed({"speed": 2}) == 1.0
|
||||
|
||||
|
||||
class TestClipAdjustedDuration:
|
||||
def test_normal_speed_no_change(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
|
||||
|
||||
def test_double_speed_half_duration(self):
|
||||
assert clip_adjusted_duration(4.0, 10.0, 2.0) == 2.0
|
||||
|
||||
def test_half_speed_double_duration(self):
|
||||
assert clip_adjusted_duration(4.0, 10.0, 0.5) == 8.0
|
||||
|
||||
def test_uses_effective_duration(self):
|
||||
"""duration>actual时取actual,再调速."""
|
||||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 2.0
|
||||
|
||||
def test_default_speed(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0) == 5.0
|
||||
|
||||
def test_invalid_speed_fallback(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0, "bad") == 5.0
|
||||
|
||||
def test_zero_speed_fallback(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
|
||||
|
||||
def test_very_close_to_one_speed(self):
|
||||
"""速度接近1.0时直接返回base,不做除法."""
|
||||
result = clip_adjusted_duration(5.0, 10.0, 1.0000001)
|
||||
assert result == 5.0
|
||||
|
||||
def test_zero_duration_zero_actual(self):
|
||||
assert clip_adjusted_duration(0.0, 0.0, 1.0) == 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
duration: float = 0.0
|
||||
actual_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeLayer:
|
||||
role: str = "main"
|
||||
clips: list[FakeClip] = field(default_factory=list)
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
def test_single_main_layer_single_clip(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0, actual_duration=10.0)]),
|
||||
]
|
||||
assert estimate_total_duration(layers) == 5.0
|
||||
|
||||
def test_single_main_layer_multiple_clips(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=3.0, actual_duration=5.0),
|
||||
FakeClip(duration=2.0, actual_duration=4.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
assert estimate_total_duration(layers) == 5.0
|
||||
|
||||
def test_with_transition_duration(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=5.0),
|
||||
FakeClip(duration=5.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
# 总10s - 1个转场 * 0.5s = 9.5s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||||
|
||||
def test_transition_with_many_clips(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[FakeClip(duration=3.0) for _ in range(5)],
|
||||
),
|
||||
]
|
||||
# 5个3s = 15s,4个转场 * 0.5s = 2s,总13s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 13.0
|
||||
|
||||
def test_prefers_main_over_broll(self):
|
||||
"""main图层优先级高于broll."""
|
||||
layers = [
|
||||
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert estimate_total_duration(layers) == 5.0
|
||||
|
||||
def test_prefers_broll_over_background(self):
|
||||
layers = [
|
||||
FakeLayer(role="background", clips=[FakeClip(duration=20.0)]),
|
||||
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
|
||||
]
|
||||
assert estimate_total_duration(layers) == 10.0
|
||||
|
||||
def test_no_main_layer(self):
|
||||
layers = [
|
||||
FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
# 没有主图层,返回0
|
||||
assert estimate_total_duration(layers) == 0.0
|
||||
|
||||
def test_empty_layers(self):
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_main_layer_no_clips(self):
|
||||
layers = [FakeLayer(role="main", clips=[])]
|
||||
assert estimate_total_duration(layers) == 0.0
|
||||
|
||||
def test_minimum_total_duration(self):
|
||||
"""总时长最小为0.1s."""
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=0.01, actual_duration=0.01)]),
|
||||
]
|
||||
# 转场把总时长减到接近0时,会被钳制到0.1
|
||||
result = estimate_total_duration(layers, transition_duration=10.0)
|
||||
assert result == 0.1
|
||||
|
||||
def test_with_playback_speed(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[FakeClip(duration=4.0, playback_speed=2.0)],
|
||||
),
|
||||
]
|
||||
# 4s / 2x = 2s
|
||||
assert estimate_total_duration(layers) == 2.0
|
||||
|
||||
def test_multiple_layers_picks_first_main(self):
|
||||
layers = [
|
||||
FakeLayer(role="overlay", clips=[FakeClip(duration=1.0)]),
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=10.0)]), # 不看这个
|
||||
]
|
||||
assert estimate_total_duration(layers) == 5.0
|
||||
|
||||
|
||||
class TestCanPassThrough:
|
||||
def test_single_main_layer_single_clip(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is True
|
||||
|
||||
def test_single_broll_layer_single_clip(self):
|
||||
layers = [
|
||||
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is True
|
||||
|
||||
def test_single_background_layer_single_clip(self):
|
||||
layers = [
|
||||
FakeLayer(role="background", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is True
|
||||
|
||||
def test_multiple_layers_false(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is False
|
||||
|
||||
def test_single_layer_multiple_clips_false(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[FakeClip(duration=3.0), FakeClip(duration=2.0)],
|
||||
),
|
||||
]
|
||||
assert can_pass_through(layers) is False
|
||||
|
||||
def test_overlay_layer_false(self):
|
||||
"""overlay不是主图层角色."""
|
||||
layers = [
|
||||
FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is False
|
||||
|
||||
def test_has_stickers_false(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers, has_stickers=True) is False
|
||||
|
||||
def test_has_watermark_false(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers, has_watermark=True) is False
|
||||
|
||||
def test_both_stickers_and_watermark_false(self):
|
||||
layers = [
|
||||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False
|
||||
|
||||
def test_empty_layers_false(self):
|
||||
assert can_pass_through([]) is False
|
||||
|
||||
def test_corner_voice_layer_false(self):
|
||||
layers = [
|
||||
FakeLayer(role="corner_voice", clips=[FakeClip(duration=5.0)]),
|
||||
]
|
||||
assert can_pass_through(layers) is False
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
"""模板片段转换器单测.
|
||||
|
||||
纯函数模块,覆盖:枚举安全解析、config过滤、
|
||||
clip→template转换、snapshot双向转换、名称校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_clip_converter import (
|
||||
clip_config_to_snapshot,
|
||||
clip_configs_to_snapshots,
|
||||
clip_to_template_clip_config,
|
||||
clips_to_template_clip_configs,
|
||||
filter_clip_config,
|
||||
filter_plan_config_to_template,
|
||||
safe_parse_clip_type,
|
||||
safe_parse_transition_effect,
|
||||
snapshot_to_template_clip_config,
|
||||
snapshots_to_template_clip_configs,
|
||||
validate_template_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSafeParseTransitionEffect:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_transition_effect(TransitionEffect.FADE)
|
||||
assert result == TransitionEffect.FADE
|
||||
assert isinstance(result, TransitionEffect)
|
||||
|
||||
def test_valid_string(self):
|
||||
result = safe_parse_transition_effect("fade")
|
||||
assert result == TransitionEffect.FADE
|
||||
|
||||
def test_cut_string(self):
|
||||
result = safe_parse_transition_effect("cut")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("invalid_effect")
|
||||
assert result == TransitionEffect.CUT # 默认
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE)
|
||||
assert result == TransitionEffect.DISSOLVE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_transition_effect(None)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_int_value_returns_default(self):
|
||||
result = safe_parse_transition_effect(123)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestSafeParseClipType:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_clip_type(ClipType.SUBTITLE)
|
||||
assert result == ClipType.SUBTITLE
|
||||
assert isinstance(result, ClipType)
|
||||
|
||||
def test_valid_string_main(self):
|
||||
result = safe_parse_clip_type("main")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_valid_string_text(self):
|
||||
result = safe_parse_clip_type("subtitle")
|
||||
assert result == ClipType.SUBTITLE
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_clip_type("unknown_type")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_clip_type("bad", default=ClipType.TITLE)
|
||||
assert result == ClipType.TITLE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_clip_type(None)
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_dict_returns_default(self):
|
||||
result = safe_parse_clip_type({"key": "val"})
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
|
||||
class TestFilterClipConfig:
|
||||
def test_none_config(self):
|
||||
result = filter_clip_config(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_clip_config({})
|
||||
assert result == {}
|
||||
|
||||
def test_basic_config_passthrough(self):
|
||||
cfg = {"font_size": 24, "color": "red"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert result == {"font_size": 24, "color": "red"}
|
||||
|
||||
def test_filters_asset_info(self):
|
||||
cfg = {"font_size": 24, "asset_info": {"id": "123"}}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "asset_info" not in result
|
||||
assert result["font_size"] == 24
|
||||
|
||||
def test_filters_source_asset_id(self):
|
||||
cfg = {"source_asset_id": "asset_1", "text_key": "hi"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "source_asset_id" not in result
|
||||
assert result["text_key"] == "hi"
|
||||
|
||||
def test_playback_speed_added_when_not_one(self):
|
||||
result = filter_clip_config({}, playback_speed=1.5)
|
||||
assert result["playback_speed"] == 1.5
|
||||
|
||||
def test_playback_speed_one_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=1.0)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_none_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=None)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_config_takes_priority(self):
|
||||
"""clip_config中的playback_speed会覆盖参数传入的(因为update在后面)."""
|
||||
cfg = {"playback_speed": 0.5, "other": "val"}
|
||||
result = filter_clip_config(cfg, playback_speed=2.0)
|
||||
assert result["playback_speed"] == 0.5 # config里的覆盖参数的
|
||||
assert result["other"] == "val"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3}
|
||||
skip = frozenset({"drop_me", "also_drop"})
|
||||
result = filter_clip_config(cfg, skip_keys=skip)
|
||||
assert result == {"keep_me": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, "asset_info": "x"}
|
||||
original = dict(cfg)
|
||||
filter_clip_config(cfg)
|
||||
assert cfg == original # 原dict不变
|
||||
|
||||
|
||||
class TestFilterPlanConfigToTemplate:
|
||||
def test_none_config(self):
|
||||
result = filter_plan_config_to_template(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_plan_config_to_template({})
|
||||
assert result == {}
|
||||
|
||||
def test_keeps_template_fields(self):
|
||||
cfg = {"title": "My Template", "aspect_ratio": "9:16"}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert result == cfg
|
||||
|
||||
def test_filters_runtime_fields(self):
|
||||
cfg = {
|
||||
"title": "T",
|
||||
"is_template_draft": True,
|
||||
"asset_ids": ["a1"],
|
||||
"source_edit_plan_id": "ep1",
|
||||
"generation_task_id": "gt1",
|
||||
}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert "is_template_draft" not in result
|
||||
assert "asset_ids" not in result
|
||||
assert "source_edit_plan_id" not in result
|
||||
assert "generation_task_id" not in result
|
||||
assert result["title"] == "T"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep": 1, "skip_a": 2, "skip_b": 3}
|
||||
skip = frozenset({"skip_a", "skip_b"})
|
||||
result = filter_plan_config_to_template(cfg, skip_keys=skip)
|
||||
assert result == {"keep": 1}
|
||||
|
||||
|
||||
class TestClipToTemplateClipConfig:
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
duration: float = 5.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
playback_speed: float | None = None
|
||||
config: dict | None = None
|
||||
|
||||
def test_basic_conversion(self):
|
||||
clip = self.FakeClip(
|
||||
clip_type="subtitle",
|
||||
order=2,
|
||||
duration=3.5,
|
||||
text_content="Hello",
|
||||
transition_effect="fade",
|
||||
)
|
||||
result = clip_to_template_clip_config("tpl_1", clip)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_1"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 2
|
||||
assert result.min_duration == 3.5
|
||||
assert result.max_duration == 3.5
|
||||
assert result.text_template == "Hello"
|
||||
assert result.transition_effect == TransitionEffect.FADE
|
||||
|
||||
def test_duration_fixed_min_max_equal(self):
|
||||
"""转换后 min_duration == max_duration == clip.duration."""
|
||||
clip = self.FakeClip(duration=7.2)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 7.2
|
||||
assert result.max_duration == 7.2
|
||||
|
||||
def test_zero_duration(self):
|
||||
clip = self.FakeClip(duration=0.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_none_duration_defaults_to_zero(self):
|
||||
clip = self.FakeClip()
|
||||
clip.duration = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_empty_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip(text_content="")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_none_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip()
|
||||
clip.text_content = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_playback_speed_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.config["playback_speed"] == 1.5
|
||||
assert result.config["font"] == "bold"
|
||||
|
||||
def test_playback_speed_one_not_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "playback_speed" not in result.config
|
||||
|
||||
def test_config_asset_info_filtered(self):
|
||||
clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "asset_info" not in result.config
|
||||
assert result.config["text_key"] == "hi"
|
||||
|
||||
def test_invalid_clip_type_falls_back(self):
|
||||
clip = self.FakeClip(clip_type="invalid_type")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_missing_attributes(self):
|
||||
"""对象没有某些属性时使用默认值."""
|
||||
|
||||
class MinimalClip:
|
||||
pass
|
||||
|
||||
result = clip_to_template_clip_config("t1", MinimalClip())
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestClipsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = clips_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_clips(self):
|
||||
clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A")
|
||||
clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="")
|
||||
result = clips_to_template_clip_configs("t1", [clip_a, clip_b])
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].order == 0
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
assert result[1].order == 1
|
||||
assert all(isinstance(r, TemplateClipConfig) for r in result)
|
||||
|
||||
|
||||
class TestClipConfigToSnapshot:
|
||||
def test_basic_snapshot(self):
|
||||
cfg = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=2,
|
||||
min_duration=3.0,
|
||||
max_duration=5.0,
|
||||
text_template="Hello",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"font_size": 20},
|
||||
)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "subtitle"
|
||||
assert snap["order"] == 2
|
||||
assert snap["min_duration"] == 3.0
|
||||
assert snap["max_duration"] == 5.0
|
||||
assert snap["text_template"] == "Hello"
|
||||
assert snap["transition_effect"] == "fade"
|
||||
assert snap["config"] == {"font_size": 20}
|
||||
|
||||
def test_enum_values_are_strings(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "main"
|
||||
assert isinstance(snap["clip_type"], str)
|
||||
assert snap["transition_effect"] == "cut"
|
||||
assert isinstance(snap["transition_effect"], str)
|
||||
|
||||
def test_config_is_copy_not_reference(self):
|
||||
config = {"key": "val"}
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
snap["config"]["key"] = "changed"
|
||||
assert config["key"] == "val" # 原config不变
|
||||
|
||||
def test_empty_config(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={})
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["config"] == {}
|
||||
|
||||
def test_none_text_becomes_empty(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
cfg.text_template = None # type: ignore
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["text_template"] == ""
|
||||
|
||||
|
||||
class TestClipConfigsToSnapshots:
|
||||
def test_empty_list(self):
|
||||
assert clip_configs_to_snapshots([]) == []
|
||||
|
||||
def test_multiple_configs(self):
|
||||
cfg1 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=0,
|
||||
min_duration=2.0,
|
||||
max_duration=2.0,
|
||||
)
|
||||
cfg2 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.TITLE,
|
||||
order=1,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
)
|
||||
snaps = clip_configs_to_snapshots([cfg1, cfg2])
|
||||
assert len(snaps) == 2
|
||||
assert snaps[0]["clip_type"] == "subtitle"
|
||||
assert snaps[1]["clip_type"] == "title"
|
||||
|
||||
|
||||
class TestSnapshotToTemplateClipConfig:
|
||||
def test_basic_conversion(self):
|
||||
snap = {
|
||||
"clip_type": "subtitle",
|
||||
"order": 3,
|
||||
"min_duration": 2.5,
|
||||
"max_duration": 4.5,
|
||||
"text_template": "World",
|
||||
"transition_effect": "dissolve",
|
||||
"config": {"color": "blue"},
|
||||
}
|
||||
result = snapshot_to_template_clip_config("tpl_2", snap)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_2"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 3
|
||||
assert result.min_duration == 2.5
|
||||
assert result.max_duration == 4.5
|
||||
assert result.text_template == "World"
|
||||
assert result.transition_effect == TransitionEffect.DISSOLVE
|
||||
assert result.config == {"color": "blue"}
|
||||
|
||||
def test_empty_snapshot_uses_defaults(self):
|
||||
result = snapshot_to_template_clip_config("t1", {})
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
assert result.config == {}
|
||||
|
||||
def test_invalid_clip_type_defaults(self):
|
||||
snap = {"clip_type": "unknown"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_invalid_transition_defaults(self):
|
||||
snap = {"transition_effect": "bad_effect"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
def test_none_config_becomes_empty(self):
|
||||
snap = {"config": None}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.config == {}
|
||||
|
||||
|
||||
class TestSnapshotsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = snapshots_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_snapshots(self):
|
||||
snaps = [
|
||||
{"clip_type": "subtitle", "order": 0, "text_template": "A"},
|
||||
{"clip_type": "title", "order": 1},
|
||||
]
|
||||
result = snapshots_to_template_clip_configs("t1", snaps)
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].text_template == "A"
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""clip → config → snapshot → config 双向转换一致性."""
|
||||
|
||||
def test_snapshot_config_round_trip(self):
|
||||
original = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=5,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
text_template="Round trip",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"key": "value"},
|
||||
)
|
||||
snap = clip_config_to_snapshot(original)
|
||||
restored = snapshot_to_template_clip_config("t1", snap)
|
||||
assert restored.clip_type == original.clip_type
|
||||
assert restored.order == original.order
|
||||
assert restored.min_duration == original.min_duration
|
||||
assert restored.max_duration == original.max_duration
|
||||
assert restored.text_template == original.text_template
|
||||
assert restored.transition_effect == original.transition_effect
|
||||
assert restored.config == original.config
|
||||
|
||||
|
||||
class TestValidateTemplateName:
|
||||
def test_valid_name(self):
|
||||
assert validate_template_name("我的模板") == "我的模板"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert validate_template_name(" Hello ") == "Hello"
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
try:
|
||||
validate_template_name("")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
try:
|
||||
validate_template_name(" ")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_none_raises(self):
|
||||
try:
|
||||
validate_template_name(None)
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
"""转场配置领域模型单测.
|
||||
|
||||
纯逻辑模块,覆盖:TransitionType枚举、名称解析与别名、
|
||||
TransitionConfig.parse解析/降级/边界、属性与校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.transition_config import (
|
||||
CUT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
MAX_TRANSITION_DURATION,
|
||||
MIN_TRANSITION_DURATION,
|
||||
TransitionConfig,
|
||||
TransitionType,
|
||||
)
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_min_duration(self):
|
||||
assert MIN_TRANSITION_DURATION == 0.3
|
||||
|
||||
def test_max_duration(self):
|
||||
assert MAX_TRANSITION_DURATION == 2.0
|
||||
|
||||
def test_default_duration(self):
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
|
||||
def test_cut_transition(self):
|
||||
assert CUT_TRANSITION == "cut"
|
||||
|
||||
|
||||
class TestTransitionTypeEnum:
|
||||
def test_cut_value(self):
|
||||
assert TransitionType.CUT.value == "cut"
|
||||
|
||||
def test_fade_value(self):
|
||||
assert TransitionType.FADE.value == "fade"
|
||||
|
||||
def test_dissolve_value(self):
|
||||
assert TransitionType.DISSOLVE.value == "dissolve"
|
||||
|
||||
def test_slide_values(self):
|
||||
assert TransitionType.SLIDE_LEFT.value == "slideleft"
|
||||
assert TransitionType.SLIDE_RIGHT.value == "slideright"
|
||||
assert TransitionType.SLIDE_UP.value == "slideup"
|
||||
assert TransitionType.SLIDE_DOWN.value == "slidedown"
|
||||
|
||||
def test_wipe_values(self):
|
||||
assert TransitionType.WIPE_LEFT.value == "wipeleft"
|
||||
assert TransitionType.WIPE_RIGHT.value == "wiperight"
|
||||
assert TransitionType.WIPE_UP.value == "wipeup"
|
||||
assert TransitionType.WIPE_DOWN.value == "wipedown"
|
||||
|
||||
def test_zoom_value(self):
|
||||
assert TransitionType.ZOOM.value == "zoom"
|
||||
|
||||
def test_circle_crop_value(self):
|
||||
assert TransitionType.CIRCLE_CROP.value == "circlecrop"
|
||||
|
||||
def test_rect_crop_value(self):
|
||||
assert TransitionType.RECT_CROP.value == "rectcrop"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(TransitionType.FADE, str)
|
||||
assert TransitionType.FADE == "fade"
|
||||
|
||||
def test_all_supported_excludes_cut(self):
|
||||
supported = TransitionType.all_supported()
|
||||
assert "cut" not in supported
|
||||
assert "fade" in supported
|
||||
assert "dissolve" in supported
|
||||
assert len(supported) == len(TransitionType) - 1
|
||||
|
||||
def test_all_supported_is_list_of_strings(self):
|
||||
supported = TransitionType.all_supported()
|
||||
assert isinstance(supported, list)
|
||||
assert all(isinstance(s, str) for s in supported)
|
||||
|
||||
|
||||
class TestTransitionTypeIsSupported:
|
||||
def test_fade_supported(self):
|
||||
assert TransitionType.is_supported("fade") is True
|
||||
|
||||
def test_cut_not_supported(self):
|
||||
"""cut不算在supported里(is_supported只看效果类型)."""
|
||||
assert TransitionType.is_supported("cut") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert TransitionType.is_supported("FADE") is True
|
||||
assert TransitionType.is_supported("Fade") is True
|
||||
|
||||
def test_underscore_normalized(self):
|
||||
assert TransitionType.is_supported("slide_left") is True
|
||||
assert TransitionType.is_supported("SLIDE_LEFT") is True
|
||||
|
||||
def test_dash_normalized(self):
|
||||
assert TransitionType.is_supported("slide-left") is True
|
||||
|
||||
def test_circle_crop_supported(self):
|
||||
assert TransitionType.is_supported("circle_crop") is True
|
||||
assert TransitionType.is_supported("circlecrop") is True
|
||||
|
||||
def test_rect_crop_supported(self):
|
||||
assert TransitionType.is_supported("rect_crop") is True
|
||||
|
||||
def test_invalid_not_supported(self):
|
||||
assert TransitionType.is_supported("nonexistent") is False
|
||||
assert TransitionType.is_supported("") is False
|
||||
|
||||
def test_alias_dissolve(self):
|
||||
assert TransitionType.is_supported("crossfade") is True
|
||||
assert TransitionType.is_supported("crossdissolve") is True
|
||||
|
||||
def test_alias_slide(self):
|
||||
assert TransitionType.is_supported("slide") is True
|
||||
|
||||
def test_alias_wipe(self):
|
||||
assert TransitionType.is_supported("wipe") is True
|
||||
|
||||
def test_alias_zoom(self):
|
||||
assert TransitionType.is_supported("zoomin") is True
|
||||
assert TransitionType.is_supported("zoomout") is True
|
||||
|
||||
def test_alias_fade_variants(self):
|
||||
assert TransitionType.is_supported("fadein") is True
|
||||
assert TransitionType.is_supported("fadeout") is True
|
||||
assert TransitionType.is_supported("fadeblack") is True
|
||||
|
||||
def test_alias_circle(self):
|
||||
assert TransitionType.is_supported("circle") is True
|
||||
|
||||
def test_alias_rect(self):
|
||||
assert TransitionType.is_supported("rect") is True
|
||||
|
||||
|
||||
class TestTransitionConfigDefaults:
|
||||
def test_default_config(self):
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_custom_config(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=1.0)
|
||||
assert cfg.effect == "fade"
|
||||
assert cfg.duration == 1.0
|
||||
|
||||
|
||||
class TestTransitionConfigParse:
|
||||
def test_parse_none_both(self):
|
||||
cfg = TransitionConfig.parse()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_parse_none_effect(self):
|
||||
cfg = TransitionConfig.parse(duration=1.0)
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == 1.0
|
||||
|
||||
def test_parse_none_duration(self):
|
||||
cfg = TransitionConfig.parse(effect="fade")
|
||||
assert cfg.effect == "fade"
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_parse_fade(self):
|
||||
cfg = TransitionConfig.parse(effect="fade", duration=0.8)
|
||||
assert cfg.effect == "fade"
|
||||
assert cfg.duration == 0.8
|
||||
|
||||
def test_parse_dissolve(self):
|
||||
cfg = TransitionConfig.parse(effect="dissolve")
|
||||
assert cfg.effect == "dissolve"
|
||||
|
||||
def test_parse_case_insensitive(self):
|
||||
cfg = TransitionConfig.parse(effect="FADE")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_parse_with_underscore(self):
|
||||
cfg = TransitionConfig.parse(effect="slide_left")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_parse_with_dash(self):
|
||||
cfg = TransitionConfig.parse(effect="slide-right")
|
||||
assert cfg.effect == "slideright"
|
||||
|
||||
def test_parse_empty_effect(self):
|
||||
cfg = TransitionConfig.parse(effect="")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_parse_whitespace_effect(self):
|
||||
cfg = TransitionConfig.parse(effect=" ")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_parse_unsupported_effect_fallback_to_cut(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(effect="nonexistent_effect")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert "不支持的转场效果" in caplog.text
|
||||
|
||||
def test_parse_cut_effect(self):
|
||||
cfg = TransitionConfig.parse(effect="cut")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_parse_cut_uppercase(self):
|
||||
cfg = TransitionConfig.parse(effect="CUT")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_parse_duration_min_boundary(self):
|
||||
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_parse_duration_max_boundary(self):
|
||||
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_parse_duration_below_min_clamped(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(duration=0.1)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
assert "小于最小值" in caplog.text
|
||||
|
||||
def test_parse_duration_above_max_clamped(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(duration=5.0)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
assert "大于最大值" in caplog.text
|
||||
|
||||
def test_parse_duration_zero_clamped(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(duration=0.0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_parse_duration_negative_clamped(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(duration=-1.0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_parse_duration_string_valid(self):
|
||||
cfg = TransitionConfig.parse(duration="1.5")
|
||||
assert cfg.duration == 1.5
|
||||
|
||||
def test_parse_duration_string_invalid_fallback(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = TransitionConfig.parse(duration="abc")
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
assert "无效的转场时长" in caplog.text
|
||||
|
||||
def test_parse_duration_none_fallback(self):
|
||||
cfg = TransitionConfig.parse(duration=None)
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_parse_alias_crossfade(self):
|
||||
cfg = TransitionConfig.parse(effect="crossfade")
|
||||
assert cfg.effect == "dissolve" # 别名→dissolve
|
||||
|
||||
def test_parse_alias_slide(self):
|
||||
cfg = TransitionConfig.parse(effect="slide")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_parse_alias_wipe(self):
|
||||
cfg = TransitionConfig.parse(effect="wipe")
|
||||
assert cfg.effect == "wipeleft"
|
||||
|
||||
def test_parse_alias_circle(self):
|
||||
cfg = TransitionConfig.parse(effect="circle")
|
||||
assert cfg.effect == "circlecrop"
|
||||
|
||||
|
||||
class TestIsCutProperty:
|
||||
def test_cut_is_true(self):
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_fade_is_false(self):
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.is_cut is False
|
||||
|
||||
def test_default_is_cut(self):
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.is_cut is True
|
||||
|
||||
|
||||
class TestFFmpegTransitionProperty:
|
||||
def test_cut_returns_empty(self):
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.ffmpeg_transition == ""
|
||||
|
||||
def test_fade_returns_fade(self):
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
def test_dissolve_returns_dissolve(self):
|
||||
cfg = TransitionConfig(effect="dissolve")
|
||||
assert cfg.ffmpeg_transition == "dissolve"
|
||||
|
||||
def test_slide_left(self):
|
||||
cfg = TransitionConfig(effect="slideleft")
|
||||
assert cfg.ffmpeg_transition == "slideleft"
|
||||
|
||||
def test_slide_right(self):
|
||||
cfg = TransitionConfig(effect="slideright")
|
||||
assert cfg.ffmpeg_transition == "slideright"
|
||||
|
||||
def test_zoom_returns_zoomin(self):
|
||||
"""transition type是zoom,ffmpeg映射到zoomin."""
|
||||
cfg = TransitionConfig(effect="zoom")
|
||||
assert cfg.ffmpeg_transition == "zoomin"
|
||||
|
||||
def test_wipe_left(self):
|
||||
cfg = TransitionConfig(effect="wipeleft")
|
||||
assert cfg.ffmpeg_transition == "wipeleft"
|
||||
|
||||
def test_circle_crop(self):
|
||||
cfg = TransitionConfig(effect="circlecrop")
|
||||
assert cfg.ffmpeg_transition == "circlecrop"
|
||||
|
||||
def test_rect_crop(self):
|
||||
cfg = TransitionConfig(effect="rectcrop")
|
||||
assert cfg.ffmpeg_transition == "rectcrop"
|
||||
|
||||
def test_unknown_effect_fallback_to_fade(self):
|
||||
"""未知效果默认返回fade(安全降级)."""
|
||||
cfg = TransitionConfig(effect="unknown_effect")
|
||||
# _resolve_transition_enum找不到就返回FADE
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_valid_cut(self):
|
||||
cfg = TransitionConfig(effect="cut", duration=0.5)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_fade(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=1.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_duration_too_low(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=0.1)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不能小于" in msg
|
||||
|
||||
def test_duration_too_high(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=5.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不能大于" in msg
|
||||
|
||||
def test_duration_at_min(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_duration_at_max(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_unsupported_effect_invalid(self):
|
||||
cfg = TransitionConfig(effect="unknown", duration=0.5)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持的转场效果" in msg
|
||||
|
||||
def test_cut_always_valid(self):
|
||||
"""cut即使duration稍微异常也能通过?不,cut也校验duration."""
|
||||
cfg = TransitionConfig(effect="cut", duration=0.5)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
"""TTSJob 领域模型单测.
|
||||
|
||||
覆盖:状态枚举、create创建校验、状态机转换、标记方法、
|
||||
重试逻辑、属性判断、to_dict序列化。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain.tts_job import (
|
||||
TERMINAL_STATUSES,
|
||||
TTSJob,
|
||||
TTSJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestTTSJobStatus:
|
||||
def test_status_values(self):
|
||||
assert TTSJobStatus.PENDING.value == "pending"
|
||||
assert TTSJobStatus.PROCESSING.value == "processing"
|
||||
assert TTSJobStatus.COMPLETED.value == "completed"
|
||||
assert TTSJobStatus.FAILED.value == "failed"
|
||||
assert TTSJobStatus.CANCELLED.value == "cancelled"
|
||||
|
||||
def test_status_is_str_enum(self):
|
||||
assert isinstance(TTSJobStatus.PENDING, str)
|
||||
assert TTSJobStatus.PENDING == "pending"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestTTSJobCreate:
|
||||
def test_create_minimal(self):
|
||||
job = TTSJob.create(user_id="user_1", input_text="你好世界")
|
||||
assert job.user_id == "user_1"
|
||||
assert job.input_text == "你好世界"
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.id # 自动生成
|
||||
assert len(job.id) == 32 # uuid4 hex
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
job = TTSJob.create(
|
||||
user_id="user_1",
|
||||
input_text="测试文本",
|
||||
voice_id="voice_clone_123",
|
||||
voice_model="cosyvoice-300m",
|
||||
project_id="proj_456",
|
||||
voice_clone_profile_id="profile_789",
|
||||
sample_rate=16000,
|
||||
format="wav",
|
||||
max_retries=5,
|
||||
metadata={"scene": "video"},
|
||||
)
|
||||
assert job.voice_id == "voice_clone_123"
|
||||
assert job.voice_model == "cosyvoice-300m"
|
||||
assert job.project_id == "proj_456"
|
||||
assert job.voice_clone_profile_id == "profile_789"
|
||||
assert job.sample_rate == 16000
|
||||
assert job.format == "wav"
|
||||
assert job.max_retries == 5
|
||||
assert job.metadata == {"scene": "video"}
|
||||
|
||||
def test_create_defaults(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert job.voice_id == ""
|
||||
assert job.voice_model == ""
|
||||
assert job.project_id == ""
|
||||
assert job.voice_clone_profile_id == ""
|
||||
assert job.sample_rate == 22050
|
||||
assert job.format == "mp3"
|
||||
assert job.max_retries == 3
|
||||
assert job.metadata == {}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
job = TTSJob.create(
|
||||
user_id=" user_1 ",
|
||||
input_text=" 你好 ",
|
||||
voice_id=" v1 ",
|
||||
)
|
||||
assert job.user_id == "user_1"
|
||||
assert job.input_text == "你好"
|
||||
assert job.voice_id == "v1"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
try:
|
||||
TTSJob.create(user_id="", input_text="hi")
|
||||
except ValueError as e:
|
||||
assert "user_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_whitespace_user_id_raises(self):
|
||||
try:
|
||||
TTSJob.create(user_id=" ", input_text="hi")
|
||||
except ValueError as e:
|
||||
assert "user_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_empty_input_text_raises(self):
|
||||
try:
|
||||
TTSJob.create(user_id="u1", input_text="")
|
||||
except ValueError as e:
|
||||
assert "input_text" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_input_text_too_long_raises(self):
|
||||
long_text = "a" * 10001
|
||||
try:
|
||||
TTSJob.create(user_id="u1", input_text=long_text)
|
||||
except ValueError as e:
|
||||
assert "10000" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_input_text_exactly_10000_ok(self):
|
||||
text = "a" * 10000
|
||||
job = TTSJob.create(user_id="u1", input_text=text)
|
||||
assert job.input_text == text
|
||||
|
||||
def test_create_invalid_format_raises(self):
|
||||
try:
|
||||
TTSJob.create(user_id="u1", input_text="hi", format="ogg")
|
||||
except ValueError as e:
|
||||
assert "不支持的输出格式" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_mp3_format_ok(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", format="mp3")
|
||||
assert job.format == "mp3"
|
||||
|
||||
def test_create_wav_format_ok(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", format="wav")
|
||||
assert job.format == "wav"
|
||||
|
||||
def test_create_pcm_format_ok(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", format="pcm")
|
||||
assert job.format == "pcm"
|
||||
|
||||
def test_create_sets_created_at(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= job.created_at <= after
|
||||
assert before <= job.updated_at <= after
|
||||
|
||||
def test_create_metadata_none_defaults_to_empty_dict(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", metadata=None)
|
||||
assert job.metadata == {}
|
||||
|
||||
|
||||
class TestStatusProperties:
|
||||
def test_is_terminal_pending(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert job.is_terminal is False
|
||||
|
||||
def test_is_terminal_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
assert job.is_terminal is False
|
||||
|
||||
def test_is_terminal_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://example.com/audio.mp3")
|
||||
assert job.is_terminal is True
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_failed("network error")
|
||||
assert job.is_terminal is True
|
||||
|
||||
def test_is_terminal_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
assert job.is_terminal is True
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err")
|
||||
assert job.is_retryable is True
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=1)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err1")
|
||||
job.prepare_retry()
|
||||
job.mark_processing()
|
||||
job.mark_failed("err2")
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_is_retryable_pending(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_is_retryable_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://ex.com/a.mp3")
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_is_completed_success(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://ex.com/a.mp3")
|
||||
assert job.is_completed is True
|
||||
|
||||
def test_is_completed_no_url(self):
|
||||
"""completed状态但没有output_url的情况."""
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.status = TTSJobStatus.COMPLETED # 手动设状态,无url
|
||||
job.output_audio_url = ""
|
||||
assert job.is_completed is False
|
||||
|
||||
|
||||
class TestTransitionTo:
|
||||
def test_pending_to_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_processing_to_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
def test_processing_to_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
try:
|
||||
job.transition_to(TTSJobStatus.COMPLETED) # pending→completed 非法
|
||||
except ValueError as e:
|
||||
assert "非法状态转换" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_completed_to_pending_invalid(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
try:
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
except ValueError as e:
|
||||
assert "非法状态转换" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_transition_with_string_status(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to("processing")
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_transition_invalid_string_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
try:
|
||||
job.transition_to("invalid_status")
|
||||
except ValueError as e:
|
||||
assert "无效状态" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
old_updated = job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.updated_at > old_updated
|
||||
|
||||
def test_cancelled_no_outgoing_transitions(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
try:
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
class TestMarkMethods:
|
||||
def test_mark_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
assert job.started_at is not None
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_processing_clears_error(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
job.error_message = "old error"
|
||||
job.retry_count = 1
|
||||
# 先回到pending再mark_processing
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
job.mark_processing()
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_completed_success(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
"https://ex.com/out.mp3",
|
||||
output_audio_key="audio/123.mp3",
|
||||
duration=5.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "https://ex.com/out.mp3"
|
||||
assert job.output_audio_key == "audio/123.mp3"
|
||||
assert job.duration == 5.5
|
||||
assert job.file_size == 102400
|
||||
assert job.completed_at is not None
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_completed_empty_url_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
try:
|
||||
job.mark_completed("")
|
||||
except ValueError as e:
|
||||
assert "output_audio_url" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_mark_completed_whitespace_url_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
try:
|
||||
job.mark_completed(" ")
|
||||
except ValueError as e:
|
||||
assert "output_audio_url" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_failed("connection timeout")
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert job.error_message == "connection timeout"
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_cancelled()
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_prepare_retry_success(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err")
|
||||
job.prepare_retry()
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
def test_prepare_retry_multiple_times(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
for i in range(3):
|
||||
job.mark_processing()
|
||||
job.mark_failed(f"err_{i}")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == i + 1
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
# 第4次应该失败
|
||||
job.mark_processing()
|
||||
job.mark_failed("err_3")
|
||||
try:
|
||||
job.prepare_retry()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError on 4th retry")
|
||||
|
||||
def test_prepare_retry_not_failed_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
try:
|
||||
job.prepare_retry() # pending状态不能重试
|
||||
except ValueError as e:
|
||||
assert "不可重试" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_prepare_retry_completed_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://ex.com/a.mp3")
|
||||
try:
|
||||
job.prepare_retry()
|
||||
except ValueError as e:
|
||||
assert "不可重试" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_prepare_retry_resets_timestamps(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://ex.com/a.mp3")
|
||||
# 手动改状态到failed来测试
|
||||
job.status = TTSJobStatus.FAILED
|
||||
job.retry_count = 0
|
||||
job.prepare_retry()
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_to_dict_basic(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
d = job.to_dict()
|
||||
assert d["id"] == job.id
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["input_text"] == "hi"
|
||||
assert d["status"] == "pending"
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_completed"] is False
|
||||
|
||||
def test_to_dict_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("https://ex.com/a.mp3", duration=3.0, file_size=5000)
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "completed"
|
||||
assert d["output_audio_url"] == "https://ex.com/a.mp3"
|
||||
assert d["duration"] == 3.0
|
||||
assert d["file_size"] == 5000
|
||||
assert d["is_completed"] is True
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
assert d["created_at"] is not None
|
||||
assert d["updated_at"] is not None
|
||||
|
||||
def test_to_dict_failed_retryable(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("timeout")
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "timeout"
|
||||
assert d["is_retryable"] is True
|
||||
|
||||
def test_to_dict_datetime_isoformat(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
d = job.to_dict()
|
||||
# ISO格式校验
|
||||
parsed = datetime.fromisoformat(d["created_at"])
|
||||
assert parsed.tzinfo is not None
|
||||
|
||||
def test_to_dict_none_timestamps(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
d = job.to_dict()
|
||||
assert d["started_at"] is None
|
||||
assert d["completed_at"] is None
|
||||
|
||||
def test_to_dict_metadata(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", metadata={"key": "value", "num": 42})
|
||||
d = job.to_dict()
|
||||
assert d["metadata"] == {"key": "value", "num": 42}
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
"""VoiceCloneProfile 音色克隆档案单测.
|
||||
|
||||
覆盖:状态枚举、create创建校验、状态机转换、标记方法、
|
||||
重试逻辑、属性判断、to_dict序列化。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain.voice_clone_profile import (
|
||||
TERMINAL_STATUSES,
|
||||
VoiceCloneProfile,
|
||||
VoiceCloneStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceCloneStatus:
|
||||
def test_status_values(self):
|
||||
assert VoiceCloneStatus.PENDING.value == "pending"
|
||||
assert VoiceCloneStatus.PROCESSING.value == "processing"
|
||||
assert VoiceCloneStatus.READY.value == "ready"
|
||||
assert VoiceCloneStatus.FAILED.value == "failed"
|
||||
assert VoiceCloneStatus.DISABLED.value == "disabled"
|
||||
|
||||
def test_status_is_str_enum(self):
|
||||
assert isinstance(VoiceCloneStatus.PENDING, str)
|
||||
assert VoiceCloneStatus.PENDING == "pending"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert VoiceCloneStatus.READY in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestVoiceCloneProfileCreate:
|
||||
def test_create_minimal(self):
|
||||
profile = VoiceCloneProfile.create(user_id="user_1", name="我的音色")
|
||||
assert profile.user_id == "user_1"
|
||||
assert profile.name == "我的音色"
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.id and len(profile.id) == 32
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user_1",
|
||||
name="甜美女声",
|
||||
description="适合播客的女声",
|
||||
source_audio_url="https://ex.com/source.wav",
|
||||
voice_model="cosyvoice-300m",
|
||||
language="en-US",
|
||||
gender="female",
|
||||
max_retries=5,
|
||||
metadata={"source": "upload"},
|
||||
)
|
||||
assert profile.description == "适合播客的女声"
|
||||
assert profile.source_audio_url == "https://ex.com/source.wav"
|
||||
assert profile.voice_model == "cosyvoice-300m"
|
||||
assert profile.language == "en-US"
|
||||
assert profile.gender == "female"
|
||||
assert profile.max_retries == 5
|
||||
assert profile.metadata == {"source": "upload"}
|
||||
|
||||
def test_create_defaults(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="t")
|
||||
assert profile.description == ""
|
||||
assert profile.source_audio_url == ""
|
||||
assert profile.voice_model == ""
|
||||
assert profile.language == "zh-CN"
|
||||
assert profile.gender == "unknown"
|
||||
assert profile.max_retries == 3
|
||||
assert profile.metadata == {}
|
||||
assert profile.voice_id == ""
|
||||
assert profile.error_message == ""
|
||||
assert profile.retry_count == 0
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=" user_1 ",
|
||||
name=" 测试音色 ",
|
||||
description=" desc ",
|
||||
language=" en-US ",
|
||||
gender=" MALE ",
|
||||
)
|
||||
assert profile.user_id == "user_1"
|
||||
assert profile.name == "测试音色"
|
||||
assert profile.description == "desc"
|
||||
assert profile.language == "en-US"
|
||||
assert profile.gender == "male" # lowercased
|
||||
|
||||
def test_create_gender_lowercased(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="t", gender="Female")
|
||||
assert profile.gender == "female"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
try:
|
||||
VoiceCloneProfile.create(user_id="", name="t")
|
||||
except ValueError as e:
|
||||
assert "user_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_whitespace_user_id_raises(self):
|
||||
try:
|
||||
VoiceCloneProfile.create(user_id=" ", name="t")
|
||||
except ValueError as e:
|
||||
assert "user_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
try:
|
||||
VoiceCloneProfile.create(user_id="u1", name="")
|
||||
except ValueError as e:
|
||||
assert "name" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_name_too_long_raises(self):
|
||||
long_name = "a" * 101
|
||||
try:
|
||||
VoiceCloneProfile.create(user_id="u1", name=long_name)
|
||||
except ValueError as e:
|
||||
assert "100" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_create_name_exactly_100_ok(self):
|
||||
name = "a" * 100
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name=name)
|
||||
assert profile.name == name
|
||||
|
||||
def test_create_sets_created_at(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="t")
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= profile.created_at <= after
|
||||
assert before <= profile.updated_at <= after
|
||||
|
||||
def test_create_metadata_none_defaults_to_empty(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="t", metadata=None)
|
||||
assert profile.metadata == {}
|
||||
|
||||
|
||||
class TestStatusProperties:
|
||||
def test_is_terminal_pending(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
assert p.is_terminal is False
|
||||
|
||||
def test_is_terminal_processing(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
assert p.is_terminal is False
|
||||
|
||||
def test_is_terminal_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_123")
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_is_terminal_disabled_from_pending(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_disabled()
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
assert p.is_retryable is True
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=1)
|
||||
p.mark_processing()
|
||||
p.mark_failed("err1")
|
||||
p.prepare_retry()
|
||||
p.mark_processing()
|
||||
p.mark_failed("err2")
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_retryable_pending(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_retryable_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("v1")
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_ready_success(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_123")
|
||||
assert p.is_ready is True
|
||||
|
||||
def test_is_ready_no_voice_id(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.status = VoiceCloneStatus.READY # 手动设状态,无voice_id
|
||||
p.voice_id = ""
|
||||
assert p.is_ready is False
|
||||
|
||||
|
||||
class TestTransitionTo:
|
||||
def test_pending_to_processing(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_pending_to_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_processing_to_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_processing_to_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
|
||||
def test_ready_to_disabled(self):
|
||||
"""已就绪音色可以被禁用."""
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
try:
|
||||
p.transition_to(VoiceCloneStatus.READY) # pending→ready 非法
|
||||
except ValueError as e:
|
||||
assert "非法状态转换" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_disabled_no_outgoing(self):
|
||||
"""disabled状态不能转换到任何状态."""
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
try:
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_transition_with_string(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to("processing")
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_transition_invalid_string_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
try:
|
||||
p.transition_to("invalid_state")
|
||||
except ValueError as e:
|
||||
assert "无效状态" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
old_updated = p.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.updated_at > old_updated
|
||||
|
||||
|
||||
class TestMarkMethods:
|
||||
def test_mark_processing(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_mark_processing_clears_error(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
p.error_message = "old error"
|
||||
p.retry_count = 1
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
p.mark_processing()
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_mark_ready_success(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_id_123")
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
assert p.voice_id == "voice_id_123"
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_mark_ready_strips_whitespace(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready(" voice_456 ")
|
||||
assert p.voice_id == "voice_456"
|
||||
|
||||
def test_mark_ready_empty_voice_id_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
try:
|
||||
p.mark_ready("")
|
||||
except ValueError as e:
|
||||
assert "voice_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_mark_ready_whitespace_voice_id_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
try:
|
||||
p.mark_ready(" ")
|
||||
except ValueError as e:
|
||||
assert "voice_id" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_mark_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_failed("训练超时")
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
assert p.error_message == "训练超时"
|
||||
|
||||
def test_mark_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_disabled()
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_prepare_retry_success(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
p.prepare_retry()
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
assert p.retry_count == 1
|
||||
assert p.error_message == ""
|
||||
assert p.voice_id == ""
|
||||
|
||||
def test_prepare_retry_multiple_times(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3)
|
||||
for i in range(3):
|
||||
p.mark_processing()
|
||||
p.mark_failed(f"err_{i}")
|
||||
p.prepare_retry()
|
||||
assert p.retry_count == i + 1
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
# 第4次应该失败
|
||||
p.mark_processing()
|
||||
p.mark_failed("err_3")
|
||||
try:
|
||||
p.prepare_retry()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError on 4th retry")
|
||||
|
||||
def test_prepare_retry_not_failed_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
try:
|
||||
p.prepare_retry()
|
||||
except ValueError as e:
|
||||
assert "不可重试" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_prepare_retry_ready_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("v1")
|
||||
try:
|
||||
p.prepare_retry()
|
||||
except ValueError as e:
|
||||
assert "不可重试" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_prepare_retry_clears_voice_id(self):
|
||||
p = VoiceCloneProfile.create(user_id="u", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("partial_id")
|
||||
# 手动改到failed来测试
|
||||
p.status = VoiceCloneStatus.FAILED
|
||||
p.retry_count = 0
|
||||
p.prepare_retry()
|
||||
assert p.voice_id == ""
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_to_dict_basic(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="测试音色")
|
||||
d = p.to_dict()
|
||||
assert d["id"] == p.id
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["name"] == "测试音色"
|
||||
assert d["status"] == "pending"
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_ready"] is False
|
||||
|
||||
def test_to_dict_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="t")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_42")
|
||||
d = p.to_dict()
|
||||
assert d["status"] == "ready"
|
||||
assert d["voice_id"] == "voice_42"
|
||||
assert d["is_ready"] is True
|
||||
assert d["created_at"] is not None
|
||||
assert d["updated_at"] is not None
|
||||
|
||||
def test_to_dict_failed_retryable(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="t", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("timeout")
|
||||
d = p.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "timeout"
|
||||
assert d["is_retryable"] is True
|
||||
|
||||
def test_to_dict_datetime_isoformat(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="t")
|
||||
d = p.to_dict()
|
||||
parsed = datetime.fromisoformat(d["created_at"])
|
||||
assert parsed.tzinfo is not None
|
||||
|
||||
def test_to_dict_metadata(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="t", metadata={"key": "val", "num": 42})
|
||||
d = p.to_dict()
|
||||
assert d["metadata"] == {"key": "val", "num": 42}
|
||||
|
||||
def test_to_dict_all_fields_present(self):
|
||||
p = VoiceCloneProfile.create(
|
||||
user_id="u1",
|
||||
name="t",
|
||||
description="d",
|
||||
source_audio_url="https://ex.com/s.wav",
|
||||
voice_model="cosyvoice",
|
||||
language="zh-CN",
|
||||
gender="male",
|
||||
)
|
||||
d = p.to_dict()
|
||||
expected_keys = {
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"source_audio_url",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"language",
|
||||
"gender",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"is_retryable",
|
||||
"is_ready",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert set(d.keys()) == expected_keys
|
||||
@@ -468,7 +468,9 @@ class TestSubtitleStyle:
|
||||
assert style3.alignment == 5
|
||||
|
||||
def test_9grid_positions(self):
|
||||
from video_processing.subtitle_render_engine import POSITION_ALIGNMENT, SubtitleStyle
|
||||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||||
|
||||
from packages.domain.subtitle_style import POSITION_ALIGNMENT
|
||||
|
||||
for pos, align in POSITION_ALIGNMENT.items():
|
||||
style = SubtitleStyle.from_dict({"position": pos})
|
||||
|
||||
Reference in New Issue
Block a user