Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a2322f73 | |||
| 0f27bfa999 | |||
| f15bc2f2a2 | |||
| 2273fb329f | |||
| 4bd3da73e3 | |||
| 7674a04a33 | |||
| 9d97e8aacc | |||
| 89639a6d3e | |||
| c4144d9e3f | |||
| de201436ea | |||
| 77a49e3365 | |||
| d7e362a637 | |||
| 028c6613ce | |||
| 696cdda87b | |||
| a85167a529 | |||
| e0b95e69d4 | |||
| 63d4048354 | |||
| e811516c6e | |||
| f9a106f36b | |||
| e902fbd65e | |||
| e8352227af | |||
| ef7596739c | |||
| c2329f3f98 | |||
| 0a0914322c | |||
| 042cae7a61 | |||
| be885eb8f0 | |||
| fd61800be9 | |||
| 183681c07d | |||
| 8d906bac72 | |||
| 8f925e6c65 | |||
| 9732cc51ee | |||
| 815b3758a7 | |||
| eb73eeab69 | |||
| 2325ffbc57 |
@@ -1777,6 +1777,7 @@ jobs:
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"integration-tests:$RESULT_INTEGRATION"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
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 }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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,238 +1,8 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @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"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batchOperations"
|
||||
} from "./asset-operations/batch-operations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
|
||||
if (!titleSettings.title.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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])
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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 }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
])
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
PlayCircleOutlined,
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
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 {
|
||||
videoRef,
|
||||
progressRef,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
} = useVideoPlayer()
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
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
|
||||
}
|
||||
const displayDuration = duration || product.duration
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
@@ -83,8 +51,6 @@ 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()}>
|
||||
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
<button className="xx-player-play-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>{formatTime(displayDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+3
-78
@@ -1,8 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { mapApiProduct } from "../utils"
|
||||
import type { ProductItem } from "../../types"
|
||||
|
||||
/** 筛选选项类型 */
|
||||
export interface Filters {
|
||||
@@ -27,32 +24,7 @@ const getProjectOptions = (products: ProductItem[]) =>
|
||||
label: name as string,
|
||||
}))
|
||||
|
||||
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])
|
||||
|
||||
/* 筛选 */
|
||||
export const useProductFiltering = (products: ProductItem[]) => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
@@ -60,12 +32,6 @@ export const useProductList = () => {
|
||||
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
|
||||
|
||||
@@ -142,42 +108,7 @@ export const useProductList = () => {
|
||||
|
||||
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,
|
||||
@@ -190,13 +121,7 @@ export const useProductList = () => {
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
filteredProducts,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+5
-123
@@ -1,11 +1,8 @@
|
||||
import React from "react"
|
||||
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 { Table } from "antd"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { TaskErrorDetail } from "./TaskErrorDetail"
|
||||
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
|
||||
|
||||
interface TaskTableProps {
|
||||
dataSource: TaskItem[]
|
||||
@@ -24,7 +21,6 @@ interface TaskTableProps {
|
||||
|
||||
/**
|
||||
* 任务列表表格
|
||||
* 含列定义、分页、展开行
|
||||
*/
|
||||
export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
dataSource,
|
||||
@@ -40,116 +36,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
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>
|
||||
},
|
||||
},
|
||||
]
|
||||
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
|
||||
|
||||
return (
|
||||
<Table
|
||||
@@ -178,12 +65,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
emptyText: <TaskEmptyState />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react"
|
||||
import { ClockCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
export const TaskEmptyState: React.FC = () => (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useTaskTableColumns } from "./useTaskTableColumns"
|
||||
export { TaskEmptyState } from "./TaskEmptyState"
|
||||
@@ -0,0 +1,128 @@
|
||||
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>
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 }
|
||||
}
|
||||
@@ -36,6 +36,10 @@ 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", () => {
|
||||
|
||||
@@ -38,7 +38,13 @@ 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"
|
||||
|
||||
@@ -21,7 +21,10 @@ 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", () => {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,23 @@ 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:
|
||||
|
||||
+13
-10
@@ -32,7 +32,13 @@ 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:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
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
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
@@ -269,13 +275,9 @@ def main():
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"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)",
|
||||
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
|
||||
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
@@ -284,7 +286,8 @@ def main():
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
@@ -301,7 +304,7 @@ def main():
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
base_ref = pr.get("base", {}).get("ref", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
"""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
|
||||
Executable
+521
@@ -0,0 +1,521 @@
|
||||
"""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
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
"""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)
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
"""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}
|
||||
Executable
+825
@@ -0,0 +1,825 @@
|
||||
"""config_schemas 模块单测.
|
||||
|
||||
覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from domain.config_schemas import (
|
||||
DEFAULT_EDIT_PLAN_CONFIG,
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG,
|
||||
BGMConfig,
|
||||
BGMSource,
|
||||
CoverConfig,
|
||||
CoverType,
|
||||
EditPlanConfigSchema,
|
||||
EditTemplateConfigSchema,
|
||||
ExportConfig,
|
||||
FilterConfig,
|
||||
ShadowConfig,
|
||||
StrokeConfig,
|
||||
SubtitleConfig,
|
||||
TextAnimation,
|
||||
TextPosition,
|
||||
TitleConfig,
|
||||
normalize_plan_config,
|
||||
normalize_template_config,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverType:
|
||||
"""CoverType 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert CoverType.AI_FRAME.value == "ai_frame"
|
||||
assert CoverType.MANUAL.value == "manual"
|
||||
assert CoverType.UPLOAD.value == "upload"
|
||||
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(CoverType.AI_FRAME, str)
|
||||
assert CoverType.AI_FRAME == "ai_frame"
|
||||
|
||||
def test_from_string(self):
|
||||
assert CoverType("ai_frame") == CoverType.AI_FRAME
|
||||
assert CoverType("manual") == CoverType.MANUAL
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
CoverType("invalid")
|
||||
|
||||
|
||||
class TestTextPosition:
|
||||
"""TextPosition 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextPosition.TOP.value == "top"
|
||||
assert TextPosition.CENTER.value == "center"
|
||||
assert TextPosition.BOTTOM.value == "bottom"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextPosition("top") == TextPosition.TOP
|
||||
assert TextPosition("bottom") == TextPosition.BOTTOM
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextPosition("left")
|
||||
|
||||
|
||||
class TestTextAnimation:
|
||||
"""TextAnimation 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextAnimation.NONE.value == "none"
|
||||
assert TextAnimation.FADE_IN.value == "fade_in"
|
||||
assert TextAnimation.SLIDE_UP.value == "slide_up"
|
||||
assert TextAnimation.SLIDE_DOWN.value == "slide_down"
|
||||
assert TextAnimation.SCALE.value == "scale"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextAnimation("fade_in") == TextAnimation.FADE_IN
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextAnimation("bounce")
|
||||
|
||||
|
||||
class TestBGMSource:
|
||||
"""BGMSource 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert BGMSource.LIBRARY.value == "library"
|
||||
assert BGMSource.UPLOAD.value == "upload"
|
||||
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
|
||||
|
||||
def test_from_string(self):
|
||||
assert BGMSource("library") == BGMSource.LIBRARY
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
BGMSource("spotify")
|
||||
|
||||
|
||||
# ── StrokeConfig / ShadowConfig ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStrokeConfig:
|
||||
"""StrokeConfig 描边配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = StrokeConfig()
|
||||
assert s.enabled is False
|
||||
assert s.color == "#000000"
|
||||
assert s.width == 1
|
||||
|
||||
def test_custom_values(self):
|
||||
s = StrokeConfig(enabled=True, color="#ff0000", width=5)
|
||||
assert s.enabled is True
|
||||
assert s.color == "#ff0000"
|
||||
assert s.width == 5
|
||||
|
||||
def test_width_min_boundary(self):
|
||||
s = StrokeConfig(width=1)
|
||||
assert s.width == 1
|
||||
|
||||
def test_width_max_boundary(self):
|
||||
s = StrokeConfig(width=10)
|
||||
assert s.width == 10
|
||||
|
||||
def test_width_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=0)
|
||||
|
||||
def test_width_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=11)
|
||||
|
||||
|
||||
class TestShadowConfig:
|
||||
"""ShadowConfig 阴影配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = ShadowConfig()
|
||||
assert s.enabled is False
|
||||
assert s.blur == 4
|
||||
assert s.offset_x == 2
|
||||
assert s.offset_y == 2
|
||||
|
||||
def test_custom_values(self):
|
||||
s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5)
|
||||
assert s.enabled is True
|
||||
assert s.blur == 10
|
||||
assert s.offset_x == 5
|
||||
assert s.offset_y == 5
|
||||
|
||||
def test_blur_min_boundary(self):
|
||||
s = ShadowConfig(blur=0)
|
||||
assert s.blur == 0
|
||||
|
||||
def test_blur_max_boundary(self):
|
||||
s = ShadowConfig(blur=20)
|
||||
assert s.blur == 20
|
||||
|
||||
def test_blur_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ShadowConfig(blur=21)
|
||||
|
||||
|
||||
# ── CoverConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverConfig:
|
||||
"""CoverConfig 封面配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = CoverConfig()
|
||||
assert c.type == CoverType.AI_FRAME
|
||||
assert c.image_url == ""
|
||||
assert c.frame_time is None
|
||||
|
||||
def test_manual_type_with_frame_time(self):
|
||||
c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5)
|
||||
assert c.type == CoverType.MANUAL
|
||||
assert c.frame_time == 5.5
|
||||
|
||||
def test_upload_type_with_image_url(self):
|
||||
c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg")
|
||||
assert c.type == CoverType.UPLOAD
|
||||
assert c.image_url == "https://example.com/cover.jpg"
|
||||
|
||||
def test_frame_time_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CoverConfig(frame_time=-1.0)
|
||||
|
||||
def test_frame_time_zero_valid(self):
|
||||
c = CoverConfig(frame_time=0.0)
|
||||
assert c.frame_time == 0.0
|
||||
|
||||
def test_from_dict_with_string_enum(self):
|
||||
c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""})
|
||||
assert c.type == CoverType.AI_REGENERATE
|
||||
|
||||
|
||||
# ── TitleConfig ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTitleConfig:
|
||||
"""TitleConfig 标题配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
t = TitleConfig()
|
||||
assert t.enabled is True
|
||||
assert t.ai_auto is True
|
||||
assert t.text == ""
|
||||
assert t.position == TextPosition.TOP
|
||||
assert t.font == "思源黑体"
|
||||
assert t.color == "#ffffff"
|
||||
assert t.size == 48
|
||||
assert t.bold is True
|
||||
assert t.italic is False
|
||||
assert isinstance(t.stroke, StrokeConfig)
|
||||
assert isinstance(t.shadow, ShadowConfig)
|
||||
|
||||
def test_custom_title(self):
|
||||
t = TitleConfig(
|
||||
enabled=True,
|
||||
ai_auto=False,
|
||||
text="我的视频标题",
|
||||
position=TextPosition.CENTER,
|
||||
font="微软雅黑",
|
||||
color="#000000",
|
||||
size=36,
|
||||
bold=False,
|
||||
italic=True,
|
||||
)
|
||||
assert t.text == "我的视频标题"
|
||||
assert t.position == TextPosition.CENTER
|
||||
assert t.size == 36
|
||||
assert t.bold is False
|
||||
assert t.italic is True
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
t = TitleConfig(size=12)
|
||||
assert t.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
t = TitleConfig(size=120)
|
||||
assert t.size == 120
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=11)
|
||||
|
||||
def test_size_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=121)
|
||||
|
||||
def test_stroke_nested_config(self):
|
||||
t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3})
|
||||
assert t.stroke.enabled is True
|
||||
assert t.stroke.color == "#ff0000"
|
||||
assert t.stroke.width == 3
|
||||
|
||||
def test_shadow_nested_config(self):
|
||||
t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3})
|
||||
assert t.shadow.enabled is True
|
||||
assert t.shadow.blur == 8
|
||||
|
||||
|
||||
# ── SubtitleConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleConfig:
|
||||
"""SubtitleConfig 字幕配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = SubtitleConfig()
|
||||
assert s.enabled is True
|
||||
assert s.position == TextPosition.BOTTOM
|
||||
assert s.font == "思源黑体"
|
||||
assert s.color == "#ffffff"
|
||||
assert s.size == 24
|
||||
assert s.animation == TextAnimation.FADE_IN
|
||||
assert s.auto_generated is False
|
||||
assert s.language == ""
|
||||
assert s.max_chars_per_line == 20
|
||||
assert s.min_chars_per_segment == 8
|
||||
|
||||
def test_custom_subtitle(self):
|
||||
s = SubtitleConfig(
|
||||
enabled=False,
|
||||
position=TextPosition.TOP,
|
||||
size=32,
|
||||
animation=TextAnimation.SLIDE_UP,
|
||||
auto_generated=True,
|
||||
language="zh",
|
||||
max_chars_per_line=30,
|
||||
min_chars_per_segment=10,
|
||||
)
|
||||
assert s.enabled is False
|
||||
assert s.position == TextPosition.TOP
|
||||
assert s.size == 32
|
||||
assert s.animation == TextAnimation.SLIDE_UP
|
||||
assert s.auto_generated is True
|
||||
assert s.language == "zh"
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
s = SubtitleConfig(size=12)
|
||||
assert s.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
s = SubtitleConfig(size=60)
|
||||
assert s.size == 60
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(size=11)
|
||||
|
||||
def test_max_chars_min_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=8)
|
||||
assert s.max_chars_per_line == 8
|
||||
|
||||
def test_max_chars_max_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=40)
|
||||
assert s.max_chars_per_line == 40
|
||||
|
||||
def test_max_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(max_chars_per_line=41)
|
||||
|
||||
def test_min_chars_min_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=2)
|
||||
assert s.min_chars_per_segment == 2
|
||||
|
||||
def test_min_chars_max_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=20)
|
||||
assert s.min_chars_per_segment == 20
|
||||
|
||||
def test_min_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(min_chars_per_segment=1)
|
||||
|
||||
|
||||
# ── BGMConfig ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGMConfig BGM配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
b = BGMConfig()
|
||||
assert b.enabled is False
|
||||
assert b.source == BGMSource.LIBRARY
|
||||
assert b.asset_id == ""
|
||||
assert b.preset_id == ""
|
||||
assert b.audio_url == ""
|
||||
assert b.volume == 0.3
|
||||
assert b.fade_in == 0.0
|
||||
assert b.fade_out == 0.0
|
||||
assert b.loop_enabled is True
|
||||
assert b.sidechain_enabled is False
|
||||
assert b.sidechain_ratio == 0.3
|
||||
assert b.sidechain_attack == 0.02
|
||||
assert b.sidechain_release == 0.5
|
||||
assert b.sidechain_threshold == -25.0
|
||||
|
||||
def test_custom_bgm(self):
|
||||
b = BGMConfig(
|
||||
enabled=True,
|
||||
source=BGMSource.UPLOAD,
|
||||
asset_id="bgm_123",
|
||||
volume=0.5,
|
||||
fade_in=2.0,
|
||||
fade_out=3.0,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
)
|
||||
assert b.enabled is True
|
||||
assert b.source == BGMSource.UPLOAD
|
||||
assert b.volume == 0.5
|
||||
assert b.sidechain_enabled is True
|
||||
assert b.sidechain_ratio == 0.5
|
||||
|
||||
def test_volume_range(self):
|
||||
b = BGMConfig(volume=0.0)
|
||||
assert b.volume == 0.0
|
||||
b = BGMConfig(volume=1.0)
|
||||
assert b.volume == 1.0
|
||||
|
||||
def test_volume_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=1.1)
|
||||
|
||||
def test_fade_in_range(self):
|
||||
b = BGMConfig(fade_in=30.0)
|
||||
assert b.fade_in == 30.0
|
||||
|
||||
def test_fade_in_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(fade_in=31.0)
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
b = BGMConfig(sidechain_attack=0.001)
|
||||
assert b.sidechain_attack == 0.001
|
||||
|
||||
def test_sidechain_attack_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_attack=0.0001)
|
||||
|
||||
def test_sidechain_threshold_range(self):
|
||||
b = BGMConfig(sidechain_threshold=-60.0)
|
||||
assert b.sidechain_threshold == -60.0
|
||||
b = BGMConfig(sidechain_threshold=0.0)
|
||||
assert b.sidechain_threshold == 0.0
|
||||
|
||||
def test_sidechain_threshold_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=-61.0)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=1.0)
|
||||
|
||||
|
||||
# ── ExportConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExportConfig:
|
||||
"""ExportConfig 导出配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
e = ExportConfig()
|
||||
assert e.resolution == "1080x1920"
|
||||
assert e.fps == 30
|
||||
assert e.video_bitrate == 8000
|
||||
assert e.audio_bitrate == 128
|
||||
assert e.format == "mp4"
|
||||
assert e.quality_preset == "balanced"
|
||||
assert e.watermark_enabled is False
|
||||
assert e.watermark_text == ""
|
||||
|
||||
def test_custom_export(self):
|
||||
e = ExportConfig(
|
||||
resolution="720x1280",
|
||||
fps=60,
|
||||
video_bitrate=5000,
|
||||
audio_bitrate=192,
|
||||
format="mov",
|
||||
quality_preset="high",
|
||||
watermark_enabled=True,
|
||||
watermark_text="我的水印",
|
||||
)
|
||||
assert e.resolution == "720x1280"
|
||||
assert e.fps == 60
|
||||
assert e.format == "mov"
|
||||
assert e.watermark_enabled is True
|
||||
|
||||
def test_fps_min_boundary(self):
|
||||
e = ExportConfig(fps=15)
|
||||
assert e.fps == 15
|
||||
|
||||
def test_fps_max_boundary(self):
|
||||
e = ExportConfig(fps=60)
|
||||
assert e.fps == 60
|
||||
|
||||
def test_fps_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=14)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=61)
|
||||
|
||||
def test_video_bitrate_range(self):
|
||||
e = ExportConfig(video_bitrate=1000)
|
||||
assert e.video_bitrate == 1000
|
||||
e = ExportConfig(video_bitrate=20000)
|
||||
assert e.video_bitrate == 20000
|
||||
|
||||
def test_video_bitrate_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=999)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=20001)
|
||||
|
||||
def test_audio_bitrate_range(self):
|
||||
e = ExportConfig(audio_bitrate=64)
|
||||
assert e.audio_bitrate == 64
|
||||
e = ExportConfig(audio_bitrate=320)
|
||||
assert e.audio_bitrate == 320
|
||||
|
||||
|
||||
# ── FilterConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterConfig:
|
||||
"""FilterConfig 滤镜配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
f = FilterConfig()
|
||||
assert f.enabled is False
|
||||
assert f.preset_id == "filter_none"
|
||||
assert f.intensity == 100
|
||||
assert f.brightness == 0.0
|
||||
assert f.contrast == 1.0
|
||||
assert f.saturation == 1.0
|
||||
assert f.warmth == 0.0
|
||||
|
||||
def test_custom_filter(self):
|
||||
f = FilterConfig(
|
||||
enabled=True,
|
||||
preset_id="vintage",
|
||||
intensity=50,
|
||||
brightness=0.3,
|
||||
contrast=1.5,
|
||||
saturation=2.0,
|
||||
warmth=-0.5,
|
||||
)
|
||||
assert f.enabled is True
|
||||
assert f.preset_id == "vintage"
|
||||
assert f.intensity == 50
|
||||
assert f.brightness == 0.3
|
||||
|
||||
def test_intensity_range(self):
|
||||
f = FilterConfig(intensity=0)
|
||||
assert f.intensity == 0
|
||||
f = FilterConfig(intensity=100)
|
||||
assert f.intensity == 100
|
||||
|
||||
def test_intensity_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=-1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=101)
|
||||
|
||||
def test_brightness_range(self):
|
||||
f = FilterConfig(brightness=-1.0)
|
||||
assert f.brightness == -1.0
|
||||
f = FilterConfig(brightness=1.0)
|
||||
assert f.brightness == 1.0
|
||||
|
||||
def test_contrast_range(self):
|
||||
f = FilterConfig(contrast=0.0)
|
||||
assert f.contrast == 0.0
|
||||
f = FilterConfig(contrast=2.0)
|
||||
assert f.contrast == 2.0
|
||||
|
||||
def test_saturation_range(self):
|
||||
f = FilterConfig(saturation=0.0)
|
||||
assert f.saturation == 0.0
|
||||
f = FilterConfig(saturation=3.0)
|
||||
assert f.saturation == 3.0
|
||||
|
||||
def test_warmth_range(self):
|
||||
f = FilterConfig(warmth=-1.0)
|
||||
assert f.warmth == -1.0
|
||||
f = FilterConfig(warmth=1.0)
|
||||
assert f.warmth == 1.0
|
||||
|
||||
def test_brightness_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=-1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=1.1)
|
||||
|
||||
|
||||
# ── 完整 Schema 模型 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditPlanConfigSchema:
|
||||
"""EditPlanConfigSchema 完整计划配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditPlanConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert isinstance(s.title, TitleConfig)
|
||||
assert isinstance(s.subtitle, SubtitleConfig)
|
||||
assert isinstance(s.bgm, BGMConfig)
|
||||
assert isinstance(s.export, ExportConfig)
|
||||
assert isinstance(s.filter, FilterConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
|
||||
def test_partial_update_via_dict(self):
|
||||
s = EditPlanConfigSchema(
|
||||
**{
|
||||
"cover": {"type": "manual", "frame_time": 10.0},
|
||||
"title": {"text": "自定义标题", "size": 60},
|
||||
"editing_mode": "template",
|
||||
}
|
||||
)
|
||||
assert s.cover.type == CoverType.MANUAL
|
||||
assert s.cover.frame_time == 10.0
|
||||
assert s.title.text == "自定义标题"
|
||||
assert s.title.size == 60
|
||||
assert s.editing_mode == "template"
|
||||
|
||||
def test_full_config_dict_roundtrip(self):
|
||||
data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
data["title"]["text"] = "测试标题"
|
||||
data["bgm"]["enabled"] = True
|
||||
s = EditPlanConfigSchema(**data)
|
||||
assert s.title.text == "测试标题"
|
||||
assert s.bgm.enabled is True
|
||||
# 默认字段保留
|
||||
assert s.subtitle.size == 24
|
||||
assert s.export.fps == 30
|
||||
|
||||
def test_invalid_subfield_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EditPlanConfigSchema(**{"title": {"size": 999}})
|
||||
|
||||
|
||||
class TestEditTemplateConfigSchema:
|
||||
"""EditTemplateConfigSchema 模板配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
assert s.transition_enabled is True
|
||||
|
||||
def test_custom_transition_enabled(self):
|
||||
s = EditTemplateConfigSchema(transition_enabled=False)
|
||||
assert s.transition_enabled is False
|
||||
|
||||
def test_has_all_plan_fields(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert hasattr(s, "cover")
|
||||
assert hasattr(s, "title")
|
||||
assert hasattr(s, "subtitle")
|
||||
assert hasattr(s, "bgm")
|
||||
assert hasattr(s, "export")
|
||||
assert hasattr(s, "filter")
|
||||
assert hasattr(s, "editing_mode")
|
||||
assert hasattr(s, "transition_enabled")
|
||||
|
||||
|
||||
# ── 默认值常量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultConfigs:
|
||||
"""默认配置常量"""
|
||||
|
||||
def test_default_plan_config_structure(self):
|
||||
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
|
||||
def test_default_template_config_extra_field(self):
|
||||
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
|
||||
|
||||
def test_template_config_inherits_plan(self):
|
||||
# 模板配置应该包含计划配置的所有字段
|
||||
for key in DEFAULT_EDIT_PLAN_CONFIG:
|
||||
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
|
||||
def test_defaults_are_valid_for_schema(self):
|
||||
# 默认值应该能通过 schema 校验
|
||||
plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG)
|
||||
assert plan.editing_mode == "one_take"
|
||||
template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert template.transition_enabled is True
|
||||
|
||||
def test_mutation_does_not_affect_original(self):
|
||||
# 修改返回的 dict 不应该影响常量
|
||||
d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
d["cover"]["type"] = "upload"
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame"
|
||||
|
||||
|
||||
# ── normalize_plan_config ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePlanConfig:
|
||||
"""normalize_plan_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_plan_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_plan_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_partial_cover_update(self):
|
||||
result = normalize_plan_config({"cover": {"type": "manual"}})
|
||||
assert result["cover"]["type"] == "manual"
|
||||
# 其他 cover 字段保留默认
|
||||
assert result["cover"]["image_url"] == ""
|
||||
assert result["cover"]["frame_time"] is None
|
||||
|
||||
def test_partial_title_update(self):
|
||||
result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}})
|
||||
assert result["title"]["text"] == "我的标题"
|
||||
assert result["title"]["size"] == 36
|
||||
assert result["title"]["font"] == "思源黑体"
|
||||
|
||||
def test_partial_subtitle_update(self):
|
||||
result = normalize_plan_config({"subtitle": {"size": 28}})
|
||||
assert result["subtitle"]["size"] == 28
|
||||
assert result["subtitle"]["position"] == "bottom"
|
||||
|
||||
def test_partial_bgm_update(self):
|
||||
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}})
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["bgm"]["volume"] == 0.5
|
||||
assert result["bgm"]["source"] == "library"
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_plan_config({"editing_mode": "template"})
|
||||
assert result["editing_mode"] == "template"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"})
|
||||
assert result["generation_task_id"] == "task_123"
|
||||
assert result["custom_field"] == "value"
|
||||
# 标准字段也保留
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_plan_config(
|
||||
{
|
||||
"cover": {"type": "upload", "image_url": "http://x.com/c.jpg"},
|
||||
"title": {"text": "标题", "size": 60},
|
||||
"bgm": {"enabled": True},
|
||||
"editing_mode": "smart",
|
||||
"extra_key": "extra_value",
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "upload"
|
||||
assert result["title"]["text"] == "标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["editing_mode"] == "smart"
|
||||
assert result["extra_key"] == "extra_value"
|
||||
|
||||
def test_non_dict_section_ignored(self):
|
||||
result = normalize_plan_config({"cover": "not_a_dict"})
|
||||
# cover 应该还是默认值
|
||||
assert result["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_editing_mode_non_string_ignored(self):
|
||||
result = normalize_plan_config({"editing_mode": 123})
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
raw = {"cover": {"type": "manual"}, "extra": "value"}
|
||||
raw_copy = copy.deepcopy(raw)
|
||||
normalize_plan_config(raw)
|
||||
assert raw == raw_copy
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
normalize_plan_config({"cover": {"type": "upload"}})
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG == original
|
||||
|
||||
|
||||
# ── normalize_template_config ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
"""normalize_template_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_template_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_template_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_partial_sections(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"title": {"text": "模板标题"},
|
||||
"bgm": {"enabled": True},
|
||||
}
|
||||
)
|
||||
assert result["title"]["text"] == "模板标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
|
||||
def test_transition_enabled_update(self):
|
||||
result = normalize_template_config({"transition_enabled": False})
|
||||
assert result["transition_enabled"] is False
|
||||
|
||||
def test_transition_enabled_non_bool_ignored(self):
|
||||
result = normalize_template_config({"transition_enabled": "yes"})
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_template_config({"editing_mode": "story"})
|
||||
assert result["editing_mode"] == "story"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_template_config({"template_version": "v2", "author": "test"})
|
||||
assert result["template_version"] == "v2"
|
||||
assert result["author"] == "test"
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"cover": {"type": "ai_regenerate"},
|
||||
"subtitle": {"size": 20},
|
||||
"transition_enabled": False,
|
||||
"editing_mode": "vlog",
|
||||
"tags": ["travel", "food"],
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "ai_regenerate"
|
||||
assert result["subtitle"]["size"] == 20
|
||||
assert result["transition_enabled"] is False
|
||||
assert result["editing_mode"] == "vlog"
|
||||
assert result["tags"] == ["travel", "food"]
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
normalize_template_config({"transition_enabled": False})
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG == original
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
"""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
|
||||
Executable
+593
@@ -0,0 +1,593 @@
|
||||
"""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 == ""
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
"""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")
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
"""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
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
"""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)
|
||||
Executable
+453
@@ -0,0 +1,453 @@
|
||||
"""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
|
||||
Executable
+497
@@ -0,0 +1,497 @@
|
||||
"""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
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
"""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
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
"""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
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
"""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
|
||||
Executable
+392
@@ -0,0 +1,392 @@
|
||||
"""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
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
"""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
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
"""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
|
||||
+292
-466
@@ -1,9 +1,6 @@
|
||||
"""BGM 混音纯逻辑单元测试."""
|
||||
"""bgm_mixer_pure 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.bgm_mixer_pure import (
|
||||
from apps.worker.video_processing.bgm_mixer_pure import (
|
||||
BGMPureConfig,
|
||||
build_bgm_filter_chain,
|
||||
build_sidechain_mix_filter,
|
||||
@@ -17,408 +14,286 @@ from video_processing.bgm_mixer_pure import (
|
||||
validate_bgm_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# should_loop_bgm 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── BGMPureConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShouldLoopBGM:
|
||||
"""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
|
||||
|
||||
def test_need_loop_when_much_shorter(self):
|
||||
"""BGM 远短于目标时长,需要循环."""
|
||||
assert should_loop_bgm(10, 100, True) is True
|
||||
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_no_loop_when_long_enough(self):
|
||||
"""BGM 够长,不需要循环."""
|
||||
assert should_loop_bgm(100, 100, True) is False
|
||||
|
||||
def test_no_loop_when_just_slightly_shorter(self):
|
||||
"""BGM 只差一点点(>90%),不循环."""
|
||||
assert should_loop_bgm(95, 100, True) is False
|
||||
# ── should_loop_bgm ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_threshold_90_percent(self):
|
||||
"""刚好 90% 阈值,不循环(<90% 才循环)."""
|
||||
assert should_loop_bgm(90, 100, True) is False
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""略低于 90%,需要循环."""
|
||||
assert should_loop_bgm(89, 100, True) is True
|
||||
class TestShouldLoopBgm:
|
||||
def test_loop_enabled_much_shorter(self):
|
||||
# BGM 10秒,目标60秒 → 需要循环
|
||||
assert should_loop_bgm(10, 60) is True
|
||||
|
||||
def test_loop_disabled(self):
|
||||
"""禁用循环,即使 BGM 很短也不循环."""
|
||||
assert should_loop_bgm(10, 100, False) is False
|
||||
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
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,不循环."""
|
||||
assert should_loop_bgm(0, 100, True) is False
|
||||
assert should_loop_bgm(0, 60) is False
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,不循环."""
|
||||
assert should_loop_bgm(-5, 100, True) is False
|
||||
assert should_loop_bgm(-1, 60) is False
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,不循环."""
|
||||
assert should_loop_bgm(10, 0, True) is False
|
||||
assert should_loop_bgm(10, 0) is False
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,不循环."""
|
||||
assert should_loop_bgm(10, -10, True) is False
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_loop_count 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── calculate_loop_count ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateLoopCount:
|
||||
"""循环次数计算测试."""
|
||||
def test_exact_fit_returns_1(self):
|
||||
assert calculate_loop_count(60, 60) == 1
|
||||
|
||||
def test_exact_multiple(self):
|
||||
"""刚好整数倍."""
|
||||
# 100/10 = 10, +2 = 12
|
||||
assert calculate_loop_count(10, 100) == 12
|
||||
def test_bgm_longer_returns_1(self):
|
||||
assert calculate_loop_count(100, 60) == 1
|
||||
|
||||
def test_not_exact_multiple(self):
|
||||
"""不是整数倍."""
|
||||
# 100/30 = 3, +2 = 5
|
||||
assert calculate_loop_count(30, 100) == 5
|
||||
def test_needs_3_loops_plus_2_margin(self):
|
||||
# 60/20 = 3 + 2 = 5
|
||||
assert calculate_loop_count(20, 60) == 5
|
||||
|
||||
def test_bgm_longer_than_target(self):
|
||||
"""BGM 比目标长,至少 1 次."""
|
||||
assert calculate_loop_count(200, 100) == 1
|
||||
def test_needs_2_loops_plus_2_margin(self):
|
||||
# 60/30 = 2 + 2 = 4
|
||||
assert calculate_loop_count(30, 60) == 4
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,返回 1."""
|
||||
assert calculate_loop_count(0, 100) == 1
|
||||
assert calculate_loop_count(0, 60) == 1
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,返回 1."""
|
||||
assert calculate_loop_count(-5, 100) == 1
|
||||
assert calculate_loop_count(-1, 60) == 1
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,返回 1."""
|
||||
assert calculate_loop_count(10, 0) == 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,返回 1."""
|
||||
assert calculate_loop_count(10, -10) == 1
|
||||
assert calculate_loop_count(10, -1) == 1
|
||||
|
||||
def test_very_short_bgm(self):
|
||||
"""非常短的 BGM,循环次数多."""
|
||||
# 100/1 = 100, +2 = 102
|
||||
assert calculate_loop_count(1, 100) == 102
|
||||
def test_minimum_is_1(self):
|
||||
assert calculate_loop_count(10, 5) == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_bgm_filter_chain 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_bgm_filter_chain ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBGMFilterChain:
|
||||
"""BGM 预处理滤镜链构建测试."""
|
||||
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
|
||||
|
||||
def test_basic_volume_only(self):
|
||||
"""只有音量调节."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.5,
|
||||
)
|
||||
def test_volume_filter_applied(self):
|
||||
result = build_bgm_filter_chain(100, 60, 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,
|
||||
loop_enabled=True,
|
||||
)
|
||||
assert "aloop=loop=" in result
|
||||
assert "volume=0.300" 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_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_volume_clamped(self):
|
||||
# volume=2.0钳制到1.0,1.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_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_volume_zero(self):
|
||||
result = build_bgm_filter_chain(100, 60, volume=0.0)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
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,
|
||||
)
|
||||
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
|
||||
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_fade_in_zero_skipped(self):
|
||||
result = build_bgm_filter_chain(100, 60, fade_in=0)
|
||||
assert "afade=t=in" 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_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_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,
|
||||
)
|
||||
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_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_loop_applied_when_needed(self):
|
||||
result = build_bgm_filter_chain(10, 60)
|
||||
assert "aloop=loop=" 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,
|
||||
)
|
||||
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_duration_fallback(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=-5,
|
||||
volume=0.5,
|
||||
)
|
||||
def test_negative_target_uses_fallback(self):
|
||||
result = build_bgm_filter_chain(100, -5)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_full_chain_with_all_effects(self):
|
||||
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
|
||||
def test_all_features_combined(self):
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.4,
|
||||
bgm_duration=15,
|
||||
target_duration=60,
|
||||
volume=0.3,
|
||||
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]
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_sidechain_ratio 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── calculate_sidechain_ratio ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateSidechainRatio:
|
||||
"""Sidechain 压缩比计算测试."""
|
||||
def test_zero_ratio_minimum(self):
|
||||
assert calculate_sidechain_ratio(0) == 2.0
|
||||
|
||||
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."""
|
||||
def test_negative_clamped(self):
|
||||
assert calculate_sidechain_ratio(-0.5) == 2.0
|
||||
|
||||
def test_ratio_1_0(self):
|
||||
"""比例 1.0,返回上限 10.0."""
|
||||
def test_one_ratio_maximum(self):
|
||||
assert calculate_sidechain_ratio(1.0) == 10.0
|
||||
|
||||
def test_ratio_greater_than_1(self):
|
||||
"""比例超过 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(2.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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_simple_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_simple_mix_filter ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSimpleMixFilter:
|
||||
"""普通混音滤镜构建测试."""
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix."""
|
||||
def test_contains_inputs_and_output(self):
|
||||
result = build_simple_mix_filter()
|
||||
assert "[0:a][1:a]" in result
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_contains_volume_compensation(self):
|
||||
"""包含 volume=2 补偿."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
assert "[final]" in result
|
||||
assert "volume=2" in result
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签为 [final]."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_duration_first(self):
|
||||
"""duration=first,以主音频时长为准."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_sidechain_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── 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_param(self):
|
||||
"""threshold 参数正确."""
|
||||
def test_threshold_in_db(self):
|
||||
result = build_sidechain_mix_filter(threshold=-30.0)
|
||||
assert "threshold=-30.0dB" 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_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_contains_amix(self):
|
||||
"""包含 amix 混音."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
assert "duration=first" in result
|
||||
|
||||
def test_volume_compensation(self):
|
||||
"""volume=1.5 轻微补偿."""
|
||||
def test_contains_volume_compensation(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "volume=1.5" in result
|
||||
|
||||
def test_bgmc_comp_label(self):
|
||||
"""包含 [bgm_comp] 中间标签."""
|
||||
def test_output_label(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_bgm_comp_label(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[bgm_comp]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# normalize_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── normalize_bgm_config ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeBGMConfig:
|
||||
"""配置规范化测试."""
|
||||
|
||||
def test_empty_dict_defaults(self):
|
||||
"""空字典返回默认值."""
|
||||
class TestNormalizeBgmConfig:
|
||||
def test_default_values(self):
|
||||
result = normalize_bgm_config({})
|
||||
assert result["volume"] == 0.3
|
||||
assert result["fade_in"] == 0.0
|
||||
@@ -426,233 +301,184 @@ 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": 1.5})
|
||||
result = normalize_bgm_config({"volume": 2.0})
|
||||
assert result["volume"] == 1.0
|
||||
result2 = normalize_bgm_config({"volume": -0.5})
|
||||
assert result2["volume"] == 0.0
|
||||
result = normalize_bgm_config({"volume": -1.0})
|
||||
assert result["volume"] == 0.0
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""淡入为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_in": -1})
|
||||
def test_fade_in_clamped_to_zero(self):
|
||||
result = normalize_bgm_config({"fade_in": -5})
|
||||
assert result["fade_in"] == 0.0
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""淡出为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_out": -1})
|
||||
def test_fade_out_clamped_to_zero(self):
|
||||
result = normalize_bgm_config({"fade_out": -5})
|
||||
assert result["fade_out"] == 0.0
|
||||
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
"""sidechain_ratio 钳制."""
|
||||
result = normalize_bgm_config({"sidechain_ratio": 1.5})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
|
||||
assert result2["sidechain_ratio"] == 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_attack_min(self):
|
||||
"""attack 最小值 0.001."""
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
result = normalize_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result = normalize_bgm_config({"sidechain_ratio": -1.0})
|
||||
assert result["sidechain_ratio"] == 0.0
|
||||
|
||||
def test_sidechain_attack_minimum(self):
|
||||
result = normalize_bgm_config({"sidechain_attack": 0})
|
||||
assert result["sidechain_attack"] == 0.001
|
||||
|
||||
def test_sidechain_release_min(self):
|
||||
"""release 最小值 0.01."""
|
||||
def test_sidechain_release_minimum(self):
|
||||
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": "2.0",
|
||||
"fade_in": "1.0",
|
||||
"sidechain_ratio": "0.7",
|
||||
}
|
||||
)
|
||||
assert result["volume"] == 0.5
|
||||
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
|
||||
assert result["fade_in"] == 1.0
|
||||
assert result["sidechain_ratio"] == 0.7
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# validate_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_bgm_config ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateBGMConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
class TestValidateBgmConfig:
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
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
|
||||
valid, errors = validate_bgm_config({"volume": 0.3})
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
def test_volume_not_number(self):
|
||||
"""volume 不是数字."""
|
||||
ok, errors = validate_bgm_config({"volume": "high"})
|
||||
assert ok is False
|
||||
def test_invalid_volume_type(self):
|
||||
valid, errors = validate_bgm_config({"volume": "abc"})
|
||||
assert valid is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_volume_out_of_range(self):
|
||||
"""volume 超出范围."""
|
||||
ok, errors = validate_bgm_config({"volume": 1.5})
|
||||
assert ok is False
|
||||
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
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""fade_in 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_in": -1})
|
||||
assert ok is False
|
||||
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
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""fade_out 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_out": -1})
|
||||
assert ok is False
|
||||
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
|
||||
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):
|
||||
"""sidechain_ratio 超出范围."""
|
||||
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert ok is False
|
||||
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
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误同时报告."""
|
||||
ok, errors = validate_bgm_config(
|
||||
valid, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 2.0,
|
||||
"volume": "bad",
|
||||
"fade_in": -1,
|
||||
"sidechain_ratio": -0.5,
|
||||
"sidechain_ratio": 2.0,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert valid 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(100, 3) == pytest.approx(97.0)
|
||||
assert calculate_fade_out_start(60, 2) == 58.0
|
||||
|
||||
def test_zero_fade_out(self):
|
||||
"""淡出时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(100, 0) is None
|
||||
assert calculate_fade_out_start(60, 0) is None
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""淡出时长为负,返回 None."""
|
||||
assert calculate_fade_out_start(100, -1) is None
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""总时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(0, 3) is None
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出等于总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 10) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# estimate_bgm_processing_duration 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateBGMProcessingDuration:
|
||||
"""BGM 处理时长估算测试."""
|
||||
|
||||
def test_normal_case_with_loop(self):
|
||||
"""正常循环情况,输出目标时长."""
|
||||
assert estimate_bgm_processing_duration(10, 100, True) == 100
|
||||
|
||||
def test_bgm_longer_no_loop(self):
|
||||
"""BGM 够长,不循环,截断到目标时长."""
|
||||
assert estimate_bgm_processing_duration(200, 100, False) == 100
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
|
||||
assert estimate_bgm_processing_duration(10, 100, False) == 100
|
||||
assert calculate_fade_out_start(60, -1) is None
|
||||
|
||||
def test_zero_target(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
|
||||
assert calculate_fade_out_start(0, 2) is None
|
||||
|
||||
def test_negative_target(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
|
||||
assert calculate_fade_out_start(-5, 2) is None
|
||||
|
||||
def test_fade_longer_than_target(self):
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_equal_to_target(self):
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# BGMPureConfig 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── estimate_bgm_processing_duration ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMPureConfig:
|
||||
"""BGMPureConfig 数据类测试."""
|
||||
class TestEstimateBgmProcessingDuration:
|
||||
def test_bgm_longer_no_loop(self):
|
||||
assert estimate_bgm_processing_duration(100, 60, loop_enabled=False) == 60.0
|
||||
|
||||
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_bgm_longer_with_loop(self):
|
||||
# 够长但允许循环,仍然截断到target
|
||||
assert estimate_bgm_processing_duration(100, 60, loop_enabled=True) == 60.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
|
||||
def test_bgm_shorter_with_loop(self):
|
||||
assert estimate_bgm_processing_duration(10, 60, loop_enabled=True) == 60.0
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
# 需要循环但不允许 → 截断到target
|
||||
assert estimate_bgm_processing_duration(10, 60, loop_enabled=False) == 60.0
|
||||
|
||||
def test_zero_target_fallback(self):
|
||||
assert estimate_bgm_processing_duration(100, 0) == 5.0
|
||||
|
||||
def test_negative_target_fallback(self):
|
||||
assert estimate_bgm_processing_duration(100, -5) == 5.0
|
||||
|
||||
def test_equal_duration(self):
|
||||
assert estimate_bgm_processing_duration(60, 60) == 60.0
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
"""concat_engine_pure 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
from apps.worker.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,200 +20,203 @@ from video_processing.concat_engine_pure import (
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── parse_fps ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
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
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 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 输入返回默认值."""
|
||||
def test_none_returns_default(self):
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
def test_integer_value(self):
|
||||
assert parse_fps(30) == 30.0
|
||||
assert parse_fps(24) == 24.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
def test_float_value(self):
|
||||
assert parse_fps(29.97) == 29.97
|
||||
|
||||
def test_string_integer(self):
|
||||
assert parse_fps("30") == 30.0
|
||||
assert parse_fps(" 60 ") == 60.0 # 带空格
|
||||
|
||||
def test_string_fraction(self):
|
||||
assert parse_fps("30/1") == 30.0
|
||||
assert abs(parse_fps("24000/1001") - 23.976) < 0.01
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 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_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_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
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):
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
# 三位小数
|
||||
parts = result.split("=")[1]
|
||||
assert len(parts.split(".")[1]) == 3
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
def test_one_fps(self):
|
||||
assert format_fps_filter(1.0) == "fps=1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── resolve_output_params ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
def test_config_specified(self):
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
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):
|
||||
"""用第一段视频信息."""
|
||||
def test_fallback_to_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_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
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):
|
||||
# 宽度配置了,高度和帧率用探测的
|
||||
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_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_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
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"})
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
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(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
def test_wider_source_pad_top_bottom(self):
|
||||
# 源是16:9,目标是9:16竖屏 → 上下填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
def test_taller_source_pad_left_right(self):
|
||||
# 源是9:16竖屏,目标是16:9横屏 → 左右填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
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
|
||||
def test_zero_source_size(self):
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
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):
|
||||
# 比例相同,尺寸不同 → 直接缩放到目标大小
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── can_use_stream_copy ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
def test_force_reencode_false(self):
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 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):
|
||||
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"},
|
||||
@@ -221,7 +224,6 @@ 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"},
|
||||
@@ -229,306 +231,349 @@ 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(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
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
def test_target_differs_from_source(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
|
||||
# 目标分辨率不同
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── generate_concat_file_list ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
result = generate_concat_file_list(["/tmp/video.mp4"])
|
||||
assert result == "file '/tmp/video.mp4'\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/my video.mp4"])
|
||||
assert "my video" in result
|
||||
result = generate_concat_file_list(["/path/to/video file.mp4"])
|
||||
assert "file '/path/to/video file.mp4'" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_scale_pad_filter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""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):
|
||||
"""保持宽高比."""
|
||||
def test_basic_filter(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_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" 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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
# ── build_setpts_filter ─────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
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) == ""
|
||||
|
||||
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
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" 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_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
|
||||
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_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
def test_multiple_inputs_no_audio(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
|
||||
|
||||
def test_negative_inputs(self):
|
||||
assert build_concat_filter(-1) == ""
|
||||
|
||||
|
||||
# ── build_single_segment_filter_chain ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
# 视频链
|
||||
assert "[0:v]" in result
|
||||
assert "[v0]" in result
|
||||
assert "scale=1920:1080" in result
|
||||
assert "fps=30" 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
|
||||
|
||||
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_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_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
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── 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,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
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)
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
valid, errors = validate_concat_config({"segments": []})
|
||||
assert valid is False
|
||||
assert len(errors) >= 1
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
config = {"segments": [{"video_path": ""}]}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
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
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
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
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
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
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_video_path ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
valid, err = validate_video_path("", "/work")
|
||||
assert valid is False
|
||||
assert "不能为空" in 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_valid(self):
|
||||
valid, err = validate_video_path("video.mp4", "/work")
|
||||
assert valid is True
|
||||
assert err == ""
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
def test_relative_path_with_subdir(self):
|
||||
valid, err = validate_video_path("sub/video.mp4", "/work")
|
||||
assert valid is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/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_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── 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}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": 20.5},
|
||||
{"duration": 5.5},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 36.0
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
def test_missing_duration_field(self):
|
||||
segs = [{"path": "a.mp4"}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == 10.0
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
def test_invalid_duration_skipped(self):
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": "abc"},
|
||||
{"duration": 20},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
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
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
"""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
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
"""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
+79
-288
@@ -1,9 +1,6 @@
|
||||
"""通用分页器单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""pagination 单元测试."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.application.common.pagination import (
|
||||
PaginatedResponse,
|
||||
@@ -12,377 +9,171 @@ 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_offset_first_page(self):
|
||||
"""第一页 offset 为 0"""
|
||||
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):
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
assert params.offset == 0
|
||||
|
||||
def test_offset_second_page(self):
|
||||
"""第二页 offset 计算正确"""
|
||||
params = PaginationParams(page=2, page_size=20)
|
||||
assert params.offset == 20
|
||||
params = PaginationParams(page=3, page_size=20)
|
||||
assert params.offset == 40
|
||||
|
||||
def test_offset_custom_page_size(self):
|
||||
"""自定义 page_size 的 offset"""
|
||||
params = PaginationParams(page=3, page_size=10)
|
||||
assert params.offset == 20
|
||||
params = PaginationParams(page=10, page_size=50)
|
||||
assert params.offset == 450
|
||||
|
||||
def test_limit_equals_page_size(self):
|
||||
"""limit 等于 page_size"""
|
||||
params = PaginationParams(page_size=50)
|
||||
assert params.limit == 50
|
||||
params = PaginationParams(page_size=30)
|
||||
assert params.limit == 30
|
||||
|
||||
def test_page_must_be_at_least_1(self):
|
||||
"""page 不能小于 1"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
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):
|
||||
"""page_size 不能小于 1"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
PaginationParams(page_size=0)
|
||||
|
||||
def test_page_size_max_100(self):
|
||||
"""page_size 最大 100"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
PaginationParams(page_size=101)
|
||||
|
||||
def test_page_size_100_is_valid(self):
|
||||
"""page_size=100 是合法的"""
|
||||
params = PaginationParams(page_size=100)
|
||||
assert params.page_size == 100
|
||||
|
||||
# ── PaginationMeta ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
assert meta.total_pages == 3 # ceil(25/10)
|
||||
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=50)
|
||||
|
||||
assert meta.page == 2
|
||||
assert meta.total_pages == 5
|
||||
meta = PaginationMeta.from_params(params, total=25)
|
||||
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_multiple(self):
|
||||
"""总数刚好是 page_size 的整数倍"""
|
||||
def test_from_params_exact_page_size(self):
|
||||
params = PaginationParams(page=1, page_size=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)
|
||||
|
||||
meta = PaginationMeta.from_params(params, total=10)
|
||||
assert meta.total_pages == 1
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginatedResponse:
|
||||
"""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]
|
||||
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
|
||||
assert response.pagination.page == 1
|
||||
assert response.pagination.total == 25
|
||||
assert response.pagination.total == 15
|
||||
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)
|
||||
|
||||
assert response.data == []
|
||||
assert response.pagination.total == 0
|
||||
assert response.pagination.total_pages == 0
|
||||
# ── paginate function ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginateFunction:
|
||||
"""paginate 函数测试(内存分页)"""
|
||||
|
||||
class TestPaginate:
|
||||
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_empty_list(self):
|
||||
"""空列表分页"""
|
||||
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_total(self):
|
||||
"""页码超出总数"""
|
||||
def test_single_page(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_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))
|
||||
result = paginate(items, PaginationParams(page=1, page_size=100))
|
||||
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)
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 0
|
||||
assert result.pagination.total_pages == 0
|
||||
|
||||
def test_page_beyond_end(self):
|
||||
items = list(range(5))
|
||||
params = PaginationParams(page=10, page_size=10)
|
||||
result = paginate(items, params)
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 5
|
||||
|
||||
def test_page_size_larger_than_items(self):
|
||||
items = list(range(5))
|
||||
params = PaginationParams(page=1, page_size=100)
|
||||
result = paginate(items, params)
|
||||
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
|
||||
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
"""密码哈希与验证模块单元测试."""
|
||||
|
||||
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
|
||||
+224
-209
@@ -1,18 +1,14 @@
|
||||
"""路径安全校验工具单元测试 — 路径遍历防护."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""path_security 单元测试."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
import pytest
|
||||
|
||||
from video_processing.path_security import ( # noqa: E402
|
||||
from apps.worker.video_processing.path_security import (
|
||||
LOCAL_SCHEMA_PREFIX,
|
||||
MAX_PATH_LENGTH,
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
is_path_safe,
|
||||
safe_resolve_path,
|
||||
@@ -21,223 +17,242 @@ from video_processing.path_security import ( # noqa: E402
|
||||
)
|
||||
|
||||
|
||||
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"},
|
||||
)
|
||||
@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 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)
|
||||
# ── safe_resolve_path ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitizeFilename(unittest.TestCase):
|
||||
"""文件名清理测试."""
|
||||
class TestSafeResolvePath:
|
||||
def test_none_path_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="不能为空"):
|
||||
safe_resolve_path(None, base_dir)
|
||||
|
||||
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):
|
||||
"""正常文件名应该保持不变."""
|
||||
self.assertEqual(sanitize_filename("video.mp4"), "video.mp4")
|
||||
assert sanitize_filename("hello.mp4") == "hello.mp4"
|
||||
|
||||
def test_path_separators_removed(self):
|
||||
"""路径分隔符应该被替换."""
|
||||
self.assertNotIn("/", sanitize_filename("../path/to/file.mp4"))
|
||||
self.assertNotIn("\\", sanitize_filename("..\\path\\file.mp4"))
|
||||
def test_empty_returns_unnamed(self):
|
||||
assert sanitize_filename("") == "unnamed"
|
||||
|
||||
def test_leading_dots_removed(self):
|
||||
"""开头的点应该被移除."""
|
||||
result = sanitize_filename(".hidden")
|
||||
self.assertFalse(result.startswith("."))
|
||||
self.assertEqual(result, "hidden")
|
||||
def test_none_default(self):
|
||||
# 空字符串会返回unnamed
|
||||
assert sanitize_filename("") == "unnamed"
|
||||
|
||||
def test_multiple_leading_dots_removed(self):
|
||||
"""多个开头的点应该全部被移除."""
|
||||
result = sanitize_filename("...hidden")
|
||||
self.assertFalse(result.startswith("."))
|
||||
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_empty_filename_default(self):
|
||||
"""空文件名应该返回 unnamed."""
|
||||
self.assertEqual(sanitize_filename(""), "unnamed")
|
||||
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_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_dangerous_chars(self):
|
||||
result = sanitize_filename("file<name>.mp4")
|
||||
assert "<" not in result
|
||||
assert ">" not in result
|
||||
|
||||
def test_chinese_filename_preserved(self):
|
||||
"""中文文件名应该保留."""
|
||||
result = sanitize_filename("视频素材.mp4")
|
||||
self.assertIn("视频素材", 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_long_filename_truncated(self):
|
||||
"""超长文件名应该被截断."""
|
||||
long_name = "a" * 300 + ".mp4"
|
||||
result = sanitize_filename(long_name)
|
||||
self.assertLessEqual(len(result), 255)
|
||||
self.assertTrue(result.endswith(".mp4"))
|
||||
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"
|
||||
|
||||
|
||||
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))
|
||||
# ── is_in_allowed_dirs ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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"
|
||||
|
||||
+413
-699
File diff suppressed because it is too large
Load Diff
Executable
+453
@@ -0,0 +1,453 @@
|
||||
"""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
@@ -1,412 +1,100 @@
|
||||
"""文本分段工具单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
"""text_splitter 单元测试."""
|
||||
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
|
||||
|
||||
class TestSplitText:
|
||||
"""split_text 函数测试"""
|
||||
|
||||
def test_empty_string_returns_empty_list(self):
|
||||
"""空字符串返回空列表"""
|
||||
def test_empty_text_returns_empty(self):
|
||||
assert split_text("") == []
|
||||
|
||||
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):
|
||||
"""纯空白文本返回空列表."""
|
||||
def test_whitespace_only(self):
|
||||
assert split_text(" \n\t ") == []
|
||||
|
||||
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)
|
||||
def test_short_text_single_segment(self):
|
||||
text = "你好世界。"
|
||||
result = split_text(text, max_chars=500)
|
||||
assert len(result) == 1
|
||||
assert result[0] == text
|
||||
|
||||
def test_one_over_max_chars_splits(self):
|
||||
"""超过 max_chars 1 个字符就会分割."""
|
||||
text = "a" * 101
|
||||
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 + ";"
|
||||
result = split_text(text, max_chars=100)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_none_raises(self):
|
||||
"""None 输入抛 AttributeError(strip 失败)."""
|
||||
with pytest.raises(AttributeError):
|
||||
split_text(None)
|
||||
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]
|
||||
|
||||
|
||||
# ── 句子边界分段补充 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── 长句强制切段补充 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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)
|
||||
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_custom_small_max_chars(self):
|
||||
"""很小的 max_chars."""
|
||||
text = "一二三四五六七八九十。" * 5
|
||||
def test_minimum_segment_length(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混合。"
|
||||
result = split_text(text, max_chars=20)
|
||||
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
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""验证码服务单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -9,9 +8,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,
|
||||
@@ -21,298 +20,560 @@ from packages.application.auth.verification_code_service import (
|
||||
)
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
# ── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
return MagicMock()
|
||||
"""mock 验证码仓储."""
|
||||
repo = MagicMock()
|
||||
repo.find_latest.return_value = None
|
||||
repo.count_today.return_value = 0
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def code_service(mock_repo):
|
||||
return VerificationCodeService(mock_repo)
|
||||
def service(mock_repo):
|
||||
"""验证码服务实例."""
|
||||
return VerificationCodeService(repo=mock_repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_code():
|
||||
code = VerificationCode.create(
|
||||
recipient="test@example.com",
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
ttl_seconds=300,
|
||||
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,
|
||||
)
|
||||
return code
|
||||
return vc
|
||||
|
||||
|
||||
class TestVerificationCodeServiceGenerate:
|
||||
# ── generate 方法测试 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
"""generate 方法测试"""
|
||||
|
||||
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)
|
||||
def test_generate_success(self, service, mock_repo):
|
||||
"""成功生成验证码."""
|
||||
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert error is None
|
||||
assert code is not None
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.recipient == "user@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_empty_recipient(self, code_service):
|
||||
"""空接收方返回错误"""
|
||||
code, error = code_service.generate("", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "接收方不能为空" in error
|
||||
def test_generate_with_custom_code(self, service, mock_repo):
|
||||
"""使用自定义验证码."""
|
||||
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999")
|
||||
|
||||
def test_generate_invalid_type(self, code_service):
|
||||
"""无效验证码类型返回错误"""
|
||||
code, error = code_service.generate("test@example.com", "invalid_type")
|
||||
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)
|
||||
assert code is None
|
||||
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")
|
||||
assert code is None
|
||||
assert "无效的验证码类型" in error
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
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
|
||||
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_generate_daily_limit_exceeded(self, code_service, mock_repo):
|
||||
"""超过每日上限返回错误"""
|
||||
mock_repo.find_latest.return_value = None # 没有冷却期问题
|
||||
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
|
||||
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
|
||||
|
||||
def test_daily_limit_reached(self, service, mock_repo):
|
||||
"""达到每日上限."""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = DAILY_LIMIT
|
||||
|
||||
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "今日发送次数已达上限" in error
|
||||
|
||||
def test_generate_recipient_stripped(self, code_service, mock_repo, sample_code):
|
||||
"""recipient 会被 strip"""
|
||||
def test_daily_limit_one_below_allows(self, service, mock_repo):
|
||||
"""未达到上限时允许."""
|
||||
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
|
||||
|
||||
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)
|
||||
# email_login 类型应该可以正常发送
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN)
|
||||
assert error is None
|
||||
assert code is not None
|
||||
|
||||
|
||||
class TestVerificationCodeServiceVerify:
|
||||
# ── verify 方法测试 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerify:
|
||||
"""verify 方法测试"""
|
||||
|
||||
def test_verify_success(self, code_service, mock_repo, sample_code):
|
||||
"""验证成功"""
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
def test_verify_success(self, service, mock_repo):
|
||||
"""验证码正确."""
|
||||
code = _make_code(code="654321")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
|
||||
assert success is True
|
||||
ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321")
|
||||
assert ok is True
|
||||
assert error is None
|
||||
assert sample_code.is_used is True
|
||||
assert code.is_used # 标记为已使用
|
||||
assert mock_repo.save.call_count >= 2 # increment + mark_used
|
||||
|
||||
def test_verify_wrong_code(self, code_service, mock_repo, sample_code):
|
||||
"""验证码错误"""
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
def test_verify_wrong_code(self, service, mock_repo):
|
||||
"""验证码错误."""
|
||||
code = _make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrongcode")
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000")
|
||||
assert ok is False
|
||||
assert "验证码错误" in error
|
||||
assert not code.is_used # 不标记为已使用
|
||||
assert code.attempts == 1 # 尝试次数+1
|
||||
|
||||
def test_verify_not_found(self, code_service, mock_repo):
|
||||
"""验证码不存在"""
|
||||
def test_verify_no_code_found(self, service, mock_repo):
|
||||
"""找不到验证码."""
|
||||
mock_repo.find_latest.return_value = None
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("u@e.com", 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
|
||||
def test_verify_empty_params(self, service):
|
||||
"""参数为空."""
|
||||
ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert ok is False
|
||||
assert "参数不完整" in error
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, expired_code.code)
|
||||
ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "")
|
||||
assert ok2 is False
|
||||
assert "参数不完整" in error2
|
||||
|
||||
assert success is False
|
||||
assert "已过期" in error
|
||||
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
|
||||
|
||||
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, " 111111 ")
|
||||
assert ok is True
|
||||
assert error is None
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
def test_verify_already_used(self, service, mock_repo):
|
||||
"""验证码已使用."""
|
||||
code = _make_code(used=True)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "已使用" in error
|
||||
|
||||
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
|
||||
def test_verify_expired(self, service, mock_repo):
|
||||
"""验证码已过期."""
|
||||
code = _make_code(ttl=-60) # 已过期
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "已过期" in error
|
||||
|
||||
assert success is False
|
||||
def test_verify_too_many_attempts(self, service, mock_repo):
|
||||
"""尝试次数过多."""
|
||||
code = _make_code(attempts=MAX_ATTEMPTS + 1)
|
||||
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_empty_params(self, code_service):
|
||||
"""空参数返回错误"""
|
||||
success, error = code_service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert success is False
|
||||
assert "参数不完整" in error
|
||||
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
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
|
||||
assert success is False
|
||||
assert "参数不完整" in error
|
||||
for _ in range(3):
|
||||
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong")
|
||||
|
||||
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
|
||||
assert code.attempts == 3
|
||||
|
||||
code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrong")
|
||||
def test_verify_without_consume(self, service, mock_repo):
|
||||
"""验证成功但不标记为已使用(consume=False)."""
|
||||
code = _make_code(code="999999")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
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 TestVerifyPhone:
|
||||
"""validate_phone 函数测试"""
|
||||
|
||||
def test_valid_phone(self):
|
||||
"""有效手机号"""
|
||||
ok, err = validate_phone("13800000001")
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
assert error is None
|
||||
assert not code.is_used # 不标记为已使用
|
||||
|
||||
def test_valid_phone_with_plus86(self):
|
||||
"""带 +86 前缀的手机号"""
|
||||
ok, err = validate_phone("+8613800000001")
|
||||
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 "已使用" 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
|
||||
|
||||
ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "验证次数过多" in error
|
||||
|
||||
|
||||
# ── validate_phone 测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidatePhone:
|
||||
"""手机号格式校验测试"""
|
||||
|
||||
def test_valid_11_digit(self):
|
||||
"""标准11位手机号."""
|
||||
ok, msg = validate_phone("13800138000")
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_with_plus_86(self):
|
||||
"""带+86前缀."""
|
||||
ok, msg = validate_phone("+8613800138000")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_phone_short(self):
|
||||
"""太短的手机号"""
|
||||
ok, err = validate_phone("123")
|
||||
def test_invalid_too_short(self):
|
||||
"""位数不足."""
|
||||
ok, msg = validate_phone("1380013800")
|
||||
assert ok is False
|
||||
assert "格式不正确" in err
|
||||
assert "格式不正确" in msg
|
||||
|
||||
def test_invalid_phone_wrong_prefix(self):
|
||||
"""号段不对的手机号"""
|
||||
ok, err = validate_phone("11000000000")
|
||||
def test_invalid_too_long(self):
|
||||
"""位数过多."""
|
||||
ok, msg = validate_phone("138001380001")
|
||||
assert ok is False
|
||||
|
||||
def test_empty_phone(self):
|
||||
"""空手机号"""
|
||||
ok, err = validate_phone("")
|
||||
def test_invalid_starts_with_2(self):
|
||||
"""开头不是1."""
|
||||
ok, msg = validate_phone("23800138000")
|
||||
assert ok is False
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_phone_with_spaces(self):
|
||||
"""带空格的手机号会被 strip"""
|
||||
ok, _ = validate_phone(" 13800000001 ")
|
||||
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 ")
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ── normalize_phone 测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePhone:
|
||||
"""normalize_phone 函数测试"""
|
||||
"""手机号标准化测试"""
|
||||
|
||||
def test_removes_plus86(self):
|
||||
"""去掉 +86 前缀"""
|
||||
assert normalize_phone("+8613800000001") == "13800000001"
|
||||
def test_strip_plus_86(self):
|
||||
"""去掉+86前缀."""
|
||||
assert normalize_phone("+8613800138000") == "13800138000"
|
||||
|
||||
def test_no_prefix_stays_same(self):
|
||||
"""没有前缀保持不变"""
|
||||
assert normalize_phone("13800000001") == "13800000001"
|
||||
"""无前缀保持不变."""
|
||||
assert normalize_phone("13800138000") == "13800138000"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""去掉两端空白"""
|
||||
assert normalize_phone(" 13800000001 ") == "13800000001"
|
||||
"""清理前后空格."""
|
||||
assert normalize_phone(" 13800138000 ") == "13800138000"
|
||||
|
||||
def test_plus_86_with_spaces(self):
|
||||
"""带空格的+86."""
|
||||
assert normalize_phone(" +8613800138000 ") == "13800138000"
|
||||
|
||||
|
||||
# ── validate_email 测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateEmail:
|
||||
"""validate_email 函数测试"""
|
||||
"""邮箱格式校验测试"""
|
||||
|
||||
def test_valid_email(self):
|
||||
"""有效邮箱"""
|
||||
ok, err = validate_email("test@example.com")
|
||||
def test_valid_simple(self):
|
||||
"""标准邮箱."""
|
||||
ok, msg = validate_email("user@example.com")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_email_with_subdomain(self):
|
||||
"""带子域名的邮箱"""
|
||||
ok, _ = validate_email("user@mail.example.com")
|
||||
def test_valid_with_dots(self):
|
||||
"""带点号的用户名."""
|
||||
ok, _ = validate_email("user.name@example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_email_with_plus(self):
|
||||
"""带 + 号的邮箱"""
|
||||
def test_valid_with_plus(self):
|
||||
"""带加号的邮箱."""
|
||||
ok, _ = validate_email("user+tag@example.com")
|
||||
assert ok is True
|
||||
|
||||
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_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 ")
|
||||
def test_valid_with_underscore(self):
|
||||
"""带下划线."""
|
||||
ok, _ = validate_email("user_name@example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_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 ")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_special_chars(self):
|
||||
"""特殊字符."""
|
||||
ok, _ = validate_email("user name@e.com")
|
||||
assert ok is False
|
||||
|
||||
def test_valid_numbers(self):
|
||||
"""数字邮箱."""
|
||||
ok, _ = validate_email("12345@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
|
||||
|
||||
Reference in New Issue
Block a user