Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot 73cb7e1479 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m34s
AI Code Review / AI Code Review (pull_request) Successful in 1m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m40s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m47s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m6s
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 14s
2026-07-29 02:35:12 +00:00
xiaoxia 6a07b43d7e fix: 修复develop分支Unit Tests 14个失败
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m14s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m5s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
YAML bug期间CI不触发,代码重构后测试未同步更新,导致14个单元测试失败。

修复内容:
1. video_processing/url_security.py: ALLOWED_VIDEO_MIME_TYPES已移至packages.domain,
   兼容层导入路径错误导致整个模块加载失败,连锁引发多个测试ImportError
2. test_multi_track_subtitle_concat.py: POSITION_ALIGNMENT已移至packages.domain.subtitle_style
3. test_classification.py: AssetLibraryKind新增IMAGE枚举,测试断言从2改为3
2026-07-29 10:30:18 +08:00
98 changed files with 4520 additions and 15765 deletions
-3
View File
@@ -1091,8 +1091,6 @@ 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 \
@@ -1777,7 +1775,6 @@ jobs:
# 后端检查
REQUIRED_BACKEND=(
"unit-tests:$RESULT_UNIT_TESTS"
"integration-tests:$RESULT_INTEGRATION"
)
# 前端检查
@@ -1,4 +0,0 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -1,58 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -1,53 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -1,86 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -1,4 +0,0 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -1,58 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -1,53 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -1,86 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -1,8 +1,238 @@
/**
* @deprecated 请从 ./batch/ 目录导入子模块
* 保持向后兼容,re-export 所有批量操作 Hook
*/
export { useBatchDelete } from "./batch/useBatchDelete"
export { useBatchTag } from "./batch/useBatchTag"
export { useBatchClassify } from "./batch/useBatchClassify"
export { useBatchMark } from "./batch/useBatchMark"
import { useState, useCallback } from "react"
import { message } from "antd"
import {
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { SmartViewType } from "../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "./constants"
/* ── 批量删除 ── */
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
/* ── 批量打标签 ── */
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
/* ── 批量改分类 ── */
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
/* ── 批量智能标记 ── */
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -12,7 +12,7 @@ import {
useBatchTag,
useBatchClassify,
useBatchMark,
} from "./asset-operations/batch-operations"
} from "./asset-operations/batchOperations"
import type { BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../components/BatchMarkModal"
+58 -14
View File
@@ -1,16 +1,18 @@
/**
* 字幕样式配置面板 — 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
@@ -38,6 +40,7 @@ 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>
@@ -52,11 +55,26 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
{config.enabled && (
<>
{/* ── 模式切换 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
<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>
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
@@ -70,6 +88,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
@@ -82,6 +101,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
@@ -93,6 +113,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -104,24 +125,46 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 字幕位置 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<SubtitlePositionSelector
position={config.position}
onPositionChange={(position) => update({ position })}
/>
<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>
</div>
{/* ── 描边 / 阴影 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<SubtitleEffectButtons
stroke={config.stroke}
shadow={config.shadow}
onStrokeChange={(stroke) => update({ stroke })}
onShadowChange={(shadow) => update({ shadow })}
/>
<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>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -136,6 +179,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 预览 ── */}
<SubtitlePreview config={config} />
</>
)}
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,25 +0,0 @@
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>
)
}
@@ -1,27 +0,0 @@
import React from "react"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
interface SubtitlePositionSelectorProps {
position: SubtitleStyleConfig["position"]
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
}
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
position,
onPositionChange,
}) => {
return (
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
>
{opt.label}
</button>
))}
</div>
)
}
@@ -1,76 +0,0 @@
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
}
@@ -0,0 +1,309 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import { getEditPlan } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../constants"
import type { TitleSettings } from "../types"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* ── 预置音色 API ── */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -1,198 +0,0 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import type { CoverConfig } from "../../../editing-planner/types"
import type { PresetVoiceItem } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../../constants"
import type { TitleSettings } from "../../types"
import { useTemplateSelection } from "./useTemplateSelection"
import { useTitleCoverSync } from "./useTitleCoverSync"
import { useVoiceState } from "./useVoiceState"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* ── 模板切换时同步标题/封面 ── */
useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
})
/* ── 配音状态 ── */
const {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
} = useVoiceState()
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
})
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -1,110 +0,0 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../../editing-planner/types"
import type { TitleSettings } from "../../types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
interface UsePlanConfigLoaderOptions {
editPlanId: string | null
planConfigStr: string | null
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
setSelectedMaterials: (ids: string[]) => void
}
/**
* 从 URL 参数或编辑计划 ID 加载表单配置
*/
export function usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
}: UsePlanConfigLoaderOptions) {
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev: TitleSettings) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr, setTitleSettings, setSelectedMaterials])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr, setTitleSettings, setCoverSettings, setSelectedMaterials])
}
@@ -1,22 +0,0 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
export function useTemplateSelection() {
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
return { selectedTemplate, setSelectedTemplate, userTemplates }
}
@@ -1,47 +0,0 @@
import { useEffect } from "react"
import type { TitleSettings } from "../../types"
import type { CoverConfig } from "../../../editing-planner/types"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseTitleCoverSyncOptions {
selectedTemplate: string
userTemplates: EditingTemplate[]
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
/**
* 当选中模板变化时,自动同步标题和封面配置
*/
export function useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
}: UseTitleCoverSyncOptions) {
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
}
@@ -1,30 +0,0 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
export function useVoiceState() {
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* 预置音色 API */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
return {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
}
}
@@ -9,11 +9,27 @@ 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 { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
import { buildVoiceConfig } from "./generate-video/voiceConfig"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate } = props
const {
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
@@ -42,9 +58,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/* ── 生成视频 ── */
const generate = useCallback(async () => {
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
message.warning(errorMsg)
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
return
}
@@ -55,13 +78,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
const payload = buildEditPlanPayload(props)
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
// 获取或创建草稿
await getEditPlan(selectedTemplate)
// 更新草稿内容 + 切换到 editing 状态
await updateEditPlan(selectedTemplate, payload)
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 generateEditPlan(selectedTemplate)
startPolling()
@@ -74,7 +125,25 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [props, selectedTemplate, clearTimer, startPolling])
}, [
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
selectedTemplate,
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
clearTimer,
startPolling,
])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
@@ -1,4 +1,4 @@
import React, { useEffect } from "react"
import React, { useRef, useState, useEffect, useCallback } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
@@ -11,7 +11,6 @@ import {
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
import { useVideoPlayer } from "../hooks/useVideoPlayer"
interface VideoPlayerProps {
product: ProductItem
@@ -28,19 +27,52 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onShare,
onViewDetail,
}) => {
const {
videoRef,
progressRef,
isPlaying,
currentTime,
duration,
progress,
togglePlay,
handleSeek,
} = useVideoPlayer()
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(product.duration)
const hasVideo = !!product.videoUrl
const displayDuration = duration || product.duration
/** 播放/暂停 */
const handlePlayPause = useCallback(() => {
const video = videoRef.current
if (!video) return
if (isPlaying) {
video.pause()
} else {
video.play().catch(() => {})
}
setIsPlaying(!isPlaying)
}, [isPlaying])
/** 视频事件监听 */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onTime = () => setCurrentTime(video.currentTime)
const onDur = () => setDuration(video.duration || product.duration)
const onEnd = () => setIsPlaying(false)
video.addEventListener("timeupdate", onTime)
video.addEventListener("loadedmetadata", onDur)
video.addEventListener("ended", onEnd)
return () => {
video.removeEventListener("timeupdate", onTime)
video.removeEventListener("loadedmetadata", onDur)
video.removeEventListener("ended", onEnd)
}
}, [product.duration])
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * duration
setCurrentTime(newTime)
if (videoRef.current) videoRef.current.currentTime = newTime
}
/** ESC 关闭 */
useEffect(() => {
@@ -51,6 +83,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener("keydown", handleKey)
}, [onClose])
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
@@ -80,7 +114,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={togglePlay}>
<button className="xx-player-play-btn" onClick={handlePlayPause}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -91,12 +125,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(displayDuration)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
@@ -1,5 +1,8 @@
import { useMemo, useState } from "react"
import type { ProductItem } from "../../types"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import type { ProductItem } from "../types"
import { mapApiProduct } from "../utils"
/** 筛选选项类型 */
export interface Filters {
@@ -24,7 +27,32 @@ const getProjectOptions = (products: ProductItem[]) =>
label: name as string,
}))
export const useProductFiltering = (products: ProductItem[]) => {
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(() => {
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
// 按创建时间倒序(最新的在最前面),无时间的排最后
return list.sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
}, [apiProducts])
/* 筛选 */
const [searchText, setSearchText] = useState("")
const [filterStatus, setFilterStatus] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
@@ -32,6 +60,12 @@ export const useProductFiltering = (products: ProductItem[]) => {
const [filterProject, setFilterProject] = useState<string>("all")
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
/* 批量选择 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
/* 派生数据 */
const batchMode = selectedIds.size > 0
const filteredProducts = useMemo(() => {
let list = products
@@ -108,7 +142,42 @@ export const useProductFiltering = (products: ProductItem[]) => {
const projectOptions = useMemo(() => getProjectOptions(products), [products])
/* 全选 */
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
}
}
/* 切换单个选择 */
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const clearSelection = () => setSelectedIds(new Set())
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
@@ -121,7 +190,13 @@ export const useProductFiltering = (products: ProductItem[]) => {
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -1,93 +0,0 @@
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import { mapApiProduct } from "../../utils"
import { useProductFiltering } from "./useProductFiltering"
import { useBatchSelection } from "./useBatchSelection"
export type { Filters } from "./useProductFiltering"
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(
() =>
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
}),
[apiProducts],
)
/* 筛选 */
const {
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
} = useProductFiltering(products)
/* 批量选择 */
const {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
} = useBatchSelection(filteredProducts)
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -1,52 +0,0 @@
import { useState, useCallback } from "react"
import type { ProductItem } from "../../types"
export const useBatchSelection = (filteredProducts: ProductItem[]) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const batchMode = selectedIds.size > 0
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = useCallback(() => {
if (allSelected) {
// 仅取消选中当前可见的项,保留筛选外的选中状态
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.delete(p.id))
return next
})
} else {
// 选中所有当前可见项
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.add(p.id))
return next
})
}
}, [allSelected, filteredProducts])
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
return {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
+123 -5
View File
@@ -1,8 +1,11 @@
import React from "react"
import { Table } from "antd"
import type { TaskItem } from "@/api/tasks"
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
import { formatDuration, formatTime } from "../utils"
import { TaskErrorDetail } from "./TaskErrorDetail"
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
interface TaskTableProps {
dataSource: TaskItem[]
@@ -21,6 +24,7 @@ interface TaskTableProps {
/**
* 任务列表表格
* 含列定义、分页、展开行
*/
export const TaskTable: React.FC<TaskTableProps> = ({
dataSource,
@@ -36,7 +40,116 @@ export const TaskTable: React.FC<TaskTableProps> = ({
onRetry,
onViewDetail,
}) => {
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
return (
<Table
@@ -65,7 +178,12 @@ export const TaskTable: React.FC<TaskTableProps> = ({
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: <TaskEmptyState />,
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
)
@@ -1,9 +0,0 @@
import React from "react"
import { ClockCircleOutlined } from "@ant-design/icons"
export const TaskEmptyState: React.FC = () => (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
)
@@ -1,2 +0,0 @@
export { useTaskTableColumns } from "./useTaskTableColumns"
export { TaskEmptyState } from "./TaskEmptyState"
@@ -1,128 +0,0 @@
import { Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
import { formatDuration, formatTime } from "../../utils"
interface UseTaskTableColumnsOptions {
retryLoading: boolean
onRetry: (id: string) => void
onViewDetail: (record: TaskItem) => void
}
export function useTaskTableColumns({
retryLoading,
onRetry,
onViewDetail,
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
return [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
}
@@ -0,0 +1,174 @@
import { useMemo, useState, useCallback } from "react"
import { message } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
export const useTitleLibrary = () => {
const queryClient = useQueryClient()
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -1,55 +0,0 @@
import { useTitleFilters } from "./useTitleFilters"
import { useTitleMutations } from "./useTitleMutations"
import { useTitleData } from "./useTitleData"
import { useTitleActions } from "./useTitleActions"
export const useTitleLibrary = () => {
/* 数据获取与派生 */
const { titles, categories, activeCatId, activeCategory, setActiveCatId } = useTitleData()
/* 筛选 */
const {
searchText,
filterType,
filterIndustry,
filterFrequency,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
filteredTitles,
} = useTitleFilters(titles, categories, activeCatId, activeCategory)
/* CRUD mutations */
const { createMutation, updateMutation, deleteMutation } = useTitleMutations()
/* 操作 handlers */
const { handleToggleFavorite, handleCopy, handleDelete } = useTitleActions(deleteMutation)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -1,34 +0,0 @@
import { useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleData } from "../../types/titleLibrary"
import { copyToClipboard } from "../../utils/titleLibrary"
export const useTitleActions = (
deleteMutation: UseMutationResult<void, Error, string, unknown>,
) => {
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
},
[deleteMutation],
)
return { handleToggleFavorite, handleCopy, handleDelete }
}
@@ -1,47 +0,0 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import type { TitleData, CategoryItem } from "../../types/titleLibrary"
import { toTitleData } from "../../utils/titleLibrary"
import { ALL_CATEGORY_ID } from "../../constants/titleLibrary"
export const useTitleData = () => {
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
const activeCategory = categories.find((c) => c.id === activeCatId)
return {
titles,
categories,
activeCatId,
activeCategory,
setActiveCatId,
}
}
@@ -1,77 +0,0 @@
import { useMemo, useState } from "react"
import type { TitleData, CategoryItem, Frequency } from "../../types/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../../constants/titleLibrary"
export const useTitleFilters = (
titles: TitleData[],
_categories: CategoryItem[],
activeCatId: string,
activeCategory: CategoryItem | undefined,
) => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
return {
searchText,
setSearchText,
filterType,
setFilterType,
filterIndustry,
setFilterIndustry,
filterFrequency,
setFilterFrequency,
filteredTitles,
}
}
@@ -1,34 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createTitle, updateTitle, deleteTitle } from "@/api/titles"
export const useTitleMutations = () => {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
message.success("标题已删除")
},
onError: () => message.error("删除标题失败"),
})
return { createMutation, updateMutation, deleteMutation }
}
@@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect } from "react"
import { useRef, useCallback } from "react"
interface UseRowProgressOptions {
duration: number
@@ -7,22 +7,6 @@ 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>) => {
@@ -32,7 +16,6 @@ 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)
}
@@ -41,26 +24,15 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
cleanupListeners()
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
// 先清理旧的,再添加新的
cleanupListeners()
listenersRef.current.move = handleMove
listenersRef.current.up = handleUp
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
},
[duration, onSeek, cleanupListeners],
[duration, onSeek],
)
// 组件卸载时清理事件监听器
useEffect(() => {
return () => {
cleanupListeners()
}
}, [cleanupListeners])
return { progressRef, handleMouseDown }
}
@@ -36,10 +36,6 @@ import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
@@ -65,9 +65,6 @@ 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"
@@ -38,13 +38,7 @@ describe("GeneratePage module smoke test", () => {
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
import "@/pages/generate/hooks/useGenerateFormState/useTitleCoverSync"
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
import "@/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader"
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 { 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"
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"
describe("generate-video module smoke test", () => {
it("should load all generate-video modules", () => {
expect(typeof getGenerationPhase).toBe("function")
expect(typeof extractBackendError).toBe("function")
expect(typeof buildVoiceConfig).toBe("function")
expect(PHASE_ORDER.length).toBeGreaterThan(0)
expect(typeof extractErrorMessage).toBe("function")
expect(typeof getDefaultVoiceConfig).toBe("function")
})
})
@@ -21,10 +21,7 @@ import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductList/useProductFiltering"
import "@/pages/products/hooks/useProductList/useBatchSelection"
import "@/pages/products/hooks/useProductActions"
import "@/pages/products/hooks/useVideoPlayer"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
@@ -1,17 +0,0 @@
/**
* Tasks 模块 smoke test
* 建立依赖链,确保 vitest related 能匹配到 tasks 目录下的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/tasks/components/TaskTable"
import "@/pages/tasks/components/task-table/useTaskTableColumns"
import "@/pages/tasks/components/task-table/TaskEmptyState"
import "@/pages/tasks/components/TaskFilterBar"
import "@/pages/tasks/components/TaskErrorDetail"
describe("Tasks module smoke test", () => {
it("should load all task modules", () => {
expect(true).toBe(true)
})
})
@@ -1,29 +0,0 @@
/**
* TitleLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* titles 目录下所有文件的改动
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/titles/TitleLibrary"
// Hooks
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleLibrary/useTitleData"
import "@/pages/titles/hooks/useTitleLibrary/useTitleFilters"
import "@/pages/titles/hooks/useTitleLibrary/useTitleMutations"
import "@/pages/titles/hooks/useTitleLibrary/useTitleActions"
// 类型与常量
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
// 工具函数
import "@/pages/titles/utils/titleLibrary"
describe("TitleLibrary module smoke test", () => {
it("should load all title modules", () => {
expect(true).toBe(true)
})
})
@@ -20,8 +20,6 @@ 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"
+1 -2
View File
@@ -8,11 +8,10 @@ packages / DB 等重依赖。
"""
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
from . import dedup_helpers, ffmpeg_utils, oss_helpers, url_security
from . import dedup_helpers, ffmpeg_utils, oss_helpers
__all__ = [
"ffmpeg_utils",
"oss_helpers",
"dedup_helpers",
"url_security",
]
+2 -34
View File
@@ -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,35 +70,3 @@ class SpeedEngine:
) -> float:
"""从 clip config 中解析 playback_speed0 或缺失则使用全局速度."""
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,
)
+3 -1
View File
@@ -4,12 +4,14 @@
所有符号均从该模块重新导出,请新代码直接 import packages.shared.url_security。
"""
from packages.domain.url_security import ( # noqa: F401
ALLOWED_VIDEO_MIME_TYPES,
)
from packages.shared.url_security import ( # noqa: F401
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
ALLOWED_PORTS,
ALLOWED_SCHEMES,
ALLOWED_VIDEO_MIME_TYPES,
DEFAULT_MAX_DOWNLOAD_SIZE,
MAX_URL_LENGTH,
TRUSTED_DOMAINS,
@@ -23,23 +23,6 @@ class InMemoryUserRepository(UserRepository):
def save(self, user: User) -> None:
"""保存用户"""
# 如果是更新,先清理旧索引
old = self._users.get(user.id)
if old:
self._email_index.pop(old.email.lower(), None)
if old.username:
self._username_index.pop(old.username.lower(), None)
if old.email_verification_token:
self._verification_token_index.pop(old.email_verification_token, None)
if old.password_reset_token:
self._reset_token_index.pop(old.password_reset_token, None)
if old.wechat_openid:
self._wechat_openid_index.pop(old.wechat_openid, None)
if old.wechat_unionid:
self._wechat_unionid_index.pop(old.wechat_unionid, None)
if old.phone:
self._phone_index.pop(old.phone, None)
self._users[user.id] = user
self._email_index[user.email.lower()] = user.id
if user.username:
+1 -3
View File
@@ -569,9 +569,7 @@ class CosyVoiceService:
清洗后的 prefix
"""
# 只保留字母和数字
import re
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
cleaned = "".join(c for c in name if c.isalnum())
# 最多10字符
cleaned = cleaned[:10]
# 如果清洗后为空,用默认值
-2
View File
@@ -26,7 +26,6 @@ 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
@@ -43,7 +42,6 @@ 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
-2
View File
@@ -102,5 +102,3 @@ ignore = [
"apps/api/app/middleware/auth.py" = ["ALL"]
"apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"]
"tests/**" = ["B011"]
+3 -26
View File
@@ -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, pr_days=0):
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
"""
清理单个仓库
@@ -441,28 +441,8 @@ 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,降级为按pr_days天保留(默认7天)
# 无Gitea token,降级为按7天保留
print(" 模式: 按时间保留7天(无Gitea token降级)")
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
for tag in pr_tags_list:
@@ -581,9 +561,6 @@ 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
@@ -656,7 +633,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, pr_days=args.pr_days
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
)
total_tags += count
total_deleted += deleted
+10 -13
View File
@@ -32,13 +32,7 @@ def api_request(token, repo, endpoint, method="GET", data=None):
resp = urllib.request.urlopen(req, context=ctx)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
body = e.read().decode()
if body:
try:
return json.loads(body), e.code
except json.JSONDecodeError:
return {"error": body}, e.code
return {"error": str(e)}, e.code
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
def get_open_prs(token, repo, base="develop"):
@@ -275,9 +269,13 @@ def main():
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
"CI/CD Pipeline / CI Gate (pull_request)",
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
"CI/CD Pipeline / PR Build API Image (pull_request)",
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
"CI/CD Pipeline / PR Build Web Image (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
@@ -286,8 +284,7 @@ def main():
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
"CI/CD Pipeline / CI Gate (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
# 获取所有open PR
@@ -304,7 +301,7 @@ def main():
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("ref", "")
base_ref = pr.get("base", {}).get("re", "")
# 跳过draft
if pr.get("draft"):
-414
View File
@@ -1,414 +0,0 @@
"""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 # 应该有较高分数
-61
View File
@@ -1,61 +0,0 @@
"""asset / asset_library 兼容层单元测试."""
from __future__ import annotations
from domain.asset import Asset, AssetStatus, AssetType, ClassificationStatus
from domain.asset_library import AssetLibrary, AssetLibraryKind, LibraryKind
class TestAssetType:
"""AssetType 常量类测试."""
def test_video_value(self):
assert AssetType.VIDEO == "video"
def test_image_value(self):
assert AssetType.IMAGE == "image"
def test_audio_value(self):
assert AssetType.AUDIO == "audio"
def test_three_types(self):
assert AssetType.VIDEO
assert AssetType.IMAGE
assert AssetType.AUDIO
class TestAssetReexports:
"""asset.py 重导出测试."""
def test_asset_reexported(self):
# Asset 类从 entities 转发,确认可访问
assert Asset is not None
def test_asset_status_reexported(self):
assert AssetStatus is not None
def test_classification_status_reexported(self):
assert ClassificationStatus is not None
class TestLibraryKind:
"""LibraryKind 常量类测试."""
def test_video_value(self):
assert LibraryKind.VIDEO == AssetLibraryKind.VIDEO
def test_voice_value(self):
assert LibraryKind.VOICE == AssetLibraryKind.VOICE
def test_image_value(self):
assert LibraryKind.IMAGE == AssetLibraryKind.IMAGE
class TestAssetLibraryReexports:
"""asset_library.py 重导出测试."""
def test_asset_library_reexported(self):
assert AssetLibrary is not None
def test_asset_library_kind_reexported(self):
assert AssetLibraryKind is not None
@@ -1,521 +0,0 @@
"""audio_track_config 多轨道音频配置单测."""
import pytest
from domain.audio_track_config import (
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
AudioTrack,
MultiTrackMixConfig,
clamp_volume,
is_valid_audio_extension,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_track_type_constants(self):
assert TRACK_TYPE_MAIN == "main"
assert TRACK_TYPE_BGM == "bgm"
assert TRACK_TYPE_VOICEOVER == "voiceover"
assert TRACK_TYPE_SFX == "sfx"
assert TRACK_TYPE_AMBIENT == "ambient"
def test_max_tracks(self):
assert MAX_AUDIO_TRACKS == 8
def test_default_volumes(self):
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
def test_allowed_extensions(self):
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
# ── AudioTrack ───────────────────────────────────────────────────────────────
class TestAudioTrackDefaults:
"""AudioTrack 默认值"""
def test_default_values(self):
t = AudioTrack()
assert t.track_id == ""
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == 1.0
assert t.fade_in == 0.0
assert t.fade_out == 0.0
assert t.start_time == 0.0
assert t.duration == 0.0
assert t.enabled is True
def test_custom_track(self):
t = AudioTrack(
track_id="bgm_001",
track_type=TRACK_TYPE_BGM,
audio_path="/music/bgm.mp3",
volume=0.5,
fade_in=1.5,
fade_out=2.0,
start_time=3.0,
duration=30.0,
enabled=False,
)
assert t.track_id == "bgm_001"
assert t.track_type == TRACK_TYPE_BGM
assert t.audio_path == "/music/bgm.mp3"
assert t.volume == 0.5
assert t.fade_in == 1.5
assert t.start_time == 3.0
assert t.duration == 30.0
assert t.enabled is False
class TestAudioTrackFromDict:
"""AudioTrack.from_dict"""
def test_empty_dict(self):
t = AudioTrack.from_dict({})
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
assert t.enabled is True
def test_full_dict(self):
t = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.8,
"fade_in": 1.0,
"fade_out": 2.0,
"start_time": 5.0,
"duration": 60.0,
"enabled": True,
}
)
assert t.track_id == "t1"
assert t.track_type == "bgm"
assert t.volume == 0.8
assert t.fade_in == 1.0
assert t.duration == 60.0
def test_volume_clamped_to_zero(self):
t = AudioTrack.from_dict({"volume": -0.5})
assert t.volume == 0.0
def test_volume_clamped_to_two(self):
t = AudioTrack.from_dict({"volume": 3.0})
assert t.volume == 2.0
def test_invalid_volume_falls_back_to_default(self):
t = AudioTrack.from_dict({"track_type": "bgm", "volume": "abc"})
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
def test_invalid_fade_in_falls_back(self):
t = AudioTrack.from_dict({"fade_in": "bad"})
assert t.fade_in == 0.0
def test_negative_fade_in_clamped(self):
t = AudioTrack.from_dict({"fade_in": -1.0})
assert t.fade_in == 0.0
def test_invalid_fade_out_falls_back(self):
t = AudioTrack.from_dict({"fade_out": None})
assert t.fade_out == 0.0
def test_negative_start_time_clamped(self):
t = AudioTrack.from_dict({"start_time": -5.0})
assert t.start_time == 0.0
def test_invalid_duration_falls_back(self):
t = AudioTrack.from_dict({"duration": "long"})
assert t.duration == 0.0
def test_bgm_default_volume(self):
t = AudioTrack.from_dict({"track_type": "bgm"})
assert t.volume == 0.3
def test_main_default_volume(self):
t = AudioTrack.from_dict({"track_type": "main"})
assert t.volume == 1.0
def test_voiceover_default_volume(self):
t = AudioTrack.from_dict({"track_type": "voiceover"})
assert t.volume == 1.0
def test_ambient_default_volume(self):
t = AudioTrack.from_dict({"track_type": "ambient"})
assert t.volume == 0.2
def test_unknown_type_default_volume(self):
t = AudioTrack.from_dict({"track_type": "unknown_type"})
assert t.volume == 1.0
def test_enabled_false(self):
t = AudioTrack.from_dict({"enabled": False})
assert t.enabled is False
class TestAudioTrackValidate:
"""AudioTrack.validate"""
def test_empty_path_invalid(self):
t = AudioTrack(audio_path="")
valid, msg = t.validate()
assert valid is False
assert "audio_path" in msg
def test_valid_track(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.5)
valid, msg = t.validate()
assert valid is True
assert msg == ""
def test_volume_below_zero_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=-0.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_above_two_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_zero_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.0)
valid, _ = t.validate()
assert valid is True
def test_volume_two_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.0)
valid, _ = t.validate()
assert valid is True
def test_negative_fade_in_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_in=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_in" in msg
def test_negative_fade_out_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_out=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_out" in msg
def test_negative_start_time_invalid(self):
t = AudioTrack(audio_path="/a.mp3", start_time=-0.5)
valid, msg = t.validate()
assert valid is False
assert "start_time" in msg
def test_negative_duration_invalid(self):
t = AudioTrack(audio_path="/a.mp3", duration=-1.0)
valid, msg = t.validate()
assert valid is False
assert "duration" in msg
class TestAudioTrackIsEffective:
"""AudioTrack.is_effective 属性"""
def test_enabled_with_path_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=True)
assert t.is_effective is True
def test_disabled_not_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=False)
assert t.is_effective is False
def test_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=True)
assert t.is_effective is False
def test_disabled_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=False)
assert t.is_effective is False
# ── MultiTrackMixConfig ──────────────────────────────────────────────────────
class TestMultiTrackMixConfigDefaults:
"""MultiTrackMixConfig 默认值"""
def test_default_values(self):
c = MultiTrackMixConfig()
assert c.tracks == []
assert c.master_volume == 1.0
assert c.normalize is True
assert c.max_output_volume == 1.5
def test_custom_config(self):
t1 = AudioTrack(track_id="t1", audio_path="/a.mp3")
c = MultiTrackMixConfig(
tracks=[t1],
master_volume=0.8,
normalize=False,
max_output_volume=2.0,
)
assert len(c.tracks) == 1
assert c.master_volume == 0.8
assert c.normalize is False
assert c.max_output_volume == 2.0
class TestMultiTrackFromConfigDict:
"""MultiTrackMixConfig.from_config_dict"""
def test_none_returns_default(self):
c = MultiTrackMixConfig.from_config_dict(None)
assert len(c.tracks) == 0
assert c.master_volume == 1.0
def test_empty_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict({})
assert len(c.tracks) == 0
def test_non_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict("not a dict")
assert len(c.tracks) == 0
def test_single_track(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/bgm.mp3", "volume": 0.5},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
assert c.tracks[0].volume == 0.5
def test_multiple_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "main", "audio_path": "/main.wav"},
{"track_id": "t2", "track_type": "bgm", "audio_path": "/bgm.mp3"},
{"track_id": "t3", "track_type": "sfx", "audio_path": "/sfx.wav"},
],
}
)
assert len(c.tracks) == 3
assert c.tracks[0].track_type == "main"
assert c.tracks[1].track_type == "bgm"
assert c.tracks[2].track_type == "sfx"
def test_skip_disabled_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
{"track_id": "t3", "audio_path": "/c.mp3"},
],
}
)
assert len(c.tracks) == 2
ids = [t.track_id for t in c.tracks]
assert "t1" in ids
assert "t2" not in ids
assert "t3" in ids
def test_skip_no_path_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
def test_skip_non_dict_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not a dict",
123,
None,
],
}
)
assert len(c.tracks) == 1
def test_master_volume_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": 3.0})
assert c.master_volume == 2.0
def test_master_volume_negative_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": -1.0})
assert c.master_volume == 0.0
def test_invalid_master_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": "high"})
assert c.master_volume == 1.0
def test_normalize_false(self):
c = MultiTrackMixConfig.from_config_dict({"normalize": False})
assert c.normalize is False
def test_max_output_volume_custom(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
assert c.max_output_volume == 2.0
def test_invalid_max_output_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": "big"})
assert c.max_output_volume == 1.5
def test_tracks_not_list_ignored(self):
c = MultiTrackMixConfig.from_config_dict({"tracks": "not a list"})
assert len(c.tracks) == 0
class TestMultiTrackProperties:
"""MultiTrackMixConfig 属性方法"""
def _make_config(self):
return MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "m1", "track_type": "main", "audio_path": "/m.wav"},
{"track_id": "b1", "track_type": "bgm", "audio_path": "/b1.mp3"},
{"track_id": "b2", "track_type": "bgm", "audio_path": "/b2.mp3", "enabled": False},
{"track_id": "s1", "track_type": "sfx", "audio_path": "/s.wav"},
{"track_id": "x", "track_type": "ambient", "audio_path": ""},
],
}
)
def test_has_effect_true(self):
c = self._make_config()
assert c.has_effect is True
def test_has_effect_false(self):
c = MultiTrackMixConfig()
assert c.has_effect is False
def test_effective_track_count(self):
c = self._make_config()
# m1 + b1 + s1 = 3个有效(b2禁用,x无路径)
assert c.effective_track_count == 3
def test_main_tracks(self):
c = self._make_config()
mains = c.main_tracks
assert len(mains) == 1
assert mains[0].track_id == "m1"
def test_bgm_tracks(self):
c = self._make_config()
bgms = c.bgm_tracks
assert len(bgms) == 1 # 只有b1有效
assert bgms[0].track_id == "b1"
def test_empty_tracks(self):
c = MultiTrackMixConfig()
assert c.effective_track_count == 0
assert c.main_tracks == []
assert c.bgm_tracks == []
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestIsValidAudioExtension:
"""is_valid_audio_extension 函数"""
def test_mp3(self):
assert is_valid_audio_extension("song.mp3") is True
def test_wav(self):
assert is_valid_audio_extension("sound.wav") is True
def test_aac(self):
assert is_valid_audio_extension("audio.aac") is True
def test_ogg(self):
assert is_valid_audio_extension("music.ogg") is True
def test_flac(self):
assert is_valid_audio_extension("lossless.flac") is True
def test_m4a(self):
assert is_valid_audio_extension("apple.m4a") is True
def test_wma(self):
assert is_valid_audio_extension("windows.wma") is True
def test_uppercase_extension(self):
assert is_valid_audio_extension("SONG.MP3") is True
def test_mixed_case_extension(self):
assert is_valid_audio_extension("song.Mp3") is True
def test_mp4_not_valid(self):
assert is_valid_audio_extension("video.mp4") is False
def test_txt_not_valid(self):
assert is_valid_audio_extension("notes.txt") is False
def test_no_extension(self):
assert is_valid_audio_extension("README") is False
def test_full_path(self):
assert is_valid_audio_extension("/home/user/music/song.mp3") is True
class TestClampVolume:
"""clamp_volume 函数"""
def test_within_range(self):
assert clamp_volume(0.5) == 0.5
def test_exact_min(self):
assert clamp_volume(0.0) == 0.0
def test_exact_max(self):
assert clamp_volume(2.0) == 2.0
def test_below_min(self):
assert clamp_volume(-1.0) == 0.0
def test_above_max(self):
assert clamp_volume(3.0) == 2.0
def test_custom_bounds(self):
assert clamp_volume(5.0, min_vol=1.0, max_vol=10.0) == 5.0
def test_custom_below_min(self):
assert clamp_volume(0.5, min_vol=1.0) == 1.0
def test_custom_above_max(self):
assert clamp_volume(15.0, max_vol=10.0) == 10.0
-159
View File
@@ -1,159 +0,0 @@
"""Auth ports (ABC接口) 单元测试.
验证抽象接口定义正确:不能直接实例化,子类必须实现所有抽象方法。
"""
from __future__ import annotations
from abc import ABC
import pytest
from domain.auth.email_service import EmailServicePort
from domain.auth.jwt_service import JWTServicePort
from domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
from domain.auth.session_store import SessionStorePort
from domain.auth.sms_service import SmsService
class TestSessionStorePort:
"""SessionStorePort 接口测试."""
def test_is_abstract(self):
assert issubclass(SessionStorePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SessionStorePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SessionStorePort.__abstractmethods__
expected = {
"save_session",
"get_session",
"get_session_by_refresh_token",
"get_refresh_token",
"update_last_active",
"delete_session",
"get_user_sessions",
"delete_all_user_sessions",
"session_exists",
}
assert expected.issubset(abstract_methods)
def test_concrete_subclass_works(self):
class ConcreteStore(SessionStorePort):
def save_session(self, **kwargs): # type: ignore[override]
return True
def get_session(self, session_id): # type: ignore[override]
return None
def get_session_by_refresh_token(self, token): # type: ignore[override]
return None
def get_refresh_token(self, session_id): # type: ignore[override]
return None
def update_last_active(self, session_id): # type: ignore[override]
return True
def delete_session(self, session_id): # type: ignore[override]
return True
def get_user_sessions(self, user_id): # type: ignore[override]
return []
def delete_all_user_sessions(self, user_id): # type: ignore[override]
return 0
def session_exists(self, session_id): # type: ignore[override]
return False
store = ConcreteStore()
assert isinstance(store, SessionStorePort)
assert store.session_exists("s1") is False
assert store.delete_session("s1") is True
class TestEmailServicePort:
"""EmailServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(EmailServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
EmailServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = EmailServicePort.__abstractmethods__
expected = {"send_email", "send_verification_email", "send_password_reset_email"}
assert expected.issubset(abstract_methods)
class TestJWTServicePort:
"""JWTServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(JWTServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
JWTServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = JWTServicePort.__abstractmethods__
expected = {
"create_access_token",
"create_refresh_token",
"verify_token",
"verify_access_token",
"verify_refresh_token",
}
assert expected.issubset(abstract_methods)
class TestPasswordHasherPort:
"""PasswordHasherPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordHasherPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordHasherPort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = PasswordHasherPort.__abstractmethods__
expected = {"hash_password", "verify_password", "needs_rehash"}
assert expected.issubset(abstract_methods)
class TestPasswordValidatorPort:
"""PasswordValidatorPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordValidatorPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordValidatorPort() # type: ignore[misc]
def test_has_validate_method(self):
assert "validate" in PasswordValidatorPort.__abstractmethods__
class TestSmsService:
"""SmsService 接口测试."""
def test_is_abstract(self):
assert issubclass(SmsService, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SmsService() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SmsService.__abstractmethods__
expected = {"send_verification_code", "send_template_sms"}
assert expected.issubset(abstract_methods)
-139
View File
@@ -1,139 +0,0 @@
"""bgm_utils 单元测试."""
from __future__ import annotations
from domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfigEmptyInputs:
"""空输入测试."""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
def test_user_empty_returns_template_copy(self):
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, {})
assert result == {"enabled": True, "volume": 0.5}
# 返回的是副本不是同一个对象
assert result is not template
def test_template_empty_returns_user_copy(self):
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == {"enabled": False, "volume": 0.8}
assert result is not user
def test_user_none_returns_template(self):
template = {"enabled": True}
result = merge_bgm_config(template, None) # type: ignore[arg-type]
assert result == template
def test_template_none_returns_user(self):
user = {"enabled": True}
result = merge_bgm_config(None, user) # type: ignore[arg-type]
assert result == user
class TestMergeBgmConfigBasicMerge:
"""基础合并测试."""
def test_user_overrides_template_field(self):
template = {"volume": 0.5, "fade_in": 1.0}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["fade_in"] == 1.0
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 2.0
def test_all_fields_overridden(self):
template = {"enabled": True, "volume": 0.5, "track_id": "t1"}
user = {"enabled": False, "volume": 1.0, "track_id": "t2"}
result = merge_bgm_config(template, user)
assert result == {"enabled": False, "volume": 1.0, "track_id": "t2"}
class TestMergeBgmConfigEnabledSpecial:
"""enabled 特殊处理测试."""
def test_user_no_enabled_keeps_template_enabled_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_no_enabled_keeps_template_enabled_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_explicit_enabled_true_overrides_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_explicit_enabled_false_overrides_template_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_template_no_enabled_user_no_enabled(self):
template = {"volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert "enabled" not in result
assert result["volume"] == 0.8
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
class TestMergeBgmConfigDoesNotMutate:
"""不修改原字典测试."""
def test_template_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
original = dict(template)
user = {"volume": 0.8, "fade": 1.0}
merge_bgm_config(template, user)
assert template == original
def test_user_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
original = dict(user)
merge_bgm_config(template, user)
assert user == original
class TestMergeBgmConfigNestedDict:
"""嵌套字典合并测试(简单合并,非深合并)."""
def test_nested_dict_user_overrides(self):
template = {"effects": {"fade_in": 1.0, "fade_out": 1.0}}
user = {"effects": {"fade_in": 2.0}}
result = merge_bgm_config(template, user)
# 简单合并,用户effects整个覆盖模板的
assert result["effects"] == {"fade_in": 2.0}
def test_nested_dict_preserved_when_no_user_override(self):
template = {"effects": {"fade_in": 1.0}}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["effects"] == {"fade_in": 1.0}
-518
View File
@@ -1,518 +0,0 @@
"""片段操作工具单测.
纯函数模块,覆盖:分割校验/计算、合并校验/计算、
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
-825
View File
@@ -1,825 +0,0 @@
"""config_schemas 模块单测.
覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。
"""
import copy
import pytest
from domain.config_schemas import (
DEFAULT_EDIT_PLAN_CONFIG,
DEFAULT_EDIT_TEMPLATE_CONFIG,
BGMConfig,
BGMSource,
CoverConfig,
CoverType,
EditPlanConfigSchema,
EditTemplateConfigSchema,
ExportConfig,
FilterConfig,
ShadowConfig,
StrokeConfig,
SubtitleConfig,
TextAnimation,
TextPosition,
TitleConfig,
normalize_plan_config,
normalize_template_config,
)
from pydantic import ValidationError
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
class TestCoverType:
"""CoverType 枚举"""
def test_enum_values(self):
assert CoverType.AI_FRAME.value == "ai_frame"
assert CoverType.MANUAL.value == "manual"
assert CoverType.UPLOAD.value == "upload"
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
def test_is_str_enum(self):
assert isinstance(CoverType.AI_FRAME, str)
assert CoverType.AI_FRAME == "ai_frame"
def test_from_string(self):
assert CoverType("ai_frame") == CoverType.AI_FRAME
assert CoverType("manual") == CoverType.MANUAL
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
CoverType("invalid")
class TestTextPosition:
"""TextPosition 枚举"""
def test_enum_values(self):
assert TextPosition.TOP.value == "top"
assert TextPosition.CENTER.value == "center"
assert TextPosition.BOTTOM.value == "bottom"
def test_from_string(self):
assert TextPosition("top") == TextPosition.TOP
assert TextPosition("bottom") == TextPosition.BOTTOM
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
TextPosition("left")
class TestTextAnimation:
"""TextAnimation 枚举"""
def test_enum_values(self):
assert TextAnimation.NONE.value == "none"
assert TextAnimation.FADE_IN.value == "fade_in"
assert TextAnimation.SLIDE_UP.value == "slide_up"
assert TextAnimation.SLIDE_DOWN.value == "slide_down"
assert TextAnimation.SCALE.value == "scale"
def test_from_string(self):
assert TextAnimation("fade_in") == TextAnimation.FADE_IN
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
TextAnimation("bounce")
class TestBGMSource:
"""BGMSource 枚举"""
def test_enum_values(self):
assert BGMSource.LIBRARY.value == "library"
assert BGMSource.UPLOAD.value == "upload"
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
def test_from_string(self):
assert BGMSource("library") == BGMSource.LIBRARY
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
BGMSource("spotify")
# ── StrokeConfig / ShadowConfig ──────────────────────────────────────────────
class TestStrokeConfig:
"""StrokeConfig 描边配置"""
def test_default_values(self):
s = StrokeConfig()
assert s.enabled is False
assert s.color == "#000000"
assert s.width == 1
def test_custom_values(self):
s = StrokeConfig(enabled=True, color="#ff0000", width=5)
assert s.enabled is True
assert s.color == "#ff0000"
assert s.width == 5
def test_width_min_boundary(self):
s = StrokeConfig(width=1)
assert s.width == 1
def test_width_max_boundary(self):
s = StrokeConfig(width=10)
assert s.width == 10
def test_width_below_min_raises(self):
with pytest.raises(ValidationError):
StrokeConfig(width=0)
def test_width_above_max_raises(self):
with pytest.raises(ValidationError):
StrokeConfig(width=11)
class TestShadowConfig:
"""ShadowConfig 阴影配置"""
def test_default_values(self):
s = ShadowConfig()
assert s.enabled is False
assert s.blur == 4
assert s.offset_x == 2
assert s.offset_y == 2
def test_custom_values(self):
s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5)
assert s.enabled is True
assert s.blur == 10
assert s.offset_x == 5
assert s.offset_y == 5
def test_blur_min_boundary(self):
s = ShadowConfig(blur=0)
assert s.blur == 0
def test_blur_max_boundary(self):
s = ShadowConfig(blur=20)
assert s.blur == 20
def test_blur_above_max_raises(self):
with pytest.raises(ValidationError):
ShadowConfig(blur=21)
# ── CoverConfig ──────────────────────────────────────────────────────────────
class TestCoverConfig:
"""CoverConfig 封面配置"""
def test_default_values(self):
c = CoverConfig()
assert c.type == CoverType.AI_FRAME
assert c.image_url == ""
assert c.frame_time is None
def test_manual_type_with_frame_time(self):
c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5)
assert c.type == CoverType.MANUAL
assert c.frame_time == 5.5
def test_upload_type_with_image_url(self):
c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg")
assert c.type == CoverType.UPLOAD
assert c.image_url == "https://example.com/cover.jpg"
def test_frame_time_negative_raises(self):
with pytest.raises(ValidationError):
CoverConfig(frame_time=-1.0)
def test_frame_time_zero_valid(self):
c = CoverConfig(frame_time=0.0)
assert c.frame_time == 0.0
def test_from_dict_with_string_enum(self):
c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""})
assert c.type == CoverType.AI_REGENERATE
# ── TitleConfig ───────────────────────────────────────────────────────────────
class TestTitleConfig:
"""TitleConfig 标题配置"""
def test_default_values(self):
t = TitleConfig()
assert t.enabled is True
assert t.ai_auto is True
assert t.text == ""
assert t.position == TextPosition.TOP
assert t.font == "思源黑体"
assert t.color == "#ffffff"
assert t.size == 48
assert t.bold is True
assert t.italic is False
assert isinstance(t.stroke, StrokeConfig)
assert isinstance(t.shadow, ShadowConfig)
def test_custom_title(self):
t = TitleConfig(
enabled=True,
ai_auto=False,
text="我的视频标题",
position=TextPosition.CENTER,
font="微软雅黑",
color="#000000",
size=36,
bold=False,
italic=True,
)
assert t.text == "我的视频标题"
assert t.position == TextPosition.CENTER
assert t.size == 36
assert t.bold is False
assert t.italic is True
def test_size_min_boundary(self):
t = TitleConfig(size=12)
assert t.size == 12
def test_size_max_boundary(self):
t = TitleConfig(size=120)
assert t.size == 120
def test_size_below_min_raises(self):
with pytest.raises(ValidationError):
TitleConfig(size=11)
def test_size_above_max_raises(self):
with pytest.raises(ValidationError):
TitleConfig(size=121)
def test_stroke_nested_config(self):
t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3})
assert t.stroke.enabled is True
assert t.stroke.color == "#ff0000"
assert t.stroke.width == 3
def test_shadow_nested_config(self):
t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3})
assert t.shadow.enabled is True
assert t.shadow.blur == 8
# ── SubtitleConfig ────────────────────────────────────────────────────────────
class TestSubtitleConfig:
"""SubtitleConfig 字幕配置"""
def test_default_values(self):
s = SubtitleConfig()
assert s.enabled is True
assert s.position == TextPosition.BOTTOM
assert s.font == "思源黑体"
assert s.color == "#ffffff"
assert s.size == 24
assert s.animation == TextAnimation.FADE_IN
assert s.auto_generated is False
assert s.language == ""
assert s.max_chars_per_line == 20
assert s.min_chars_per_segment == 8
def test_custom_subtitle(self):
s = SubtitleConfig(
enabled=False,
position=TextPosition.TOP,
size=32,
animation=TextAnimation.SLIDE_UP,
auto_generated=True,
language="zh",
max_chars_per_line=30,
min_chars_per_segment=10,
)
assert s.enabled is False
assert s.position == TextPosition.TOP
assert s.size == 32
assert s.animation == TextAnimation.SLIDE_UP
assert s.auto_generated is True
assert s.language == "zh"
def test_size_min_boundary(self):
s = SubtitleConfig(size=12)
assert s.size == 12
def test_size_max_boundary(self):
s = SubtitleConfig(size=60)
assert s.size == 60
def test_size_below_min_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(size=11)
def test_max_chars_min_boundary(self):
s = SubtitleConfig(max_chars_per_line=8)
assert s.max_chars_per_line == 8
def test_max_chars_max_boundary(self):
s = SubtitleConfig(max_chars_per_line=40)
assert s.max_chars_per_line == 40
def test_max_chars_out_of_range_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(max_chars_per_line=41)
def test_min_chars_min_boundary(self):
s = SubtitleConfig(min_chars_per_segment=2)
assert s.min_chars_per_segment == 2
def test_min_chars_max_boundary(self):
s = SubtitleConfig(min_chars_per_segment=20)
assert s.min_chars_per_segment == 20
def test_min_chars_out_of_range_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(min_chars_per_segment=1)
# ── BGMConfig ─────────────────────────────────────────────────────────────────
class TestBGMConfig:
"""BGMConfig BGM配置"""
def test_default_values(self):
b = BGMConfig()
assert b.enabled is False
assert b.source == BGMSource.LIBRARY
assert b.asset_id == ""
assert b.preset_id == ""
assert b.audio_url == ""
assert b.volume == 0.3
assert b.fade_in == 0.0
assert b.fade_out == 0.0
assert b.loop_enabled is True
assert b.sidechain_enabled is False
assert b.sidechain_ratio == 0.3
assert b.sidechain_attack == 0.02
assert b.sidechain_release == 0.5
assert b.sidechain_threshold == -25.0
def test_custom_bgm(self):
b = BGMConfig(
enabled=True,
source=BGMSource.UPLOAD,
asset_id="bgm_123",
volume=0.5,
fade_in=2.0,
fade_out=3.0,
sidechain_enabled=True,
sidechain_ratio=0.5,
)
assert b.enabled is True
assert b.source == BGMSource.UPLOAD
assert b.volume == 0.5
assert b.sidechain_enabled is True
assert b.sidechain_ratio == 0.5
def test_volume_range(self):
b = BGMConfig(volume=0.0)
assert b.volume == 0.0
b = BGMConfig(volume=1.0)
assert b.volume == 1.0
def test_volume_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(volume=-0.1)
with pytest.raises(ValidationError):
BGMConfig(volume=1.1)
def test_fade_in_range(self):
b = BGMConfig(fade_in=30.0)
assert b.fade_in == 30.0
def test_fade_in_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(fade_in=31.0)
def test_sidechain_attack_min(self):
b = BGMConfig(sidechain_attack=0.001)
assert b.sidechain_attack == 0.001
def test_sidechain_attack_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(sidechain_attack=0.0001)
def test_sidechain_threshold_range(self):
b = BGMConfig(sidechain_threshold=-60.0)
assert b.sidechain_threshold == -60.0
b = BGMConfig(sidechain_threshold=0.0)
assert b.sidechain_threshold == 0.0
def test_sidechain_threshold_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(sidechain_threshold=-61.0)
with pytest.raises(ValidationError):
BGMConfig(sidechain_threshold=1.0)
# ── ExportConfig ──────────────────────────────────────────────────────────────
class TestExportConfig:
"""ExportConfig 导出配置"""
def test_default_values(self):
e = ExportConfig()
assert e.resolution == "1080x1920"
assert e.fps == 30
assert e.video_bitrate == 8000
assert e.audio_bitrate == 128
assert e.format == "mp4"
assert e.quality_preset == "balanced"
assert e.watermark_enabled is False
assert e.watermark_text == ""
def test_custom_export(self):
e = ExportConfig(
resolution="720x1280",
fps=60,
video_bitrate=5000,
audio_bitrate=192,
format="mov",
quality_preset="high",
watermark_enabled=True,
watermark_text="我的水印",
)
assert e.resolution == "720x1280"
assert e.fps == 60
assert e.format == "mov"
assert e.watermark_enabled is True
def test_fps_min_boundary(self):
e = ExportConfig(fps=15)
assert e.fps == 15
def test_fps_max_boundary(self):
e = ExportConfig(fps=60)
assert e.fps == 60
def test_fps_out_of_range_raises(self):
with pytest.raises(ValidationError):
ExportConfig(fps=14)
with pytest.raises(ValidationError):
ExportConfig(fps=61)
def test_video_bitrate_range(self):
e = ExportConfig(video_bitrate=1000)
assert e.video_bitrate == 1000
e = ExportConfig(video_bitrate=20000)
assert e.video_bitrate == 20000
def test_video_bitrate_out_of_range_raises(self):
with pytest.raises(ValidationError):
ExportConfig(video_bitrate=999)
with pytest.raises(ValidationError):
ExportConfig(video_bitrate=20001)
def test_audio_bitrate_range(self):
e = ExportConfig(audio_bitrate=64)
assert e.audio_bitrate == 64
e = ExportConfig(audio_bitrate=320)
assert e.audio_bitrate == 320
# ── FilterConfig ──────────────────────────────────────────────────────────────
class TestFilterConfig:
"""FilterConfig 滤镜配置"""
def test_default_values(self):
f = FilterConfig()
assert f.enabled is False
assert f.preset_id == "filter_none"
assert f.intensity == 100
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.warmth == 0.0
def test_custom_filter(self):
f = FilterConfig(
enabled=True,
preset_id="vintage",
intensity=50,
brightness=0.3,
contrast=1.5,
saturation=2.0,
warmth=-0.5,
)
assert f.enabled is True
assert f.preset_id == "vintage"
assert f.intensity == 50
assert f.brightness == 0.3
def test_intensity_range(self):
f = FilterConfig(intensity=0)
assert f.intensity == 0
f = FilterConfig(intensity=100)
assert f.intensity == 100
def test_intensity_out_of_range_raises(self):
with pytest.raises(ValidationError):
FilterConfig(intensity=-1)
with pytest.raises(ValidationError):
FilterConfig(intensity=101)
def test_brightness_range(self):
f = FilterConfig(brightness=-1.0)
assert f.brightness == -1.0
f = FilterConfig(brightness=1.0)
assert f.brightness == 1.0
def test_contrast_range(self):
f = FilterConfig(contrast=0.0)
assert f.contrast == 0.0
f = FilterConfig(contrast=2.0)
assert f.contrast == 2.0
def test_saturation_range(self):
f = FilterConfig(saturation=0.0)
assert f.saturation == 0.0
f = FilterConfig(saturation=3.0)
assert f.saturation == 3.0
def test_warmth_range(self):
f = FilterConfig(warmth=-1.0)
assert f.warmth == -1.0
f = FilterConfig(warmth=1.0)
assert f.warmth == 1.0
def test_brightness_out_of_range_raises(self):
with pytest.raises(ValidationError):
FilterConfig(brightness=-1.1)
with pytest.raises(ValidationError):
FilterConfig(brightness=1.1)
# ── 完整 Schema 模型 ──────────────────────────────────────────────────────────
class TestEditPlanConfigSchema:
"""EditPlanConfigSchema 完整计划配置"""
def test_default_values(self):
s = EditPlanConfigSchema()
assert isinstance(s.cover, CoverConfig)
assert isinstance(s.title, TitleConfig)
assert isinstance(s.subtitle, SubtitleConfig)
assert isinstance(s.bgm, BGMConfig)
assert isinstance(s.export, ExportConfig)
assert isinstance(s.filter, FilterConfig)
assert s.editing_mode == "one_take"
def test_partial_update_via_dict(self):
s = EditPlanConfigSchema(
**{
"cover": {"type": "manual", "frame_time": 10.0},
"title": {"text": "自定义标题", "size": 60},
"editing_mode": "template",
}
)
assert s.cover.type == CoverType.MANUAL
assert s.cover.frame_time == 10.0
assert s.title.text == "自定义标题"
assert s.title.size == 60
assert s.editing_mode == "template"
def test_full_config_dict_roundtrip(self):
data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
data["title"]["text"] = "测试标题"
data["bgm"]["enabled"] = True
s = EditPlanConfigSchema(**data)
assert s.title.text == "测试标题"
assert s.bgm.enabled is True
# 默认字段保留
assert s.subtitle.size == 24
assert s.export.fps == 30
def test_invalid_subfield_raises(self):
with pytest.raises(ValidationError):
EditPlanConfigSchema(**{"title": {"size": 999}})
class TestEditTemplateConfigSchema:
"""EditTemplateConfigSchema 模板配置"""
def test_default_values(self):
s = EditTemplateConfigSchema()
assert isinstance(s.cover, CoverConfig)
assert s.editing_mode == "one_take"
assert s.transition_enabled is True
def test_custom_transition_enabled(self):
s = EditTemplateConfigSchema(transition_enabled=False)
assert s.transition_enabled is False
def test_has_all_plan_fields(self):
s = EditTemplateConfigSchema()
assert hasattr(s, "cover")
assert hasattr(s, "title")
assert hasattr(s, "subtitle")
assert hasattr(s, "bgm")
assert hasattr(s, "export")
assert hasattr(s, "filter")
assert hasattr(s, "editing_mode")
assert hasattr(s, "transition_enabled")
# ── 默认值常量 ────────────────────────────────────────────────────────────────
class TestDefaultConfigs:
"""默认配置常量"""
def test_default_plan_config_structure(self):
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
def test_default_template_config_extra_field(self):
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
def test_template_config_inherits_plan(self):
# 模板配置应该包含计划配置的所有字段
for key in DEFAULT_EDIT_PLAN_CONFIG:
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
def test_defaults_are_valid_for_schema(self):
# 默认值应该能通过 schema 校验
plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG)
assert plan.editing_mode == "one_take"
template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG)
assert template.transition_enabled is True
def test_mutation_does_not_affect_original(self):
# 修改返回的 dict 不应该影响常量
d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
d["cover"]["type"] = "upload"
assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame"
# ── normalize_plan_config ────────────────────────────────────────────────────
class TestNormalizePlanConfig:
"""normalize_plan_config 工具函数"""
def test_none_returns_full_defaults(self):
result = normalize_plan_config(None)
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
def test_empty_dict_returns_defaults(self):
result = normalize_plan_config({})
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
def test_partial_cover_update(self):
result = normalize_plan_config({"cover": {"type": "manual"}})
assert result["cover"]["type"] == "manual"
# 其他 cover 字段保留默认
assert result["cover"]["image_url"] == ""
assert result["cover"]["frame_time"] is None
def test_partial_title_update(self):
result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}})
assert result["title"]["text"] == "我的标题"
assert result["title"]["size"] == 36
assert result["title"]["font"] == "思源黑体"
def test_partial_subtitle_update(self):
result = normalize_plan_config({"subtitle": {"size": 28}})
assert result["subtitle"]["size"] == 28
assert result["subtitle"]["position"] == "bottom"
def test_partial_bgm_update(self):
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}})
assert result["bgm"]["enabled"] is True
assert result["bgm"]["volume"] == 0.5
assert result["bgm"]["source"] == "library"
def test_editing_mode_update(self):
result = normalize_plan_config({"editing_mode": "template"})
assert result["editing_mode"] == "template"
def test_extra_fields_preserved(self):
result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"})
assert result["generation_task_id"] == "task_123"
assert result["custom_field"] == "value"
# 标准字段也保留
assert result["editing_mode"] == "one_take"
def test_combined_update(self):
result = normalize_plan_config(
{
"cover": {"type": "upload", "image_url": "http://x.com/c.jpg"},
"title": {"text": "标题", "size": 60},
"bgm": {"enabled": True},
"editing_mode": "smart",
"extra_key": "extra_value",
}
)
assert result["cover"]["type"] == "upload"
assert result["title"]["text"] == "标题"
assert result["bgm"]["enabled"] is True
assert result["editing_mode"] == "smart"
assert result["extra_key"] == "extra_value"
def test_non_dict_section_ignored(self):
result = normalize_plan_config({"cover": "not_a_dict"})
# cover 应该还是默认值
assert result["cover"]["type"] == "ai_frame"
def test_editing_mode_non_string_ignored(self):
result = normalize_plan_config({"editing_mode": 123})
assert result["editing_mode"] == "one_take"
def test_does_not_mutate_input(self):
raw = {"cover": {"type": "manual"}, "extra": "value"}
raw_copy = copy.deepcopy(raw)
normalize_plan_config(raw)
assert raw == raw_copy
def test_does_not_mutate_defaults(self):
original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
normalize_plan_config({"cover": {"type": "upload"}})
assert DEFAULT_EDIT_PLAN_CONFIG == original
# ── normalize_template_config ─────────────────────────────────────────────────
class TestNormalizeTemplateConfig:
"""normalize_template_config 工具函数"""
def test_none_returns_full_defaults(self):
result = normalize_template_config(None)
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
def test_empty_dict_returns_defaults(self):
result = normalize_template_config({})
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
assert result["transition_enabled"] is True
def test_partial_sections(self):
result = normalize_template_config(
{
"title": {"text": "模板标题"},
"bgm": {"enabled": True},
}
)
assert result["title"]["text"] == "模板标题"
assert result["bgm"]["enabled"] is True
def test_transition_enabled_update(self):
result = normalize_template_config({"transition_enabled": False})
assert result["transition_enabled"] is False
def test_transition_enabled_non_bool_ignored(self):
result = normalize_template_config({"transition_enabled": "yes"})
assert result["transition_enabled"] is True
def test_editing_mode_update(self):
result = normalize_template_config({"editing_mode": "story"})
assert result["editing_mode"] == "story"
def test_extra_fields_preserved(self):
result = normalize_template_config({"template_version": "v2", "author": "test"})
assert result["template_version"] == "v2"
assert result["author"] == "test"
assert result["transition_enabled"] is True
def test_combined_update(self):
result = normalize_template_config(
{
"cover": {"type": "ai_regenerate"},
"subtitle": {"size": 20},
"transition_enabled": False,
"editing_mode": "vlog",
"tags": ["travel", "food"],
}
)
assert result["cover"]["type"] == "ai_regenerate"
assert result["subtitle"]["size"] == 20
assert result["transition_enabled"] is False
assert result["editing_mode"] == "vlog"
assert result["tags"] == ["travel", "food"]
def test_does_not_mutate_defaults(self):
original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
normalize_template_config({"transition_enabled": False})
assert DEFAULT_EDIT_TEMPLATE_CONFIG == original
-63
View File
@@ -1,63 +0,0 @@
"""EmailConfig 单元测试."""
from __future__ import annotations
import pytest
from domain.auth.email_service import EmailConfig
class TestEmailConfigDefaults:
"""默认值测试."""
def test_default_values(self):
config = EmailConfig()
assert config.smtp_host == "smtp.gmail.com"
assert config.smtp_port == 587
assert config.smtp_user == ""
assert config.smtp_password == ""
assert config.from_email == ""
assert config.from_name == "小虾 SaaS"
assert config.use_tls is True
def test_custom_construction(self):
config = EmailConfig(
smtp_host="smtp.example.com",
smtp_port=465,
smtp_user="user@example.com",
smtp_password="secret",
from_email="no-reply@example.com",
from_name="Example App",
use_tls=False,
)
assert config.smtp_host == "smtp.example.com"
assert config.smtp_port == 465
assert config.smtp_user == "user@example.com"
assert config.smtp_password == "secret"
assert config.from_email == "no-reply@example.com"
assert config.from_name == "Example App"
assert config.use_tls is False
def test_is_dataclass(self):
# 可重复创建相同配置
c1 = EmailConfig(smtp_host="h.com", smtp_port=25)
c2 = EmailConfig(smtp_host="h.com", smtp_port=25)
assert c1 == c2
class TestEmailConfigEquality:
"""相等性测试."""
def test_equal_same_values(self):
c1 = EmailConfig()
c2 = EmailConfig()
assert c1 == c2
def test_not_equal_different_host(self):
c1 = EmailConfig(smtp_host="a.com")
c2 = EmailConfig(smtp_host="b.com")
assert c1 != c2
def test_not_equal_different_port(self):
c1 = EmailConfig(smtp_port=587)
c2 = EmailConfig(smtp_port=465)
assert c1 != c2
-107
View File
@@ -1,107 +0,0 @@
"""domain exceptions 单元测试."""
from __future__ import annotations
import pytest
from domain.exceptions import (
DomainError,
NotFoundError,
QuotaExceededError,
ValidationError,
)
class TestDomainError:
"""DomainError 基类测试."""
def test_is_exception(self):
assert issubclass(DomainError, Exception)
def test_raise_and_catch(self):
with pytest.raises(DomainError):
raise DomainError("something went wrong")
def test_message(self):
err = DomainError("test message")
assert str(err) == "test message"
def test_empty_message(self):
err = DomainError()
assert str(err) == ""
class TestNotFoundError:
"""NotFoundError 测试."""
def test_is_domain_error(self):
assert issubclass(NotFoundError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise NotFoundError("user not found")
def test_raise_and_catch_specific(self):
with pytest.raises(NotFoundError):
raise NotFoundError("user not found")
def test_message(self):
err = NotFoundError("resource not found")
assert str(err) == "resource not found"
class TestValidationError:
"""ValidationError 测试."""
def test_is_domain_error(self):
assert issubclass(ValidationError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise ValidationError("invalid input")
def test_raise_and_catch_specific(self):
with pytest.raises(ValidationError):
raise ValidationError("invalid input")
def test_message(self):
err = ValidationError("bad data")
assert str(err) == "bad data"
class TestQuotaExceededError:
"""QuotaExceededError 测试."""
def test_is_domain_error(self):
assert issubclass(QuotaExceededError, DomainError)
def test_constructor_stores_fields(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert err.dimension == "storage"
assert err.limit == 100.0
assert err.used == 150.0
def test_message_format(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert "storage" in str(err)
assert "150.0" in str(err)
assert "100.0" in str(err)
assert "Quota exceeded" in str(err)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_raise_and_catch_specific(self):
with pytest.raises(QuotaExceededError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_int_values(self):
err = QuotaExceededError("count", limit=100, used=150)
assert err.limit == 100
assert err.used == 150
assert "150/100" in str(err)
def test_float_values(self):
err = QuotaExceededError("size", limit=10.5, used=20.3)
assert err.limit == 10.5
assert err.used == 20.3
@@ -1,593 +0,0 @@
"""intro_outro_config 片头片尾配置单测."""
import pytest
from domain.intro_outro_config import (
INTRO_OUTRO_TYPE_FOLLOW,
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_VIDEO,
TRANSITION_FADE,
TRANSITION_SLIDE,
TRANSITION_WIPE,
IntroOutroConfig,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_type_constants(self):
assert INTRO_OUTRO_TYPE_NONE == "none"
assert INTRO_OUTRO_TYPE_VIDEO == "video"
assert INTRO_OUTRO_TYPE_TEXT == "text"
assert INTRO_OUTRO_TYPE_FOLLOW == "follow"
def test_transition_constants(self):
assert TRANSITION_FADE == "fade"
assert TRANSITION_SLIDE == "slide"
assert TRANSITION_WIPE == "wipe"
# ── 默认值与基础属性 ──────────────────────────────────────────────────────────
class TestDefaultConfig:
"""IntroOutroConfig 默认值"""
def test_default_not_enabled(self):
c = IntroOutroConfig()
assert c.enabled is False
def test_default_intro(self):
c = IntroOutroConfig()
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.intro_video_path == ""
assert c.intro_duration == 3.0
assert c.intro_background == "#000000"
assert c.intro_title == ""
assert c.intro_subtitle == ""
assert c.intro_title_color == "white"
assert c.intro_title_size == 48
assert c.intro_subtitle_color == "gray"
assert c.intro_subtitle_size == 24
def test_default_outro(self):
c = IntroOutroConfig()
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_video_path == ""
assert c.outro_duration == 3.0
assert c.outro_background == "#000000"
assert c.outro_title == "感谢观看"
assert c.outro_subtitle == "点赞关注不迷路"
assert c.outro_title_color == "white"
assert c.outro_title_size == 48
assert c.outro_subtitle_color == "gray"
assert c.outro_subtitle_size == 24
def test_default_transition(self):
c = IntroOutroConfig()
assert c.transition_effect == TRANSITION_FADE
assert c.transition_duration == 0.5
# ── from_dict 构造 ───────────────────────────────────────────────────────────
class TestFromDict:
"""from_dict 工厂方法"""
def test_none_returns_default(self):
c = IntroOutroConfig.from_dict(None)
assert c.enabled is False
def test_empty_dict_returns_default(self):
c = IntroOutroConfig.from_dict({})
assert c.enabled is False
def test_enabled_false_returns_default(self):
c = IntroOutroConfig.from_dict({"enabled": False})
assert c.enabled is False
def test_minimal_enabled(self):
c = IntroOutroConfig.from_dict({"enabled": True})
assert c.enabled is True
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
def test_intro_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "video",
"video_path": "/tmp/intro.mp4",
"duration": 5.0,
},
}
)
assert c.intro_type == INTRO_OUTRO_TYPE_VIDEO
assert c.intro_video_path == "/tmp/intro.mp4"
assert c.intro_duration == 5.0
def test_intro_text(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎来到",
"subtitle": "我的频道",
"background": "#ffffff",
"title_color": "black",
"title_size": 64,
"subtitle_color": "darkgray",
"subtitle_size": 32,
},
}
)
assert c.intro_type == INTRO_OUTRO_TYPE_TEXT
assert c.intro_title == "欢迎来到"
assert c.intro_subtitle == "我的频道"
assert c.intro_background == "#ffffff"
assert c.intro_title_size == 64
assert c.intro_subtitle_size == 32
def test_outro_text(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "text",
"title": "再见",
"subtitle": "下次见",
"title_size": 56,
},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_TEXT
assert c.outro_title == "再见"
assert c.outro_subtitle == "下次见"
assert c.outro_title_size == 56
def test_outro_follow_type(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "follow", "title": "关注我"},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_FOLLOW
assert c.outro_title == "关注我"
def test_outro_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 4.0,
},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_VIDEO
assert c.outro_video_path == "/tmp/outro.mp4"
assert c.outro_duration == 4.0
def test_transition_config(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"transition": "slide",
"transition_duration": 1.0,
}
)
assert c.transition_effect == TRANSITION_SLIDE
assert c.transition_duration == 1.0
def test_video_field_alias(self):
# video 字段兼容(video_path 和 video 都能用)
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "video", "video": "old_path.mp4"},
}
)
assert c.intro_video_path == "old_path.mp4"
def test_video_path_preferred_over_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "video", "video_path": "new.mp4", "video": "old.mp4"},
}
)
assert c.intro_video_path == "new.mp4"
def test_invalid_duration_falls_back(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"duration": "abc"},
}
)
assert c.intro_duration == 3.0
def test_invalid_size_falls_back(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"title_size": "not_a_number"},
}
)
assert c.intro_title_size == 48
def test_none_intro_outro(self):
c = IntroOutroConfig.from_dict({"enabled": True, "intro": None, "outro": None})
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
def test_empty_title_defaults_for_outro(self):
# outro title 为空时回退到默认值
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "text", "title": ""},
}
)
assert c.outro_title == "感谢观看"
def test_empty_subtitle_defaults_for_outro(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"subtitle": ""},
}
)
assert c.outro_subtitle == "点赞关注不迷路"
def test_combined_full_config(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "片头标题",
"subtitle": "片头副标题",
"background": "#123456",
"duration": 2.5,
"title_size": 72,
},
"outro": {
"type": "video",
"video_path": "/outro.mp4",
"duration": 4.0,
},
"transition": "wipe",
"transition_duration": 0.8,
}
)
assert c.intro_title == "片头标题"
assert c.intro_duration == 2.5
assert c.outro_type == "video"
assert c.outro_video_path == "/outro.mp4"
assert c.transition_effect == "wipe"
assert c.transition_duration == 0.8
def test_does_not_mutate_input(self):
data = {"enabled": True, "intro": {"type": "text", "title": "test"}}
data_copy = {
"enabled": True,
"intro": {"type": "text", "title": "test"},
}
IntroOutroConfig.from_dict(data)
assert data == data_copy
# ── has_intro / has_outro 属性 ───────────────────────────────────────────────
class TestHasIntroOutro:
"""has_intro / has_outro 属性"""
def test_disabled_no_intro(self):
c = IntroOutroConfig()
assert c.has_intro is False
def test_disabled_no_outro(self):
c = IntroOutroConfig()
assert c.has_outro is False
def test_enabled_none_type_no_intro(self):
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_NONE)
assert c.has_intro is False
def test_video_intro_has_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/x.mp4",
)
assert c.has_intro is True
def test_text_intro_has_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
)
assert c.has_intro is True
def test_video_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="/x.mp4",
)
assert c.has_outro is True
def test_text_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
)
assert c.has_outro is True
def test_follow_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="关注",
)
assert c.has_outro is True
def test_follow_type_no_intro(self):
# follow 只是片尾类型,片头不支持
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_FOLLOW)
assert c.has_intro is False
# ── total_extra_duration ─────────────────────────────────────────────────────
class TestTotalExtraDuration:
"""total_extra_duration 属性"""
def test_disabled_zero(self):
c = IntroOutroConfig()
assert c.total_extra_duration == 0.0
def test_only_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=3.0,
)
assert c.total_extra_duration == 3.0
def test_only_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=4.0,
)
assert c.total_extra_duration == 4.0
def test_both_intro_outro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/i.mp4",
intro_duration=2.5,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="/o.mp4",
outro_duration=3.5,
)
assert c.total_extra_duration == 6.0
def test_zero_duration_not_counted(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=0.0,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=0.0,
)
assert c.total_extra_duration == 0.0
def test_negative_duration_not_counted(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=-1.0,
)
assert c.total_extra_duration == 0.0
# ── validate 校验 ────────────────────────────────────────────────────────────
class TestValidate:
"""validate 方法"""
def test_disabled_always_valid(self):
c = IntroOutroConfig()
valid, msg = c.validate()
assert valid is True
assert msg == ""
def test_none_types_valid(self):
c = IntroOutroConfig(enabled=True)
valid, msg = c.validate()
assert valid is True
def test_invalid_intro_type(self):
c = IntroOutroConfig(enabled=True, intro_type="invalid")
valid, msg = c.validate()
assert valid is False
assert "片头类型" in msg
def test_invalid_outro_type(self):
c = IntroOutroConfig(enabled=True, outro_type="invalid")
valid, msg = c.validate()
assert valid is False
assert "片尾类型" in msg
def test_video_intro_no_path(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="",
)
valid, msg = c.validate()
assert valid is False
assert "video_path" in msg
def test_video_intro_with_path_valid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/path.mp4",
)
valid, _ = c.validate()
assert valid is True
def test_text_intro_no_title(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_text_intro_with_title_valid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
)
valid, _ = c.validate()
assert valid is True
def test_video_outro_no_path(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="",
)
valid, msg = c.validate()
assert valid is False
assert "video_path" in msg
def test_text_outro_no_title(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_follow_outro_no_title(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_follow_outro_with_title_valid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="关注",
)
valid, _ = c.validate()
assert valid is True
def test_zero_intro_duration_invalid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=0.0,
)
valid, msg = c.validate()
assert valid is False
assert "片头时长" in msg
def test_negative_outro_duration_invalid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=-1.0,
)
valid, msg = c.validate()
assert valid is False
assert "片尾时长" in msg
def test_negative_transition_duration_invalid(self):
c = IntroOutroConfig(enabled=True, transition_duration=-0.5)
valid, msg = c.validate()
assert valid is False
assert "转场" in msg
def test_zero_transition_valid(self):
c = IntroOutroConfig(enabled=True, transition_duration=0.0)
valid, _ = c.validate()
assert valid is True
def test_zero_title_size_invalid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_title_size=0,
)
valid, msg = c.validate()
assert valid is False
assert "标题字号" in msg
def test_negative_subtitle_size_invalid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_subtitle_size=-1,
)
valid, msg = c.validate()
assert valid is False
assert "副标题字号" in msg
def test_full_valid_config(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=3.0,
intro_title_size=48,
intro_subtitle_size=24,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=3.0,
outro_title_size=48,
outro_subtitle_size=24,
transition_duration=0.5,
)
valid, msg = c.validate()
assert valid is True
assert msg == ""
-290
View File
@@ -1,290 +0,0 @@
"""media_validation 媒体文件校验单测."""
import pytest
from domain.media_validation import (
MIN_AUDIO_FILE_SIZE,
MIN_IMAGE_FILE_SIZE,
MIN_VIDEO_FILE_SIZE,
SUPPORTED_VIDEO_CODECS,
is_valid_media,
safe_parse_fps,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_min_sizes(self):
assert MIN_VIDEO_FILE_SIZE == 1024
assert MIN_AUDIO_FILE_SIZE == 100
assert MIN_IMAGE_FILE_SIZE == 100
def test_supported_codecs_is_frozenset(self):
assert isinstance(SUPPORTED_VIDEO_CODECS, frozenset)
def test_supported_codecs_includes_common(self):
assert "h264" in SUPPORTED_VIDEO_CODECS
assert "hevc" in SUPPORTED_VIDEO_CODECS
assert "vp9" in SUPPORTED_VIDEO_CODECS
assert "av1" in SUPPORTED_VIDEO_CODECS
assert "mpeg4" in SUPPORTED_VIDEO_CODECS
assert "prores" in SUPPORTED_VIDEO_CODECS
def test_supported_codecs_count(self):
assert len(SUPPORTED_VIDEO_CODECS) >= 20
# ── safe_parse_fps ───────────────────────────────────────────────────────────
class TestSafeParseFps:
"""safe_parse_fps 函数"""
def test_simple_decimal(self):
assert safe_parse_fps("30.0") == 30.0
def test_integer_string(self):
assert safe_parse_fps("24") == 24.0
def test_fraction_format(self):
assert abs(safe_parse_fps("30000/1001") - 29.97) < 0.01
def test_simple_fraction(self):
assert safe_parse_fps("30/1") == 30.0
def test_24fps_fraction(self):
assert safe_parse_fps("24/1") == 24.0
def test_60fps_fraction(self):
assert safe_parse_fps("60000/1001") == pytest.approx(59.94, abs=0.01)
def test_zero_denominator_returns_zero(self):
assert safe_parse_fps("30/0") == 0.0
def test_empty_string_returns_zero(self):
assert safe_parse_fps("") == 0.0
def test_invalid_string_returns_zero(self):
assert safe_parse_fps("invalid") == 0.0
def test_none_numerator_fraction(self):
assert safe_parse_fps("abc/1001") == 0.0
def test_negative_fps(self):
assert safe_parse_fps("-30") == -30.0
def test_very_high_fps(self):
assert safe_parse_fps("240/1") == 240.0
def test_multiple_slashes(self):
# 只按第一个 / 分割
# "30/1/2" → num="30", den="1/2" → float("1/2") 抛异常 → 返回 0
assert safe_parse_fps("30/1/2") == 0.0
def test_float_fraction(self):
result = safe_parse_fps("29.97/1")
assert result == pytest.approx(29.97)
def test_zero_fps(self):
assert safe_parse_fps("0") == 0.0
def test_zero_numerator(self):
assert safe_parse_fps("0/1000") == 0.0
# ── is_valid_media - video ───────────────────────────────────────────────────
class TestIsValidMediaVideo:
"""is_valid_media 视频校验"""
def test_valid_video(self):
metadata = {
"size_bytes": 1024 * 1024, # 1MB
"duration": 10.0,
"codec": "h264",
"width": 1920,
"height": 1080,
}
assert is_valid_media(metadata, "video") is True
def test_small_file_invalid(self):
metadata = {"size_bytes": 100, "duration": 10.0}
assert is_valid_media(metadata, "video") is False
def test_exact_min_size_valid(self):
metadata = {"size_bytes": MIN_VIDEO_FILE_SIZE, "duration": 1.0}
assert is_valid_media(metadata, "video") is True
def test_zero_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 0}
assert is_valid_media(metadata, "video") is False
def test_negative_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": -1.0}
assert is_valid_media(metadata, "video") is False
def test_unsupported_codec_still_valid(self):
# 非白名单编码仍允许通过(不做严格拦截)
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "unknown_codec_xyz",
}
assert is_valid_media(metadata, "video") is True
def test_empty_codec_valid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0, "codec": ""}
assert is_valid_media(metadata, "video") is True
def test_no_codec_valid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0}
assert is_valid_media(metadata, "video") is True
def test_hevc_codec_valid(self):
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "hevc",
}
assert is_valid_media(metadata, "video") is True
def test_codec_case_insensitive(self):
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "H264",
}
assert is_valid_media(metadata, "video") is True
def test_missing_size_invalid(self):
metadata = {"duration": 10.0}
assert is_valid_media(metadata, "video") is False
def test_missing_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024}
assert is_valid_media(metadata, "video") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "video") is False
# ── is_valid_media - audio ───────────────────────────────────────────────────
class TestIsValidMediaAudio:
"""is_valid_media 音频校验"""
def test_valid_audio(self):
metadata = {"size_bytes": 1024, "duration": 30.0, "codec": "aac"}
assert is_valid_media(metadata, "audio") is True
def test_small_audio_invalid(self):
metadata = {"size_bytes": 50, "duration": 30.0}
assert is_valid_media(metadata, "audio") is False
def test_exact_min_size_valid(self):
metadata = {"size_bytes": MIN_AUDIO_FILE_SIZE, "duration": 1.0}
assert is_valid_media(metadata, "audio") is True
def test_zero_duration_invalid(self):
metadata = {"size_bytes": 1024, "duration": 0}
assert is_valid_media(metadata, "audio") is False
def test_negative_duration_invalid(self):
metadata = {"size_bytes": 1024, "duration": -5.0}
assert is_valid_media(metadata, "audio") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "audio") is False
def test_very_short_audio_valid(self):
metadata = {"size_bytes": 200, "duration": 0.5}
assert is_valid_media(metadata, "audio") is True
# ── is_valid_media - image ───────────────────────────────────────────────────
class TestIsValidMediaImage:
"""is_valid_media 图片校验"""
def test_valid_image(self):
metadata = {"size_bytes": 1024, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is True
def test_small_image_invalid(self):
metadata = {"size_bytes": 50, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_exact_min_size_valid(self):
metadata = {
"size_bytes": MIN_IMAGE_FILE_SIZE,
"width": 100,
"height": 100,
}
assert is_valid_media(metadata, "image") is True
def test_zero_width_invalid(self):
metadata = {"size_bytes": 1024, "width": 0, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_zero_height_invalid(self):
metadata = {"size_bytes": 1024, "width": 1920, "height": 0}
assert is_valid_media(metadata, "image") is False
def test_negative_width_invalid(self):
metadata = {"size_bytes": 1024, "width": -1, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_small_image_valid(self):
metadata = {"size_bytes": 200, "width": 10, "height": 10}
assert is_valid_media(metadata, "image") is True
def test_missing_width_invalid(self):
metadata = {"size_bytes": 1024, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_missing_height_invalid(self):
metadata = {"size_bytes": 1024, "width": 1920}
assert is_valid_media(metadata, "image") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "image") is False
# ── is_valid_media - edge cases ─────────────────────────────────────────────
class TestIsValidMediaEdgeCases:
"""is_valid_media 边界情况"""
def test_invalid_media_type(self):
metadata = {"size_bytes": 1024, "duration": 10.0}
assert is_valid_media(metadata, "document") is False
def test_empty_media_type(self):
metadata = {"size_bytes": 1024}
assert is_valid_media(metadata, "") is False
def test_string_size_converted(self):
metadata = {"size_bytes": "2048", "duration": "5.0"}
assert is_valid_media(metadata, "video") is True
def test_video_size_as_string(self):
metadata = {"size_bytes": "1000000", "duration": "30"}
assert is_valid_media(metadata, "video") is True
def test_invalid_size_string_raises(self):
# int("abc") 会抛 ValueError
metadata = {"size_bytes": "abc", "duration": 10.0}
with pytest.raises(ValueError):
is_valid_media(metadata, "video")
def test_none_size_raises(self):
# int(None) 会抛 TypeError
metadata = {"size_bytes": None, "duration": 10.0}
with pytest.raises(TypeError):
is_valid_media(metadata, "video")
@@ -1,416 +0,0 @@
"""音频降噪配置领域模型单测.
纯逻辑模块,覆盖:等级枚举、配置解析、参数计算、
滤镜构建、便捷函数。
"""
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)
-299
View File
@@ -1,299 +0,0 @@
"""preset_bgm 预设BGM库单测."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
# ── PresetBGM dataclass ──────────────────────────────────────────────────────
class TestPresetBGM:
"""PresetBGM dataclass"""
def test_minimal_creation(self):
b = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=60.0)
assert b.id == "test_001"
assert b.name == "测试音乐"
assert b.style == "upbeat"
assert b.duration == 60.0
assert b.artist == ""
assert b.description == ""
assert b.tags == []
assert b.audio_url == ""
def test_full_creation(self):
b = PresetBGM(
id="bgm_001",
name="阳光清晨",
style="upbeat",
duration=120.5,
artist="音乐人A",
description="轻快明亮的吉他",
tags=["轻快", "阳光"],
audio_url="https://cdn.example.com/bgm.mp3",
)
assert b.id == "bgm_001"
assert b.artist == "音乐人A"
assert b.description == "轻快明亮的吉他"
assert b.tags == ["轻快", "阳光"]
assert b.audio_url == "https://cdn.example.com/bgm.mp3"
def test_frozen_immutable(self):
b = PresetBGM(id="test", name="Test", style="relax", duration=100.0)
with pytest.raises(FrozenInstanceError):
b.name = "NewName"
def test_equality(self):
b1 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
b2 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
assert b1 == b2
def test_inequality(self):
b1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0)
b2 = PresetBGM(id="b", name="B", style="relax", duration=90.0)
assert b1 != b2
def test_not_hashable_due_to_list_tags(self):
# 包含 list 字段(tags)的 frozen dataclass 不可哈希
b = PresetBGM(id="test", name="Test", style="tech", duration=60.0)
with pytest.raises(TypeError):
hash(b)
# ── PRESET_BGM_LIBRARY 清单 ──────────────────────────────────────────────────
class TestPresetBGMLibrary:
"""预设BGM库清单"""
def test_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_all_are_preset_bgm(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm, PresetBGM)
def test_unique_ids(self):
ids = [b.id for b in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_have_required_fields(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.id != ""
assert bgm.name != ""
assert bgm.style != ""
assert bgm.duration > 0
def test_upbeat_style_count(self):
upbeats = [b for b in PRESET_BGM_LIBRARY if b.style == "upbeat"]
assert len(upbeats) >= 3
def test_relax_style_count(self):
relax = [b for b in PRESET_BGM_LIBRARY if b.style == "relax"]
assert len(relax) >= 3
def test_tech_style_count(self):
tech = [b for b in PRESET_BGM_LIBRARY if b.style == "tech"]
assert len(tech) >= 2
def test_commerce_style_count(self):
commerce = [b for b in PRESET_BGM_LIBRARY if b.style == "commerce"]
assert len(commerce) >= 2
def test_sunny_morning_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_upbeat_001")
assert b.name == "阳光清晨"
assert b.style == "upbeat"
assert b.duration == 120.0
assert "吉他" in b.description
assert "vlog" in b.tags
def test_quiet_time_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_relax_001")
assert b.name == "静谧时光"
assert b.style == "relax"
assert b.duration == 180.0
def test_future_tech_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_tech_001")
assert b.name == "未来科技"
assert b.style == "tech"
def test_all_durations_positive(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0
def test_all_tags_are_lists(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm.tags, list)
# ── BGM_STYLES 风格字典 ─────────────────────────────────────────────────────
class TestBGMStyles:
"""BGM_STYLES 风格分类字典"""
def test_styles_exist(self):
assert "upbeat" in BGM_STYLES
assert "relax" in BGM_STYLES
assert "tech" in BGM_STYLES
assert "commerce" in BGM_STYLES
assert "emotional" in BGM_STYLES
assert "cinematic" in BGM_STYLES
def test_style_names_chinese(self):
assert BGM_STYLES["upbeat"] == "轻快"
assert BGM_STYLES["relax"] == "治愈"
assert BGM_STYLES["tech"] == "科技"
assert BGM_STYLES["commerce"] == "电商"
assert BGM_STYLES["emotional"] == "情感"
assert BGM_STYLES["cinematic"] == "电影"
def test_library_styles_are_defined(self):
# 库中的所有风格都应该在 BGM_STYLES 中有定义
styles_in_library = {b.style for b in PRESET_BGM_LIBRARY}
for style in styles_in_library:
assert style in BGM_STYLES, f"style {style} not defined in BGM_STYLES"
# ── get_preset_bgm ──────────────────────────────────────────────────────────
class TestGetPresetBGM:
"""get_preset_bgm 函数"""
def test_get_existing(self):
b = get_preset_bgm("bgm_upbeat_001")
assert b is not None
assert b.id == "bgm_upbeat_001"
assert b.name == "阳光清晨"
def test_get_relax(self):
b = get_preset_bgm("bgm_relax_002")
assert b is not None
assert b.style == "relax"
def test_get_nonexistent_returns_none(self):
b = get_preset_bgm("nonexistent_id")
assert b is None
def test_get_empty_string_returns_none(self):
b = get_preset_bgm("")
assert b is None
def test_returns_same_instance(self):
b1 = get_preset_bgm("bgm_upbeat_001")
b2 = get_preset_bgm("bgm_upbeat_001")
assert b1 is b2
# ── list_preset_bgm_by_style ─────────────────────────────────────────────────
class TestListPresetBGMByStyle:
"""list_preset_bgm_by_style 函数"""
def test_upbeat_style(self):
result = list_preset_bgm_by_style("upbeat")
assert len(result) >= 3
for b in result:
assert b.style == "upbeat"
def test_relax_style(self):
result = list_preset_bgm_by_style("relax")
assert len(result) >= 3
for b in result:
assert b.style == "relax"
def test_tech_style(self):
result = list_preset_bgm_by_style("tech")
assert len(result) >= 2
def test_commerce_style(self):
result = list_preset_bgm_by_style("commerce")
assert len(result) >= 2
def test_unknown_style_empty(self):
result = list_preset_bgm_by_style("nonexistent_style")
assert len(result) == 0
def test_empty_string_empty(self):
result = list_preset_bgm_by_style("")
assert len(result) == 0
def test_returns_new_list(self):
# 修改返回值不应影响原始列表
result = list_preset_bgm_by_style("upbeat")
result.clear()
assert len(list_preset_bgm_by_style("upbeat")) >= 3
# ── search_preset_bgm ────────────────────────────────────────────────────────
class TestSearchPresetBGM:
"""search_preset_bgm 函数"""
def test_search_by_name(self):
result = search_preset_bgm("阳光")
assert len(result) >= 1
assert any("阳光" in b.name for b in result)
def test_search_by_description(self):
result = search_preset_bgm("钢琴")
assert len(result) >= 1
# 应该匹配描述里有钢琴的
def test_search_by_tag(self):
result = search_preset_bgm("vlog")
assert len(result) >= 1
assert any("vlog" in b.tags for b in result)
def test_search_tech_keyword(self):
result = search_preset_bgm("科技")
assert len(result) >= 2
def test_search_case_insensitive(self):
r1 = search_preset_bgm("UPBEAT")
r2 = search_preset_bgm("upbeat")
assert len(r1) == len(r2)
def test_search_no_match(self):
result = search_preset_bgm("完全不存在的关键词_xyz123")
assert len(result) == 0
def test_search_empty_string(self):
# 空字符串应该匹配所有(因为 "" in any string 是 True
result = search_preset_bgm("")
assert len(result) == len(PRESET_BGM_LIBRARY)
def test_search_electronic(self):
result = search_preset_bgm("电子")
assert len(result) >= 2
def test_order_preserved(self):
# 搜索结果应该保持原列表顺序
result = search_preset_bgm("bgm")
ids = [b.id for b in result]
all_ids = [b.id for b in PRESET_BGM_LIBRARY]
# 验证相对顺序
pos_in_result = {bgm_id: i for i, bgm_id in enumerate(ids)}
prev_pos = -1
for bgm_id in all_ids:
if bgm_id in pos_in_result:
assert pos_in_result[bgm_id] > prev_pos
prev_pos = pos_in_result[bgm_id]
def test_search_partial_tag_match(self):
# 关键词是标签的子串也能匹配
result = search_preset_bgm("吉他")
assert len(result) >= 1
-245
View File
@@ -1,245 +0,0 @@
"""preset_voices 预置音色配置单测."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_voices import (
PRESET_VOICES,
PresetVoice,
get_preset_voice_by_id,
get_preset_voices,
is_preset_voice,
)
# ── PresetVoice dataclass ────────────────────────────────────────────────────
class TestPresetVoice:
"""PresetVoice dataclass"""
def test_minimal_creation(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
)
assert v.voice_id == "test_v1"
assert v.name == "测试音色"
assert v.description == "测试描述"
assert v.gender == "female"
assert v.language == "zh-CN"
assert v.preview_url == ""
assert v.tags is None
def test_full_creation(self):
v = PresetVoice(
voice_id="test_v2",
name="完整音色",
description="完整描述",
gender="male",
language="en-US",
preview_url="https://example.com/preview.mp3",
tags=["沉稳", "男声"],
)
assert v.gender == "male"
assert v.language == "en-US"
assert v.preview_url == "https://example.com/preview.mp3"
assert v.tags == ["沉稳", "男声"]
def test_frozen_immutable(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
with pytest.raises(FrozenInstanceError):
v.name = "NewName"
def test_equality(self):
v1 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
v2 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
assert v1 == v2
def test_inequality(self):
v1 = PresetVoice(voice_id="a", name="A", description="da", gender="female")
v2 = PresetVoice(voice_id="b", name="B", description="db", gender="male")
assert v1 != v2
def test_to_dict(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
language="zh-CN",
preview_url="https://x.com/a.mp3",
tags=["温柔", "女声"],
)
d = v.to_dict()
assert isinstance(d, dict)
assert d["voice_id"] == "test_v1"
assert d["name"] == "测试音色"
assert d["description"] == "测试描述"
assert d["gender"] == "female"
assert d["language"] == "zh-CN"
assert d["preview_url"] == "https://x.com/a.mp3"
assert d["tags"] == ["温柔", "女声"]
def test_to_dict_none_tags_becomes_empty_list(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
d = v.to_dict()
assert d["tags"] == []
assert isinstance(d["tags"], list)
def test_to_dict_has_all_keys(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
d = v.to_dict()
assert set(d.keys()) == {
"voice_id",
"name",
"description",
"gender",
"language",
"preview_url",
"tags",
}
def test_slots_no_extra_attrs(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
# frozen + slots dataclass 不允许动态添加属性
with pytest.raises((AttributeError, TypeError)):
v.nonexistent_field = "value"
# ── PRESET_VOICES 列表 ───────────────────────────────────────────────────────
class TestPresetVoicesList:
"""PRESET_VOICES 预置音色列表"""
def test_not_empty(self):
assert len(PRESET_VOICES) > 0
def test_count(self):
assert len(PRESET_VOICES) == 8
def test_all_are_preset_voice(self):
for v in PRESET_VOICES:
assert isinstance(v, PresetVoice)
def test_unique_voice_ids(self):
ids = [v.voice_id for v in PRESET_VOICES]
assert len(ids) == len(set(ids))
def test_unique_names(self):
names = [v.name for v in PRESET_VOICES]
assert len(names) == len(set(names))
def test_all_have_required_fields(self):
for v in PRESET_VOICES:
assert v.voice_id != ""
assert v.name != ""
assert v.description != ""
assert v.gender in ("male", "female")
def test_all_chinese(self):
for v in PRESET_VOICES:
assert v.language == "zh-CN"
def test_longxiaochun_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochun_v3")
assert v.name == "龙小淳"
assert v.gender == "female"
assert "温柔" in v.description
def test_longxiaochen_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochen_v3")
assert v.name == "龙小晨"
assert v.gender == "male"
def test_male_voices_count(self):
males = [v for v in PRESET_VOICES if v.gender == "male"]
assert len(males) == 3 # 龙小晨/龙书/龙博
def test_female_voices_count(self):
females = [v for v in PRESET_VOICES if v.gender == "female"]
assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜
def test_all_have_tags(self):
for v in PRESET_VOICES:
assert v.tags is not None
assert len(v.tags) > 0
def test_voice_id_pattern(self):
# 所有音色 ID 都以 _v3 结尾
for v in PRESET_VOICES:
assert v.voice_id.endswith("_v3")
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestGetPresetVoices:
"""get_preset_voices 函数"""
def test_returns_full_list(self):
result = get_preset_voices()
assert len(result) == len(PRESET_VOICES)
assert result is PRESET_VOICES # 返回同一列表引用
def test_all_are_preset_voice(self):
result = get_preset_voices()
for v in result:
assert isinstance(v, PresetVoice)
class TestGetPresetVoiceById:
"""get_preset_voice_by_id 函数"""
def test_get_existing_female(self):
v = get_preset_voice_by_id("longxiaochun_v3")
assert v is not None
assert v.voice_id == "longxiaochun_v3"
assert v.name == "龙小淳"
def test_get_existing_male(self):
v = get_preset_voice_by_id("longxiaochen_v3")
assert v is not None
assert v.gender == "male"
def test_get_nonexistent_returns_none(self):
v = get_preset_voice_by_id("nonexistent_voice")
assert v is None
def test_get_empty_string_returns_none(self):
v = get_preset_voice_by_id("")
assert v is None
def test_returns_same_instance(self):
v1 = get_preset_voice_by_id("longyue_v3")
v2 = get_preset_voice_by_id("longyue_v3")
assert v1 is v2
def test_all_voices_reachable(self):
for v in PRESET_VOICES:
found = get_preset_voice_by_id(v.voice_id)
assert found is not None
assert found.voice_id == v.voice_id
class TestIsPresetVoice:
"""is_preset_voice 函数"""
def test_existing_voice_true(self):
assert is_preset_voice("longxiaochun_v3") is True
def test_all_existing_are_true(self):
for v in PRESET_VOICES:
assert is_preset_voice(v.voice_id) is True
def test_nonexistent_voice_false(self):
assert is_preset_voice("fake_voice") is False
def test_empty_string_false(self):
assert is_preset_voice("") is False
def test_consistent_with_get_by_id(self):
for v in PRESET_VOICES:
assert is_preset_voice(v.voice_id) == (get_preset_voice_by_id(v.voice_id) is not None)
@@ -1,406 +0,0 @@
"""渲染图层工具函数单测.
纯函数模块,覆盖:图层角色映射、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 = 15s4个转场 * 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
-453
View File
@@ -1,453 +0,0 @@
"""speed_config 调速配置领域模型单测."""
import pytest
from domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
adjust_duration,
build_audio_filter,
build_clip_speed_filter,
build_video_filter,
resolve_clip_speed,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_speed_limits(self):
assert MIN_SPEED == 0.25
assert MAX_SPEED == 4.0
assert DEFAULT_SPEED == 1.0
# ── SpeedConfig 默认值与基础 ────────────────────────────────────────────────
class TestSpeedConfigDefaults:
"""SpeedConfig 默认值"""
def test_default_values(self):
c = SpeedConfig()
assert c.speed == 1.0
assert c.pitch_correct is True
def test_custom_values(self):
c = SpeedConfig(speed=2.0, pitch_correct=False)
assert c.speed == 2.0
assert c.pitch_correct is False
# ── SpeedConfig.parse ───────────────────────────────────────────────────────
class TestSpeedConfigParse:
"""SpeedConfig.parse 工厂方法"""
def test_none_returns_default(self):
c = SpeedConfig.parse(None)
assert c.speed == 1.0
assert c.pitch_correct is True
def test_empty_dict_returns_default(self):
c = SpeedConfig.parse({})
assert c.speed == 1.0
def test_not_dict_returns_default(self):
c = SpeedConfig.parse("not a dict")
assert c.speed == 1.0
def test_valid_speed(self):
c = SpeedConfig.parse({"speed": 2.0})
assert c.speed == 2.0
def test_valid_speed_int(self):
c = SpeedConfig.parse({"speed": 2})
assert c.speed == 2.0
assert isinstance(c.speed, float)
def test_pitch_correct_false(self):
c = SpeedConfig.parse({"pitch_correct": False})
assert c.pitch_correct is False
def test_pitch_correct_non_bool_falls_back(self):
c = SpeedConfig.parse({"pitch_correct": "true"})
assert c.pitch_correct is True
def test_invalid_speed_string_falls_back(self):
c = SpeedConfig.parse({"speed": "fast"})
assert c.speed == 1.0
def test_speed_below_min_clamped(self):
c = SpeedConfig.parse({"speed": 0.1})
assert c.speed == MIN_SPEED
def test_speed_above_max_clamped(self):
c = SpeedConfig.parse({"speed": 10.0})
assert c.speed == MAX_SPEED
def test_zero_speed_falls_back_to_default(self):
c = SpeedConfig.parse({"speed": 0})
assert c.speed == DEFAULT_SPEED
def test_negative_speed_falls_back(self):
c = SpeedConfig.parse({"speed": -1.0})
assert c.speed == DEFAULT_SPEED
def test_min_speed_boundary(self):
c = SpeedConfig.parse({"speed": 0.25})
assert c.speed == 0.25
def test_max_speed_boundary(self):
c = SpeedConfig.parse({"speed": 4.0})
assert c.speed == 4.0
# ── SpeedConfig.clamp ───────────────────────────────────────────────────────
class TestSpeedConfigClamp:
"""SpeedConfig.clamp 方法"""
def test_normal_speed_no_change(self):
c = SpeedConfig(speed=1.5)
c.clamp()
assert c.speed == 1.5
def test_zero_speed_reset_default(self):
c = SpeedConfig(speed=0.0)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_negative_speed_reset_default(self):
c = SpeedConfig(speed=-0.5)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_below_min_clamped(self):
c = SpeedConfig(speed=0.1)
c.clamp()
assert c.speed == MIN_SPEED
def test_above_max_clamped(self):
c = SpeedConfig(speed=5.0)
c.clamp()
assert c.speed == MAX_SPEED
def test_exact_min_unchanged(self):
c = SpeedConfig(speed=MIN_SPEED)
c.clamp()
assert c.speed == MIN_SPEED
def test_exact_max_unchanged(self):
c = SpeedConfig(speed=MAX_SPEED)
c.clamp()
assert c.speed == MAX_SPEED
# ── SpeedConfig 属性方法 ────────────────────────────────────────────────────
class TestSpeedConfigProperties:
"""SpeedConfig 属性方法"""
def test_is_original_true(self):
c = SpeedConfig(speed=1.0)
assert c.is_original is True
def test_is_original_very_close(self):
c = SpeedConfig(speed=1.0 + 1e-7)
assert c.is_original is True
def test_is_original_false_fast(self):
c = SpeedConfig(speed=1.5)
assert c.is_original is False
def test_is_original_false_slow(self):
c = SpeedConfig(speed=0.8)
assert c.is_original is False
def test_is_fast_true(self):
c = SpeedConfig(speed=2.0)
assert c.is_fast is True
def test_is_fast_false(self):
c = SpeedConfig(speed=0.5)
assert c.is_fast is False
def test_is_fast_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_fast is False
def test_is_slow_true(self):
c = SpeedConfig(speed=0.5)
assert c.is_slow is True
def test_is_slow_false(self):
c = SpeedConfig(speed=2.0)
assert c.is_slow is False
def test_is_slow_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_slow is False
# ── build_video_filter ───────────────────────────────────────────────────────
class TestBuildVideoFilter:
"""build_video_filter 视频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_video_filter(c) == ""
def test_double_speed(self):
c = SpeedConfig(speed=2.0)
result = build_video_filter(c)
assert "setpts=PTS/2.0" in result
def test_half_speed(self):
c = SpeedConfig(speed=0.5)
result = build_video_filter(c)
assert "setpts=PTS/0.5" in result
def test_format_precision(self):
c = SpeedConfig(speed=1.5)
result = build_video_filter(c)
# 应该是 4 位小数
assert "1.5000" in result
def test_min_speed(self):
c = SpeedConfig(speed=0.25)
result = build_video_filter(c)
assert result.startswith("setpts=PTS/")
def test_max_speed(self):
c = SpeedConfig(speed=4.0)
result = build_video_filter(c)
assert "4.0000" in result
# ── build_audio_filter / atempo 拆分 ────────────────────────────────────────
class TestBuildAudioFilter:
"""build_audio_filter 音频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_audio_filter(c) == ""
def test_within_range_single_stage(self):
c = SpeedConfig(speed=1.5)
result = build_audio_filter(c)
assert result == "atempo=1.5000"
def test_05_speed_single_stage(self):
c = SpeedConfig(speed=0.5)
result = build_audio_filter(c)
assert result == "atempo=0.5000"
def test_20_speed_single_stage(self):
c = SpeedConfig(speed=2.0)
result = build_audio_filter(c)
assert result == "atempo=2.0000"
def test_4x_speed_two_stages(self):
c = SpeedConfig(speed=4.0)
result = build_audio_filter(c)
# 2.0 * 2.0 = 4.0
assert result == "atempo=2.0000,atempo=2.0000"
def test_025_speed_two_stages(self):
c = SpeedConfig(speed=0.25)
result = build_audio_filter(c)
# 0.5 * 0.5 = 0.25
assert result == "atempo=0.5000,atempo=0.5000"
def test_3x_speed_two_stages(self):
c = SpeedConfig(speed=3.0)
result = build_audio_filter(c)
# 2.0 * 1.5 = 3.0
stages = result.split(",")
assert len(stages) == 2
assert "atempo=2.0000" in stages[0]
assert "atempo=1.5000" in stages[1]
def test_03_speed_two_stages(self):
c = SpeedConfig(speed=0.3)
result = build_audio_filter(c)
stages = result.split(",")
assert len(stages) == 2
# 0.5 * 0.6 = 0.3
assert "atempo=0.5000" in stages[0]
def test_format_each_stage(self):
c = SpeedConfig(speed=1.2345)
result = build_audio_filter(c)
assert "atempo=1.2345" in result
class TestAtempoStages:
"""atempo 多级拆分逻辑验证"""
def _extract_speeds(self, filter_str: str) -> list[float]:
"""从 atempo 滤镜字符串中提取速度值."""
import re
return [float(m) for m in re.findall(r"atempo=([\d.]+)", filter_str)]
def test_product_equals_speed_fast_3x(self):
c = SpeedConfig(speed=3.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 3.0) < 1e-4
def test_product_equals_speed_4x(self):
c = SpeedConfig(speed=4.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 4.0) < 1e-4
def test_product_equals_speed_slow_025(self):
c = SpeedConfig(speed=0.25)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.25) < 1e-4
def test_product_equals_speed_slow_03(self):
c = SpeedConfig(speed=0.3)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.3) < 1e-4
def test_each_stage_in_range_fast(self):
c = SpeedConfig(speed=3.5)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
def test_each_stage_in_range_slow(self):
c = SpeedConfig(speed=0.35)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
# ── adjust_duration ──────────────────────────────────────────────────────────
class TestAdjustDuration:
"""adjust_duration 时长计算"""
def test_original_speed_no_change(self):
assert adjust_duration(10.0, SpeedConfig(speed=1.0)) == 10.0
def test_double_speed_half_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=2.0)) == 5.0
def test_half_speed_double_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.5)) == 20.0
def test_zero_duration_unchanged(self):
assert adjust_duration(0.0, SpeedConfig(speed=2.0)) == 0.0
def test_negative_duration_unchanged(self):
assert adjust_duration(-1.0, SpeedConfig(speed=2.0)) == -1.0
def test_original_with_zero_duration(self):
assert adjust_duration(0.0, SpeedConfig(speed=1.0)) == 0.0
def test_triple_speed(self):
assert adjust_duration(30.0, SpeedConfig(speed=3.0)) == 10.0
def test_quarter_speed(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.25)) == 40.0
# ── build_clip_speed_filter ──────────────────────────────────────────────────
class TestBuildClipSpeedFilter:
"""build_clip_speed_filter 便捷方法"""
def test_returns_tuple_of_three(self):
result = build_clip_speed_filter(1.5)
assert len(result) == 3
video_filter, audio_filter, config = result
assert isinstance(video_filter, str)
assert isinstance(audio_filter, str)
assert isinstance(config, SpeedConfig)
def test_normal_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(1.0)
assert video_filter == ""
assert audio_filter == ""
assert config.speed == 1.0
def test_double_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(2.0)
assert "setpts" in video_filter
assert "atempo" in audio_filter
assert config.speed == 2.0
def test_clamps_speed(self):
_, _, config = build_clip_speed_filter(10.0)
assert config.speed == MAX_SPEED
def test_pitch_correct_false(self):
# pitch_correct=False 时仍然生成滤镜(实际使用中可能换其他算法,但接口返回不变)
video_filter, audio_filter, config = build_clip_speed_filter(2.0, pitch_correct=False)
assert config.pitch_correct is False
assert "setpts" in video_filter
# ── resolve_clip_speed ───────────────────────────────────────────────────────
class TestResolveClipSpeed:
"""resolve_clip_speed 片段速度解析"""
def test_none_config_uses_global(self):
assert resolve_clip_speed(None, 1.5) == 1.5
def test_zero_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
def test_missing_key_uses_global(self):
assert resolve_clip_speed({}, 2.0) == 2.0
def test_valid_speed(self):
assert resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
def test_negative_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": -1.0}, 1.0) == 1.0
def test_invalid_type_uses_global(self):
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
def test_default_global_is_one(self):
assert resolve_clip_speed({"playback_speed": 0}) == 1.0
def test_int_speed(self):
result = resolve_clip_speed({"playback_speed": 2})
assert result == 2.0
assert isinstance(result, float)
def test_very_small_positive_uses_it(self):
# 只要 > 0 就用
result = resolve_clip_speed({"playback_speed": 0.1})
assert result == 0.1
-497
View File
@@ -1,497 +0,0 @@
"""subtitle 字幕时间轴领域模型单测."""
import pytest
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
# ── SubtitleWord ─────────────────────────────────────────────────────────────
class TestSubtitleWord:
"""SubtitleWord 词级字幕单元"""
def test_basic(self):
w = SubtitleWord(text="你好", start=1.0, end=1.5)
assert w.text == "你好"
assert w.start == 1.0
assert w.end == 1.5
def test_duration(self):
w = SubtitleWord(text="test", start=0.0, end=2.5)
assert w.duration == 2.5
def test_duration_zero(self):
w = SubtitleWord(text="x", start=5.0, end=5.0)
assert w.duration == 0.0
def test_duration_negative_becomes_zero(self):
w = SubtitleWord(text="x", start=3.0, end=2.0)
assert w.duration == 0.0
# ── SubtitleSegment ──────────────────────────────────────────────────────────
class TestSubtitleSegment:
"""SubtitleSegment 字幕片段"""
def test_basic(self):
s = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
assert s.text == "你好世界"
assert s.start == 0.0
assert s.end == 2.0
assert s.words == []
def test_with_words(self):
words = [
SubtitleWord("你好", 0.0, 0.5),
SubtitleWord("世界", 0.5, 1.0),
]
s = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
assert len(s.words) == 2
assert s.words[0].text == "你好"
def test_duration(self):
s = SubtitleSegment(text="test", start=1.5, end=3.5)
assert s.duration == 2.0
def test_duration_negative_becomes_zero(self):
s = SubtitleSegment(text="test", start=5.0, end=3.0)
assert s.duration == 0.0
def test_char_count(self):
s = SubtitleSegment(text="你好世界", start=0, end=1)
assert s.char_count == 4
def test_char_count_empty(self):
s = SubtitleSegment(text="", start=0, end=1)
assert s.char_count == 0
def test_char_count_mixed(self):
s = SubtitleSegment(text="Hello 世界", start=0, end=1)
assert s.char_count == 8 # H-e-l-l-o- -世-界
# ── SubtitleTimeline 基础 ────────────────────────────────────────────────────
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性"""
def test_defaults(self):
tl = SubtitleTimeline()
assert tl.segments == []
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
def test_segment_count(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
assert tl.segment_count == 2
def test_segment_count_empty(self):
tl = SubtitleTimeline()
assert tl.segment_count == 0
def test_total_chars(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1),
SubtitleSegment("世界", 1, 2),
]
)
assert tl.total_chars == 4
def test_total_chars_empty(self):
tl = SubtitleTimeline()
assert tl.total_chars == 0
# ── merge_short_segments ─────────────────────────────────────────────────────
class TestMergeShortSegments:
"""merge_short_segments 合并过短片段"""
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments()
assert result.segment_count == 0
def test_single_segment(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == ""
def test_two_short_segments_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1), # 2
SubtitleSegment("世界", 1, 2), # 2
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 2.0
def test_multiple_short_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 0.5), # 1
SubtitleSegment("", 0.5, 1.0), # 1
SubtitleSegment("", 1.0, 1.5), # 1
SubtitleSegment("", 1.5, 2.0), # 1
SubtitleSegment("", 2.0, 2.5), # 1
SubtitleSegment("六七八", 2.5, 3.5), # 3
SubtitleSegment("八九十", 3.5, 4.5), # 3
]
)
result = tl.merge_short_segments(min_chars=5)
# 一二三四五 5个=5 → 合并为1段
# 六七八+八九十 3+3=6 → 合并为1段
assert result.segment_count == 2
assert result.segments[0].text == "一二三四五"
assert result.segments[1].text == "六七八八九十"
def test_long_segment_stays_alone(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("这是一段很长的字幕内容", 0, 2), # 11
SubtitleSegment("", 2, 2.5), # 1
SubtitleSegment("", 2.5, 3.0), # 1
]
)
result = tl.merge_short_segments(min_chars=8)
# 第一段11字>=8,单独输出;后两段加起来2字<8,合并到上一段
assert result.segment_count == 1
assert result.segments[0].text == "这是一段很长的字幕内容短语"
def test_tail_short_merged_with_previous(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五六七八", 0, 2), # 8
SubtitleSegment("", 2, 2.5), # 1,太短了
]
)
result = tl.merge_short_segments(min_chars=5)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八尾"
def test_preserves_language_and_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a", 0, 1)],
language="en",
total_duration=10.0,
)
result = tl.merge_short_segments()
assert result.language == "en"
assert result.total_duration == 10.0
def test_default_min_chars_is_8(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五", 0, 1), # 5 < 8
SubtitleSegment("六七八", 1, 2), # 3 → 5+3=8
]
)
result = tl.merge_short_segments()
assert result.segment_count == 1
def test_merges_words(self):
words1 = [SubtitleWord("", 0.0, 0.3), SubtitleWord("", 0.3, 0.6)]
words2 = [SubtitleWord("", 1.0, 1.3), SubtitleWord("", 1.3, 1.6)]
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0.0, 0.6, words=words1),
SubtitleSegment("世界", 1.0, 1.6, words=words2),
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
assert result.segments[0].words[0].text == ""
assert result.segments[0].words[3].text == ""
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
original_count = tl.segment_count
tl.merge_short_segments(min_chars=5)
assert tl.segment_count == original_count
# ── split_long_segments ──────────────────────────────────────────────────────
class TestSplitLongSegments:
"""split_long_segments 拆分过长片段"""
def test_short_segment_no_split(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("短文本", 0, 1),
]
)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 1
assert result.segments[0].text == "短文本"
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.split_long_segments()
assert result.segment_count == 0
def test_split_by_sentence_end(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(
"这是第一句话。这是第二句话。这是第三句话。",
start=0.0,
end=9.0,
),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 第一句应该是完整的
assert result.segments[0].text.endswith("")
def test_split_preserves_total_text(self):
original = "这是第一句话。这是第二句话。这是第三句话,很长的一句话。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(original, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=8)
# 拆分后所有片段拼起来应该等于原文
combined = "".join(s.text for s in result.segments)
assert combined == original
def test_split_time_proportional(self):
text = "一二三四五六七八九十。一二三四五六七八九十。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=12)
assert result.segment_count >= 2
# 第一段结束时间应该早于总时长
assert result.segments[0].end < 10.0
# 最后一段结束应该等于原结束时间
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_no_punctuation_hard_split(self):
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3
combined = "".join(s.text for s in result.segments)
assert combined == text
def test_multiple_mixed_segments(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
SubtitleSegment("这是一段非常非常长的字幕文本内容需要拆分", 1, 5),
SubtitleSegment("短的", 5, 6),
]
)
result = tl.split_long_segments(max_chars=10)
# 第一个和第三个保持不变,中间被拆分
assert result.segment_count > 3
assert result.segments[0].text == ""
assert result.segments[-1].text == "短的"
def test_preserves_language_and_total_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a" * 30, 0, 10)],
language="ja",
total_duration=20.0,
)
result = tl.split_long_segments(max_chars=10)
assert result.language == "ja"
assert result.total_duration == 20.0
def test_default_max_chars_is_20(self):
text = "" * 25
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=5),
]
)
result = tl.split_long_segments()
assert result.segment_count >= 2
def test_split_with_words(self):
words = [SubtitleWord(f"w{i}", i * 0.5, i * 0.5 + 0.4) for i in range(20)]
text = "".join(w.text for w in words)
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0, words=words),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 所有片段的词数之和应该等于原词数
total_words = sum(len(s.words) for s in result.segments)
assert total_words <= len(words) + 1 # 可能有边界误差
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a" * 30, 0, 10),
]
)
original_count = tl.segment_count
tl.split_long_segments(max_chars=10)
assert tl.segment_count == original_count
# ── _split_text_by_punctuation 静态方法 ─────────────────────────────────────
class TestSplitTextByPunctuation:
"""_split_text_by_punctuation 静态方法"""
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", max_chars=20)
assert len(result) == 1
assert result[0] == "短文本"
def test_sentence_end_punctuation_split(self):
result = SubtitleTimeline._split_text_by_punctuation(
"第一句。第二句。第三句。",
max_chars=5,
)
assert len(result) >= 2
assert result[0] == "第一句。"
def test_clause_pause_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"今天天气很好,阳光明媚,适合出去玩。",
max_chars=8,
)
assert len(result) >= 2
def test_exclamation_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"太精彩了!真的很棒!",
max_chars=5,
)
assert len(result) >= 2
def test_question_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"你是谁?从哪里来?",
max_chars=5,
)
assert len(result) >= 2
def test_english_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"Hello, world! How are you?",
max_chars=10,
)
assert len(result) >= 2
def test_no_punctuation_hard_split(self):
text = "" * 25
result = SubtitleTimeline._split_text_by_punctuation(text, max_chars=10)
assert len(result) >= 3
assert "".join(result) == text
def test_empty_string(self):
result = SubtitleTimeline._split_text_by_punctuation("", max_chars=10)
assert len(result) == 0 or (len(result) == 1 and result[0] == "")
def test_semicolon_colon(self):
result = SubtitleTimeline._split_text_by_punctuation(
"注意事项:第一,要认真;第二,要仔细。",
max_chars=8,
)
assert len(result) >= 2
# ── _merge_segments 静态方法 ────────────────────────────────────────────────
class TestMergeSegmentsStatic:
"""_merge_segments 静态方法"""
def test_empty_list(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_single_segment(self):
seg = SubtitleSegment("hello", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "hello"
assert result.start == 1.0
assert result.end == 2.0
def test_two_segments(self):
s1 = SubtitleSegment("你好", 0.0, 1.0)
s2 = SubtitleSegment("世界", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([s1, s2])
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 2.0
def test_merges_words(self):
w1 = [SubtitleWord("", 0, 0.5)]
w2 = [SubtitleWord("", 0.5, 1.0)]
s1 = SubtitleSegment("", 0, 0.5, words=w1)
s2 = SubtitleSegment("", 0.5, 1.0, words=w2)
result = SubtitleTimeline._merge_segments([s1, s2])
assert len(result.words) == 2
assert result.words[0].text == ""
assert result.words[1].text == ""
# ── 端到端:先合并再拆分 ────────────────────────────────────────────────────
class TestMergeAndSplit:
"""合并和拆分组合使用"""
def test_merge_then_split_roundtrip(self):
# 很多短句先合并,再按合理长度拆分
segments = [
SubtitleSegment("你好", 0, 0.5),
SubtitleSegment("我是小明", 0.5, 1.5),
SubtitleSegment("今天天气真好。", 1.5, 3.0),
SubtitleSegment("我们出去玩吧。", 3.0, 5.0),
]
tl = SubtitleTimeline(segments=segments)
merged = tl.merge_short_segments(min_chars=5)
split = merged.split_long_segments(max_chars=15)
# 结果应该合理(不保证完全一样,但文本应该完整)
original_text = "".join(s.text for s in segments)
result_text = "".join(s.text for s in split.segments)
assert original_text == result_text
@@ -1,496 +0,0 @@
"""模板片段转换器单测.
纯函数模块,覆盖:枚举安全解析、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")
-134
View File
@@ -1,134 +0,0 @@
"""EditTemplateVersion 单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from domain.template_version import EditTemplateVersion
class TestEditTemplateVersionCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
v = EditTemplateVersion.create("tmpl_001", 1)
assert v.id is not None
assert len(v.id) == 32
assert v.template_id == "tmpl_001"
assert v.version == 1
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
assert v.created_at is not None
def test_create_with_all_fields(self):
v = EditTemplateVersion.create(
"tmpl_001",
3,
name="第三版",
editing_mode="pip",
config={"bgm": True},
clip_configs=[{"id": "c1", "type": "video"}],
change_note="优化剪辑逻辑",
published_by="user_123",
)
assert v.template_id == "tmpl_001"
assert v.version == 3
assert v.name == "第三版"
assert v.editing_mode == "pip"
assert v.config == {"bgm": True}
assert v.clip_configs == [{"id": "c1", "type": "video"}]
assert v.change_note == "优化剪辑逻辑"
assert v.published_by == "user_123"
def test_create_config_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, config=None)
assert v.config == {}
def test_create_clip_configs_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, clip_configs=None)
assert v.clip_configs == []
def test_create_unique_ids(self):
v1 = EditTemplateVersion.create("t1", 1)
v2 = EditTemplateVersion.create("t1", 1)
assert v1.id != v2.id
def test_create_sets_created_at(self):
before = datetime.now(timezone.utc)
v = EditTemplateVersion.create("t1", 1)
after = datetime.now(timezone.utc)
assert before <= v.created_at <= after
class TestEditTemplateVersionConstruction:
"""直接构造测试."""
def test_direct_construction(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v = EditTemplateVersion(
id="v1",
template_id="t1",
version=5,
name="v5",
editing_mode="voice_over",
config={"key": "value"},
clip_configs=[{"a": 1}, {"b": 2}],
change_note="test",
published_by="admin",
created_at=now,
)
assert v.id == "v1"
assert v.template_id == "t1"
assert v.version == 5
assert v.name == "v5"
assert v.editing_mode == "voice_over"
assert v.config == {"key": "value"}
assert v.clip_configs == [{"a": 1}, {"b": 2}]
assert v.change_note == "test"
assert v.published_by == "admin"
assert v.created_at == now
def test_default_values(self):
v = EditTemplateVersion(id="v1", template_id="t1", version=1)
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
class TestEditTemplateVersionSlots:
"""slots 测试."""
def test_slots_no_extra_attrs(self):
v = EditTemplateVersion.create("t1", 1)
with pytest.raises((AttributeError, TypeError)):
v.new_field = "value" # type: ignore[attr-defined]
class TestEditTemplateVersionEquality:
"""相等性测试."""
def test_equal_same_id_and_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
assert v1 == v2
def test_not_equal_different_id(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="v1", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="v2", template_id="t1", version=1, created_at=now)
assert v1 != v2
def test_not_equal_different_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=2, created_at=now)
assert v1 != v2
-376
View File
@@ -1,376 +0,0 @@
"""转场配置领域模型单测.
纯逻辑模块,覆盖: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是zoomffmpeg映射到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
-314
View File
@@ -1,314 +0,0 @@
"""TtsConfig 单元测试."""
from __future__ import annotations
import pytest
from domain.tts_config import TtsConfig
class TestTtsConfigDefaults:
"""默认值测试."""
def test_default_values(self):
config = TtsConfig()
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
assert config.pitch == 0.0
assert config.volume == 0.8
assert config.text == ""
assert config.align_mode == "full"
assert config.overlap_mode == "replace"
def test_custom_construction(self):
config = TtsConfig(
enabled=True,
voice_id="voice_001",
speed=1.5,
pitch=3.0,
volume=0.9,
text="hello",
align_mode="subtitle",
overlap_mode="mix",
)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 3.0
assert config.volume == 0.9
assert config.text == "hello"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_slots_no_extra_attrs(self):
config = TtsConfig()
with pytest.raises((AttributeError, TypeError)):
config.new_attr = "value" # type: ignore[attr-defined]
def test_equality_same_values(self):
a = TtsConfig(enabled=True, voice_id="v1")
b = TtsConfig(enabled=True, voice_id="v1")
assert a == b
def test_equality_different_values(self):
a = TtsConfig(enabled=True)
b = TtsConfig(enabled=False)
assert a != b
class TestTtsConfigParseNoneAndEmpty:
"""parse 空输入测试."""
def test_parse_none(self):
config = TtsConfig.parse(None)
assert config == TtsConfig()
def test_parse_empty_dict(self):
config = TtsConfig.parse({})
assert config == TtsConfig()
def test_parse_non_dict_string(self):
config = TtsConfig.parse("not a dict") # type: ignore[arg-type]
assert config == TtsConfig()
def test_parse_non_dict_list(self):
config = TtsConfig.parse([]) # type: ignore[arg-type]
assert config == TtsConfig()
def test_parse_non_dict_number(self):
config = TtsConfig.parse(123) # type: ignore[arg-type]
assert config == TtsConfig()
class TestTtsConfigParseDisabled:
"""parse disabled 场景."""
def test_parse_enabled_false_returns_default(self):
config = TtsConfig.parse({"enabled": False})
assert config.enabled is False
assert config.speed == 1.0
assert config.voice_id == ""
def test_parse_enabled_false_ignores_other_fields(self):
config = TtsConfig.parse(
{
"enabled": False,
"voice_id": "v1",
"speed": 1.5,
}
)
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
def test_parse_enabled_non_bool_falls_to_false(self):
config = TtsConfig.parse({"enabled": "true"})
assert config.enabled is False
def test_parse_enabled_int_falls_to_false(self):
config = TtsConfig.parse({"enabled": 1})
assert config.enabled is False
class TestTtsConfigParseNormal:
"""parse 正常数据测试."""
def test_parse_full_data(self):
data = {
"enabled": True,
"voice_id": "voice_001",
"speed": 1.5,
"pitch": 2.5,
"volume": 0.7,
"text": "你好世界",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
config = TtsConfig.parse(data)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 2.5
assert config.volume == 0.7
assert config.text == "你好世界"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_parse_int_speed_becomes_float(self):
config = TtsConfig.parse({"enabled": True, "speed": 2})
assert isinstance(config.speed, float)
assert config.speed == 2.0
def test_parse_int_pitch_becomes_float(self):
config = TtsConfig.parse({"enabled": True, "pitch": -3})
assert isinstance(config.pitch, float)
assert config.pitch == -3.0
class TestTtsConfigParseTypeFallback:
"""parse 类型错误回退测试."""
def test_parse_voice_id_non_string_fallback(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert config.pitch == 0.0
def test_parse_volume_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text_non_string_fallback(self):
config = TtsConfig.parse({"enabled": True, "text": 456})
assert config.text == ""
def test_parse_voice_id_list_fallback(self):
config = TtsConfig.parse({"enabled": True, "voice_id": ["v1"]})
assert config.voice_id == ""
class TestTtsConfigParseClamp:
"""parse 边界钳制测试."""
def test_parse_speed_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert config.speed == 0.5
def test_parse_speed_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert config.speed == 2.0
def test_parse_speed_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert config.speed == 0.5
def test_parse_speed_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert config.speed == 2.0
def test_parse_pitch_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
assert config.pitch == -12
def test_parse_pitch_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
assert config.pitch == 12
def test_parse_pitch_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": -12})
assert config.pitch == -12
def test_parse_pitch_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": 12})
assert config.pitch == 12
def test_parse_volume_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert config.volume == 0.0
def test_parse_volume_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
assert config.volume == 1.0
def test_parse_volume_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
assert config.volume == 0.0
def test_parse_volume_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
assert config.volume == 1.0
class TestTtsConfigParseAlignMode:
"""align_mode 解析测试."""
def test_parse_align_mode_subtitle(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
assert config.align_mode == "subtitle"
def test_parse_align_mode_full(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
assert config.align_mode == "full"
def test_parse_align_mode_invalid_fallback(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "auto"})
assert config.align_mode == "full"
def test_parse_align_mode_empty_fallback(self):
config = TtsConfig.parse({"enabled": True, "align_mode": ""})
assert config.align_mode == "full"
class TestTtsConfigParseOverlapMode:
"""overlap_mode 解析测试."""
def test_parse_overlap_mode_replace(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_mix(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert config.overlap_mode == "mix"
def test_parse_overlap_mode_invalid_fallback(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "add"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_empty_fallback(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": ""})
assert config.overlap_mode == "replace"
class TestTtsConfigClamp:
"""_clamp 直接调用测试."""
def test_clamp_speed_low(self):
config = TtsConfig(enabled=True, speed=0.1)
config._clamp()
assert config.speed == 0.5
def test_clamp_speed_high(self):
config = TtsConfig(enabled=True, speed=5.0)
config._clamp()
assert config.speed == 2.0
def test_clamp_speed_normal_unchanged(self):
config = TtsConfig(enabled=True, speed=1.2)
config._clamp()
assert config.speed == 1.2
def test_clamp_pitch_low(self):
config = TtsConfig(enabled=True, pitch=-20)
config._clamp()
assert config.pitch == -12
def test_clamp_pitch_high(self):
config = TtsConfig(enabled=True, pitch=20)
config._clamp()
assert config.pitch == 12
def test_clamp_pitch_normal_unchanged(self):
config = TtsConfig(enabled=True, pitch=5.0)
config._clamp()
assert config.pitch == 5.0
def test_clamp_volume_low(self):
config = TtsConfig(enabled=True, volume=-1.0)
config._clamp()
assert config.volume == 0.0
def test_clamp_volume_high(self):
config = TtsConfig(enabled=True, volume=2.0)
config._clamp()
assert config.volume == 1.0
def test_clamp_volume_normal_unchanged(self):
config = TtsConfig(enabled=True, volume=0.5)
config._clamp()
assert config.volume == 0.5
-502
View File
@@ -1,502 +0,0 @@
"""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}
-280
View File
@@ -1,280 +0,0 @@
"""VerificationCode 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.id is not None
assert len(vc.id) == 32
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_login"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
assert vc.created_at is not None
assert vc.expires_at > vc.created_at
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_login")
assert vc.recipient == "test@example.com"
def test_create_custom_code(self):
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
assert vc.code == "123456"
def test_create_custom_ttl(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
assert vc.expires_at == fixed_now + timedelta(seconds=60)
def test_create_default_ttl_300(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.expires_at == fixed_now + timedelta(seconds=300)
def test_create_unique_ids(self):
vc1 = VerificationCode.create("a@b.com", "email_login")
vc2 = VerificationCode.create("a@b.com", "email_login")
assert vc1.id != vc2.id
def test_create_unique_codes(self):
codes = set()
for _ in range(20):
vc = VerificationCode.create("a@b.com", "email_login")
codes.add(vc.code)
# 20个随机6位码几乎肯定不都一样
assert len(codes) > 1
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_all_code_types(self):
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
vc = VerificationCode.create("test@example.com", ct)
assert vc.code_type == ct
class TestVerificationCodeIsExpired:
"""is_expired 属性测试."""
def test_not_expired_future(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_expired is False
def test_expired_past(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_expired is True
def test_expired_boundary_exact(self):
# 用mock固定时间,expires_at等于当前时间不算过期
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=fixed_now,
)
assert vc.is_expired is False
class TestVerificationCodeIsUsed:
"""is_used 属性测试."""
def test_not_used_default(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.is_used is False
def test_is_used_after_mark(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
assert vc.is_used is True
class TestVerificationCodeIsValid:
"""is_valid 属性测试."""
def test_valid_fresh(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_valid is True
def test_invalid_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_valid is False
def test_invalid_used(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
vc.mark_used()
assert vc.is_valid is False
def test_invalid_expired_and_used(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeMarkUsed:
"""mark_used 方法测试."""
def test_mark_used_sets_timestamp(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.used_at is None
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert vc.used_at is not None
assert before <= vc.used_at <= after
def test_mark_used_twice_overwrites(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
first = vc.used_at
# 时间足够短,一般不会不同,但确保可以重复调用
vc.mark_used()
assert vc.used_at is not None
class TestVerificationCodeIncrementAttempts:
"""increment_attempts 方法测试."""
def test_default_zero(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.attempts == 0
def test_increment_once(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_multiple(self):
vc = VerificationCode.create("a@b.com", "email_login")
for _i in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeBasics:
"""基础构造和 slots 测试."""
def test_direct_construction(self):
now = datetime.now(timezone.utc)
vc = VerificationCode(
id="abc123",
recipient="test@test.com",
code="000000",
code_type="email_bind",
expires_at=now + timedelta(minutes=5),
used_at=None,
attempts=0,
created_at=now,
)
assert vc.id == "abc123"
assert vc.recipient == "test@test.com"
assert vc.code == "000000"
def test_slots_no_extra_attrs(self):
vc = VerificationCode.create("a@b.com", "email_login")
with pytest.raises((AttributeError, TypeError)):
vc.new_field = "value" # type: ignore[attr-defined]
def test_equality_same_id(self):
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
vc1 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
vc2 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
assert vc1 == vc2
def test_equality_different_id(self):
now = datetime.now(timezone.utc)
vc1 = VerificationCode(
id="id1",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
vc2 = VerificationCode(
id="id2",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
assert vc1 != vc2
-392
View File
@@ -1,392 +0,0 @@
"""video_concat 视频拼接配置单测."""
import pytest
from domain.video_concat import (
ALLOWED_VIDEO_EXTENSIONS,
CONCAT_DEMUXER_REQUIRED_PARAMS,
MAX_CONCAT_SEGMENTS,
ConcatConfig,
ConcatSegment,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_max_concat_segments(self):
assert MAX_CONCAT_SEGMENTS == 50
def test_allowed_extensions(self):
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
assert ".flv" in ALLOWED_VIDEO_EXTENSIONS
assert ".wmv" in ALLOWED_VIDEO_EXTENSIONS
def test_concat_demuxer_params(self):
params = CONCAT_DEMUXER_REQUIRED_PARAMS
assert "codec_name" in params
assert "width" in params
assert "height" in params
assert "r_frame_rate" in params
assert "pix_fmt" in params
assert "sample_rate" in params
assert "channels" in params
assert "audio_codec" in params
assert len(params) == 8
# ── ConcatSegment ────────────────────────────────────────────────────────────
class TestConcatSegmentDefaults:
"""ConcatSegment 默认值"""
def test_required_path(self):
s = ConcatSegment(video_path="/video.mp4")
assert s.video_path == "/video.mp4"
assert s.start_time == 0.0
assert s.duration == 0.0
assert s.has_audio is True
def test_all_custom(self):
s = ConcatSegment(
video_path="/clip.mp4",
start_time=5.0,
duration=10.0,
has_audio=False,
)
assert s.video_path == "/clip.mp4"
assert s.start_time == 5.0
assert s.duration == 10.0
assert s.has_audio is False
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict"""
def test_none_returns_empty_path(self):
s = ConcatSegment.from_dict(None)
assert s.video_path == ""
assert s.is_valid is False
def test_empty_dict(self):
s = ConcatSegment.from_dict({})
assert s.video_path == ""
def test_not_dict(self):
s = ConcatSegment.from_dict("not a dict")
assert s.video_path == ""
def test_full_dict(self):
s = ConcatSegment.from_dict(
{
"video_path": "/clip.mp4",
"start_time": 2.5,
"duration": 15.0,
"has_audio": False,
}
)
assert s.video_path == "/clip.mp4"
assert s.start_time == 2.5
assert s.duration == 15.0
assert s.has_audio is False
def test_invalid_start_time_falls_back(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": "bad"})
assert s.start_time == 0.0
def test_negative_start_time_clamped(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": -5.0})
assert s.start_time == 0.0
def test_invalid_duration_falls_back(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": None})
assert s.duration == 0.0
def test_negative_duration_clamped(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": -10.0})
assert s.duration == 0.0
def test_has_audio_default_true(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4"})
assert s.has_audio is True
def test_has_audio_false(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "has_audio": False})
assert s.has_audio is False
def test_path_is_string(self):
s = ConcatSegment.from_dict({"video_path": 123})
assert s.video_path == "123"
class TestConcatSegmentProperties:
"""ConcatSegment 属性方法"""
def test_is_valid_true(self):
s = ConcatSegment(video_path="/a.mp4")
assert s.is_valid is True
def test_is_valid_false_empty(self):
s = ConcatSegment(video_path="")
assert s.is_valid is False
def test_effective_duration_positive(self):
s = ConcatSegment(video_path="/a.mp4", duration=10.0)
assert s.effective_duration == 10.0
def test_effective_duration_zero(self):
s = ConcatSegment(video_path="/a.mp4", duration=0.0)
assert s.effective_duration == 0.0
def test_effective_duration_negative(self):
s = ConcatSegment(video_path="/a.mp4", duration=-5.0)
assert s.effective_duration == 0.0
# ── ConcatConfig ─────────────────────────────────────────────────────────────
class TestConcatConfigDefaults:
"""ConcatConfig 默认值"""
def test_default_values(self):
c = ConcatConfig()
assert c.segments == []
assert c.output_width == 0
assert c.output_height == 0
assert c.output_fps == 0.0
assert c.force_reencode is False
assert c.transition == "none"
assert c.transition_duration == 0.3
class TestConcatConfigFromDict:
"""ConcatConfig.from_config_dict"""
def test_none_returns_default(self):
c = ConcatConfig.from_config_dict(None)
assert c.segments == []
def test_empty_dict_returns_default(self):
c = ConcatConfig.from_config_dict({})
assert c.segments == []
def test_not_dict_returns_default(self):
c = ConcatConfig.from_config_dict("config")
assert c.segments == []
def test_single_segment(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4", "duration": 10.0},
],
}
)
assert len(c.segments) == 1
assert c.segments[0].video_path == "/a.mp4"
def test_multiple_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4", "duration": 10.0},
{"video_path": "/b.mp4", "duration": 20.0},
{"video_path": "/c.mp4", "duration": 15.0},
],
}
)
assert len(c.segments) == 3
def test_skip_no_path_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"duration": 5.0}, # 没有 video_path
{"video_path": "/b.mp4"},
],
}
)
assert len(c.segments) == 2
def test_skip_non_dict_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4"},
"not a dict",
123,
None,
{"video_path": "/b.mp4"},
],
}
)
assert len(c.segments) == 2
def test_output_resolution(self):
c = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_width": 1920,
"output_height": 1080,
}
)
assert c.output_width == 1920
assert c.output_height == 1080
def test_output_width_clamped(self):
c = ConcatConfig.from_config_dict({"output_width": -100})
assert c.output_width == 0
def test_invalid_output_width_falls_back(self):
c = ConcatConfig.from_config_dict({"output_width": "wide"})
assert c.output_width == 0
def test_output_fps(self):
c = ConcatConfig.from_config_dict({"output_fps": 30.0})
assert c.output_fps == 30.0
def test_output_fps_clamped(self):
c = ConcatConfig.from_config_dict({"output_fps": -1.0})
assert c.output_fps == 0.0
def test_invalid_output_fps_falls_back(self):
c = ConcatConfig.from_config_dict({"output_fps": "fast"})
assert c.output_fps == 0.0
def test_force_reencode_true(self):
c = ConcatConfig.from_config_dict({"force_reencode": True})
assert c.force_reencode is True
def test_transition_crossfade(self):
c = ConcatConfig.from_config_dict({"transition": "crossfade"})
assert c.transition == "crossfade"
def test_transition_duration(self):
c = ConcatConfig.from_config_dict({"transition_duration": 1.0})
assert c.transition_duration == 1.0
def test_transition_duration_min_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.01})
# max(0.1, 0.01) = 0.1
assert c.transition_duration == 0.1
# 代码里 transition_duration = max(0.1, ...),默认 0.3
# 0.01 < 0.1 ,所以被钳制到 0.1
def test_invalid_transition_duration_falls_back(self):
c = ConcatConfig.from_config_dict({"transition_duration": "long"})
assert c.transition_duration == 0.3
def test_segments_not_list_ignored(self):
c = ConcatConfig.from_config_dict({"segments": "not a list"})
assert c.segments == []
class TestConcatConfigProperties:
"""ConcatConfig 属性方法"""
def _make_config(self, n=3):
return ConcatConfig.from_config_dict(
{
"segments": [{"video_path": f"/s{i}.mp4", "duration": 10.0 + i} for i in range(n)],
}
)
def test_has_effect_true(self):
c = self._make_config(3)
assert c.has_effect is True
def test_has_effect_false_one_segment(self):
c = self._make_config(1)
assert c.has_effect is False
def test_has_effect_false_empty(self):
c = ConcatConfig()
assert c.has_effect is False
def test_valid_segment_count(self):
c = self._make_config(5)
assert c.valid_segment_count == 5
def test_total_segments_alias(self):
c = self._make_config(4)
assert c.total_segments == 4
assert c.total_segments == c.valid_segment_count
def test_first_valid_segment(self):
c = self._make_config(3)
first = c.first_valid_segment
assert first is not None
assert first.video_path == "/s0.mp4"
def test_first_valid_segment_empty(self):
c = ConcatConfig()
assert c.first_valid_segment is None
def test_estimated_total_duration(self):
c = ConcatConfig(
segments=[
ConcatSegment("/a.mp4", duration=10.0),
ConcatSegment("/b.mp4", duration=20.0),
ConcatSegment("/c.mp4", duration=0.0), # 不计入
]
)
assert c.estimated_total_duration == 30.0
def test_estimated_total_duration_empty(self):
c = ConcatConfig()
assert c.estimated_total_duration == 0.0
def test_clamp_segments_within_limit(self):
c = self._make_config(10)
original = len(c.segments)
c.clamp_segments(max_segments=50)
assert len(c.segments) == original
def test_clamp_segments_over_limit(self):
c = self._make_config(10)
c.clamp_segments(max_segments=3)
assert len(c.segments) == 3
assert c.segments[0].video_path == "/s0.mp4"
assert c.segments[2].video_path == "/s2.mp4"
def test_clamp_segments_default_max(self):
# 默认应该是 MAX_CONCAT_SEGMENTS
c = ConcatConfig(segments=[ConcatSegment(f"/s{i}.mp4") for i in range(100)])
c.clamp_segments()
assert len(c.segments) == MAX_CONCAT_SEGMENTS
class TestTransitionDurationClamp:
"""transition_duration 钳制边界"""
def test_min_boundary_01(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.1})
assert c.transition_duration == 0.1
def test_below_min_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.05})
# max(0.1, 0.05) = 0.1
assert c.transition_duration == 0.1
def test_large_duration_ok(self):
c = ConcatConfig.from_config_dict({"transition_duration": 5.0})
assert c.transition_duration == 5.0
def test_zero_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.0})
# max(0.1, 0.0) = 0.1
assert c.transition_duration == 0.1
def test_negative_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": -1.0})
# max(0.1, -1.0) = 0.1
assert c.transition_duration == 0.1
-290
View File
@@ -1,290 +0,0 @@
"""video_share 视频分享领域实体单测."""
from datetime import datetime, timedelta, timezone
import pytest
from domain.video_share import (
VideoShare,
_hash_password,
generate_share_token,
)
# ── _hash_password ───────────────────────────────────────────────────────────
class TestHashPassword:
"""_hash_password 函数"""
def test_empty_password_returns_empty(self):
assert _hash_password("") == ""
def test_none_password_returns_empty(self):
assert _hash_password(None) == ""
def test_same_password_same_hash(self):
h1 = _hash_password("mypassword")
h2 = _hash_password("mypassword")
assert h1 == h2
def test_different_passwords_different_hashes(self):
h1 = _hash_password("password1")
h2 = _hash_password("password2")
assert h1 != h2
def test_hash_is_hex_string(self):
h = _hash_password("test")
assert isinstance(h, str)
assert len(h) == 64 # SHA-256 hex
int(h, 16) # 应该能被解析为16进制
def test_hash_contains_salt(self):
# 直接的 SHA-256(password) 应该不等于加盐后的
from hashlib import sha256
raw = sha256("mypass".encode()).hexdigest()
salted = _hash_password("mypass")
assert raw != salted
# ── generate_share_token ─────────────────────────────────────────────────────
class TestGenerateShareToken:
"""generate_share_token 函数"""
def test_default_length(self):
token = generate_share_token()
assert len(token) == 12
def test_custom_length(self):
token = generate_share_token(20)
assert len(token) == 20
def test_short_token(self):
token = generate_share_token(6)
assert len(token) == 6
def test_url_friendly_chars(self):
token = generate_share_token(100)
# 不应该有容易混淆的字符 i,l,o,0,1
assert "i" not in token
assert "l" not in token
assert "o" not in token
assert "0" not in token
assert "1" not in token
def test_unique_tokens(self):
tokens = {generate_share_token() for _ in range(100)}
assert len(tokens) == 100 # 应该都是唯一的
def test_alphanumeric(self):
token = generate_share_token(50)
assert token.isalnum()
# ── VideoShare.create ───────────────────────────────────────────────────────
class TestVideoShareCreate:
"""VideoShare.create 工厂方法"""
def test_minimal_create(self):
s = VideoShare.create(video_id="vid_001", user_id="user_001")
assert s.id is not None
assert len(s.id) == 32 # uuid4 hex
assert s.video_id == "vid_001"
assert s.user_id == "user_001"
assert s.share_token is not None
assert len(s.share_token) == 12
assert s.password_hash is None
assert s.expires_at is None
assert s.view_count == 0
assert s.download_count == 0
assert s.is_active is True
def test_with_password(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
assert s.password_hash is not None
assert s.password_hash != "secret123" # 不是明文
assert len(s.password_hash) == 64 # SHA-256
def test_with_expiry(self):
future = datetime.now(timezone.utc) + timedelta(days=7)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.expires_at == future
def test_empty_video_id_raises(self):
with pytest.raises(ValueError, match="video_id"):
VideoShare.create(video_id="", user_id="u1")
def test_whitespace_video_id_raises(self):
with pytest.raises(ValueError):
VideoShare.create(video_id=" ", user_id="u1")
def test_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
VideoShare.create(video_id="v1", user_id="")
def test_past_expiry_raises(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
with pytest.raises(ValueError, match="past"):
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
def test_video_id_stripped(self):
s = VideoShare.create(video_id=" vid_123 ", user_id="u1")
assert s.video_id == "vid_123"
def test_user_id_stripped(self):
s = VideoShare.create(video_id="v1", user_id=" user_456 ")
assert s.user_id == "user_456"
def test_unique_ids(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.id != s2.id
def test_unique_tokens(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.share_token != s2.share_token
def test_timestamps_set(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.created_at.tzinfo is not None
assert s.updated_at.tzinfo is not None
# ── VideoShare 属性方法 ─────────────────────────────────────────────────────
class TestVideoShareProperties:
"""VideoShare 属性方法"""
def test_has_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="pass")
assert s.has_password is True
def test_has_password_false(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.has_password is False
def test_is_expired_false_no_expiry(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_expired is False
def test_is_expired_false_future_expiry(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.is_expired is False
def test_is_expired_true_past_expiry(self):
# 直接构造一个已过期的
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_expired is True
def test_is_accessible_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
def test_is_accessible_false_inactive(self):
s = VideoShare.create(video_id="v1", user_id="u1")
s.is_active = False
assert s.is_accessible is False
def test_is_accessible_false_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_accessible is False
# ── VideoShare 方法 ─────────────────────────────────────────────────────────
class TestVideoShareMethods:
"""VideoShare 方法"""
def test_verify_password_no_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.verify_password("anything") is True
assert s.verify_password("") is True
def test_verify_password_correct(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("mypass") is True
def test_verify_password_wrong(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("wrongpass") is False
def test_verify_password_empty_false(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("") is False
def test_increment_view_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.view_count == 0
s.increment_view_count()
assert s.view_count == 1
s.increment_view_count()
assert s.view_count == 2
def test_increment_download_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.download_count == 0
s.increment_download_count()
assert s.download_count == 1
s.increment_download_count()
assert s.download_count == 2
def test_revoke(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_active is True
s.revoke()
assert s.is_active is False
def test_revoke_makes_inaccessible(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
s.revoke()
assert s.is_accessible is False
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
class TestVideoShareBasics:
"""VideoShare 基础特性"""
def test_slots_no_extra_attrs(self):
s = VideoShare.create(video_id="v1", user_id="u1")
with pytest.raises(AttributeError):
s.nonexistent = "value"
def test_direct_construction(self):
s = VideoShare(
id="custom_id",
video_id="v1",
user_id="u1",
share_token="abc123",
)
assert s.id == "custom_id"
assert s.share_token == "abc123"
def test_equality_same_id(self):
now = datetime.now(timezone.utc)
s1 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
s2 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
assert s1 == s2
@@ -1,501 +0,0 @@
"""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
-368
View File
@@ -1,368 +0,0 @@
"""voice_presets 配音音色预设模块单测."""
import pytest
from domain.voice_presets import (
MOCK_VOICES,
VoiceGender,
VoicePreset,
VoiceStyle,
get_default_voice,
get_voice,
list_voices,
)
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
class TestVoiceGender:
"""VoiceGender 音色性别枚举"""
def test_enum_values(self):
assert VoiceGender.MALE.value == "male"
assert VoiceGender.FEMALE.value == "female"
assert VoiceGender.CHILD.value == "child"
def test_is_str(self):
assert isinstance(VoiceGender.MALE, str)
assert VoiceGender.FEMALE == "female"
def test_from_string(self):
assert VoiceGender("male") == VoiceGender.MALE
assert VoiceGender("child") == VoiceGender.CHILD
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceGender("unknown")
class TestVoiceStyle:
"""VoiceStyle 音色风格枚举"""
def test_enum_values(self):
assert VoiceStyle.STABLE.value == "stable"
assert VoiceStyle.LIVELY.value == "lively"
assert VoiceStyle.CUSTOMER_SERVICE.value == "customer_service"
assert VoiceStyle.NARRATION.value == "narration"
assert VoiceStyle.NEWS.value == "news"
assert VoiceStyle.STORY.value == "story"
def test_is_str(self):
assert isinstance(VoiceStyle.NARRATION, str)
assert VoiceStyle.STORY == "story"
def test_from_string(self):
assert VoiceStyle("news") == VoiceStyle.NEWS
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceStyle("rock")
# ── VoicePreset dataclass ─────────────────────────────────────────────────────
class TestVoicePreset:
"""VoicePreset 音色预设 dataclass"""
def test_minimal_creation(self):
v = VoicePreset(voice_id="test_voice", name="测试音色")
assert v.voice_id == "test_voice"
assert v.name == "测试音色"
# 默认值
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.description == ""
assert v.provider == "mock"
assert v.provider_voice_id == ""
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_full_creation(self):
v = VoicePreset(
voice_id="male_deep",
name="深沉男声",
gender=VoiceGender.MALE,
style=VoiceStyle.STABLE,
description="非常深沉的男声",
provider="aliyun",
provider_voice_id="zhiyuan",
default_speed=0.8,
default_pitch=-1.0,
sample_rate=16000,
language="zh-CN",
)
assert v.voice_id == "male_deep"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.provider == "aliyun"
assert v.default_speed == 0.8
assert v.sample_rate == 16000
def test_str_gender_creation(self):
# 用字符串值创建也可以(因为是 StrEnum)
v = VoicePreset(voice_id="v1", name="V1", gender="male")
assert v.gender == VoiceGender.MALE
def test_str_style_creation(self):
v = VoicePreset(voice_id="v1", name="V1", style="news")
assert v.style == VoiceStyle.NEWS
def test_equality(self):
v1 = VoicePreset(voice_id="same", name="同名")
v2 = VoicePreset(voice_id="same", name="同名")
assert v1 == v2
def test_inequality(self):
v1 = VoicePreset(voice_id="a", name="A")
v2 = VoicePreset(voice_id="b", name="B")
assert v1 != v2
def test_slots_no_extra_attrs(self):
v = VoicePreset(voice_id="test", name="Test")
with pytest.raises(AttributeError):
v.nonexistent_field = "value"
# ── MOCK_VOICES 列表 ─────────────────────────────────────────────────────────
class TestMockVoices:
"""Mock 音色预设列表"""
def test_not_empty(self):
assert len(MOCK_VOICES) > 0
def test_count(self):
assert len(MOCK_VOICES) == 8
def test_all_are_voice_preset(self):
for v in MOCK_VOICES:
assert isinstance(v, VoicePreset)
def test_unique_voice_ids(self):
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids))
def test_female_warm_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_warm")
assert v.name == "温暖女声"
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.default_speed == 1.0
assert "温柔" in v.description
def test_male_stable_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "male_stable")
assert v.name == "沉稳男声"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.default_speed == 0.9
def test_female_lively_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_lively")
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.LIVELY
assert v.default_speed == 1.2
assert v.default_pitch == 2.0
def test_child_cute_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "child_cute")
assert v.gender == VoiceGender.CHILD
assert v.style == VoiceStyle.STORY
assert v.default_pitch == 4.0
def test_all_mock_provider(self):
for v in MOCK_VOICES:
assert v.provider == "mock"
def test_all_have_provider_voice_id(self):
for v in MOCK_VOICES:
assert v.provider_voice_id != ""
def test_all_chinese(self):
for v in MOCK_VOICES:
assert v.language == "zh-CN"
# ── get_voice ─────────────────────────────────────────────────────────────────
class TestGetVoice:
"""get_voice 函数"""
def test_get_existing_voice(self):
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_get_male_stable(self):
v = get_voice("male_stable")
assert v is not None
assert v.gender == VoiceGender.MALE
def test_get_child_cute(self):
v = get_voice("child_cute")
assert v is not None
assert v.gender == VoiceGender.CHILD
def test_get_nonexistent_returns_none(self):
v = get_voice("nonexistent_voice")
assert v is None
def test_get_empty_string_returns_none(self):
v = get_voice("")
assert v is None
def test_non_mock_provider_returns_none(self):
v = get_voice("female_warm", provider="aliyun")
assert v is None
def test_non_mock_provider_nonexistent(self):
v = get_voice("whatever", provider="xunfei")
assert v is None
def test_mock_provider_explicit(self):
v = get_voice("female_warm", provider="mock")
assert v is not None
assert v.voice_id == "female_warm"
def test_returns_same_instance(self):
# 应该返回同一个对象(缓存的)
v1 = get_voice("female_warm")
v2 = get_voice("female_warm")
assert v1 is v2
# ── list_voices ───────────────────────────────────────────────────────────────
class TestListVoices:
"""list_voices 函数"""
def test_no_filters_returns_all(self):
result = list_voices()
assert len(result) == len(MOCK_VOICES)
assert len(result) == 8
def test_filter_by_gender_male(self):
result = list_voices(gender="male")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_filter_by_gender_female(self):
result = list_voices(gender="female")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
def test_filter_by_gender_child(self):
result = list_voices(gender="child")
assert len(result) == 1
assert result[0].voice_id == "child_cute"
def test_filter_by_gender_invalid_returns_empty(self):
result = list_voices(gender="alien")
assert len(result) == 0
def test_filter_by_style_stable(self):
result = list_voices(style="stable")
assert len(result) > 0
for v in result:
assert v.style == VoiceStyle.STABLE
def test_filter_by_style_lively(self):
result = list_voices(style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_filter_by_style_story(self):
result = list_voices(style="story")
assert len(result) >= 2
for v in result:
assert v.style == VoiceStyle.STORY
def test_filter_by_style_invalid_returns_empty(self):
result = list_voices(style="punk")
assert len(result) == 0
def test_filter_by_provider_mock(self):
result = list_voices(provider="mock")
assert len(result) == len(MOCK_VOICES)
def test_filter_by_provider_other_returns_empty(self):
result = list_voices(provider="aliyun")
assert len(result) == 0
def test_filter_by_keyword_name(self):
result = list_voices(keyword="女声")
assert len(result) > 0
for v in result:
assert "女声" in v.name or "女声" in v.description or "女声" in v.voice_id
def test_filter_by_keyword_description(self):
result = list_voices(keyword="商务")
assert len(result) > 0
# 沉稳男声描述里有"商务"
def test_filter_by_keyword_voice_id(self):
result = list_voices(keyword="male_stable")
assert len(result) == 1
assert result[0].voice_id == "male_stable"
def test_filter_by_keyword_case_insensitive(self):
result1 = list_voices(keyword="Female")
result2 = list_voices(keyword="female")
assert len(result1) == len(result2)
def test_filter_by_keyword_nonexistent(self):
result = list_voices(keyword="不存在的关键词999")
assert len(result) == 0
def test_combined_gender_and_style(self):
result = list_voices(gender="female", style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_combined_gender_style_keyword(self):
result = list_voices(gender="male", style="story", keyword="磁性")
assert len(result) == 1
assert result[0].voice_id == "male_magnetic"
def test_combined_no_match(self):
result = list_voices(gender="child", style="news")
assert len(result) == 0
def test_returns_new_list(self):
# 修改返回值不应影响原始列表
result = list_voices()
result.clear()
assert len(MOCK_VOICES) == 8
# ── get_default_voice ─────────────────────────────────────────────────────────
class TestGetDefaultVoice:
"""get_default_voice 函数"""
def test_returns_voice_preset(self):
v = get_default_voice()
assert isinstance(v, VoicePreset)
def test_returns_first_mock_voice(self):
v = get_default_voice()
assert v == MOCK_VOICES[0]
def test_default_is_female_warm(self):
v = get_default_voice()
assert v.voice_id == "female_warm"
assert v.gender == VoiceGender.FEMALE
def test_multiple_calls_same(self):
v1 = get_default_voice()
v2 = get_default_voice()
assert v1 is v2
+473 -299
View File
@@ -1,6 +1,9 @@
"""bgm_mixer_pure 单元测试."""
"""BGM 混音纯逻辑单元测试."""
from apps.worker.video_processing.bgm_mixer_pure import (
from __future__ import annotations
import pytest
from video_processing.bgm_mixer_pure import (
BGMPureConfig,
build_bgm_filter_chain,
build_sidechain_mix_filter,
@@ -14,286 +17,408 @@ from apps.worker.video_processing.bgm_mixer_pure import (
validate_bgm_config,
)
# ── BGMPureConfig ────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# should_loop_bgm 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBGMPureConfig:
def test_default_values(self):
cfg = BGMPureConfig()
assert cfg.volume == 0.3
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
assert cfg.loop_enabled is True
assert cfg.sidechain_enabled is False
assert cfg.sidechain_ratio == 0.3
assert cfg.sidechain_attack == 0.02
assert cfg.sidechain_release == 0.5
assert cfg.sidechain_threshold == -25.0
class TestShouldLoopBGM:
"""BGM 循环判断测试."""
def test_custom_values(self):
cfg = BGMPureConfig(
volume=0.5,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
)
assert cfg.volume == 0.5
assert cfg.loop_enabled is False
assert cfg.sidechain_enabled is True
assert cfg.sidechain_ratio == 0.5
def test_need_loop_when_much_shorter(self):
"""BGM 远短于目标时长,需要循环."""
assert should_loop_bgm(10, 100, True) is True
def test_no_loop_when_long_enough(self):
"""BGM 够长,不需要循环."""
assert should_loop_bgm(100, 100, True) is False
# ── should_loop_bgm ─────────────────────────────────────────────────────────
def test_no_loop_when_just_slightly_shorter(self):
"""BGM 只差一点点(>90%),不循环."""
assert should_loop_bgm(95, 100, True) is False
def test_threshold_90_percent(self):
"""刚好 90% 阈值,不循环(<90% 才循环)."""
assert should_loop_bgm(90, 100, True) is False
class TestShouldLoopBgm:
def test_loop_enabled_much_shorter(self):
# BGM 10秒,目标60秒 → 需要循环
assert should_loop_bgm(10, 60) is True
def test_just_below_threshold(self):
"""略低于 90%,需要循环."""
assert should_loop_bgm(89, 100, True) is True
def test_loop_disabled(self):
assert should_loop_bgm(10, 60, loop_enabled=False) is False
def test_bgm_longer_than_target(self):
# BGM 100秒,目标60秒 → 不需要循环
assert should_loop_bgm(100, 60) is False
def test_bgm_slightly_shorter_no_loop(self):
# BGM 58秒,目标60秒 → 58 > 60*0.9=54,不需要循环
assert should_loop_bgm(58, 60) is False
def test_bgm_significantly_shorter_loops(self):
# BGM 50秒,目标60秒 → 50 < 54,需要循环
assert should_loop_bgm(50, 60) is True
"""禁用循环,即使 BGM 很短也不循环."""
assert should_loop_bgm(10, 100, False) is False
def test_zero_bgm_duration(self):
assert should_loop_bgm(0, 60) is False
"""BGM 时长为 0,不循环."""
assert should_loop_bgm(0, 100, True) is False
def test_negative_bgm_duration(self):
assert should_loop_bgm(-1, 60) is False
"""BGM 时长为负,不循环."""
assert should_loop_bgm(-5, 100, True) is False
def test_zero_target_duration(self):
assert should_loop_bgm(10, 0) is False
"""目标时长为 0,不循环."""
assert should_loop_bgm(10, 0, True) is False
def test_negative_target_duration(self):
assert should_loop_bgm(10, -1) is False
def test_exact_90_percent_no_loop(self):
# 边界:bgm == target * 0.9 → 不小于,不循环
assert should_loop_bgm(54, 60) is False
"""目标时长为负,不循环."""
assert should_loop_bgm(10, -10, True) is False
# ── calculate_loop_count ────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# calculate_loop_count 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateLoopCount:
def test_exact_fit_returns_1(self):
assert calculate_loop_count(60, 60) == 1
"""循环次数计算测试."""
def test_bgm_longer_returns_1(self):
assert calculate_loop_count(100, 60) == 1
def test_exact_multiple(self):
"""刚好整数倍."""
# 100/10 = 10, +2 = 12
assert calculate_loop_count(10, 100) == 12
def test_needs_3_loops_plus_2_margin(self):
# 60/20 = 3 + 2 = 5
assert calculate_loop_count(20, 60) == 5
def test_not_exact_multiple(self):
"""不是整数倍."""
# 100/30 = 3, +2 = 5
assert calculate_loop_count(30, 100) == 5
def test_needs_2_loops_plus_2_margin(self):
# 60/30 = 2 + 2 = 4
assert calculate_loop_count(30, 60) == 4
def test_bgm_longer_than_target(self):
"""BGM 比目标长,至少 1 次."""
assert calculate_loop_count(200, 100) == 1
def test_zero_bgm_duration(self):
assert calculate_loop_count(0, 60) == 1
"""BGM 时长为 0,返回 1."""
assert calculate_loop_count(0, 100) == 1
def test_negative_bgm_duration(self):
assert calculate_loop_count(-1, 60) == 1
"""BGM 时长为负,返回 1."""
assert calculate_loop_count(-5, 100) == 1
def test_zero_target_duration(self):
"""目标时长为 0,返回 1."""
assert calculate_loop_count(10, 0) == 1
def test_negative_target_duration(self):
assert calculate_loop_count(10, -1) == 1
"""目标时长为负,返回 1."""
assert calculate_loop_count(10, -10) == 1
def test_minimum_is_1(self):
assert calculate_loop_count(10, 5) == 1
def test_very_short_bgm(self):
"""非常短的 BGM,循环次数多."""
# 100/1 = 100, +2 = 102
assert calculate_loop_count(1, 100) == 102
# ── build_bgm_filter_chain ──────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# build_bgm_filter_chain 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildBgmFilterChain:
def test_basic_structure(self):
result = build_bgm_filter_chain(100, 60)
parts = result.split(",")
# 至少有 atrim + asetpts
assert any("atrim=" in p for p in parts)
assert "asetpts=N/SR/TB" in parts
class TestBuildBGMFilterChain:
"""BGM 预处理滤镜链构建测试."""
def test_volume_filter_applied(self):
result = build_bgm_filter_chain(100, 60, volume=0.5)
assert "volume=0.500" in result
def test_volume_one_omitted(self):
result = build_bgm_filter_chain(100, 60, volume=1.0)
assert "volume=" not in result
def test_volume_clamped(self):
# volume=2.0钳制到1.01.0等于默认值所以被跳过
result = build_bgm_filter_chain(100, 60, volume=2.0)
assert "volume=" not in result # 钳制到1.0后与默认相同,跳过
# 用0.5验证音量过滤器本身存在
result2 = build_bgm_filter_chain(100, 60, volume=0.5)
assert "volume=0.500" in result2
def test_volume_zero(self):
result = build_bgm_filter_chain(100, 60, volume=0.0)
assert "volume=0.000" in result
def test_fade_in_applied(self):
result = build_bgm_filter_chain(100, 60, fade_in=1.5)
assert "afade=t=in:st=0:d=1.500" in result
def test_fade_in_zero_skipped(self):
result = build_bgm_filter_chain(100, 60, fade_in=0)
assert "afade=t=in" not in result
def test_fade_out_applied(self):
result = build_bgm_filter_chain(100, 60, fade_out=2.0)
assert "afade=t=out:st=58.000:d=2.000" in result
def test_fade_out_longer_than_target_skipped(self):
result = build_bgm_filter_chain(100, 10, fade_out=20)
# fade_out >= safe_target,不做淡出
assert "afade=t=out" not in result
def test_loop_applied_when_needed(self):
result = build_bgm_filter_chain(10, 60)
assert "aloop=loop=" in result
def test_no_loop_when_bgm_long(self):
result = build_bgm_filter_chain(100, 60)
assert "aloop=" not in result
def test_loop_disabled(self):
result = build_bgm_filter_chain(10, 60, loop_enabled=False)
assert "aloop=" not in result
def test_trim_to_target_duration(self):
result = build_bgm_filter_chain(100, 60)
assert "atrim=0:60.000" in result
def test_zero_target_uses_fallback(self):
result = build_bgm_filter_chain(100, 0)
# 兜底5秒
assert "atrim=0:5.000" in result
def test_negative_target_uses_fallback(self):
result = build_bgm_filter_chain(100, -5)
assert "atrim=0:5.000" in result
def test_all_features_combined(self):
def test_basic_volume_only(self):
"""只有音量调节."""
result = build_bgm_filter_chain(
bgm_duration=15,
target_duration=60,
bgm_duration=200,
target_duration=100,
volume=0.5,
)
assert "volume=0.500" in result
assert "aloop" not in result
assert "afade=t=in" not in result
assert "afade=t=out" not in result
assert "atrim=0:100.000" in result
assert "asetpts=N/SR/TB" in result
def test_with_loop(self):
"""需要循环的情况."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
fade_in=1.0,
fade_out=2.0,
loop_enabled=True,
)
assert "aloop=loop=" in result
assert "volume=0.300" in result
assert "afade=t=in" in result
assert "afade=t=out" in result
assert "atrim=0:60.000" in result
assert "asetpts=N/SR/TB" in result
def test_no_loop_when_disabled(self):
"""禁用循环,即使 BGM 短也不循环."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=False,
)
assert "aloop" not in result
def test_fade_in_only(self):
"""只有淡入."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=2.5,
)
assert "afade=t=in:st=0:d=2.500" in result
assert "afade=t=out" not in result
assert "volume=" not in result # volume=1.0 不加
def test_fade_out_only(self):
"""只有淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_out=3.0,
)
assert "afade=t=out:st=97.000:d=3.000" in result
assert "afade=t=in" not in result
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=1.5,
fade_out=2.0,
)
assert "afade=t=in:st=0:d=1.500" in result
assert "afade=t=out:st=98.000:d=2.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 时不添加 volume 滤镜."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
)
assert "volume=" not in result
def test_volume_0(self):
"""音量为 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.0,
)
assert "volume=0.000" in result
def test_volume_clamped_high(self):
"""音量超过 1.0 被钳制."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.5,
)
assert "volume=1.000" not in result # 1.0不加
# 钳制到1.0后和1.0一样,不加volume滤镜
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
assert "volume=" not in result
def test_volume_clamped_low(self):
"""音量为负被钳制到 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=-0.5,
)
assert "volume=0.000" in result
def test_fade_out_longer_than_duration(self):
"""淡出时长超过总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=20.0,
)
assert "afade=t=out" not in result
def test_fade_out_equal_to_duration(self):
"""淡出时长等于总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=10.0,
)
assert "afade=t=out" not in result
def test_zero_target_duration_fallback(self):
"""目标时长为 0,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=0,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_negative_target_duration_fallback(self):
"""目标时长为负,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=-5,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_full_chain_with_all_effects(self):
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.4,
fade_in=1.0,
fade_out=2.0,
loop_enabled=True,
)
parts = result.split(",")
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
assert len(parts) >= 6
assert "aloop" in parts[0]
assert "volume" in parts[1]
assert "afade=t=in" in parts[2]
assert "afade=t=out" in parts[3]
assert "atrim" in parts[4]
assert "asetpts" in parts[5]
# ── calculate_sidechain_ratio ───────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# calculate_sidechain_ratio 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateSidechainRatio:
def test_zero_ratio_minimum(self):
assert calculate_sidechain_ratio(0) == 2.0
"""Sidechain 压缩比计算测试."""
def test_negative_clamped(self):
def test_default_ratio_0_3(self):
"""默认 0.3."""
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_5(self):
"""比例 0.5."""
# 1 / (1 - 0.5) = 2.0
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_8(self):
"""比例 0.8."""
# 1 / (1 - 0.8) = 5.0
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
def test_ratio_0_9(self):
"""比例 0.9."""
# 1 / (1 - 0.9) = 10.0
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
def test_ratio_0(self):
"""比例 0,返回下限 2.0."""
assert calculate_sidechain_ratio(0.0) == 2.0
def test_ratio_negative(self):
"""比例为负,返回下限 2.0."""
assert calculate_sidechain_ratio(-0.5) == 2.0
def test_one_ratio_maximum(self):
def test_ratio_1_0(self):
"""比例 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(1.0) == 10.0
def test_above_one_clamped(self):
assert calculate_sidechain_ratio(1.5) == 10.0
def test_mid_value(self):
# ratio = 1/(1-0.5) = 2.0
result = calculate_sidechain_ratio(0.5)
assert abs(result - 2.0) < 0.01
def test_high_value(self):
# 1/(1-0.9) = 10 → 钳制到10
assert calculate_sidechain_ratio(0.9) == 10.0
def test_03_default(self):
# 1/(1-0.3) = 1.428... → 钳制到2.0
result = calculate_sidechain_ratio(0.3)
assert result >= 2.0
def test_ratio_greater_than_1(self):
"""比例超过 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(2.0) == 10.0
# ── build_simple_mix_filter ─────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# build_simple_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSimpleMixFilter:
def test_contains_inputs_and_output(self):
"""普通混音滤镜构建测试."""
def test_contains_amix(self):
"""包含 amix."""
result = build_simple_mix_filter()
assert "[0:a][1:a]" in result
assert "amix=inputs=2" in result
assert "duration=first" in result
assert "[final]" in result
def test_contains_volume_compensation(self):
"""包含 volume=2 补偿."""
result = build_simple_mix_filter()
assert "volume=2" in result
def test_output_label(self):
"""输出标签为 [final]."""
result = build_simple_mix_filter()
assert "[final]" in result
# ── build_sidechain_mix_filter ──────────────────────────────────────────────
def test_duration_first(self):
"""duration=first,以主音频时长为准."""
result = build_simple_mix_filter()
assert "duration=first" in result
# ─────────────────────────────────────────────────────────────────────────────
# build_sidechain_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSidechainMixFilter:
"""Sidechain 混音滤镜构建测试."""
def test_contains_sidechaincompress(self):
"""包含 sidechaincompress."""
result = build_sidechain_mix_filter()
assert "sidechaincompress=" in result
assert "[1:a][0:a]sidechaincompress" in result
def test_threshold_in_db(self):
def test_threshold_param(self):
"""threshold 参数正确."""
result = build_sidechain_mix_filter(threshold=-30.0)
assert "threshold=-30.0dB" in result
def test_attack_and_release(self):
result = build_sidechain_mix_filter(attack=0.01, release=0.3)
assert "attack=0.010" in result
assert "release=0.300" in result
def test_attack_param(self):
"""attack 参数正确."""
result = build_sidechain_mix_filter(attack=0.05)
assert "attack=0.050" in result
def test_release_param(self):
"""release 参数正确."""
result = build_sidechain_mix_filter(release=0.8)
assert "release=0.800" in result
def test_knee_param(self):
"""knee=6 参数."""
result = build_sidechain_mix_filter()
assert "knee=6" in result
def test_contains_amix(self):
"""包含 amix 混音."""
result = build_sidechain_mix_filter()
assert "amix=inputs=2" in result
assert "duration=first" in result
def test_contains_volume_compensation(self):
def test_volume_compensation(self):
"""volume=1.5 轻微补偿."""
result = build_sidechain_mix_filter()
assert "volume=1.5" in result
def test_output_label(self):
result = build_sidechain_mix_filter()
assert "[final]" in result
def test_bgm_comp_label(self):
def test_bgmc_comp_label(self):
"""包含 [bgm_comp] 中间标签."""
result = build_sidechain_mix_filter()
assert "[bgm_comp]" in result
# ── normalize_bgm_config ────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# normalize_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeBgmConfig:
def test_default_values(self):
class TestNormalizeBGMConfig:
"""配置规范化测试."""
def test_empty_dict_defaults(self):
"""空字典返回默认值."""
result = normalize_bgm_config({})
assert result["volume"] == 0.3
assert result["fade_in"] == 0.0
@@ -301,184 +426,233 @@ class TestNormalizeBgmConfig:
assert result["loop_enabled"] is True
assert result["sidechain_enabled"] is False
assert result["sidechain_ratio"] == 0.3
assert result["sidechain_attack"] == 0.02
assert result["sidechain_release"] == 0.5
assert result["sidechain_threshold"] == -25.0
def test_volume_clamped(self):
result = normalize_bgm_config({"volume": 2.0})
"""音量钳制."""
result = normalize_bgm_config({"volume": 1.5})
assert result["volume"] == 1.0
result = normalize_bgm_config({"volume": -1.0})
assert result["volume"] == 0.0
result2 = normalize_bgm_config({"volume": -0.5})
assert result2["volume"] == 0.0
def test_fade_in_clamped_to_zero(self):
result = normalize_bgm_config({"fade_in": -5})
def test_fade_in_negative(self):
"""淡入为负钳制到 0."""
result = normalize_bgm_config({"fade_in": -1})
assert result["fade_in"] == 0.0
def test_fade_out_clamped_to_zero(self):
result = normalize_bgm_config({"fade_out": -5})
def test_fade_out_negative(self):
"""淡出为负钳制到 0."""
result = normalize_bgm_config({"fade_out": -1})
assert result["fade_out"] == 0.0
def test_loop_enabled_bool_conversion(self):
assert normalize_bgm_config({"loop_enabled": True})["loop_enabled"] is True
assert normalize_bgm_config({"loop_enabled": False})["loop_enabled"] is False
assert normalize_bgm_config({"loop_enabled": 1})["loop_enabled"] is True
assert normalize_bgm_config({"loop_enabled": 0})["loop_enabled"] is False
def test_sidechain_ratio_clamped(self):
result = normalize_bgm_config({"sidechain_ratio": 2.0})
"""sidechain_ratio 钳制."""
result = normalize_bgm_config({"sidechain_ratio": 1.5})
assert result["sidechain_ratio"] == 1.0
result = normalize_bgm_config({"sidechain_ratio": -1.0})
assert result["sidechain_ratio"] == 0.0
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
assert result2["sidechain_ratio"] == 0.0
def test_sidechain_attack_minimum(self):
def test_sidechain_attack_min(self):
"""attack 最小值 0.001."""
result = normalize_bgm_config({"sidechain_attack": 0})
assert result["sidechain_attack"] == 0.001
def test_sidechain_release_minimum(self):
def test_sidechain_release_min(self):
"""release 最小值 0.01."""
result = normalize_bgm_config({"sidechain_release": 0})
assert result["sidechain_release"] == 0.01
def test_sidechain_threshold_pass_through(self):
result = normalize_bgm_config({"sidechain_threshold": -40.0})
assert result["sidechain_threshold"] == -40.0
def test_string_values_converted(self):
"""字符串数值被转换."""
result = normalize_bgm_config(
{
"volume": "0.5",
"fade_in": "1.0",
"sidechain_ratio": "0.7",
"fade_in": "2.0",
}
)
assert result["volume"] == 0.5
assert result["fade_in"] == 1.0
assert result["sidechain_ratio"] == 0.7
assert result["fade_in"] == 2.0
def test_loop_enabled_truthy(self):
"""loop_enabled 真值转换."""
result = normalize_bgm_config({"loop_enabled": 1})
assert result["loop_enabled"] is True
result2 = normalize_bgm_config({"loop_enabled": 0})
assert result2["loop_enabled"] is False
def test_preserves_unknown_keys(self):
"""未知 key 不保留."""
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
assert "unknown_key" not in result
assert result["volume"] == 0.5
# ── validate_bgm_config ─────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# validate_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateBgmConfig:
class TestValidateBGMConfig:
"""配置验证测试."""
def test_valid_config(self):
valid, errors = validate_bgm_config({"volume": 0.3})
assert valid is True
assert errors == []
"""合法配置."""
ok, errors = validate_bgm_config(
{
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 2.0,
"sidechain_ratio": 0.3,
}
)
assert ok is True
assert len(errors) == 0
def test_invalid_volume_type(self):
valid, errors = validate_bgm_config({"volume": "abc"})
assert valid is False
def test_volume_not_number(self):
"""volume 不是数字."""
ok, errors = validate_bgm_config({"volume": "high"})
assert ok is False
assert any("volume" in e for e in errors)
def test_volume_out_of_range(self):
valid, errors = validate_bgm_config({"volume": -0.1})
assert valid is False
assert any("volume" in e for e in errors)
valid, errors = validate_bgm_config({"volume": 1.1})
assert valid is False
"""volume 超出范围."""
ok, errors = validate_bgm_config({"volume": 1.5})
assert ok is False
assert any("volume" in e for e in errors)
def test_volume_at_boundaries(self):
assert validate_bgm_config({"volume": 0})[0] is True
assert validate_bgm_config({"volume": 1})[0] is True
def test_invalid_fade_in_type(self):
valid, errors = validate_bgm_config({"fade_in": "abc"})
assert valid is False
def test_fade_in_negative(self):
"""fade_in 为负."""
ok, errors = validate_bgm_config({"fade_in": -1})
assert ok is False
assert any("fade_in" in e for e in errors)
def test_negative_fade_in(self):
valid, errors = validate_bgm_config({"fade_in": -1})
assert valid is False
assert any("fade_in" in e for e in errors)
def test_invalid_fade_out_type(self):
valid, errors = validate_bgm_config({"fade_out": "abc"})
assert valid is False
def test_fade_out_negative(self):
"""fade_out 为负."""
ok, errors = validate_bgm_config({"fade_out": -1})
assert ok is False
assert any("fade_out" in e for e in errors)
def test_negative_fade_out(self):
valid, errors = validate_bgm_config({"fade_out": -1})
assert valid is False
assert any("fade_out" in e for e in errors)
def test_invalid_sidechain_ratio_type(self):
valid, errors = validate_bgm_config({"sidechain_ratio": "abc"})
assert valid is False
assert any("sidechain_ratio" in e for e in errors)
def test_sidechain_ratio_out_of_range(self):
valid, errors = validate_bgm_config({"sidechain_ratio": -0.1})
assert valid is False
assert any("sidechain_ratio" in e for e in errors)
valid, errors = validate_bgm_config({"sidechain_ratio": 1.1})
assert valid is False
"""sidechain_ratio 超出范围."""
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
assert ok is False
assert any("sidechain_ratio" in e for e in errors)
def test_multiple_errors(self):
valid, errors = validate_bgm_config(
"""多个错误同时报告."""
ok, errors = validate_bgm_config(
{
"volume": "bad",
"volume": 2.0,
"fade_in": -1,
"sidechain_ratio": 2.0,
"sidechain_ratio": -0.5,
}
)
assert valid is False
assert ok is False
assert len(errors) >= 3
def test_empty_config_valid(self):
"""空配置(全用默认值)视为合法."""
ok, errors = validate_bgm_config({})
assert ok is True
assert len(errors) == 0
# ── calculate_fade_out_start ────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# calculate_fade_out_start 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
assert calculate_fade_out_start(60, 2) == 58.0
"""正常情况."""
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
def test_zero_fade_out(self):
assert calculate_fade_out_start(60, 0) is None
"""淡出时长为 0,返回 None."""
assert calculate_fade_out_start(100, 0) is None
def test_negative_fade_out(self):
assert calculate_fade_out_start(60, -1) is None
"""淡出时长为负,返回 None."""
assert calculate_fade_out_start(100, -1) is None
def test_zero_target(self):
assert calculate_fade_out_start(0, 2) is None
def test_zero_duration(self):
"""总时长为 0,返回 None."""
assert calculate_fade_out_start(0, 3) is None
def test_negative_target(self):
assert calculate_fade_out_start(-5, 2) is None
def test_fade_longer_than_target(self):
def test_fade_out_longer_than_duration(self):
"""淡出超过总时长,返回 None."""
assert calculate_fade_out_start(10, 20) is None
def test_fade_equal_to_target(self):
def test_fade_out_equal_to_duration(self):
"""淡出等于总时长,返回 None."""
assert calculate_fade_out_start(10, 10) is None
def test_float_values(self):
assert calculate_fade_out_start(60.5, 2.5) == 58.0
# ─────────────────────────────────────────────────────────────────────────────
# estimate_bgm_processing_duration 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── estimate_bgm_processing_duration ────────────────────────────────────────
class TestEstimateBGMProcessingDuration:
"""BGM 处理时长估算测试."""
def test_normal_case_with_loop(self):
"""正常循环情况,输出目标时长."""
assert estimate_bgm_processing_duration(10, 100, True) == 100
class TestEstimateBgmProcessingDuration:
def test_bgm_longer_no_loop(self):
assert estimate_bgm_processing_duration(100, 60, loop_enabled=False) == 60.0
def test_bgm_longer_with_loop(self):
# 够长但允许循环,仍然截断到target
assert estimate_bgm_processing_duration(100, 60, loop_enabled=True) == 60.0
def test_bgm_shorter_with_loop(self):
assert estimate_bgm_processing_duration(10, 60, loop_enabled=True) == 60.0
"""BGM 够长,不循环,截断到目标时长."""
assert estimate_bgm_processing_duration(200, 100, False) == 100
def test_bgm_shorter_no_loop(self):
# 需要循环但不允许 → 截断到target
assert estimate_bgm_processing_duration(10, 60, loop_enabled=False) == 60.0
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
assert estimate_bgm_processing_duration(10, 100, False) == 100
def test_zero_target_fallback(self):
assert estimate_bgm_processing_duration(100, 0) == 5.0
def test_zero_target(self):
"""目标时长为 0,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
def test_negative_target_fallback(self):
assert estimate_bgm_processing_duration(100, -5) == 5.0
def test_negative_target(self):
"""目标时长为负,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
def test_equal_duration(self):
assert estimate_bgm_processing_duration(60, 60) == 60.0
# ─────────────────────────────────────────────────────────────────────────────
# BGMPureConfig 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBGMPureConfig:
"""BGMPureConfig 数据类测试."""
def test_default_values(self):
"""默认值正确."""
config = BGMPureConfig()
assert config.volume == 0.3
assert config.fade_in == 0.0
assert config.fade_out == 0.0
assert config.loop_enabled is True
assert config.sidechain_enabled is False
assert config.sidechain_ratio == 0.3
assert config.sidechain_attack == 0.02
assert config.sidechain_release == 0.5
assert config.sidechain_threshold == -25.0
def test_custom_values(self):
"""自定义值."""
config = BGMPureConfig(
volume=0.7,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
sidechain_attack=0.05,
sidechain_release=0.8,
sidechain_threshold=-30.0,
)
assert config.volume == 0.7
assert config.loop_enabled is False
assert config.sidechain_enabled is True
assert config.sidechain_threshold == -30.0
+289 -334
View File
@@ -1,12 +1,12 @@
"""concat_engine_pure 单元测试."""
"""视频拼接引擎纯逻辑单元测试."""
from pathlib import Path
from __future__ import annotations
from apps.worker.video_processing.concat_engine_pure import (
import pytest
from video_processing.concat_engine_pure import (
build_concat_filter,
build_fps_filter,
build_scale_pad_filter,
build_setpts_filter,
build_single_segment_filter_chain,
calculate_scaled_size,
can_use_stream_copy,
@@ -20,203 +20,200 @@ from apps.worker.video_processing.concat_engine_pure import (
validate_video_path,
)
# ── parse_fps ────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 帧率解析测试
# ─────────────────────────────────────────────────────────────────────────────
class TestParseFps:
def test_none_returns_default(self):
assert parse_fps(None) == 30.0
"""parse_fps 测试."""
def test_integer_value(self):
def test_integer_fps(self):
"""整数帧率."""
assert parse_fps(30) == 30.0
assert parse_fps(24) == 24.0
def test_float_value(self):
assert parse_fps(29.97) == 29.97
def test_float_fps(self):
"""浮点帧率."""
assert parse_fps(29.97) == pytest.approx(29.97)
def test_string_integer(self):
"""字符串整数."""
assert parse_fps("30") == 30.0
assert parse_fps(" 60 ") == 60.0 # 带空格
def test_string_fraction(self):
"""分数字符串(30/1."""
assert parse_fps("30/1") == 30.0
assert abs(parse_fps("24000/1001") - 23.976) < 0.01
def test_zero_denominator(self):
assert parse_fps("30/0") == 30.0
def test_fraction_24000_1001(self):
"""23.976 帧率."""
result = parse_fps("24000/1001")
assert result == pytest.approx(23.976, rel=0.01)
def test_none_input(self):
"""None 输入返回默认值."""
assert parse_fps(None) == 30.0
def test_empty_string(self):
"""空字符串返回默认值."""
assert parse_fps("") == 30.0
assert parse_fps(" ") == 30.0
def test_invalid_string(self):
"""无效字符串."""
assert parse_fps("abc") == 30.0
assert parse_fps("30fps") == 30.0
def test_zero_denominator(self):
"""分母为 0."""
assert parse_fps("30/0") == 30.0
def test_negative_fps(self):
"""负帧率."""
assert parse_fps(-30) == -30.0
def test_zero_fps(self):
assert parse_fps(0) == 0.0
# ── format_fps_filter ───────────────────────────────────────────────────────
class TestFormatFpsFilter:
"""format_fps_filter 测试."""
def test_integer_fps(self):
"""整数帧率."""
assert format_fps_filter(30.0) == "fps=30"
def test_near_integer_fps(self):
# 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入)
assert format_fps_filter(30.0001) == "fps=30"
assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30
def test_non_integer_fps(self):
result = format_fps_filter(23.976)
assert result.startswith("fps=")
assert "23.976" in result
def test_float_precision(self):
def test_float_fps(self):
"""浮点帧率."""
result = format_fps_filter(29.97)
assert result.startswith("fps=")
# 三位小数
parts = result.split("=")[1]
assert len(parts.split(".")[1]) == 3
assert "29.97" in result
def test_one_fps(self):
assert format_fps_filter(1.0) == "fps=1"
def test_near_integer(self):
"""接近整数."""
assert format_fps_filter(30.0001) == "fps=30"
# ── resolve_output_params ───────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 输出参数计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestResolveOutputParams:
def test_config_specified(self):
"""resolve_output_params 测试."""
def test_all_specified(self):
"""全部显式指定."""
w, h, fps = resolve_output_params(1920, 1080, 60.0)
assert w == 1920
assert h == 1080
assert fps == 60.0
def test_fallback_to_first_video_info(self):
def test_no_specified_use_defaults(self):
"""全部未指定,用默认值."""
w, h, fps = resolve_output_params(0, 0, 0)
assert w == 1080
assert h == 1920
assert fps == 30.0
def test_use_first_video_info(self):
"""用第一段视频信息."""
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(0, 0, 0, info)
assert w == 1280
assert h == 720
assert fps == 24.0
def test_fallback_to_defaults(self):
w, h, fps = resolve_output_params(0, 0, 0)
assert w == 1080 # default_width
assert h == 1920 # default_height
assert fps == 30.0
def test_partial_config(self):
# 宽度配置了,高度和帧率用探测的
def test_partial_specified(self):
"""部分指定,未指定的用探测值."""
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(1920, 0, 0, info)
assert w == 1920
assert h == 720
assert w == 1920 # 指定的
assert h == 720 # 探测的
assert fps == 24.0
def test_custom_defaults(self):
w, h, fps = resolve_output_params(
0,
0,
0,
default_width=640,
default_height=480,
default_fps=25.0,
)
assert w == 640
assert h == 480
assert fps == 25.0
def test_minimum_size(self):
w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"})
def test_zero_size_clamped(self):
"""零尺寸被钳制."""
w, h, fps = resolve_output_params(0, 0, 0, {})
assert w >= 1
assert h >= 1
assert fps >= 1.0
def test_fps_fraction_in_info(self):
info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}
_, _, fps = resolve_output_params(0, 0, 0, info)
assert abs(fps - 23.976) < 0.01
# ── calculate_scaled_size ───────────────────────────────────────────────────
def test_custom_defaults(self):
"""自定义默认值."""
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
assert w == 640
assert h == 480
assert fps == 25.0
class TestCalculateScaledSize:
"""calculate_scaled_size 测试."""
def test_same_ratio(self):
"""比例相同."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_wider_source_pad_top_bottom(self):
# 源是16:9,目标是9:16竖屏 → 上下填黑边
def test_wider_source(self):
"""源更宽,上下填黑边."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
assert sw == 1080 # 以宽度为准
assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607
assert sh < 1920 # 高度按比例
assert ox == 0
assert oy > 0 # 垂直居中
def test_taller_source_pad_left_right(self):
# 源是9:16竖屏,目标是16:9横屏 → 左右填黑边
def test_taller_source(self):
"""源更高,左右填黑边."""
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
assert sh == 1080 # 以高度为准
assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607
assert sw < 1920 # 宽度按比例
assert ox > 0 # 水平居中
assert oy == 0
def test_zero_source_size(self):
sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080)
assert sw == 1920
assert sh == 1080
def test_zero_source(self):
"""零尺寸源."""
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
assert sw == 100
assert sh == 100
def test_scale_down(self):
"""缩小."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
assert sw == 640
assert sh == 360
assert ox == 0
assert oy == 0
def test_negative_source_size(self):
sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_target_same_ratio_different_size(self):
# 比例相同,尺寸不同 → 直接缩放到目标大小
def test_scale_up(self):
"""放大."""
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
# ── can_use_stream_copy ─────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# stream copy 判断测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCanUseStreamCopy:
def test_force_reencode_false(self):
assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False
"""can_use_stream_copy 测试."""
def test_empty_segments(self):
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
def test_single_segment_matching_params(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
def test_multiple_segments_same_params(self):
def test_identical_segments(self):
"""所有段参数相同,可以 stream copy."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
def test_force_reencode(self):
"""强制重编码."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
def test_different_codec(self):
"""编码不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
@@ -224,6 +221,7 @@ class TestCanUseStreamCopy:
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_resolution(self):
"""分辨率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
@@ -231,349 +229,306 @@ class TestCanUseStreamCopy:
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_fps(self):
"""帧率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_target_differs_from_source(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
# 目标分辨率不同
def test_target_differs(self):
"""目标参数与源不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
# 目标帧率不同
assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False
def test_fps_fraction_match(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}]
assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True
def test_empty_segments(self):
"""空列表."""
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
def test_single_segment(self):
"""单段."""
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
# ── generate_concat_file_list ───────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 文件列表生成测试
# ─────────────────────────────────────────────────────────────────────────────
class TestGenerateConcatFileList:
"""generate_concat_file_list 测试."""
def test_single_file(self):
result = generate_concat_file_list(["/tmp/video.mp4"])
assert result == "file '/tmp/video.mp4'\n"
"""单个文件."""
result = generate_concat_file_list(["/a.mp4"])
assert "file '/a.mp4'" in result
assert result.endswith("\n")
def test_multiple_files(self):
"""多个文件."""
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
lines = result.strip().split("\n")
assert len(lines) == 3
assert lines[0] == "file '/a.mp4'"
assert lines[1] == "file '/b.mp4'"
assert lines[2] == "file '/c.mp4'"
assert result.endswith("\n")
def test_escapes_single_quotes(self):
result = generate_concat_file_list(["/path/with'quote.mp4"])
# 单引号转义: '\''
assert "'\\''" in result
def test_empty_list(self):
"""空列表."""
result = generate_concat_file_list([])
assert result == "\n"
def test_path_with_single_quote(self):
"""路径包含单引号(转义)."""
result = generate_concat_file_list(["/path/to/file's.mp4"])
# 单引号应该被转义
assert "'\\''" in result or file
assert "file '" in result
def test_path_with_spaces(self):
result = generate_concat_file_list(["/path/to/video file.mp4"])
assert "file '/path/to/video file.mp4'" in result
"""路径包含空格."""
result = generate_concat_file_list(["/path/to/my video.mp4"])
assert "my video" in result
# ── build_scale_pad_filter ──────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildScalePadFilter:
def test_basic_filter(self):
"""scale+pad 滤镜测试."""
def test_contains_scale(self):
"""包含 scale."""
result = build_scale_pad_filter(1920, 1080)
assert "scale=" in result
def test_contains_pad(self):
"""包含 pad."""
result = build_scale_pad_filter(1920, 1080)
assert "pad=" in result
assert "1920:1080" in result
def test_force_original_aspect_ratio(self):
"""保持宽高比."""
result = build_scale_pad_filter(1920, 1080)
assert "scale=1920:1080" in result
assert "force_original_aspect_ratio=decrease" in result
assert "pad=1920:1080" in result
assert "black" in result
assert "(ow-iw)/2" in result
assert "(oh-ih)/2" in result
def test_different_resolution(self):
result = build_scale_pad_filter(1080, 1920)
assert "scale=1080:1920" in result
assert "pad=1080:1920" in result
def test_ignores_source_size(self):
# src_w/src_h 目前不影响输出,都是用表达式
result1 = build_scale_pad_filter(1920, 1080)
result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720)
assert result1 == result2
# ── build_fps_filter ────────────────────────────────────────────────────────
def test_black_padding(self):
"""黑边填充."""
result = build_scale_pad_filter(1920, 1080)
assert ":black" in result
class TestBuildFpsFilter:
"""fps 滤镜测试."""
def test_integer_fps(self):
"""整数帧率."""
assert build_fps_filter(30.0) == "fps=30"
def test_float_fps(self):
"""浮点帧率."""
result = build_fps_filter(29.97)
assert result.startswith("fps=")
# ── build_setpts_filter ─────────────────────────────────────────────────────
class TestBuildSetptsFilter:
def test_returns_correct_string(self):
assert build_setpts_filter() == "setpts=PTS-STARTPTS"
# ── build_concat_filter ─────────────────────────────────────────────────────
class TestBuildConcatFilter:
def test_zero_inputs(self):
assert build_concat_filter(0) == ""
"""concat 滤镜测试."""
def test_single_input_with_audio(self):
result = build_concat_filter(1)
assert "[0:v][0:a]" in result
assert "concat=n=1:v=1:a=1" in result
def test_two_inputs_with_audio(self):
"""两路输入,有音频."""
result = build_concat_filter(2, has_audio=True)
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
assert "[concat_v][concat_a]" in result
def test_single_input_no_audio(self):
result = build_concat_filter(1, has_audio=False)
assert "[0:v]" in result
assert "concat=n=1:v=1:a=0" in result
assert "[concat_v]" in result
assert "[concat_a]" not in result
def test_multiple_inputs_with_audio(self):
result = build_concat_filter(3)
assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result
assert "concat=n=3:v=1:a=1" in result
def test_multiple_inputs_no_audio(self):
def test_three_inputs_video_only(self):
"""三路输入,无音频."""
result = build_concat_filter(3, has_audio=False)
assert "[0:v][1:v][2:v]" in result
assert "concat=n=3:v=1:a=0" in result
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
assert "[concat_v]" in result
def test_negative_inputs(self):
assert build_concat_filter(-1) == ""
def test_single_input(self):
"""单路输入."""
result = build_concat_filter(1, has_audio=True)
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
# ── build_single_segment_filter_chain ───────────────────────────────────────
def test_zero_inputs(self):
"""零输入."""
assert build_concat_filter(0) == ""
class TestBuildSingleSegmentFilterChain:
"""单段滤镜链测试."""
def test_with_audio(self):
"""有音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
# 视频链
assert "[0:v]" in result
assert "[v0]" in result
assert "scale=1920:1080" in result
assert "fps=30" in result
assert "scale=" in result
assert "fps=" in result
assert "setpts=PTS-STARTPTS" in result
# 音频链
assert "[0:a]" in result
assert "[a0]" in result
assert "asetpts=PTS-STARTPTS" in result
# 用分号分隔
assert ";" in result
assert "[v0]" in result
assert "[a0]" in result
def test_without_audio(self):
result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False)
assert "[2:v]" in result
assert "[v2]" in result
assert "[2:a]" not in result
assert ";" not in result # 没有音频就没有分号
def test_video_only(self):
"""无音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
assert "scale=" in result
assert "setpts=" in result
assert "asetpts" not in result
assert "[v1]" in result
def test_segment_index_propagated(self):
for idx in [0, 5, 10]:
result = build_single_segment_filter_chain(1920, 1080, 30.0, idx)
assert f"[{idx}:v]" in result
assert f"[v{idx}]" in result
def test_segment_index_in_labels(self):
"""段索引在标签中."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
assert "[5:v]" in result
assert "[v5]" in result
# ── validate_concat_config ──────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 配置验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateConcatConfig:
"""配置验证测试."""
def test_valid_config(self):
"""合法配置."""
config = {
"segments": [
{"video_path": "/a.mp4"},
{"video_path": "/b.mp4"},
],
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
"output_width": 1920,
"output_height": 1080,
"output_fps": 30,
}
valid, errors = validate_concat_config(config)
assert valid is True
assert errors == []
def test_no_segments(self):
valid, errors = validate_concat_config({})
assert valid is False
assert any("至少需要一个" in e for e in errors)
ok, errors = validate_concat_config(config)
assert ok is True
assert len(errors) == 0
def test_empty_segments(self):
valid, errors = validate_concat_config({"segments": []})
assert valid is False
assert len(errors) >= 1
"""空段列表."""
ok, errors = validate_concat_config({"segments": []})
assert ok is False
assert any("至少需要" in e or "视频段" in e for e in errors)
def test_missing_video_path(self):
config = {"segments": [{"video_path": ""}]}
valid, errors = validate_concat_config(config)
assert valid is False
"""缺少 video_path."""
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("video_path" in e for e in errors)
def test_multiple_missing_paths(self):
config = {
"segments": [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"video_path": ""},
]
}
valid, errors = validate_concat_config(config)
assert valid is False
path_errors = [e for e in errors if "video_path" in e]
assert len(path_errors) == 2
def test_negative_output_width(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1}
valid, errors = validate_concat_config(config)
assert valid is False
def test_negative_width(self):
"""负宽度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_width" in e for e in errors)
def test_negative_output_height(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1}
valid, errors = validate_concat_config(config)
assert valid is False
def test_negative_height(self):
"""负高度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_height" in e for e in errors)
def test_negative_output_fps(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1}
valid, errors = validate_concat_config(config)
assert valid is False
def test_negative_fps(self):
"""负帧率."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_fps" in e for e in errors)
def test_zero_output_params_valid(self):
# 0值表示未指定,是合法的
config = {
"segments": [{"video_path": "/a.mp4"}],
"output_width": 0,
"output_height": 0,
"output_fps": 0,
}
valid, errors = validate_concat_config(config)
assert valid is True
def test_zero_output_params_ok(self):
"""零输出参数合法(表示自动探测)."""
config = {"segments": [{"video_path": "/a.mp4"}]}
ok, errors = validate_concat_config(config)
assert ok is True
# ── validate_video_path ─────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 路径验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateVideoPath:
"""视频路径验证测试."""
def test_empty_path(self):
valid, err = validate_video_path("", "/work")
assert valid is False
assert "不能为空" in err
"""空路径."""
ok, msg = validate_video_path("", "/work")
assert ok is False
assert "不能为空" in msg
def test_relative_path_valid(self):
valid, err = validate_video_path("video.mp4", "/work")
assert valid is True
assert err == ""
def test_path_traversal(self):
"""路径遍历."""
ok, msg = validate_video_path("../etc/passwd", "/work")
assert ok is False
assert "回溯" in msg or ".." in msg
def test_relative_path_with_subdir(self):
valid, err = validate_video_path("sub/video.mp4", "/work")
assert valid is True
def test_valid_relative_path(self):
"""相对路径(不检查边界)."""
ok, msg = validate_video_path("video.mp4", "/work")
assert ok is True
def test_path_traversal_rejected(self):
valid, err = validate_video_path("../secret.mp4", "/work")
assert valid is False
assert ".." in err
def test_valid_absolute_path(self):
"""绝对路径在工作目录内."""
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
assert ok is True
def test_nested_path_traversal_rejected(self):
valid, err = validate_video_path("sub/../../secret.mp4", "/work")
assert valid is False
def test_absolute_path_inside_workdir(self):
valid, err = validate_video_path("/work/sub/video.mp4", "/work")
assert valid is True
def test_absolute_path_outside_workdir(self):
valid, err = validate_video_path("/etc/passwd", "/work")
assert valid is False
assert "工作目录内" in err
def test_path_object_input(self):
valid, err = validate_video_path(Path("video.mp4"), Path("/work"))
assert valid is True
def test_path_outside_work_dir(self):
"""路径在工作目录外."""
ok, msg = validate_video_path("/etc/passwd", "/work")
assert ok is False
assert "工作目录" in msg
# ── estimate_total_duration ─────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateTotalDuration:
def test_single_segment(self):
assert estimate_total_duration([{"duration": 10.5}]) == 10.5
"""总时长估算测试."""
def test_multiple_segments(self):
segs = [
{"duration": 10},
{"duration": 20.5},
{"duration": 5.5},
]
assert estimate_total_duration(segs) == 36.0
"""多段视频."""
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
assert estimate_total_duration(segs) == pytest.approx(35.5)
def test_empty_list(self):
"""空列表."""
assert estimate_total_duration([]) == 0.0
def test_missing_duration_field(self):
segs = [{"path": "a.mp4"}, {"duration": 10}]
assert estimate_total_duration(segs) == 10.0
def test_invalid_duration_skipped(self):
segs = [
{"duration": 10},
{"duration": "abc"},
{"duration": 20},
]
assert estimate_total_duration(segs) == 30.0
"""无效时长跳过."""
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
assert estimate_total_duration(segs) == pytest.approx(30.0)
def test_string_duration(self):
segs = [{"duration": "15.5"}]
assert estimate_total_duration(segs) == 15.5
def test_negative_duration(self):
segs = [{"duration": -5}]
assert estimate_total_duration(segs) == -5.0
# ── count_valid_segments ────────────────────────────────────────────────────
def test_missing_duration(self):
"""缺 duration 字段."""
segs = [{}, {"duration": 10}]
assert estimate_total_duration(segs) == pytest.approx(10.0)
class TestCountValidSegments:
"""有效段统计测试."""
def test_all_valid(self):
segs = [
{"video_path": "/a.mp4"},
{"video_path": "/b.mp4"},
]
"""全部有效."""
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
assert count_valid_segments(segs) == 2
def test_some_invalid(self):
segs = [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"video_path": "/c.mp4"},
]
assert count_valid_segments(segs) == 2
def test_none_valid(self):
segs = [
{"video_path": ""},
{"other_field": "x"},
]
assert count_valid_segments(segs) == 0
"""部分无效."""
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
assert count_valid_segments(segs) == 1
def test_empty_list(self):
"""空列表."""
assert count_valid_segments([]) == 0
@@ -1,401 +0,0 @@
"""InMemoryAssetRepository 单元测试."""
import pytest
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
from packages.domain.entities import Asset, AssetStatus, ClassificationStatus
@pytest.fixture
def repo() -> InMemoryAssetRepository:
return InMemoryAssetRepository()
@pytest.fixture
def sample_asset() -> Asset:
return Asset.create(
project_id="proj-1",
library_id="lib-1",
name="test.mp4",
storage_key="storage/key1",
mime_type="video/mp4",
file_size=1024,
file_hash="hash-abc",
)
@pytest.fixture
def asset2() -> Asset:
return Asset.create(
project_id="proj-1",
library_id="lib-1",
name="test2.jpg",
storage_key="storage/key2",
mime_type="image/jpeg",
file_size=512,
file_hash="hash-def",
)
@pytest.fixture
def asset_other_project() -> Asset:
return Asset.create(
project_id="proj-2",
library_id="lib-2",
name="other.mp3",
storage_key="storage/key3",
mime_type="audio/mpeg",
file_size=256,
file_hash="hash-ghi",
)
class TestCreateAndGet:
def test_create_returns_asset(self, repo, sample_asset):
result = repo.create(sample_asset)
assert result.id == sample_asset.id
assert result.name == "test.mp4"
def test_get_existing_asset(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.get(sample_asset.id)
assert result is not None
assert result.id == sample_asset.id
def test_get_nonexistent_returns_none(self, repo):
assert repo.get("nonexistent") is None
def test_find_by_id_same_as_get(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_id(sample_asset.id).id == repo.get(sample_asset.id).id
class TestListByProject:
def test_list_by_project_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
proj1 = repo.list_by_project("proj-1")
assert len(proj1) == 2
assert all(a.project_id == "proj-1" for a in proj1)
proj2 = repo.list_by_project("proj-2")
assert len(proj2) == 1
assert proj2[0].id == asset_other_project.id
def test_list_by_project_empty(self, repo):
assert repo.list_by_project("nonexistent") == []
class TestListByLibrary:
def test_list_by_library_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
lib1 = repo.list_by_library("lib-1")
assert len(lib1) == 2
lib2 = repo.list_by_library("lib-2")
assert len(lib2) == 1
assert lib2[0].id == asset_other_project.id
def test_find_by_library_is_alias(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_library("lib-1") == repo.list_by_library("lib-1")
def test_list_by_library_empty(self, repo):
assert repo.list_by_library("nonexistent") == []
class TestFindByLibraryAndFileType:
def test_filter_by_video(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
videos = repo.find_by_library_and_file_type("lib-1", "video")
assert len(videos) == 1
assert videos[0].mime_type.startswith("video/")
def test_filter_by_image(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
images = repo.find_by_library_and_file_type("lib-1", "image")
assert len(images) == 1
assert images[0].mime_type.startswith("image/")
def test_filter_by_audio(self, repo, sample_asset, asset_other_project):
repo.create(sample_asset)
repo.create(asset_other_project)
audio = repo.find_by_library_and_file_type("lib-2", "audio")
assert len(audio) == 1
def test_empty_result(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_library_and_file_type("lib-1", "audio") == []
class TestUpdate:
def test_update_existing_asset(self, repo, sample_asset):
repo.create(sample_asset)
sample_asset.name = "updated.mp4"
sample_asset.file_size = 2048
result = repo.update(sample_asset)
assert result.name == "updated.mp4"
assert result.file_size == 2048
fetched = repo.get(sample_asset.id)
assert fetched.name == "updated.mp4"
def test_update_nonexistent_creates(self, repo, sample_asset):
"""update 直接覆盖,不存在则相当于 create."""
result = repo.update(sample_asset)
assert result.id == sample_asset.id
assert repo.get(sample_asset.id) is not None
class TestDelete:
def test_delete_existing(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.delete(sample_asset.id) is True
assert repo.get(sample_asset.id) is None
def test_delete_nonexistent(self, repo):
assert repo.delete("nonexistent") is False
class TestBatchDelete:
def test_batch_delete_soft_delete(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_delete([sample_asset.id, asset2.id])
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert a1.status == AssetStatus.DELETED
assert a2.status == AssetStatus.DELETED
assert a1.updated_at is not None
assert a2.updated_at is not None
def test_batch_delete_skip_already_deleted(self, repo, sample_asset):
repo.create(sample_asset)
sample_asset.status = AssetStatus.DELETED
repo.update(sample_asset)
count = repo.batch_delete([sample_asset.id])
assert count == 0
def test_batch_delete_nonexistent(self, repo):
count = repo.batch_delete(["nonexistent-1", "nonexistent-2"])
assert count == 0
def test_batch_delete_partial(self, repo, sample_asset):
repo.create(sample_asset)
count = repo.batch_delete([sample_asset.id, "nonexistent"])
assert count == 1
class TestBatchUpdateMetadata:
def test_batch_update_metadata_merge(self, repo, sample_asset, asset2):
sample_asset.metadata = {"key1": "val1"}
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_update_metadata(
[sample_asset.id, asset2.id],
{"key2": "val2"},
)
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert a1.metadata == {"key1": "val1", "key2": "val2"}
assert a2.metadata == {"key2": "val2"}
def test_batch_update_metadata_overwrite_existing_key(self, repo, sample_asset):
sample_asset.metadata = {"key1": "old"}
repo.create(sample_asset)
count = repo.batch_update_metadata([sample_asset.id], {"key1": "new"})
assert count == 1
assert repo.get(sample_asset.id).metadata["key1"] == "new"
def test_batch_update_metadata_nonexistent(self, repo):
count = repo.batch_update_metadata(["nonexistent"], {"key": "val"})
assert count == 0
class TestBatchAddTags:
def test_batch_add_tags_new_tags(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_add_tags([sample_asset.id, asset2.id], ["tag1", "tag2"])
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert set(a1.tag_ids) == {"tag1", "tag2"}
assert set(a2.tag_ids) == {"tag1", "tag2"}
def test_batch_add_tags_dedup(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
assert count == 1 # tag1已存在,但tag2新增,所以有变化
tags = repo.get(sample_asset.id).tag_ids
assert tags.count("tag1") == 1
assert "tag2" in tags
def test_batch_add_tags_no_change_when_all_exist(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1", "tag2"]
repo.create(sample_asset)
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
assert count == 0 # 没有变化
def test_batch_add_tags_nonexistent_assets(self, repo):
count = repo.batch_add_tags(["nonexistent"], ["tag1"])
assert count == 0
class TestBatchReplaceTags:
def test_batch_replace_tags_override(self, repo, sample_asset):
sample_asset.tag_ids = ["old1", "old2"]
repo.create(sample_asset)
count = repo.batch_replace_tags([sample_asset.id], ["new1", "new2", "new3"])
assert count == 1
tags = repo.get(sample_asset.id).tag_ids
assert tags == ["new1", "new2", "new3"]
def test_batch_replace_tags_empty(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
count = repo.batch_replace_tags([sample_asset.id], [])
assert count == 1
assert repo.get(sample_asset.id).tag_ids == []
def test_batch_replace_tags_nonexistent(self, repo):
count = repo.batch_replace_tags(["nonexistent"], ["tag1"])
assert count == 0
class TestFindByProjectPagination:
@pytest.fixture
def five_assets(self, repo):
assets = []
for i in range(5):
a = Asset.create(
project_id="proj-paged",
library_id="lib-paged",
name=f"asset-{i}.mp4",
storage_key=f"key-{i}",
mime_type="video/mp4",
)
repo.create(a)
assets.append(a)
return assets
def test_find_by_project_default_pagination(self, repo, five_assets):
result = repo.find_by_project("proj-paged")
assert len(result) == 5
def test_find_by_project_skip(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=2)
assert len(result) == 3
def test_find_by_project_limit(self, repo, five_assets):
result = repo.find_by_project("proj-paged", limit=2)
assert len(result) == 2
def test_find_by_project_skip_and_limit(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=1, limit=2)
assert len(result) == 2
def test_find_by_project_skip_past_end(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=10)
assert result == []
def test_find_by_project_empty(self, repo):
assert repo.find_by_project("nonexistent") == []
class TestFindByTagIds:
def test_find_by_tag_ids_match_all(self, repo, sample_asset, asset2):
sample_asset.tag_ids = ["tag1", "tag2", "tag3"]
asset2.tag_ids = ["tag1", "tag2"]
repo.create(sample_asset)
repo.create(asset2)
result = repo.find_by_tag_ids(["tag1", "tag2"])
assert len(result) == 2
def test_find_by_tag_ids_subset_match(self, repo, sample_asset, asset2):
sample_asset.tag_ids = ["tag1", "tag2"]
asset2.tag_ids = ["tag1"]
repo.create(sample_asset)
repo.create(asset2)
result = repo.find_by_tag_ids(["tag1", "tag2"])
assert len(result) == 1
assert result[0].id == sample_asset.id
def test_find_by_tag_ids_empty_tag_list(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
assert repo.find_by_tag_ids([]) == []
def test_find_by_tag_ids_no_match(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
assert repo.find_by_tag_ids(["tag999"]) == []
def test_find_by_tag_ids_pagination(self, repo):
for i in range(5):
a = Asset.create(
project_id="p1",
library_id="l1",
name=f"a{i}.mp4",
storage_key=f"k{i}",
mime_type="video/mp4",
)
a.tag_ids = ["shared-tag"]
repo.create(a)
result = repo.find_by_tag_ids(["shared-tag"], skip=1, limit=2)
assert len(result) == 2
class TestFindByLibraryAndFileHash:
def test_find_by_hash_match(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "hash-abc")
assert result is not None
assert result.id == sample_asset.id
def test_find_by_hash_wrong_library(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-2", "hash-abc")
assert result is None
def test_find_by_hash_wrong_hash(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "hash-wrong")
assert result is None
def test_find_by_hash_empty_hash(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "")
assert result is None
-191
View File
@@ -1,191 +0,0 @@
"""InMemoryUserRepository 单元测试."""
from datetime import datetime, timezone
import pytest
from packages.adapters.in_memory.user_repository import InMemoryUserRepository
from packages.domain.entities import User
@pytest.fixture
def repo() -> InMemoryUserRepository:
return InMemoryUserRepository()
@pytest.fixture
def sample_user() -> User:
return User(
id="user-1",
email="Test@Example.com",
display_name="Test User",
username="testuser",
password_hash="hashed-pw",
email_verification_token="verify-token-123",
password_reset_token="reset-token-456",
wechat_openid="wx-openid-abc",
wechat_unionid="wx-unionid-def",
phone="13800138000",
created_at=datetime.now(timezone.utc),
)
class TestSaveAndFindById:
def test_save_and_find_by_id(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_id("user-1")
assert found is not None
assert found.id == "user-1"
assert found.email == "Test@Example.com"
def test_find_by_id_not_found(self, repo):
assert repo.find_by_id("nonexistent") is None
def test_save_overwrite_existing(self, repo, sample_user):
repo.save(sample_user)
sample_user.display_name = "Updated Name"
repo.save(sample_user)
found = repo.find_by_id("user-1")
assert found.display_name == "Updated Name"
class TestFindByEmail:
def test_find_by_email_case_insensitive(self, repo, sample_user):
repo.save(sample_user)
# 用不同大小写查找
found = repo.find_by_email("test@example.com")
assert found is not None
assert found.id == "user-1"
def test_find_by_email_exact_case(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_email("Test@Example.com")
assert found is not None
def test_find_by_email_not_found(self, repo):
assert repo.find_by_email("notfound@example.com") is None
class TestFindByUsername:
def test_find_by_username_case_insensitive(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_username("TESTUSER")
assert found is not None
assert found.id == "user-1"
def test_find_by_username_not_found(self, repo):
assert repo.find_by_username("nobody") is None
def test_find_by_username_empty(self, repo, sample_user):
sample_user.username = ""
repo.save(sample_user)
# 空 username 不应该建立索引,但查找空字符串应该返回None
found = repo.find_by_username("")
assert found is None
class TestFindByVerificationToken:
def test_find_by_verification_token(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_verification_token("verify-token-123")
assert found is not None
assert found.id == "user-1"
def test_find_by_verification_token_not_found(self, repo):
assert repo.find_by_verification_token("bad-token") is None
class TestFindByPasswordResetToken:
def test_find_by_password_reset_token(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_password_reset_token("reset-token-456")
assert found is not None
assert found.id == "user-1"
def test_find_by_password_reset_token_not_found(self, repo):
assert repo.find_by_password_reset_token("bad-token") is None
class TestFindByWechat:
def test_find_by_wechat_openid(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_wechat_openid("wx-openid-abc")
assert found is not None
assert found.id == "user-1"
def test_find_by_wechat_openid_not_found(self, repo):
assert repo.find_by_wechat_openid("bad-openid") is None
def test_find_by_wechat_unionid(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_wechat_unionid("wx-unionid-def")
assert found is not None
assert found.id == "user-1"
def test_find_by_wechat_unionid_not_found(self, repo):
assert repo.find_by_wechat_unionid("bad-unionid") is None
def test_find_by_wechat_unionid_empty(self, repo, sample_user):
sample_user.wechat_unionid = None
repo.save(sample_user)
assert repo.find_by_wechat_unionid("") is None
class TestFindByPhone:
def test_find_by_phone(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_phone("13800138000")
assert found is not None
assert found.id == "user-1"
def test_find_by_phone_not_found(self, repo):
assert repo.find_by_phone("13900139000") is None
def test_find_by_phone_empty(self, repo, sample_user):
sample_user.phone = None
repo.save(sample_user)
assert repo.find_by_phone("") is None
class TestDelete:
def test_delete_existing_user(self, repo, sample_user):
repo.save(sample_user)
assert repo.delete("user-1") is True
assert repo.find_by_id("user-1") is None
def test_delete_cleans_all_indexes(self, repo, sample_user):
repo.save(sample_user)
repo.delete("user-1")
assert repo.find_by_email("test@example.com") is None
assert repo.find_by_username("testuser") is None
assert repo.find_by_verification_token("verify-token-123") is None
assert repo.find_by_password_reset_token("reset-token-456") is None
def test_delete_nonexistent_user(self, repo):
assert repo.delete("nonexistent") is False
def test_delete_twice_returns_false(self, repo, sample_user):
repo.save(sample_user)
assert repo.delete("user-1") is True
assert repo.delete("user-1") is False
class TestIndexUpdates:
def test_save_new_user_with_same_email_overwrites_index(self, repo, sample_user):
"""不同用户同邮箱,后者覆盖索引."""
repo.save(sample_user)
user2 = User(
id="user-2",
email="test@example.com", # 同邮箱不同大小写
display_name="User 2",
username="user2",
)
repo.save(user2)
# 邮箱索引指向最后保存的用户
found = repo.find_by_email("test@example.com")
assert found.id == "user-2"
# 原用户仍然可通过ID找到
assert repo.find_by_id("user-1") is not None
File diff suppressed because it is too large Load Diff
@@ -468,9 +468,8 @@ class TestSubtitleStyle:
assert style3.alignment == 5
def test_9grid_positions(self):
from video_processing.subtitle_render_engine import SubtitleStyle
from packages.domain.subtitle_style import POSITION_ALIGNMENT
from video_processing.subtitle_render_engine import SubtitleStyle
for pos, align in POSITION_ALIGNMENT.items():
style = SubtitleStyle.from_dict({"position": pos})
+279 -70
View File
@@ -1,6 +1,9 @@
"""pagination 单元测试."""
"""通用分页器单元测试."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from packages.application.common.pagination import (
PaginatedResponse,
@@ -9,171 +12,377 @@ from packages.application.common.pagination import (
paginate,
)
# ── PaginationParams ────────────────────────────────────────────────────────
class TestPaginationParams:
"""PaginationParams 测试"""
def test_default_values(self):
"""默认值正确"""
params = PaginationParams()
assert params.page == 1
assert params.page_size == 20
def test_custom_values(self):
params = PaginationParams(page=3, page_size=50)
assert params.page == 3
assert params.page_size == 50
def test_offset_calculation(self):
def test_offset_first_page(self):
"""第一页 offset 为 0"""
params = PaginationParams(page=1, page_size=20)
assert params.offset == 0
params = PaginationParams(page=3, page_size=20)
assert params.offset == 40
def test_offset_second_page(self):
"""第二页 offset 计算正确"""
params = PaginationParams(page=2, page_size=20)
assert params.offset == 20
params = PaginationParams(page=10, page_size=50)
assert params.offset == 450
def test_offset_custom_page_size(self):
"""自定义 page_size 的 offset"""
params = PaginationParams(page=3, page_size=10)
assert params.offset == 20
def test_limit_equals_page_size(self):
params = PaginationParams(page_size=30)
assert params.limit == 30
"""limit 等于 page_size"""
params = PaginationParams(page_size=50)
assert params.limit == 50
def test_page_must_be_at_least_1(self):
with pytest.raises(ValueError):
"""page 不能小于 1"""
with pytest.raises(ValidationError):
PaginationParams(page=0)
def test_page_negative_raises(self):
"""page 不能为负数"""
with pytest.raises(ValidationError):
PaginationParams(page=-1)
def test_page_size_must_be_at_least_1(self):
with pytest.raises(ValueError):
"""page_size 不能小于 1"""
with pytest.raises(ValidationError):
PaginationParams(page_size=0)
def test_page_size_max_100(self):
with pytest.raises(ValueError):
"""page_size 最大 100"""
with pytest.raises(ValidationError):
PaginationParams(page_size=101)
# ── PaginationMeta ──────────────────────────────────────────────────────────
def test_page_size_100_is_valid(self):
"""page_size=100 是合法的"""
params = PaginationParams(page_size=100)
assert params.page_size == 100
class TestPaginationMeta:
"""PaginationMeta 测试"""
def test_from_params_first_page(self):
"""第一页元数据"""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=25)
assert meta.page == 1
assert meta.page_size == 10
assert meta.total == 25
assert meta.total_pages == 3 # ceil(25/10)
assert meta.total_pages == 3
assert meta.has_next is True
assert meta.has_prev is False
def test_from_params_last_page(self):
"""最后一页元数据"""
params = PaginationParams(page=3, page_size=10)
meta = PaginationMeta.from_params(params, total=25)
assert meta.page == 3
assert meta.total_pages == 3
assert meta.has_next is False
assert meta.has_prev is True
def test_from_params_middle_page(self):
"""中间页元数据"""
params = PaginationParams(page=2, page_size=10)
meta = PaginationMeta.from_params(params, total=25)
meta = PaginationMeta.from_params(params, total=50)
assert meta.page == 2
assert meta.total_pages == 5
assert meta.has_next is True
assert meta.has_prev is True
def test_from_params_single_page(self):
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=5)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
def test_from_params_zero_total(self):
"""总数为 0 时"""
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total == 0
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is False
def test_from_params_exact_page_size(self):
def test_from_params_exact_multiple(self):
"""总数刚好是 page_size 的整数倍"""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=10)
meta = PaginationMeta.from_params(params, total=30)
assert meta.total_pages == 3
def test_from_params_single_page(self):
"""单页即可放下所有数据"""
params = PaginationParams(page=1, page_size=100)
meta = PaginationMeta.from_params(params, total=50)
assert meta.total_pages == 1
def test_from_params_one_extra(self):
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=11)
assert meta.total_pages == 2
# ── PaginatedResponse ───────────────────────────────────────────────────────
assert meta.has_next is False
assert meta.has_prev is False
class TestPaginatedResponse:
def test_create_response(self):
params = PaginationParams(page=1, page_size=5)
data = [1, 2, 3, 4, 5]
response = PaginatedResponse.create(data, params, total=15)
assert response.data == data
"""PaginatedResponse 测试"""
def test_create_success(self):
"""创建分页响应"""
params = PaginationParams(page=1, page_size=10)
data = [1, 2, 3]
response = PaginatedResponse.create(data, params, total=25)
assert response.data == [1, 2, 3]
assert response.pagination.page == 1
assert response.pagination.total == 15
assert response.pagination.total == 25
assert response.pagination.total_pages == 3
def test_create_empty_data(self):
"""空数据分页响应"""
params = PaginationParams(page=1, page_size=20)
response = PaginatedResponse.create([], params, total=0)
# ── paginate function ───────────────────────────────────────────────────────
assert response.data == []
assert response.pagination.total == 0
assert response.pagination.total_pages == 0
class TestPaginate:
class TestPaginateFunction:
"""paginate 函数测试(内存分页)"""
def test_first_page(self):
"""第一页分页"""
items = list(range(30))
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == list(range(10))
assert result.pagination.total == 30
assert result.pagination.total_pages == 3
assert result.pagination.has_next is True
assert result.pagination.has_prev is False
def test_second_page(self):
"""第二页分页"""
items = list(range(30))
params = PaginationParams(page=2, page_size=10)
result = paginate(items, params)
assert result.data == list(range(10, 20))
assert result.pagination.page == 2
def test_last_page(self):
"""最后一页分页"""
items = list(range(25))
params = PaginationParams(page=3, page_size=10)
result = paginate(items, params)
assert result.data == list(range(20, 25))
assert len(result.data) == 5
assert result.pagination.has_next is False
assert result.pagination.has_prev is True
def test_single_page(self):
items = list(range(5))
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == items
assert result.pagination.total_pages == 1
def test_empty_list(self):
items = []
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
"""空列表分页"""
params = PaginationParams(page=1, page_size=20)
result = paginate([], params)
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_beyond_end(self):
def test_page_beyond_total(self):
"""页码超出总数"""
items = list(range(5))
params = PaginationParams(page=10, page_size=10)
result = paginate(items, params)
assert result.data == []
assert result.pagination.total == 5
assert result.pagination.total_pages == 1
def test_custom_page_size(self):
"""自定义每页数量"""
items = list(range(100))
params = PaginationParams(page=1, page_size=50)
result = paginate(items, params)
assert len(result.data) == 50
assert result.pagination.total_pages == 2
def test_single_item(self):
"""单条数据"""
items = ["only_one"]
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == ["only_one"]
assert result.pagination.total == 1
assert result.pagination.total_pages == 1
def test_generic_type_preserved(self):
"""泛型类型数据正确"""
items = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert len(result.data) == 2
assert result.data[0]["id"] == 1
# ── PaginationParams 补充边界 ───────────────────────────────────────────────
class TestPaginationParamsEdgeCases:
"""PaginationParams 补充边界场景."""
def test_page_size_1_minimum(self):
"""page_size=1 是允许的最小值."""
params = PaginationParams(page_size=1)
assert params.page_size == 1
assert params.limit == 1
def test_page_size_100_maximum(self):
"""page_size=100 是允许的最大值."""
params = PaginationParams(page_size=100)
assert params.page_size == 100
def test_offset_page_1_size_100(self):
"""第1页每页100条 offset=0."""
params = PaginationParams(page=1, page_size=100)
assert params.offset == 0
def test_offset_page_100_size_100(self):
"""第100页每页100条 offset=9900."""
params = PaginationParams(page=100, page_size=100)
assert params.offset == 9900
def test_large_page_number_accepted(self):
"""极大页码(超过实际页数)允许."""
params = PaginationParams(page=999999, page_size=20)
assert params.page == 999999
assert params.offset == (999999 - 1) * 20
# ── PaginationMeta 补充边界 ─────────────────────────────────────────────────
class TestPaginationMetaEdgeCases:
"""PaginationMeta 补充边界场景."""
def test_total_0_page_1(self):
"""total=0, page=1 时 total_pages=0, 无上下页."""
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is False
def test_total_0_page_beyond(self):
"""total=0, page>1 时 has_prev=True(因为page>1."""
params = PaginationParams(page=3, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is True
def test_exact_last_page(self):
"""刚好是最后一页时 has_next=False."""
params = PaginationParams(page=5, page_size=10)
meta = PaginationMeta.from_params(params, total=50)
assert meta.total_pages == 5
assert meta.has_next is False
assert meta.has_prev is True
def test_one_more_than_exact(self):
"""比整数页多1条时总页数+1."""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=51)
assert meta.total_pages == 6
def test_page_exactly_total_pages(self):
"""page == total_pages 时 has_next=False."""
params = PaginationParams(page=3, page_size=10)
meta = PaginationMeta.from_params(params, total=30)
assert meta.has_next is False
def test_total_1_page_1_size_1(self):
"""1条数据1页."""
params = PaginationParams(page=1, page_size=1)
meta = PaginationMeta.from_params(params, total=1)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
# ── paginate 补充边界 ──────────────────────────────────────────────────────
class TestPaginateEdgeCases:
"""paginate 补充边界场景."""
def test_single_item_list(self):
"""单元素列表."""
result = paginate([42], PaginationParams(page=1, page_size=10))
assert result.data == [42]
assert result.pagination.total == 1
assert result.pagination.total_pages == 1
def test_page_exactly_last(self):
"""刚好在最后一页."""
items = list(range(25))
result = paginate(items, PaginationParams(page=3, page_size=10))
assert result.data == list(range(20, 25))
assert result.pagination.has_next is False
def test_page_past_end_returns_empty(self):
"""页码超过总数返回空."""
items = list(range(5))
result = paginate(items, PaginationParams(page=10, page_size=10))
assert result.data == []
assert result.pagination.total == 5
def test_page_size_larger_than_items(self):
def test_empty_list_page_1(self):
"""空列表第1页."""
result = paginate([], PaginationParams(page=1, page_size=10))
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_size_1_iterates_all(self):
"""page_size=1 时每页1条."""
items = ["a", "b", "c"]
r1 = paginate(items, PaginationParams(page=1, page_size=1))
r2 = paginate(items, PaginationParams(page=2, page_size=1))
r3 = paginate(items, PaginationParams(page=3, page_size=1))
assert r1.data == ["a"]
assert r2.data == ["b"]
assert r3.data == ["c"]
def test_does_not_mutate_input(self):
"""不修改输入列表."""
items = [1, 2, 3, 4, 5]
original = items[:]
paginate(items, PaginationParams(page=1, page_size=2))
assert items == original
def test_page_size_greater_than_total(self):
"""每页条数大于总数."""
items = list(range(5))
params = PaginationParams(page=1, page_size=100)
result = paginate(items, params)
result = paginate(items, PaginationParams(page=1, page_size=100))
assert result.data == items
assert result.pagination.total_pages == 1
def test_middle_page(self):
items = list(range(100))
params = PaginationParams(page=5, page_size=10)
result = paginate(items, params)
assert result.data == list(range(40, 50))
assert result.pagination.has_next is True
assert result.pagination.has_prev is True
-318
View File
@@ -1,318 +0,0 @@
"""密码哈希与验证模块单元测试."""
import pytest
from packages.application.auth.password_handler import (
PasswordHandler,
configure_password_handler,
get_password_handler,
)
from packages.application.auth.password_hasher import (
PasswordHasher,
PasswordValidator,
)
# ==================== PasswordValidator ====================
class TestPasswordValidator:
@pytest.fixture
def default_validator(self):
return PasswordValidator()
@pytest.fixture
def strict_validator(self):
return PasswordValidator(
min_length=12,
require_uppercase=True,
require_lowercase=True,
require_digit=True,
require_special=True,
)
class TestBasicValidation:
def test_valid_password(self, default_validator):
ok, msg = default_validator.validate("SecurePass123")
assert ok is True
assert msg is None
def test_empty_password(self, default_validator):
ok, msg = default_validator.validate("")
assert ok is False
assert "empty" in msg.lower()
def test_none_password_treated_as_empty(self, default_validator):
# None被not判定为falsy,返回空密码错误
ok, msg = default_validator.validate(None)
assert ok is False
assert "empty" in msg.lower()
class TestMinLength:
def test_too_short(self, default_validator):
ok, msg = default_validator.validate("Ab1")
assert ok is False
assert "8" in msg
def test_exact_min_length(self, default_validator):
# 刚好8个字符
ok, _ = default_validator.validate("Abcdefg1")
assert ok is True
def test_custom_min_length(self, strict_validator):
ok, msg = strict_validator.validate("Short1!")
assert ok is False
assert "12" in msg
class TestUppercase:
def test_no_uppercase(self, default_validator):
ok, msg = default_validator.validate("password123")
assert ok is False
assert "uppercase" in msg.lower()
def test_with_uppercase(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_uppercase=False)
ok, _ = v.validate("password123")
assert ok is True
class TestLowercase:
def test_no_lowercase(self, default_validator):
ok, msg = default_validator.validate("PASSWORD123")
assert ok is False
assert "lowercase" in msg.lower()
def test_with_lowercase(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_lowercase=False)
ok, _ = v.validate("PASSWORD123")
assert ok is True
class TestDigit:
def test_no_digit(self, default_validator):
ok, msg = default_validator.validate("Passworddd")
assert ok is False
assert "digit" in msg.lower()
def test_with_digit(self, default_validator):
ok, _ = default_validator.validate("Password1")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_digit=False)
ok, _ = v.validate("Passworddd")
assert ok is True
class TestSpecialChar:
def test_no_special_when_not_required(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_no_special_when_required(self, strict_validator):
ok, msg = strict_validator.validate("Password1234")
assert ok is False
assert "special" in msg.lower()
def test_with_special(self, strict_validator):
ok, _ = strict_validator.validate("Password123!")
assert ok is True
def test_various_special_chars(self, strict_validator):
for char in "!@#$%^&*()_+-=[]{}|;:,.<>?~":
pw = f"LongPassword1{char}" # 13字符,含大小写数字特殊
ok, msg = strict_validator.validate(pw)
assert ok is True, f"special char {char} should be valid: {msg}"
class TestAllDisabled:
def test_all_disabled_min_length_only(self):
v = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
ok, _ = v.validate("a")
assert ok is True
def test_all_disabled_empty_still_fails(self):
v = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
ok, _ = v.validate("")
assert ok is False
# ==================== PasswordHasher ====================
class TestPasswordHasher:
@pytest.fixture
def hasher(self):
return PasswordHasher(rounds=4) # 用最低rounds加速测试
class TestHashPassword:
def test_hash_returns_string(self, hasher):
result = hasher.hash_password("testpassword")
assert isinstance(result, str)
assert len(result) > 0
def test_hash_starts_with_bcrypt_prefix(self, hasher):
result = hasher.hash_password("testpassword")
assert result.startswith("$2")
def test_hash_contains_rounds(self, hasher):
result = hasher.hash_password("testpassword")
parts = result.split("$")
assert parts[2] == "04" # bcrypt rounds格式是两位数
def test_hash_different_salts(self, hasher):
# 同一密码两次哈希结果不同(因为salt随机)
h1 = hasher.hash_password("samepassword")
h2 = hasher.hash_password("samepassword")
assert h1 != h2
def test_hash_empty_password_raises(self, hasher):
with pytest.raises(ValueError, match="empty"):
hasher.hash_password("")
def test_hash_unicode_password(self, hasher):
result = hasher.hash_password("密码Pass123!")
assert isinstance(result, str)
assert len(result) > 0
def test_hash_long_password(self, hasher):
long_pw = "a" * 72 # bcrypt最大72字节
result = hasher.hash_password(long_pw)
assert isinstance(result, str)
class TestVerifyPassword:
def test_verify_correct_password(self, hasher):
hashed = hasher.hash_password("CorrectPass123")
assert hasher.verify_password("CorrectPass123", hashed) is True
def test_verify_wrong_password(self, hasher):
hashed = hasher.hash_password("CorrectPass123")
assert hasher.verify_password("WrongPass123", hashed) is False
def test_verify_empty_password(self, hasher):
hashed = hasher.hash_password("testpass")
assert hasher.verify_password("", hashed) is False
def test_verify_empty_hash(self, hasher):
assert hasher.verify_password("testpass", "") is False
def test_verify_invalid_hash_format(self, hasher):
assert hasher.verify_password("testpass", "invalid-hash-format") is False
def test_verify_none_hash(self, hasher):
assert hasher.verify_password("testpass", None) is False
def test_verify_unicode_password(self, hasher):
pw = "密码Pass123!"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
class TestNeedsRehash:
def test_same_rounds_no_rehash(self, hasher):
hashed = hasher.hash_password("testpass")
assert hasher.needs_rehash(hashed) is False
def test_lower_rounds_needs_rehash(self):
hasher_low = PasswordHasher(rounds=4)
hashed = hasher_low.hash_password("testpass")
hasher_high = PasswordHasher(rounds=5)
assert hasher_high.needs_rehash(hashed) is True
def test_higher_rounds_needs_rehash(self):
hasher_high = PasswordHasher(rounds=5)
hashed = hasher_high.hash_password("testpass")
hasher_low = PasswordHasher(rounds=4)
assert hasher_low.needs_rehash(hashed) is True
def test_invalid_hash_no_rehash(self, hasher):
assert hasher.needs_rehash("invalid-format") is False
def test_empty_hash_no_rehash(self, hasher):
assert hasher.needs_rehash("") is False
class TestInit:
def test_rounds_below_min_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=3)
def test_rounds_above_max_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=32)
def test_min_rounds_ok(self):
h = PasswordHasher(rounds=4)
assert h.rounds == 4
def test_max_rounds_ok(self):
h = PasswordHasher(rounds=31)
assert h.rounds == 31
# ==================== PasswordHandler ====================
class TestPasswordHandler:
@pytest.fixture
def handler(self):
return PasswordHandler(rounds=4)
def test_hash_and_verify_roundtrip(self, handler):
hashed = handler.hash_password("MySecurePass123")
assert handler.verify_password("MySecurePass123", hashed) is True
assert handler.verify_password("WrongPass", hashed) is False
def test_needs_rehash(self, handler):
# 用当前rounds哈希,不需要rehash
hashed = handler.hash_password("testpass")
assert handler.needs_rehash(hashed) is False
def test_validate_strength(self, handler):
# 强密码通过
ok, msg = handler.validate_strength("StrongPass123")
assert ok is True
assert msg is None
# 弱密码不通过
ok, msg = handler.validate_strength("weak")
assert ok is False
assert msg is not None
def test_hash_empty_raises(self, handler):
with pytest.raises(ValueError):
handler.hash_password("")
class TestGlobalHandler:
def test_get_password_handler_returns_instance(self):
# 重置全局实例
import packages.application.auth.password_handler as ph
ph._default_handler = None
handler = get_password_handler()
assert isinstance(handler, PasswordHandler)
def test_configure_password_handler(self):
handler = configure_password_handler(rounds=4)
assert isinstance(handler, PasswordHandler)
# get应该返回同一个配置好的实例
same_handler = get_password_handler()
assert same_handler is handler
+209 -224
View File
@@ -1,14 +1,18 @@
"""path_security 单元测试."""
"""路径安全校验工具单元测试 — 路径遍历防护."""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
from apps.worker.video_processing.path_security import (
LOCAL_SCHEMA_PREFIX,
MAX_PATH_LENGTH,
from video_processing.path_security import ( # noqa: E402
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
is_path_safe,
safe_resolve_path,
@@ -17,242 +21,223 @@ from apps.worker.video_processing.path_security import (
)
@pytest.fixture
def base_dir():
with tempfile.TemporaryDirectory() as tmpdir:
# 创建一个子文件用于测试
with open(os.path.join(tmpdir, "test.mp4"), "w") as f:
f.write("test")
subdir = os.path.join(tmpdir, "subdir")
os.makedirs(subdir)
with open(os.path.join(subdir, "audio.mp3"), "w") as f:
f.write("test")
yield tmpdir
class TestSafeResolvePath(unittest.TestCase):
"""安全路径解析测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
# ── 正常路径 ─────────────────────────────────────────────────────────
def test_simple_relative_path(self):
"""简单相对路径应该正常解析."""
result = safe_resolve_path("test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
self.assertTrue(str(result).startswith(self.tmpdir))
def test_subdirectory_path(self):
"""子目录路径应该正常解析."""
result = safe_resolve_path("sub/dir/file.mp4", self.tmpdir)
self.assertTrue(str(result).startswith(self.tmpdir))
self.assertIn("sub/dir/file.mp4", str(result).replace("\\", "/"))
def test_dot_slash_path(self):
"""./ 开头的路径应该正常解析."""
result = safe_resolve_path("./test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
# ── 路径遍历防护 ─────────────────────────────────────────────────────
def test_parent_traversal_rejected(self):
"""../ 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../etc/passwd", self.tmpdir)
def test_multiple_parent_traversal_rejected(self):
"""多级 ../ 遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../../etc/passwd", self.tmpdir)
def test_mixed_traversal_rejected(self):
"""混合路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("./sub/../../etc/shadow", self.tmpdir)
def test_absolute_path_rejected(self):
"""绝对路径(超出基目录)应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/etc/passwd", self.tmpdir)
# ── 空字节注入 ───────────────────────────────────────────────────────
def test_null_byte_rejected(self):
"""空字节注入应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("test\x00.mp4", self.tmpdir)
# ── 空路径 ──────────────────────────────────────────────────────────
def test_empty_path_rejected(self):
"""空路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("", self.tmpdir)
def test_none_path_rejected(self):
"""None 路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(None, self.tmpdir) # type: ignore
def test_whitespace_path_rejected(self):
"""空白路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(" ", self.tmpdir)
# ── 路径长度 ────────────────────────────────────────────────────────
def test_too_long_path_rejected(self):
"""超长路径应该被拒绝."""
long_path = "a" * 5000 + ".mp4"
with self.assertRaises(PathSecurityError):
safe_resolve_path(long_path, self.tmpdir)
# ── 系统路径防护 ─────────────────────────────────────────────────────
def test_proc_path_rejected_when_absolute(self):
"""/proc/ 路径在绝对路径模式下应该被拒绝(因为超出基目录)."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/proc/self/environ", self.tmpdir)
# ── 扩展名校验 ───────────────────────────────────────────────────────
def test_extension_whitelist_pass(self):
"""白名单内的扩展名应该通过."""
result = safe_resolve_path(
"test.mp4",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
self.assertEqual(result.suffix.lower(), ".mp4")
def test_extension_whitelist_reject(self):
"""白名单外的扩展名应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(
"test.exe",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
# ── safe_resolve_path ────────────────────────────────────────────────────────
class TestLocalSchemaPath(unittest.TestCase):
"""local:// schema 路径测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_valid_local_schema(self):
"""有效的 local:// 相对路径应该通过."""
# 创建测试文件
test_file = Path(self.tmpdir) / "test.mp4"
test_file.touch()
result = validate_local_schema_path("local://test.mp4", self.tmpdir)
self.assertTrue(result.exists())
def test_local_schema_absolute_rejected(self):
"""local:// + 绝对路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", self.tmpdir)
def test_local_schema_traversal_rejected(self):
"""local:// + 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local://../etc/passwd", self.tmpdir)
def test_non_local_schema_rejected(self):
"""非 local:// 开头的路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("http://example.com/test", self.tmpdir)
class TestSafeResolvePath:
def test_none_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(None, base_dir)
class TestSanitizeFilename(unittest.TestCase):
"""文件名清理测试."""
def test_empty_string_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path("", base_dir)
def test_whitespace_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(" ", base_dir)
def test_too_long_path_raises(self, base_dir):
long_path = "a" * (MAX_PATH_LENGTH + 1)
with pytest.raises(PathSecurityError, match="路径过长"):
safe_resolve_path(long_path, base_dir)
def test_null_byte_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="空字节"):
safe_resolve_path("file\x00.mp4", base_dir)
def test_relative_path_within_base(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_subdirectory_path(self, base_dir):
result = safe_resolve_path("subdir/audio.mp3", base_dir)
assert result.name == "audio.mp3"
assert "subdir" in str(result)
def test_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("../etc/passwd", base_dir)
def test_nested_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("subdir/../../etc/passwd", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("/etc/passwd", base_dir)
def test_absolute_path_with_allow_outside(self, base_dir):
# allow_outside=True 时允许绝对路径(但会被危险路径模式检查)
with pytest.raises(PathSecurityError, match="系统路径"):
safe_resolve_path("/etc/passwd", base_dir, allow_outside=True)
def test_local_schema_relative(self, base_dir):
result = safe_resolve_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_local_schema_absolute_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("local:///etc/passwd", base_dir)
def test_local_schema_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("local://../secret", base_dir)
def test_invalid_base_dir_raises(self):
with pytest.raises(PathSecurityError, match="基路径"):
safe_resolve_path("file.txt", "/nonexistent/dir")
def test_allowed_extensions_valid(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp4"})
assert result.suffix.lower() == ".mp4"
def test_allowed_extensions_invalid_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="文件类型"):
safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp3"})
def test_no_extension_restriction(self, base_dir):
# allowed_extensions=None 时不检查
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions=None)
assert result is not None
def test_path_object_input(self, base_dir):
from pathlib import Path
result = safe_resolve_path(Path("test.mp4"), base_dir)
assert result.name == "test.mp4"
def test_path_object_base_dir(self, base_dir):
from pathlib import Path
result = safe_resolve_path("test.mp4", Path(base_dir))
assert result.name == "test.mp4"
# ── is_path_safe ────────────────────────────────────────────────────────────
class TestIsPathSafe:
def test_safe_path_returns_true(self, base_dir):
assert is_path_safe("test.mp4", base_dir) is True
def test_unsafe_path_returns_false(self, base_dir):
assert is_path_safe("../etc/passwd", base_dir) is False
def test_none_returns_false(self, base_dir):
assert is_path_safe(None, base_dir) is False
# ── validate_local_schema_path ──────────────────────────────────────────────
class TestValidateLocalSchemaPath:
def test_valid_local_path(self, base_dir):
result = validate_local_schema_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
def test_missing_prefix_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="开头"):
validate_local_schema_path("test.mp4", base_dir)
def test_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local://../secret", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", base_dir)
# ── sanitize_filename ───────────────────────────────────────────────────────
class TestSanitizeFilename:
def test_normal_filename(self):
assert sanitize_filename("hello.mp4") == "hello.mp4"
"""正常文件名应该保持不变."""
self.assertEqual(sanitize_filename("video.mp4"), "video.mp4")
def test_empty_returns_unnamed(self):
assert sanitize_filename("") == "unnamed"
def test_path_separators_removed(self):
"""路径分隔符应该被替换."""
self.assertNotIn("/", sanitize_filename("../path/to/file.mp4"))
self.assertNotIn("\\", sanitize_filename("..\\path\\file.mp4"))
def test_none_default(self):
# 空字符串会返回unnamed
assert sanitize_filename("") == "unnamed"
def test_leading_dots_removed(self):
"""开头的点应该被移除."""
result = sanitize_filename(".hidden")
self.assertFalse(result.startswith("."))
self.assertEqual(result, "hidden")
def test_removes_path_separators(self):
assert "/" not in sanitize_filename("path/to/file.mp4")
assert "\\" not in sanitize_filename("path\\to\\file.mp4")
def test_multiple_leading_dots_removed(self):
"""多个开头的点应该全部被移除."""
result = sanitize_filename("...hidden")
self.assertFalse(result.startswith("."))
def test_removes_control_characters(self):
result = sanitize_filename("file\x01\x02name.mp4")
assert "\x01" not in result
assert "\x02" not in result
def test_empty_filename_default(self):
"""空文件名应该返回 unnamed."""
self.assertEqual(sanitize_filename(""), "unnamed")
def test_removes_dangerous_chars(self):
result = sanitize_filename("file<name>.mp4")
assert "<" not in result
assert ">" not in result
def test_special_chars_removed(self):
"""特殊字符应该被替换."""
result = sanitize_filename('file<name>:"test|?*.mp4')
self.assertNotIn("<", result)
self.assertNotIn(">", result)
self.assertNotIn(":", result)
self.assertNotIn('"', result)
self.assertNotIn("|", result)
self.assertNotIn("?", result)
self.assertNotIn("*", result)
def test_removes_leading_dots(self):
assert not sanitize_filename(".hidden").startswith(".")
assert not sanitize_filename("..hidden").startswith(".")
def test_chinese_characters_preserved(self):
result = sanitize_filename("视频文件.mp4")
assert "视频文件" in result
def test_chinese_filename_preserved(self):
"""中文文件名应该保留."""
result = sanitize_filename("视频素材.mp4")
self.assertIn("视频素材", result)
def test_long_filename_truncated(self):
"""超长文件名应该被截断."""
long_name = "a" * 300 + ".mp4"
result = sanitize_filename(long_name)
assert len(result) <= 255
assert result.endswith(".mp4")
def test_spaces_preserved(self):
result = sanitize_filename("my file.mp4")
assert "my file.mp4" == result
def test_underscores_hyphens_preserved(self):
result = sanitize_filename("my_file-name.mp4")
assert result == "my_file-name.mp4"
def test_all_dots_returns_unnamed(self):
assert sanitize_filename("...") == "unnamed"
self.assertLessEqual(len(result), 255)
self.assertTrue(result.endswith(".mp4"))
# ── is_in_allowed_dirs ──────────────────────────────────────────────────────
class TestAllowedDirs(unittest.TestCase):
"""允许目录配置测试."""
def test_get_allowed_dirs_returns_list(self):
"""get_allowed_local_dirs 应该返回列表."""
dirs = get_allowed_local_dirs()
self.assertIsInstance(dirs, list)
def test_is_in_allowed_dirs_tmp(self):
"""/tmp 应该在默认允许目录内."""
self.assertTrue(is_in_allowed_dirs("/tmp/test.mp4"))
def test_is_path_safe_convenience(self):
"""is_path_safe 便捷函数应该正常工作."""
with tempfile.TemporaryDirectory() as tmpdir:
self.assertTrue(is_path_safe("test.mp4", tmpdir))
self.assertFalse(is_path_safe("../etc/passwd", tmpdir))
class TestIsInAllowedDirs:
def test_path_in_allowed_dir(self, base_dir):
filepath = os.path.join(base_dir, "test.mp4")
from pathlib import Path
assert is_in_allowed_dirs(filepath, [Path(base_dir)]) is True
def test_path_not_in_allowed_dir(self, base_dir):
from pathlib import Path
assert is_in_allowed_dirs("/etc/passwd", [Path(base_dir)]) is False
def test_subdirectory_in_allowed(self, base_dir):
from pathlib import Path
sub = os.path.join(base_dir, "subdir", "audio.mp3")
assert is_in_allowed_dirs(sub, [Path(base_dir)]) is True
def test_none_allowed_dirs_uses_default(self):
# None 使用默认配置(包含 /tmp)
result = is_in_allowed_dirs("/tmp/test.mp4")
assert isinstance(result, bool)
def test_allowed_dirs_list_is_empty(self):
from pathlib import Path
assert is_in_allowed_dirs("/tmp/test", []) is False
# ── PathSecurityError class ─────────────────────────────────────────────────
class TestPathSecurityError:
def test_is_value_error(self):
assert issubclass(PathSecurityError, ValueError)
def test_message_preserved(self):
err = PathSecurityError("test message")
assert str(err) == "test message"
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
-453
View File
@@ -1,453 +0,0 @@
"""shared.ai_service 单元测试.
主要测试纯逻辑部分:_parse_recommend_response / _fallback_recommend_clips / _call_ai_cover_service.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from shared.ai_service import (
_call_ai_cover_service,
_fallback_recommend_clips,
_parse_recommend_response,
)
# ── _parse_recommend_response 测试 ────────────────────────────────────────
class TestParseRecommendResponseBasic:
"""基础解析测试."""
def test_parse_valid_json(self):
content = json.dumps(
{
"clips": [
{
"clip_type": "intro",
"order": 0,
"text_content": "开场",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": "asset1",
"start_time": 0.0,
"config": {},
},
{
"clip_type": "outro",
"order": 1,
"text_content": "结尾",
"duration": 2.0,
"transition_effect": "fade",
"asset_id": "",
"start_time": 0.0,
"config": {},
},
],
"title": "测试视频",
"confidence": 0.85,
}
)
result = _parse_recommend_response(content, ["asset1"], 30.0)
assert result is not None
assert len(result["clips"]) == 2
assert result["confidence"] == 0.85
assert result["total_duration"] == 5.0
assert result["config"]["title"]["text"] == "测试视频"
assert result["config"]["title"]["ai_auto"] is True
def test_parse_none_returns_none(self):
result = _parse_recommend_response(None, ["a1"], 30.0) # type: ignore[arg-type]
assert result is None
def test_parse_empty_string_returns_none(self):
result = _parse_recommend_response("", ["a1"], 30.0)
assert result is None
def test_parse_whitespace_only_returns_none(self):
result = _parse_recommend_response(" ", ["a1"], 30.0)
assert result is None
def test_parse_invalid_json_returns_none(self):
result = _parse_recommend_response("not json", ["a1"], 30.0)
assert result is None
def test_parse_non_dict_json_returns_none(self):
result = _parse_recommend_response("[1, 2, 3]", ["a1"], 30.0)
assert result is None
class TestParseRecommendResponseClips:
"""clips 解析测试."""
def test_parse_no_clips_returns_none(self):
content = json.dumps({"title": "test", "clips": []})
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is None
def test_parse_clips_not_list_returns_none(self):
content = json.dumps({"clips": "not a list"})
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is None
def test_parse_clips_sorted_by_order(self):
content = json.dumps(
{
"clips": [
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
{"clip_type": "intro", "order": 0, "duration": 3, "asset_id": "a1"},
{"clip_type": "showcase", "order": 1, "duration": 5, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 3
assert result["clips"][0]["clip_type"] == "intro"
assert result["clips"][1]["clip_type"] == "showcase"
assert result["clips"][2]["clip_type"] == "outro"
def test_parse_clips_renumbered_continuously(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 10, "duration": 2, "asset_id": "a1"},
{"clip_type": "outro", "order": 20, "duration": 2, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["order"] == 0
assert result["clips"][1]["order"] == 1
def test_parse_skips_invalid_clip_dicts(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"},
"not a dict",
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 2
class TestParseRecommendResponseFields:
"""各字段解析与边界测试."""
def test_parse_duration_clamped_min(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 0.5, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["duration"] == 1.0
def test_parse_duration_clamped_max(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 100, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["duration"] == 30.0
def test_parse_start_time_clamped_min(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1", "start_time": -5.0},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["start_time"] == 0.0
def test_parse_asset_id_not_in_list_empty(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "unknown_asset"},
],
}
)
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
assert result is not None
assert result["clips"][0]["asset_id"] == ""
def test_parse_asset_id_in_list_kept(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a2"},
],
}
)
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
assert result is not None
assert result["clips"][0]["asset_id"] == "a2"
def test_parse_default_values(self):
content = json.dumps(
{
"clips": [
{"order": 0},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
clip = result["clips"][0]
assert clip["clip_type"] == "showcase"
assert clip["text_content"] == ""
assert clip["duration"] == 3.0
assert clip["transition_effect"] == "cut"
assert clip["asset_id"] == ""
assert clip["start_time"] == 0.0
assert clip["config"] == {}
class TestParseRecommendResponseMarkdown:
"""Markdown 代码块包裹的 JSON 测试."""
def test_parse_markdown_json(self):
content = (
"```json\n"
+ json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"title": "md test",
}
)
+ "\n```"
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 1
assert result["config"]["title"]["text"] == "md test"
def test_parse_backticks_no_language(self):
content = (
"```\n"
+ json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
+ "\n```"
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 1
class TestParseRecommendResponseConfidence:
"""confidence 解析测试."""
def test_parse_confidence_normal(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": 0.85,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.85
def test_parse_confidence_clamped_min(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": -0.5,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.0
def test_parse_confidence_clamped_max(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": 1.5,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 1.0
def test_parse_confidence_default(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.7
class TestParseRecommendResponseConfig:
"""config 生成测试."""
def test_parse_no_title_no_ai_auto(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
# 没有 title 时,config 的 title.text 保持默认(DEFAULT_EDIT_PLAN_CONFIG 中的值)
assert "title" in result["config"]
def test_parse_config_is_deep_copy(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"title": "test",
}
)
result1 = _parse_recommend_response(content, ["a1"], 30.0)
result2 = _parse_recommend_response(content, ["a1"], 30.0)
# 修改其中一个不影响另一个
result1["config"]["title"]["text"] = "modified"
assert result2["config"]["title"]["text"] != "modified"
class TestParseRecommendResponseTotalDuration:
"""total_duration 计算测试."""
def test_parse_total_duration_sum(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 3.5, "asset_id": "a1"},
{"clip_type": "showcase", "order": 1, "duration": 5.2, "asset_id": "a1"},
{"clip_type": "outro", "order": 2, "duration": 2.0, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["total_duration"] == pytest.approx(10.7, abs=0.01)
# ── _fallback_recommend_clips 测试 ────────────────────────────────────────
class TestFallbackRecommendClips:
"""本地降级推荐方案测试."""
def test_fallback_returns_dict_with_clips(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
assert "clips" in result
assert "config" in result
assert "total_duration" in result
assert "confidence" in result
def test_fallback_has_intro_and_outro(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
clips = result["clips"]
assert clips[0]["clip_type"] == "intro"
assert clips[-1]["clip_type"] == "outro"
def test_fallback_showcase_count_matches_assets(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
showcase_clips = [c for c in result["clips"] if c["clip_type"] == "showcase"]
assert len(showcase_clips) == 3
def test_fallback_no_assets_still_works(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", [], "one_take", 30.0)
assert len(result["clips"]) >= 2 # 至少有intro和outro
def test_fallback_intro_uses_first_asset(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
assert result["clips"][0]["asset_id"] == "a1"
def test_fallback_outro_has_empty_asset(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
assert result["clips"][-1]["asset_id"] == ""
def test_fallback_confidence_in_range(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
assert 0.75 <= result["confidence"] <= 0.95
def test_fallback_title_contains_asset_count(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
assert "3" in result["config"]["title"]["text"]
assert result["config"]["title"]["ai_auto"] is True
def test_fallback_total_duration_matches(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
total = sum(c["duration"] for c in result["clips"])
assert result["total_duration"] == round(total, 1)
def test_fallback_orders_are_sequential(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
orders = [c["order"] for c in result["clips"]]
assert orders == list(range(len(result["clips"])))
# ── _call_ai_cover_service 测试 ───────────────────────────────────────────
class TestAiCoverService:
"""AI封面生成服务测试."""
def test_cover_type_upload(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "upload")
assert result["type"] == "upload"
assert result["image_url"] == ""
def test_cover_type_manual_with_frame_time(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
assert result["type"] == "manual"
assert result["frame_time"] == 5.5
assert "5.5" in result["image_url"]
def test_cover_type_ai_frame(self):
with patch("shared.ai_service.time.sleep"):
with patch("shared.ai_service.random.uniform", side_effect=[5.0, 0.9]):
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
assert result["type"] == "ai_frame"
assert result["frame_time"] == 5.0
assert result["confidence"] == 0.9
assert "plan1" in result["image_url"]
def test_cover_type_ai_regenerate(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
assert result["type"] == "ai_frame"
def test_cover_frame_time_in_range(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
assert 1.0 <= result["frame_time"] <= 10.0
File diff suppressed because it is too large Load Diff
+389 -77
View File
@@ -1,100 +1,412 @@
"""text_splitter 单元测试."""
"""文本分段工具单元测试."""
from __future__ import annotations
import pytest
from packages.application.tts_job.text_splitter import split_text
class TestSplitText:
def test_empty_text_returns_empty(self):
"""split_text 函数测试"""
def test_empty_string_returns_empty_list(self):
"""空字符串返回空列表"""
assert split_text("") == []
def test_whitespace_only(self):
def test_whitespace_only_returns_empty_list(self):
"""纯空白字符返回空列表"""
assert split_text(" \n \t ") == []
def test_short_text_returns_single_segment(self):
"""短文本直接返回单段"""
text = "这是一段短文本。"
result = split_text(text, max_chars=500)
assert result == [text]
def test_text_length_equals_max_chars(self):
"""文本长度恰好等于 max_chars 时返回单段"""
text = "a" * 100
result = split_text(text, max_chars=100)
assert len(result) == 1
assert len(result[0]) == 100
def test_splits_on_sentence_boundary(self):
"""在句子边界处分段"""
# 构造长文本,确保超过 max_chars
sentences = ["今天天气真好。我们一起去公园散步吧。", "公园里有很多花。还有很多小朋友在玩耍。"] * 10
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) >= 2
# 每段都不超过 max_chars
for seg in result:
assert len(seg) <= 200
def test_all_segments_within_max_chars(self):
"""所有分段都不超过 max_chars"""
text = "这是第一句话。这是第二句话。这是第三句话。这是第四句话。这是第五句话。" * 10
result = split_text(text, max_chars=100)
for seg in result:
assert len(seg) <= 100
def test_long_single_sentence_hard_cut(self):
"""超长单句会被硬切"""
text = "a" * 1000 # 没有标点
result = split_text(text, max_chars=200)
assert len(result) > 1
for seg in result:
assert len(seg) <= 200
def test_newline_is_sentence_end(self):
"""换行符作为句子结束符"""
text = "第一行内容\n第二行内容\n第三行内容" * 10
result = split_text(text, max_chars=50)
assert len(result) > 1
for seg in result:
assert len(seg) <= 50
def test_chinese_punctuation(self):
"""中文标点(。!?;)作为句子结束符"""
text = "你好!今天吃什么?我吃米饭;你呢?我也吃米饭。" * 10
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
def test_english_punctuation(self):
"""英文标点(.!?;)作为句子结束符"""
text = "Hello! How are you? I'm fine; thank you. Good bye." * 10
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
def test_merged_short_segments(self):
"""过短的段落会被合并"""
# 构造很多短句
text = "你好。再见。谢谢。抱歉。好的。不行。可以。去吧。" * 5 # 每句3-4字
result = split_text(text, max_chars=100)
# 合并后段数应该比单纯按句切的少
assert len(result) < len(text) // 3 # 粗略估计
for seg in result:
assert len(seg) <= 100
def test_preserves_content(self):
"""分段后内容总和与原文基本一致(忽略strip的空白)"""
text = "这是测试文本。包含多个句子。用来验证分段正确性。" * 5
result = split_text(text, max_chars=50)
# 合并所有分段,去掉空白后应该与原文去掉空白后基本一致
combined = "".join(result).replace(" ", "")
original = text.strip().replace(" ", "")
assert combined == original
def test_custom_max_chars(self):
"""支持自定义 max_chars"""
text = "测试" * 100 # 200字
result_50 = split_text(text, max_chars=50)
result_100 = split_text(text, max_chars=100)
# max_chars 越小,段数应该越多
assert len(result_50) >= len(result_100)
def test_single_char_text(self):
"""单字符文本"""
assert split_text("", max_chars=10) == [""]
def test_text_with_only_punctuation(self):
"""纯标点文本"""
text = "。。。。。。。。。。" # 10个句号
result = split_text(text, max_chars=5)
assert len(result) >= 1
for seg in result:
assert len(seg) <= 5
def test_mixed_content(self):
"""中英文混合内容"""
text = "今天的天气是 sunny and warm。我们去了 park 玩。真的很开心!" * 5
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
# ── 短文本与空文本补充 ──────────────────────────────────────────────────────
class TestSplitTextEmptyAndShort:
"""空文本与短文本补充场景."""
def test_whitespace_only_returns_empty(self):
"""纯空白文本返回空列表."""
assert split_text(" \n\t ") == []
def test_short_text_single_segment(self):
text = "你好世界。"
result = split_text(text, max_chars=500)
def test_single_char(self):
"""单字符文本."""
assert split_text("", max_chars=10) == [""]
def test_exactly_max_chars_no_split(self):
"""刚好等于 max_chars 不分割."""
text = "a" * 100
result = split_text(text, max_chars=100)
assert len(result) == 1
assert result[0] == text
def test_exact_max_chars(self):
text = "a" * 500
result = split_text(text, max_chars=500)
assert len(result) == 1
assert len(result[0]) == 500
def test_splits_on_sentence_boundary(self):
# 两个长句子,各300字左右,超过50字阈值
sent1 = "" * 300 + ""
sent2 = "" * 300 + ""
text = sent1 + sent2
result = split_text(text, max_chars=500)
assert len(result) == 2
assert result[0] == sent1
assert result[1] == sent2
def test_long_sentence_hard_cut(self):
# 一个超长句子,没有句末标点,会被硬切
text = "" * 800
result = split_text(text, max_chars=500)
assert len(result) >= 2
assert all(len(seg) <= 500 for seg in result)
# 合起来应该等于原文本
assert "".join(result) == text
def test_short_segments_merged(self):
# 多个短句应该被合并
sentences = [f"{i}句。" for i in range(10)]
text = "".join(sentences)
result = split_text(text, max_chars=200)
# 每句5字左右,10句才50字,应该合并成1段
assert len(result) < 10
assert len(result[0]) <= 200
def test_preserves_content(self):
text = "今天天气真好。我们去公园玩吧!你觉得怎么样?好的,走吧。"
result = split_text(text, max_chars=20)
# 合并后内容应一致
assert "".join(result) == text
def test_multiple_punctuation_types(self):
# 构造足够长的文本触发分段
text = "第一" * 30 + "" + "第二" * 30 + "" + "第三" * 30 + "" + "第四" * 30 + ""
def test_one_over_max_chars_splits(self):
"""超过 max_chars 1 个字符就会分割."""
text = "a" * 101
result = split_text(text, max_chars=100)
assert len(result) >= 2
assert "".join(result) == text
def test_custom_max_chars(self):
text = "a" * 100 + "" + "b" * 100 + ""
result = split_text(text, max_chars=150)
assert len(result) == 2
assert "a" in result[0]
assert "b" in result[1]
def test_none_raises(self):
"""None 输入抛 AttributeErrorstrip 失败)."""
with pytest.raises(AttributeError):
split_text(None)
def test_newline_as_sentence_end(self):
text = "第一段\n第二段\n第三段"
result = split_text(text, max_chars=50)
assert len(result) >= 1
assert "".join(result) == text.strip()
def test_minimum_segment_length(self):
# 句子太短(<50字)不会立即分段
text = "短句一。短句二。短句三。"
# ── 句子边界分段补充 ──────────────────────────────────────────────────────
class TestSplitTextSentenceBoundaries:
"""句子边界分段补充场景."""
def test_split_on_fullwidth_period(self):
"""全角句号分段."""
text = "第一句很长的内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
for seg in result:
assert len(seg) <= 60
def test_split_on_fullwidth_question(self):
"""全角问号分段."""
text = "你知道这是为什么吗?" + "是的。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_fullwidth_exclamation(self):
"""全角感叹号分段."""
text = "真是太棒了!" + "内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_newline(self):
"""换行符分段."""
lines = ["这是第一行很长的一段文字内容" * 3 for _ in range(5)]
text = "\n".join(lines)
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_split_on_semicolon(self):
"""全角分号分段."""
text = "第一项内容;" + "其他内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_english_period_splits(self):
"""英文句号分段."""
text = "Hello world. " * 30
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_short_sentences_stay_merged(self):
"""短句(都 < 50字的句子不会单独成段,会累积到一起."""
text = "你好。我好。大家好。"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_trailing_content_added(self):
# 最后一段不完整的句子也要加上
text = "完整的句子。剩余内容"
result = split_text(text, max_chars=50)
assert "".join(result) == text
def test_no_empty_segments(self):
text = "。。。。。" # 全是标点
result = split_text(text, max_chars=2)
assert all(len(seg) > 0 for seg in result)
# ── 长句强制切段补充 ──────────────────────────────────────────────────────
def test_chinese_and_english_mixed(self):
text = "Hello世界。这是测试Test文本。Mixed混合。"
class TestSplitTextLongSentenceForce:
"""超长单句强制切段补充."""
def test_no_punctuation_forced_split(self):
"""完全没有标点的超长文本硬切."""
text = "" * 300
result = split_text(text, max_chars=100)
assert len(result) == 3
for seg in result:
assert len(seg) == 100
def test_force_split_preserves_content(self):
"""硬切不丢字符."""
text = "a" * 250
result = split_text(text, max_chars=100)
assert sum(len(s) for s in result) == 250
def test_mixed_long_and_short(self):
"""长句短句混合."""
long_part = "非常长的句子没有标点符号" * 15
text = long_part + "。结尾。"
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
# ── 短段合并补充 ─────────────────────────────────────────────────────────
class TestSplitTextShortSegmentMerge:
"""短段合并补充场景."""
def test_multiple_short_sentences_merged(self):
"""多个短句合并成一段."""
sentences = ["你好。", "我好。", "大家好。", "天气好。", "心情好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_short_tail_merged(self):
"""尾部短段被合并到前一段."""
# 前面一段接近 max_chars,尾部很短
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "完。" # 2字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾部短的应该被合并
assert len(result) <= 2
# ── 边界情况补充 ─────────────────────────────────────────────────────────
class TestSplitTextEdgeCases:
"""边界情况补充."""
def test_only_punctuation(self):
"""纯标点符号."""
text = "。。。。。"
result = split_text(text, max_chars=10)
assert len(result) == 1
def test_mixed_chinese_english(self):
"""中英文混合."""
text = "你好Hello。World!" * 20
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
def test_strip_whitespace(self):
"""首尾空白被去除."""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_total_length_preserved(self):
"""分段后总长度等于原文 strip 后长度."""
text = "这是一段用于测试的文本内容。" * 20
result = split_text(text, max_chars=100)
assert "".join(result) == text.strip()
def test_custom_small_max_chars(self):
"""很小的 max_chars."""
text = "一二三四五六七八九十。" * 5
result = split_text(text, max_chars=20)
assert len(result) >= 2
assert "".join(result) == text
assert len(result) > 1
for seg in result:
assert len(seg) <= 20
# ── 更多边界场景补充 ─────────────────────────────────────────────────────────
class TestSplitTextMoreEdgeCases:
"""更多边界场景补充"""
def test_max_chars_one(self):
"""max_chars=1 每个字符一段"""
text = "一二三四五"
result = split_text(text, max_chars=1)
assert len(result) == 5
for seg in result:
assert len(seg) == 1
def test_consecutive_newlines(self):
"""连续多个换行符"""
text = "第一段\n\n\n第二段\n\n第三段"
result = split_text(text, max_chars=100)
# 合并后应该是一段(内容不长且合并逻辑会被合并)
assert len(result) >= 1
assert "第一段" in result[0]
for seg in result:
assert len(seg) <= 100
def test_only_newlines_only(self):
"""只有换行符(纯空白被strip掉返回空"""
assert split_text("\n\n\n\n") == []
def test_leading_trailing_whitespace(self):
"""首尾空白被去除"""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_very_long_single_sentence_many_segments(self):
"""超长单句被切成很多段"""
text = "" * 1000
result = split_text(text, max_chars=100)
assert len(result) == 10
for seg in result:
assert len(seg) == 100
def test_mixed_punctuation_types(self):
"""全角半角标点混合"""
text = "你好!再见。谢谢?抱歉;好的"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_last_segment_short_merged_to_previous(self):
"""尾部极短段被合并到前一段"""
# 构造第一段接近max_chars,结尾有个短句尾巴
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "" # 1字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾巴应该被合并
combined = "".join(result)
assert combined == text.strip()
assert len(result) <= 2
def test_all_short_sentences_merged_into_one(self):
"""大量短句全部合并成一段"""
sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_punctuation_only_long(self):
"""很长的纯标点文本"""
text = "" * 200
result = split_text(text, max_chars=50)
assert len(result) >= 4
for seg in result:
assert len(seg) <= 50
def test_tab_not_sentence_end(self):
"""制表符不是句子结束符"""
text = "这是一段\t包含制表符的文本内容" + "" * 100
result = split_text(text, max_chars=50)
# 制表符不在句子结束符集合中,不会触发分段
# 制表符会保留在分段内容中
has_tab = any("\t" in seg for seg in result)
assert has_tab
+200 -461
View File
@@ -1,6 +1,7 @@
"""验证码服务单元测试."""
import re
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
@@ -8,9 +9,9 @@ import pytest
from packages.application.auth.verification_code_service import (
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_BIND,
DAILY_LIMIT,
DEFAULT_TTL_SECONDS,
MAX_ATTEMPTS,
RESEND_COOLDOWN_SECONDS,
VerificationCodeService,
@@ -20,560 +21,298 @@ from packages.application.auth.verification_code_service import (
)
from packages.domain.verification_code import VerificationCode
# ── Test Fixtures ────────────────────────────────────────────────────────────
@pytest.fixture
def mock_repo():
"""mock 验证码仓储."""
repo = MagicMock()
repo.find_latest.return_value = None
repo.count_today.return_value = 0
return repo
return MagicMock()
@pytest.fixture
def service(mock_repo):
"""验证码服务实例."""
return VerificationCodeService(repo=mock_repo)
def code_service(mock_repo):
return VerificationCodeService(mock_repo)
def _make_code(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
code="123456",
ttl=300,
used=False,
attempts=0,
created_at=None,
):
"""创建一个测试用验证码实体."""
now = created_at or datetime.now(timezone.utc)
vc = VerificationCode(
id="test-code-id",
recipient=recipient,
code=code,
code_type=code_type,
expires_at=now + timedelta(seconds=ttl),
used_at=now if used else None,
attempts=attempts,
created_at=now,
@pytest.fixture
def sample_code():
code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=300,
)
return vc
return code
# ── generate 方法测试 ───────────────────────────────────────────────────────
class TestGenerate:
class TestVerificationCodeServiceGenerate:
"""generate 方法测试"""
def test_generate_success(self, service, mock_repo):
"""成功生成验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND)
def test_generate_success(self, code_service, mock_repo, sample_code):
"""生成验证码成功"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
assert code.recipient == "user@example.com"
assert code.recipient == "test@example.com"
assert code.code_type == CODE_TYPE_EMAIL_BIND
assert len(code.code) == 6
assert code.code.isdigit()
assert not code.is_used
mock_repo.save.assert_called_once()
def test_generate_with_custom_code(self, service, mock_repo):
"""使用自定义验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999")
assert error is None
assert code.code == "999999"
def test_generate_custom_ttl(self, service, mock_repo):
"""自定义 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 600
def test_generate_default_ttl(self, service, mock_repo):
"""默认 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 300 # 默认5分钟
def test_generate_empty_recipient(self, service):
"""空接收方."""
code, error = service.generate("", CODE_TYPE_EMAIL_BIND)
def test_generate_empty_recipient(self, code_service):
"""空接收方返回错误"""
code, error = code_service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
assert "接收方不能为空" in error
def test_generate_whitespace_recipient(self, service):
"""全空白接收方."""
code, error = service.generate(" ", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
def test_generate_invalid_type(self, service):
"""无效验证码类型."""
code, error = service.generate("u@e.com", "invalid_type")
def test_generate_invalid_type(self, code_service):
"""无效验证码类型返回错误"""
code, error = code_service.generate("test@example.com", "invalid_type")
assert code is None
assert "无效的验证码类型" in error
def test_generate_recipient_stripped(self, service, mock_repo):
"""接收方前后空格会被清理."""
code, _ = service.generate(" user@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "user@e.com"
def test_generate_phone_code(self, service, mock_repo):
"""手机验证码生成."""
code, error = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
assert error is None
assert code.code_type == CODE_TYPE_PHONE_BIND
assert len(code.code) == 6
# ── generate 频控测试 ───────────────────────────────────────────────────────
class TestGenerateRateLimit:
"""generate 频控测试"""
def test_cooldown_active_rejects(self, service, mock_repo):
"""冷却期内拒绝重发."""
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in error
# 等待时间应该接近 50 秒 (60-10)
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 45 <= wait <= 55
def test_cooldown_expired_allows(self, service, mock_repo):
"""冷却期过后允许重发."""
old = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
mock_repo.find_latest.return_value = old
def test_generate_cooldown(self, code_service, mock_repo, sample_code):
"""冷却期内返回频控错误"""
# 最新的验证码刚创建10秒前
sample_code.created_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = sample_code
mock_repo.count_today.return_value = 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
def test_daily_limit_reached(self, service, mock_repo):
"""达到每日上限."""
mock_repo.find_latest.return_value = None
assert code is None
assert "发送太频繁" in error
assert "秒后再试" in error
def test_generate_daily_limit_exceeded(self, code_service, mock_repo):
"""超过每日上限返回错误"""
mock_repo.find_latest.return_value = None # 没有冷却期问题
mock_repo.count_today.return_value = DAILY_LIMIT
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in error
def test_daily_limit_one_below_allows(self, service, mock_repo):
"""未达到上限时允许."""
def test_generate_recipient_stripped(self, code_service, mock_repo, sample_code):
"""recipient 会被 strip"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT - 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
def test_custom_daily_limit(self, mock_repo):
"""自定义每日上限."""
svc = VerificationCodeService(repo=mock_repo, daily_limit=3)
mock_repo.count_today.return_value = 3
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "已达上限" in error
def test_custom_cooldown(self, mock_repo):
"""自定义冷却时间."""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=30)
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 15 <= wait <= 25
def test_cooldown_different_types_independent(self, service, mock_repo):
"""不同类型的验证码冷却独立."""
# email_bind 类型有一个近期验证码
recent = _make_code(code_type=CODE_TYPE_EMAIL_BIND)
mock_repo.find_latest.side_effect = lambda r, t: recent if t == CODE_TYPE_EMAIL_BIND else None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
# email_login 类型应该可以正常发送
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN)
assert error is None
code_service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
# 传给 repo 的应该是 strip 后的值
save_call = mock_repo.save.call_args[0][0]
assert save_call.recipient == "test@example.com"
def test_generate_custom_code(self, code_service, mock_repo):
"""使用自定义验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="123456")
assert code.code == "123456"
def test_generate_custom_ttl(self, code_service, mock_repo):
"""自定义 TTL"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
assert code is not None
# ── verify 方法测试 ─────────────────────────────────────────────────────────
class TestVerify:
class TestVerificationCodeServiceVerify:
"""verify 方法测试"""
def test_verify_success(self, service, mock_repo):
"""验证码正确."""
code = _make_code(code="654321")
mock_repo.find_latest.return_value = code
def test_verify_success(self, code_service, mock_repo, sample_code):
"""验证成功"""
mock_repo.find_latest.return_value = sample_code
ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321")
assert ok is True
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
assert success is True
assert error is None
assert code.is_used # 标记为已使用
assert mock_repo.save.call_count >= 2 # increment + mark_used
assert sample_code.is_used is True
def test_verify_wrong_code(self, service, mock_repo):
"""验证码错误."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
def test_verify_wrong_code(self, code_service, mock_repo, sample_code):
"""验证码错误"""
mock_repo.find_latest.return_value = sample_code
ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000")
assert ok is False
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrongcode")
assert success is False
assert "验证码错误" in error
assert not code.is_used # 不标记为已使用
assert code.attempts == 1 # 尝试次数+1
def test_verify_no_code_found(self, service, mock_repo):
"""找不到验证码."""
def test_verify_not_found(self, code_service, mock_repo):
"""验证码不存在"""
mock_repo.find_latest.return_value = None
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
assert "不存在或已过期" in error
def test_verify_empty_params(self, service):
"""参数为空."""
ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
assert "参数不完整" in error
def test_verify_expired(self, code_service, mock_repo):
"""验证码已过期"""
expired_code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=1, # 1秒过期
)
# 手动设置过期时间
expired_code.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = expired_code
ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "")
assert ok2 is False
assert "参数不完整" in error2
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, expired_code.code)
def test_verify_whitespace_params(self, service, mock_repo):
"""参数前后空格会被清理."""
code = _make_code(recipient="u@e.com", code="111111")
mock_repo.find_latest.return_value = code
ok, error = service.verify(" u@e.com ", CODE_TYPE_EMAIL_BIND, " 111111 ")
assert ok is True
assert error is None
def test_verify_already_used(self, service, mock_repo):
"""验证码已使用."""
code = _make_code(used=True)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已使用" in error
def test_verify_expired(self, service, mock_repo):
"""验证码已过期."""
code = _make_code(ttl=-60) # 已过期
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert success is False
assert "已过期" in error
def test_verify_too_many_attempts(self, service, mock_repo):
"""尝试次数过多."""
code = _make_code(attempts=MAX_ATTEMPTS + 1)
mock_repo.find_latest.return_value = code
def test_verify_already_used(self, code_service, mock_repo, sample_code):
"""验证码已使用"""
sample_code.mark_used()
mock_repo.find_latest.return_value = sample_code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "验证次数过多" in error
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
def test_verify_attempts_increment_each_time(self, service, mock_repo):
"""每次错误尝试都增加尝试次数."""
code = _make_code(code="123456", attempts=0)
mock_repo.find_latest.return_value = code
for _ in range(3):
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong")
assert code.attempts == 3
def test_verify_without_consume(self, service, mock_repo):
"""验证成功但不标记为已使用(consume=False."""
code = _make_code(code="999999")
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False)
assert ok is True
assert error is None
assert not code.is_used # 不标记为已使用
def test_verify_consume_default_true(self, service, mock_repo):
"""默认 consume=True."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert code.is_used
def test_verify_used_checked_before_attempts(self, service, mock_repo):
"""已使用优先于其他检查."""
code = _make_code(used=True, attempts=0)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert success is False
assert "已使用" in error
# attempts 会被 increment,但错误原因是已使用
assert code.attempts == 1
def test_custom_max_attempts(self, mock_repo):
"""自定义最大尝试次数."""
svc = VerificationCodeService(repo=mock_repo, max_attempts=2)
code = _make_code(attempts=2)
mock_repo.find_latest.return_value = code
def test_verify_max_attempts_exceeded(self, code_service, mock_repo, sample_code):
"""尝试次数过多"""
# 先把尝试次数加到超过上限
for _ in range(MAX_ATTEMPTS + 1):
sample_code.increment_attempts()
mock_repo.find_latest.return_value = sample_code
ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
assert success is False
assert "验证次数过多" in error
def test_verify_empty_params(self, code_service):
"""空参数返回错误"""
success, error = code_service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
assert "参数不完整" in error
# ── validate_phone 测试 ─────────────────────────────────────────────────────
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
assert success is False
assert "参数不完整" in error
def test_verify_increments_attempts(self, code_service, mock_repo, sample_code):
"""验证会增加尝试次数"""
initial_attempts = sample_code.attempts
mock_repo.find_latest.return_value = sample_code
code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrong")
assert sample_code.attempts == initial_attempts + 1
def test_verify_no_consume(self, code_service, mock_repo, sample_code):
"""consume=False 时不标记为已使用"""
mock_repo.find_latest.return_value = sample_code
success, _ = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code, consume=False)
assert success is True
assert sample_code.is_used is False
class TestValidatePhone:
"""手机号格式校验测试"""
class TestVerifyPhone:
"""validate_phone 函数测试"""
def test_valid_11_digit(self):
"""标准11位手机号."""
ok, msg = validate_phone("13800138000")
def test_valid_phone(self):
"""有效手机号"""
ok, err = validate_phone("13800000001")
assert ok is True
assert msg == ""
assert err == ""
def test_valid_with_plus_86(self):
"""带+86前缀."""
ok, msg = validate_phone("+8613800138000")
def test_valid_phone_with_plus86(self):
""" +86 前缀的手机号"""
ok, err = validate_phone("+8613800000001")
assert ok is True
def test_invalid_too_short(self):
"""位数不足."""
ok, msg = validate_phone("1380013800")
def test_invalid_phone_short(self):
"""太短的手机号"""
ok, err = validate_phone("123")
assert ok is False
assert "格式不正确" in msg
assert "格式不正确" in err
def test_invalid_too_long(self):
"""位数过多."""
ok, msg = validate_phone("138001380001")
def test_invalid_phone_wrong_prefix(self):
"""号段不对的手机号"""
ok, err = validate_phone("11000000000")
assert ok is False
def test_invalid_starts_with_2(self):
"""开头不是1."""
ok, msg = validate_phone("23800138000")
def test_empty_phone(self):
"""空手机号"""
ok, err = validate_phone("")
assert ok is False
assert "不能为空" in err
def test_invalid_starts_with_12(self):
"""第二位不在3-9."""
ok, msg = validate_phone("12800138000")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_phone("")
assert ok is False
assert "不能为空" in msg
def test_invalid_whitespace_only(self):
"""仅空白."""
ok, msg = validate_phone(" ")
assert ok is False
assert "不能为空" in msg
def test_valid_all_prefixes_3_to_9(self):
"""第二位3-9都有效."""
for n in range(3, 10):
ok, _ = validate_phone(f"1{n}800138000")
assert ok is True, f"1{n} prefix should be valid"
def test_invalid_contains_letters(self):
"""包含字母."""
ok, msg = validate_phone("13800abc000")
assert ok is False
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_phone(" 13800138000 ")
def test_phone_with_spaces(self):
"""带空格的手机号会被 strip"""
ok, _ = validate_phone(" 13800000001 ")
assert ok is True
# ── normalize_phone 测试 ────────────────────────────────────────────────────
class TestNormalizePhone:
"""手机号标准化测试"""
"""normalize_phone 函数测试"""
def test_strip_plus_86(self):
"""去掉+86前缀."""
assert normalize_phone("+8613800138000") == "13800138000"
def test_removes_plus86(self):
"""去掉 +86 前缀"""
assert normalize_phone("+8613800000001") == "13800000001"
def test_no_prefix_stays_same(self):
"""前缀保持不变."""
assert normalize_phone("13800138000") == "13800138000"
"""没有前缀保持不变"""
assert normalize_phone("13800000001") == "13800000001"
def test_strips_whitespace(self):
"""清理前后空格."""
assert normalize_phone(" 13800138000 ") == "13800138000"
def test_plus_86_with_spaces(self):
"""带空格的+86."""
assert normalize_phone(" +8613800138000 ") == "13800138000"
# ── validate_email 测试 ─────────────────────────────────────────────────────
"""去掉两端空白"""
assert normalize_phone(" 13800000001 ") == "13800000001"
class TestValidateEmail:
"""邮箱格式校验测试"""
"""validate_email 函数测试"""
def test_valid_simple(self):
"""标准邮箱."""
ok, msg = validate_email("user@example.com")
def test_valid_email(self):
"""有效邮箱"""
ok, err = validate_email("test@example.com")
assert ok is True
assert msg == ""
assert err == ""
def test_valid_with_dots(self):
"""点号的用户名."""
ok, _ = validate_email("user.name@example.com")
assert ok is True
def test_valid_with_plus(self):
"""带加号的邮箱."""
ok, _ = validate_email("user+tag@example.com")
assert ok is True
def test_valid_with_underscore(self):
"""带下划线."""
ok, _ = validate_email("user_name@example.com")
assert ok is True
def test_valid_subdomain(self):
"""多级域名."""
def test_valid_email_with_subdomain(self):
"""子域名的邮箱"""
ok, _ = validate_email("user@mail.example.com")
assert ok is True
def test_invalid_no_at(self):
"""没有@."""
ok, msg = validate_email("userexample.com")
assert ok is False
assert "格式不正确" in msg
def test_invalid_empty_local(self):
"""@前为空."""
ok, _ = validate_email("@example.com")
assert ok is False
def test_invalid_empty_domain(self):
"""@后为空."""
ok, _ = validate_email("user@")
assert ok is False
def test_invalid_no_tld(self):
"""没有顶级域名."""
ok, _ = validate_email("user@example")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_email("")
assert ok is False
assert "不能为空" in msg
def test_invalid_spaces_only(self):
"""仅空白."""
ok, msg = validate_email(" ")
assert ok is False
assert "不能为空" in msg
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_email(" user@e.com ")
def test_valid_email_with_plus(self):
"""带 + 号的邮箱"""
ok, _ = validate_email("user+tag@example.com")
assert ok is True
def test_invalid_special_chars(self):
"""特殊字符."""
ok, _ = validate_email("user name@e.com")
def test_invalid_email_no_at(self):
"""没有 @ 的邮箱"""
ok, err = validate_email("notanemail")
assert ok is False
assert "格式不正确" in err
def test_invalid_email_no_domain(self):
"""没有域名的邮箱"""
ok, err = validate_email("user@")
assert ok is False
def test_valid_numbers(self):
"""数字邮箱."""
ok, _ = validate_email("12345@example.com")
def test_empty_email(self):
"""邮箱"""
ok, err = validate_email("")
assert ok is False
assert "不能为空" in err
def test_email_with_spaces(self):
"""带空格的邮箱会被 strip"""
ok, _ = validate_email(" test@example.com ")
assert ok is True
# ── VerificationCode 实体辅助验证 ──────────────────────────────────────────
class TestVerificationCodeEntity:
"""VerificationCode 实体属性测试"""
def test_is_expired_false_when_fresh(self):
code = _make_code(ttl=300)
assert code.is_expired is False
def test_is_expired_true_when_past(self):
code = _make_code(ttl=-1)
assert code.is_expired is True
def test_is_used_false_initially(self):
code = _make_code()
assert code.is_used is False
def test_is_used_after_mark_used(self):
code = _make_code()
code.mark_used()
assert code.is_used is True
assert code.used_at is not None
def test_is_valid_fresh(self):
code = _make_code()
assert code.is_valid is True
def test_is_valid_when_expired(self):
code = _make_code(ttl=-100)
assert code.is_valid is False
def test_is_valid_when_used(self):
code = _make_code(used=True)
assert code.is_valid is False
def test_increment_attempts(self):
code = _make_code(attempts=0)
code.increment_attempts()
assert code.attempts == 1
code.increment_attempts()
assert code.attempts == 2
def test_create_generates_6_digit_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND)
assert len(code.code) == 6
assert code.code.isdigit()
def test_create_custom_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, custom_code="555555")
assert code.code == "555555"
def test_create_strips_recipient(self):
code = VerificationCode.create(" u@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "u@e.com"
def test_create_sets_expiry(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=120)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 120