Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d548075b8d | |||
| f506048240 | |||
| 69f2846c95 | |||
| 6437b96e54 | |||
| dfbd141895 | |||
| 7cbe5d6457 | |||
| 2e352ec127 | |||
| 5db88d0325 | |||
| 8bf85821b7 | |||
| 644d33c064 | |||
| ce27555b00 | |||
| 5b967cf74d | |||
| 4b62c5227d | |||
| bdb2b403b8 | |||
| 32a95d09f0 | |||
| 8d280e3df9 | |||
| 0cc02c698c |
+64
@@ -0,0 +1,64 @@
|
||||
"""#642 - 生成任务新增 bgm_config 字段
|
||||
|
||||
Revision ID: 052_generation_task_bgm_config
|
||||
Revises: 051_generation_task_resolution
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
|
||||
2. 为空时使用默认空字典
|
||||
|
||||
背景:
|
||||
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
|
||||
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
|
||||
创建生成任务时直接 500。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "052_generation_task_bgm_config"
|
||||
down_revision = "051_generation_task_resolution"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查列是否已存在(幂等)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"bgm_config",
|
||||
sa.JSON,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::json"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查列是否存在(幂等)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is None:
|
||||
return
|
||||
|
||||
op.drop_column("generation_tasks", "bgm_config")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import { thumbGradient } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
import { StatusPill } from "./AssetSkeleton"
|
||||
|
||||
/* ============================================================
|
||||
* AssetCard — 素材卡片(网格视图)
|
||||
* ============================================================ */
|
||||
export interface AssetCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetCard
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
|
||||
* ============================================================ */
|
||||
export interface AssetFilterBarProps {
|
||||
searchText: string
|
||||
onSearchChange: (value: string) => void
|
||||
filterType: string
|
||||
onFilterTypeChange: (value: string) => void
|
||||
filterTime: string
|
||||
onFilterTimeChange: (value: string) => void
|
||||
onSelectAll: () => void
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
filterType,
|
||||
onFilterTypeChange,
|
||||
filterTime,
|
||||
onFilterTimeChange,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => (
|
||||
<div className="xx-assets-filters">
|
||||
<div className="xx-assets-filters-left">
|
||||
<Input
|
||||
placeholder="搜索素材名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={onFilterTypeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TYPE_FILTER_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={onFilterTimeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TIME_FILTER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-assets-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span className="xx-assets-filter-count">共 {totalCount} 个素材</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetFilterBar
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import type { StatusType } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* StatusPill — 状态标签
|
||||
* ============================================================ */
|
||||
export interface StatusPillProps {
|
||||
status: StatusType
|
||||
label: string
|
||||
}
|
||||
|
||||
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||||
* ============================================================ */
|
||||
export const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* BatchClassifyModal — 批量改分类弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchClassifyModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
category: string
|
||||
onCategoryChange: (value: string) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
category,
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">将选中的 {selectedCount} 个素材统一修改为以下分类:</p>
|
||||
<AntSelect
|
||||
value={category || undefined}
|
||||
onChange={(v) => onCategoryChange(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* BatchMarkModal — 批量智能标记弹窗
|
||||
* ============================================================ */
|
||||
export type SmartViewType = "recommended" | "caution" | "high_risk"
|
||||
|
||||
export interface BatchMarkModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
smartView: SmartViewType
|
||||
onSmartViewChange: (value: SmartViewType) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
smartView,
|
||||
onSmartViewChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">将选中的 {selectedCount} 个素材标记为:</p>
|
||||
<Radio.Group
|
||||
value={smartView}
|
||||
onChange={(e) => onSmartViewChange(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchMarkModal
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
import {
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
/* ============================================================
|
||||
* BatchOperationBar — 批量操作栏
|
||||
* ============================================================ */
|
||||
export interface BatchOperationBarProps {
|
||||
selectedCount: number
|
||||
onDeselectAll: () => void
|
||||
onTagClick: () => void
|
||||
onClassifyClick: () => void
|
||||
onMarkClick: () => void
|
||||
onBatchDelete: () => void
|
||||
}
|
||||
|
||||
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
|
||||
selectedCount,
|
||||
onDeselectAll,
|
||||
onTagClick,
|
||||
onClassifyClick,
|
||||
onMarkClick,
|
||||
onBatchDelete,
|
||||
}) => (
|
||||
<div className="xx-assets-batch-bar">
|
||||
<span className="xx-assets-batch-count">已选 {selectedCount} 项</span>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
|
||||
打标签
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
|
||||
改分类
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedCount} 个素材?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default BatchOperationBar
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
/* ============================================================
|
||||
* BatchTagModal — 批量打标签弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchTagModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
tags: string[]
|
||||
onTagInputChange: (value: string) => void
|
||||
onTagInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
tagInput: string
|
||||
tagMode: "add" | "replace"
|
||||
onTagModeChange: (mode: "add" | "replace") => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchTagModal: React.FC<BatchTagModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
tags,
|
||||
onTagInputChange,
|
||||
onTagInputKeyDown,
|
||||
onRemoveTag,
|
||||
tagInput,
|
||||
tagMode,
|
||||
onTagModeChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={onTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && tags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchTagModal
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { AssetKind } from "@/pages/assets/types"
|
||||
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* CreateLibraryModal — 新建视频库弹窗
|
||||
* ============================================================ */
|
||||
export interface CreateLibraryModalProps {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
name: string
|
||||
onNameChange: (value: string) => void
|
||||
kind: AssetKind
|
||||
onKindChange: (value: AssetKind) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onOk,
|
||||
name,
|
||||
onNameChange,
|
||||
kind,
|
||||
onKindChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title="新建视频库"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<div className="xx-asset-form-body">
|
||||
<div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入视频库名称"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="xx-asset-form-label">类型</div>
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(v) => onKindChange(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={LIBRARY_KIND_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default CreateLibraryModal
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { LibraryItem } from "@/pages/assets/types"
|
||||
import { kindLabel } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
|
||||
/* ============================================================
|
||||
* LibrarySidebar — 左侧素材库列表
|
||||
* ============================================================ */
|
||||
export interface LibrarySidebarProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
libraries,
|
||||
activeLibId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onCreateClick,
|
||||
}) => (
|
||||
<div className="xx-asset-library-list">
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
key={lib.id}
|
||||
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
onClick={() => onSelect(lib.id)}
|
||||
>
|
||||
<div className="xx-asset-library-header">
|
||||
<h4>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
title={`确定删除视频库 "${lib.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete(lib.id)
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-asset-library-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除视频库"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<span>
|
||||
{kindLabel(lib.kind)} · {lib.count} 个素材
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建视频库 */}
|
||||
<div className="xx-asset-library-add" onClick={onCreateClick}>
|
||||
<PlusOutlined />
|
||||
新建视频库
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default LibrarySidebar
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* PlayModal — 视频/音频播放弹窗
|
||||
* ============================================================ */
|
||||
export interface PlayModalProps {
|
||||
open: boolean
|
||||
asset: AssetItem | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<AntModal
|
||||
title={asset?.name ?? "播放"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{asset?.fileUrl ? (
|
||||
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
|
||||
) : (
|
||||
<div className="xx-asset-empty-fallback">
|
||||
<p>暂无可播放的文件地址</p>
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
|
||||
/* ============================================================
|
||||
* ResultDrawer — 操作结果抽屉
|
||||
* ============================================================ */
|
||||
export interface ResultDrawerProps {
|
||||
open: boolean
|
||||
title: string
|
||||
result: BatchOperationResult | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
{result && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">总计 {result.total} 个</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {result.success_count} 个</span>
|
||||
</div>
|
||||
{result.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {result.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(result?.succeeded?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.succeeded?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result?.failed?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.failed?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
export default ResultDrawer
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
|
||||
* ============================================================ */
|
||||
export interface UploadProgressModalProps {
|
||||
open: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
|
||||
<AntModal
|
||||
open={open}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{progress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default UploadProgressModal
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 诊断状态 ── */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 批量打标签表单 ── */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* ── 批量改分类表单 ── */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* ── 批量智能标记表单 ── */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
invalidateAssets()
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[invalidateAssets, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
setResultDrawerOpen(true)
|
||||
if (clearSelection) setSelectedIds(new Set())
|
||||
},
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showOperationResult(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, showOperationResult])
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
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"] })
|
||||
showOperationResult(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, showOperationResult])
|
||||
|
||||
/* ── 标签输入处理 ── */
|
||||
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],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
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"] })
|
||||
showOperationResult(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, showOperationResult])
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
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"] })
|
||||
showOperationResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
setResultDrawerOpen(false)
|
||||
setOperationResult(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 诊断
|
||||
diagnosingId,
|
||||
handleDiagnose,
|
||||
// 单个操作
|
||||
handleSingleDelete,
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
resultDrawerOpen,
|
||||
operationResult,
|
||||
operationTitle,
|
||||
handleResultDrawerClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材选中态管理 Hook
|
||||
* 封装单选、全选、取消全选等选中逻辑
|
||||
*/
|
||||
interface UseAssetSelectionProps {
|
||||
filteredAssets: AssetItem[]
|
||||
}
|
||||
|
||||
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
|
||||
}, [filteredAssets])
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
toggleSelect,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
selectedCount: selectedIds.size,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
|
||||
|
||||
/**
|
||||
* 素材上传 Hook
|
||||
* 封装上传状态、进度管理和上传逻辑
|
||||
*/
|
||||
interface UseAssetUploadProps {
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, queryClient],
|
||||
)
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
getAssetLibraries,
|
||||
getAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
} from "@/api/assets"
|
||||
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材库数据 Hook
|
||||
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
|
||||
*/
|
||||
export function useAssetsData() {
|
||||
/* ── 视频库列表查询 ── */
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const libraries: LibraryItem[] = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiLibraries) ? apiLibraries : [])
|
||||
.map(mapLibrary)
|
||||
.filter((lib) => lib.kind === "video"),
|
||||
[apiLibraries],
|
||||
)
|
||||
|
||||
/* ── 当前选中的视频库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("")
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
const effectiveLibId = activeLibId || libraries[0]?.id || ""
|
||||
|
||||
/* ── 当前库的素材列表查询 ── */
|
||||
const {
|
||||
data: apiAssets = { items: [], total: 0 },
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const assets: AssetItem[] = useMemo(
|
||||
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
/* ── 筛选状态 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
|
||||
/* ── 筛选后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
|
||||
/* 按素材类型过滤 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType)
|
||||
}
|
||||
|
||||
/* 按时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((a) => {
|
||||
const d = new Date(a.createdAt)
|
||||
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (filterTime === "today") return diffDays < 1
|
||||
if (filterTime === "week") return diffDays < 7
|
||||
if (filterTime === "month") return diffDays < 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((a) => a.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [assets, filterType, filterTime, searchText])
|
||||
|
||||
return {
|
||||
// 视频库
|
||||
libraries,
|
||||
libLoading,
|
||||
activeLibId,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
// 素材列表
|
||||
assets,
|
||||
assetsLoading,
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filteredAssets,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
|
||||
import type { AssetKind, LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 视频库管理 Hook
|
||||
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
|
||||
*/
|
||||
interface UseLibraryManagementProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
setActiveLibId: (id: string) => void
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useLibraryManagement({
|
||||
libraries,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
}: UseLibraryManagementProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 状态 ── */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [newLibName, setNewLibName] = useState("")
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库创建成功")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteLibMutation = useMutation({
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 新建视频库 ── */
|
||||
const handleCreateLibrary = useCallback(async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入视频库名称")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync({
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
})
|
||||
setActiveLibId(newLib.id)
|
||||
setCreateModalOpen(false)
|
||||
setNewLibName("")
|
||||
setNewLibKind("video")
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
|
||||
|
||||
/* ── 删除视频库 ── */
|
||||
const handleDeleteLibrary = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id)
|
||||
if (effectiveLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id)
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id)
|
||||
else setActiveLibId("")
|
||||
}
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
},
|
||||
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
|
||||
)
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
// 表单状态
|
||||
newLibName,
|
||||
setNewLibName,
|
||||
newLibKind,
|
||||
setNewLibKind,
|
||||
// Mutations
|
||||
isCreating: createLibMutation.isPending,
|
||||
isDeleting: deleteLibMutation.isPending,
|
||||
// Handlers
|
||||
handleCreateLibrary,
|
||||
handleDeleteLibrary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
|
||||
import type { AssetKind } from "../types"
|
||||
|
||||
/** 根据素材类型返回对应图标 */
|
||||
export const kindIcon = (kind: AssetKind): React.ReactNode => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />
|
||||
case "image":
|
||||
return <PictureOutlined />
|
||||
case "voice":
|
||||
return <AudioOutlined />
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* AssetLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* assets 目录下所有文件的改动(包括子组件和工具函数)
|
||||
* assets 目录下所有文件的改动(包括子组件、Hook 和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/assets/AssetLibrary"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/assets/components/AssetCard"
|
||||
import "@/pages/assets/components/AssetFilterBar"
|
||||
import "@/pages/assets/components/AssetSkeleton"
|
||||
import "@/pages/assets/components/BatchClassifyModal"
|
||||
import "@/pages/assets/components/BatchMarkModal"
|
||||
import "@/pages/assets/components/BatchOperationBar"
|
||||
import "@/pages/assets/components/BatchTagModal"
|
||||
import "@/pages/assets/components/CreateLibraryModal"
|
||||
import "@/pages/assets/components/LibrarySidebar"
|
||||
import "@/pages/assets/components/PlayModal"
|
||||
import "@/pages/assets/components/ResultDrawer"
|
||||
import "@/pages/assets/components/UploadProgressModal"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/assets/types"
|
||||
import "@/pages/assets/constants"
|
||||
@@ -16,6 +30,13 @@ import "@/pages/assets/constants"
|
||||
import "@/pages/assets/utils/format"
|
||||
import "@/pages/assets/utils/asset"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/assets/hooks/useAssetsData"
|
||||
import "@/pages/assets/hooks/useLibraryManagement"
|
||||
import "@/pages/assets/hooks/useAssetUpload"
|
||||
import "@/pages/assets/hooks/useAssetSelection"
|
||||
import "@/pages/assets/hooks/useAssetOperations"
|
||||
|
||||
describe("AssetLibrary module smoke test", () => {
|
||||
it("should load all asset modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
|
||||
+148
-89
@@ -1,105 +1,164 @@
|
||||
"""BGM 配置工具函数单元测试."""
|
||||
"""BGM工具函数单元测试。"""
|
||||
|
||||
import pytest
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
from domain.bgm_utils import merge_bgm_config
|
||||
|
||||
|
||||
class TestMergeBgmConfig:
|
||||
"""merge_bgm_config 测试"""
|
||||
class TestMergeBgmConfigBothEmpty:
|
||||
"""两边都为空的情况。"""
|
||||
|
||||
def test_user_bgm_empty_returns_template_copy(self):
|
||||
"""用户配置为空时,返回模板配置的拷贝"""
|
||||
template = {"enabled": True, "volume": 0.5, "asset_id": "tpl_123"}
|
||||
result = merge_bgm_config(template, {})
|
||||
assert result == template
|
||||
assert result is not template
|
||||
|
||||
def test_user_bgm_none_returns_template_copy(self):
|
||||
"""用户配置为 None 时,返回模板配置的拷贝"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(template, None) # type: ignore
|
||||
assert result == template
|
||||
|
||||
def test_template_bgm_empty_returns_user_copy(self):
|
||||
"""模板配置为空时,返回用户配置的拷贝"""
|
||||
user = {"enabled": False, "volume": 0.8, "asset_id": "user_456"}
|
||||
result = merge_bgm_config({}, user)
|
||||
assert result == user
|
||||
assert result is not user
|
||||
|
||||
def test_template_bgm_none_returns_user_copy(self):
|
||||
"""模板配置为 None 时,返回用户配置的拷贝"""
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config(None, user) # type: ignore
|
||||
assert result == user
|
||||
|
||||
def test_user_fields_override_template(self):
|
||||
"""用户显式指定的字段覆盖模板对应字段"""
|
||||
template = {
|
||||
"enabled": True,
|
||||
"volume": 0.5,
|
||||
"asset_id": "tpl_123",
|
||||
"fade_in": 1.0,
|
||||
}
|
||||
user = {
|
||||
"volume": 0.8,
|
||||
"asset_id": "user_456",
|
||||
}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.8
|
||||
assert result["asset_id"] == "user_456"
|
||||
assert result["fade_in"] == 1.0 # 模板值保留
|
||||
|
||||
def test_enabled_not_in_user_preserves_template_enabled(self):
|
||||
"""enabled 特殊处理:用户没传 enabled 时保留模板的 enabled 值"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8} # 没传 enabled
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True # 保留模板的
|
||||
assert result["volume"] == 0.8 # 用户指定的覆盖
|
||||
|
||||
def test_enabled_in_user_overrides_template(self):
|
||||
"""用户传了 enabled 时覆盖模板的 enabled"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_user_adds_new_fields(self):
|
||||
"""用户配置中的新字段会被添加到结果中"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"sidechain_enabled": True, "sidechain_ratio": 0.6}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.5
|
||||
assert result["sidechain_enabled"] is True
|
||||
assert result["sidechain_ratio"] == 0.6
|
||||
|
||||
def test_both_empty_returns_empty_dict(self):
|
||||
"""两者都为空时返回空字典"""
|
||||
def test_both_empty(self):
|
||||
result = merge_bgm_config({}, {})
|
||||
assert result == {}
|
||||
# 确保返回的是新字典,不是同一个引用
|
||||
assert result is not {}
|
||||
|
||||
def test_nested_dict_shallow_merge(self):
|
||||
"""嵌套字典是浅合并(当前设计)"""
|
||||
template = {"enabled": True, "config": {"eq": True, "compression": False}}
|
||||
user = {"config": {"compression": True, "reverb": 0.5}}
|
||||
def test_user_none_returns_template_copy(self):
|
||||
"""用户传 None 视为空配置,返回模板副本。"""
|
||||
result = merge_bgm_config({}, None)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestMergeBgmConfigOnlyTemplate:
|
||||
"""只有模板配置。"""
|
||||
|
||||
def test_only_template_returns_copy(self):
|
||||
template = {"enabled": True, "volume": 0.5, "track": "default.mp3"}
|
||||
result = merge_bgm_config(template, {})
|
||||
assert result == template
|
||||
# 确保是副本,不是同一引用
|
||||
result["volume"] = 0.9
|
||||
assert template["volume"] == 0.5
|
||||
|
||||
def test_only_template_with_none_user(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(template, None)
|
||||
assert result == template
|
||||
|
||||
|
||||
class TestMergeBgmConfigOnlyUser:
|
||||
"""只有用户配置。"""
|
||||
|
||||
def test_only_user_returns_copy(self):
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config({}, user)
|
||||
assert result == user
|
||||
# 确保是副本
|
||||
result["volume"] = 0.1
|
||||
assert user["volume"] == 0.8
|
||||
|
||||
def test_only_user_with_none_template(self):
|
||||
user = {"enabled": False}
|
||||
result = merge_bgm_config(None, user)
|
||||
assert result == user
|
||||
|
||||
|
||||
class TestMergeBgmConfigBasicOverride:
|
||||
"""用户配置覆盖模板配置。"""
|
||||
|
||||
def test_volume_override(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
# 浅合并:整个 config 被用户值覆盖
|
||||
assert result["config"] == {"compression": True, "reverb": 0.5}
|
||||
assert result["volume"] == 0.8
|
||||
assert result["enabled"] is True # 用户没传,保留模板
|
||||
|
||||
def test_does_not_mutate_template(self):
|
||||
"""不修改原始模板配置"""
|
||||
def test_track_override(self):
|
||||
template = {"track": "default.mp3", "volume": 0.5}
|
||||
user = {"track": "custom.mp3"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["track"] == "custom.mp3"
|
||||
assert result["volume"] == 0.5
|
||||
|
||||
def test_multiple_fields_override(self):
|
||||
template = {"enabled": True, "volume": 0.5, "track": "a.mp3", "fade_in": 2}
|
||||
user = {"volume": 0.9, "track": "b.mp3"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.9
|
||||
assert result["track"] == "b.mp3"
|
||||
assert result["fade_in"] == 2
|
||||
assert result["enabled"] is True
|
||||
|
||||
|
||||
class TestMergeBgmConfigEnabledSpecialHandling:
|
||||
"""enabled 字段的特殊处理:用户没传就保留模板的。"""
|
||||
|
||||
def test_user_does_not_pass_enabled_keeps_template_true(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_user_does_not_pass_enabled_keeps_template_false(self):
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_user_explicitly_sets_enabled_true(self):
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"enabled": True}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_user_explicitly_sets_enabled_false(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 = {"track": "a.mp3"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert "enabled" not in result
|
||||
|
||||
def test_template_no_enabled_user_has_enabled(self):
|
||||
template = {"volume": 0.5}
|
||||
user = {"enabled": True}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_user_sets_enabled_none_explicitly(self):
|
||||
"""用户显式传 None 也视为传了,会覆盖模板。"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"enabled": None}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is None
|
||||
|
||||
|
||||
class TestMergeBgmConfigNewFields:
|
||||
"""用户配置新增模板没有的字段。"""
|
||||
|
||||
def test_user_adds_new_field(self):
|
||||
template = {"volume": 0.5}
|
||||
user = {"fade_out": 3}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_out"] == 3
|
||||
|
||||
def test_user_adds_multiple_new_fields(self):
|
||||
template = {"enabled": True}
|
||||
user = {"volume": 0.7, "track": "x.mp3", "loop": True}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.7
|
||||
assert result["track"] == "x.mp3"
|
||||
assert result["loop"] is True
|
||||
|
||||
|
||||
class TestMergeBgmConfigImmutableInput:
|
||||
"""确保输入字典不被修改。"""
|
||||
|
||||
def test_template_not_modified(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
original = dict(template)
|
||||
merge_bgm_config(template, {"volume": 0.8})
|
||||
merge_bgm_config(template, {"volume": 0.9})
|
||||
assert template == original
|
||||
|
||||
def test_does_not_mutate_user(self):
|
||||
"""不修改原始用户配置"""
|
||||
user = {"volume": 0.8}
|
||||
def test_user_not_modified(self):
|
||||
user = {"enabled": False, "track": "x.mp3"}
|
||||
original = dict(user)
|
||||
merge_bgm_config({"enabled": True}, user)
|
||||
merge_bgm_config({"volume": 0.5}, user)
|
||||
assert user == original
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
"""BGM工具函数领域层单元测试."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
|
||||
|
||||
class TestMergeBgmConfig:
|
||||
"""merge_bgm_config 函数测试."""
|
||||
|
||||
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 == template
|
||||
# 确保是副本不是引用
|
||||
result["volume"] = 0.9
|
||||
assert template["volume"] == 0.5
|
||||
|
||||
def test_template_empty_returns_user_copy(self):
|
||||
"""模板配置为空,返回用户配置的拷贝."""
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config({}, user)
|
||||
assert result == user
|
||||
# 确保是副本
|
||||
result["volume"] = 0.1
|
||||
assert user["volume"] == 0.8
|
||||
|
||||
def test_user_overrides_template(self):
|
||||
"""用户配置覆盖模板配置."""
|
||||
template = {"enabled": True, "volume": 0.5, "track": "default"}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.8
|
||||
assert result["track"] == "default"
|
||||
|
||||
def test_enabled_special_handling_user_not_set(self):
|
||||
"""enabled 特殊处理:用户没传就保留模板的."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8} # 没传 enabled
|
||||
result = merge_bgm_config(template, user)
|
||||
# 用户没传 enabled,保留模板的 True
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_enabled_user_explicit_false(self):
|
||||
"""用户显式传 enabled=False,应该覆盖模板."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"enabled": False}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_enabled_user_explicit_true(self):
|
||||
"""用户显式传 enabled=True,覆盖模板的 False."""
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"enabled": True}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_full_override(self):
|
||||
"""用户完全覆盖模板."""
|
||||
template = {"enabled": True, "volume": 0.3, "track": "piano"}
|
||||
user = {"enabled": False, "volume": 0.9, "track": "guitar"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result == user
|
||||
|
||||
def test_partial_override_keep_rest(self):
|
||||
"""部分覆盖,其余保留模板值."""
|
||||
template = {
|
||||
"enabled": True,
|
||||
"volume": 0.5,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 1.0,
|
||||
"track": "default",
|
||||
}
|
||||
user = {"volume": 0.7, "fade_in": 2.0}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.7
|
||||
assert result["fade_in"] == 2.0
|
||||
assert result["fade_out"] == 1.0
|
||||
assert result["track"] == "default"
|
||||
assert result["enabled"] is True # 用户没传,保留模板
|
||||
|
||||
def test_user_none(self):
|
||||
"""user_bgm 为 None 的情况."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(template, None) # type: ignore
|
||||
assert result == template
|
||||
|
||||
def test_template_none(self):
|
||||
"""template_bgm 为 None 的情况."""
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config(None, user) # type: ignore
|
||||
assert result == user
|
||||
|
||||
def test_preserves_extra_fields(self):
|
||||
"""保留模板中的额外字段(用户没覆盖的)."""
|
||||
template = {"enabled": True, "volume": 0.5, "custom_field": "value"}
|
||||
user = {"volume": 0.6}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["custom_field"] == "value"
|
||||
|
||||
def test_user_adds_new_fields(self):
|
||||
"""用户可以添加模板中没有的新字段."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"loop": True, "start_time": 5.0}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.5
|
||||
assert result["loop"] is True
|
||||
assert result["start_time"] == 5.0
|
||||
|
||||
def test_nested_dict_behavior(self):
|
||||
"""嵌套字典的合并行为(简单替换,不深度合并)."""
|
||||
template = {"enabled": True, "effects": {"fade": True, "reverb": False}}
|
||||
user = {"effects": {"reverb": True}}
|
||||
result = merge_bgm_config(template, user)
|
||||
# 简单合并,用户的 effects 整体覆盖模板的
|
||||
assert result["effects"] == {"reverb": True}
|
||||
|
||||
def test_enabled_in_template_only(self):
|
||||
"""只有模板有 enabled,用户没有."""
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"volume": 0.7}
|
||||
result = merge_bgm_config(template, user)
|
||||
# 用户没传 enabled,保留模板的 False
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_both_have_enabled_false(self):
|
||||
"""两边都有 enabled 且都是 False."""
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"enabled": False}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_return_type_is_dict(self):
|
||||
"""返回类型是 dict."""
|
||||
result = merge_bgm_config({"a": 1}, {"b": 2})
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_does_not_mutate_template(self):
|
||||
"""不修改原始模板字典."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
template_copy = template.copy()
|
||||
user = {"volume": 0.9}
|
||||
merge_bgm_config(template, user)
|
||||
assert template == template_copy
|
||||
|
||||
def test_does_not_mutate_user(self):
|
||||
"""不修改原始用户字典."""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.9}
|
||||
user_copy = user.copy()
|
||||
merge_bgm_config(template, user)
|
||||
assert user == user_copy
|
||||
@@ -1,4 +1,6 @@
|
||||
"""classification 模块单元测试."""
|
||||
"""分类领域模型单元测试 - 纯逻辑部分。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from domain.classification import (
|
||||
@@ -6,99 +8,203 @@ from domain.classification import (
|
||||
AssetLibraryKind,
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
ClassificationStatus,
|
||||
IngestJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetLibraryKind:
|
||||
"""AssetLibraryKind 枚举测试."""
|
||||
"""素材库类型枚举。"""
|
||||
|
||||
def test_values(self):
|
||||
def test_video_value(self):
|
||||
assert AssetLibraryKind.VIDEO == "video"
|
||||
|
||||
def test_voice_value(self):
|
||||
assert AssetLibraryKind.VOICE == "voice"
|
||||
|
||||
def test_image_value(self):
|
||||
assert AssetLibraryKind.IMAGE == "image"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(AssetLibraryKind.VIDEO, str)
|
||||
assert AssetLibraryKind.VIDEO + "_test" == "video_test"
|
||||
|
||||
def test_members_count(self):
|
||||
assert len(AssetLibraryKind) == 3
|
||||
|
||||
|
||||
class TestIngestJobStatus:
|
||||
"""IngestJobStatus 枚举测试."""
|
||||
"""导入任务状态枚举。"""
|
||||
|
||||
def test_values(self):
|
||||
def test_pending_value(self):
|
||||
assert IngestJobStatus.PENDING == "pending"
|
||||
|
||||
def test_processing_value(self):
|
||||
assert IngestJobStatus.PROCESSING == "processing"
|
||||
|
||||
def test_completed_value(self):
|
||||
assert IngestJobStatus.COMPLETED == "completed"
|
||||
|
||||
def test_failed_value(self):
|
||||
assert IngestJobStatus.FAILED == "failed"
|
||||
|
||||
def test_members_count(self):
|
||||
assert len(IngestJobStatus) == 4
|
||||
|
||||
class TestClassificationJobStatus:
|
||||
"""ClassificationJobStatus 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert ClassificationJobStatus.PENDING == "pending"
|
||||
assert ClassificationJobStatus.PROCESSING == "processing"
|
||||
assert ClassificationJobStatus.COMPLETED == "completed"
|
||||
assert ClassificationJobStatus.FAILED == "failed"
|
||||
class TestClassificationJobStatusMissing:
|
||||
"""ClassificationJobStatus._missing_ 兼容性测试。"""
|
||||
|
||||
def test_standard_values(self):
|
||||
"""标准值正常解析。"""
|
||||
assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING
|
||||
assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING
|
||||
assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_done_maps_to_completed(self):
|
||||
"""历史值 done 映射到 COMPLETED。"""
|
||||
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_success_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_finished_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_complete_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_fail_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_error_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_err_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_process_maps_to_processing(self):
|
||||
assert ClassificationJobStatus("process") == ClassificationJobStatus.PROCESSING
|
||||
|
||||
def test_running_maps_to_processing(self):
|
||||
assert ClassificationJobStatus("running") == ClassificationJobStatus.PROCESSING
|
||||
|
||||
def test_run_maps_to_processing(self):
|
||||
assert ClassificationJobStatus("run") == ClassificationJobStatus.PROCESSING
|
||||
|
||||
def test_unknown_value_defaults_to_pending(self):
|
||||
"""未知值兜底为 PENDING。"""
|
||||
assert ClassificationJobStatus("unknown") == ClassificationJobStatus.PENDING
|
||||
assert ClassificationJobStatus("whatever") == ClassificationJobStatus.PENDING
|
||||
assert ClassificationJobStatus("") == ClassificationJobStatus.PENDING
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""大小写不敏感。"""
|
||||
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("FAIL") == ClassificationJobStatus.FAILED
|
||||
assert ClassificationJobStatus("Error") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_stripped(self):
|
||||
"""前后空白字符被忽略。"""
|
||||
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("\tfail\n") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_none_returns_pending(self):
|
||||
"""None 值也返回 PENDING(不报错)。"""
|
||||
assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING
|
||||
|
||||
def test_integer_returns_pending(self):
|
||||
"""非字符串值返回 PENDING。"""
|
||||
assert ClassificationJobStatus(123) == ClassificationJobStatus.PENDING
|
||||
|
||||
|
||||
class TestClassificationStatusAlias:
|
||||
"""向后兼容别名。"""
|
||||
|
||||
def test_alias_same_class(self):
|
||||
assert ClassificationStatus is ClassificationJobStatus
|
||||
|
||||
def test_alias_values_same(self):
|
||||
assert ClassificationStatus.PENDING == ClassificationJobStatus.PENDING
|
||||
assert ClassificationStatus.COMPLETED == ClassificationJobStatus.COMPLETED
|
||||
|
||||
|
||||
class TestAssetClassification:
|
||||
"""AssetClassification 枚举测试."""
|
||||
"""素材分类枚举。"""
|
||||
|
||||
def test_values(self):
|
||||
def test_scenic(self):
|
||||
assert AssetClassification.SCENIC == "scenic"
|
||||
|
||||
def test_product(self):
|
||||
assert AssetClassification.PRODUCT == "product"
|
||||
|
||||
def test_person(self):
|
||||
assert AssetClassification.PERSON == "person"
|
||||
|
||||
def test_animal(self):
|
||||
assert AssetClassification.ANIMAL == "animal"
|
||||
|
||||
def test_food(self):
|
||||
assert AssetClassification.FOOD == "food"
|
||||
|
||||
def test_tech(self):
|
||||
assert AssetClassification.TECH == "tech"
|
||||
|
||||
def test_sport(self):
|
||||
assert AssetClassification.SPORT == "sport"
|
||||
|
||||
def test_music(self):
|
||||
assert AssetClassification.MUSIC == "music"
|
||||
|
||||
def test_other(self):
|
||||
assert AssetClassification.OTHER == "other"
|
||||
|
||||
def test_members_count(self):
|
||||
assert len(AssetClassification) == 9
|
||||
|
||||
|
||||
class TestClassificationJobCreate:
|
||||
"""ClassificationJob.create 工厂方法测试."""
|
||||
"""ClassificationJob.create 工厂方法。"""
|
||||
|
||||
def test_create_with_valid_params(self):
|
||||
job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001")
|
||||
assert job.id
|
||||
assert len(job.id) == 32
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.asset_id == "asset_001"
|
||||
def test_create_basic(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
assert job.project_id == "proj-1"
|
||||
assert job.asset_id == "asset-1"
|
||||
assert job.status == ClassificationJobStatus.PENDING
|
||||
assert job.classification == ""
|
||||
assert job.confidence == 0.0
|
||||
assert job.error_message == ""
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
assert job.id # 自动生成的 ID 非空
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
job = ClassificationJob.create(
|
||||
project_id=" proj_002 ",
|
||||
asset_id=" asset_002 ",
|
||||
)
|
||||
assert job.project_id == "proj_002"
|
||||
assert job.asset_id == "asset_002"
|
||||
def test_create_strips_whitespace(self):
|
||||
job = ClassificationJob.create(project_id=" proj-1 ", asset_id="\tasset-1\n")
|
||||
assert job.project_id == "proj-1"
|
||||
assert job.asset_id == "asset-1"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
ClassificationJob.create(project_id="", asset_id="a")
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id="", asset_id="asset-1")
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
ClassificationJob.create(project_id=" ", asset_id="a")
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id=" ", asset_id="asset-1")
|
||||
|
||||
def test_create_empty_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
ClassificationJob.create(project_id="p", asset_id="")
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
ClassificationJob.create(project_id="proj-1", asset_id="")
|
||||
|
||||
def test_create_whitespace_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
ClassificationJob.create(project_id="p", asset_id=" ")
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
ClassificationJob.create(project_id="proj-1", asset_id=" \t ")
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
j1 = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
j2 = ClassificationJob.create(project_id="p", asset_id="b")
|
||||
assert j1.id != j2.id
|
||||
def test_create_generates_unique_ids(self):
|
||||
job1 = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
job2 = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job1.id != job2.id
|
||||
|
||||
def test_create_timestamps_are_utc(self):
|
||||
def test_create_id_is_hex(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.created_at.tzinfo is not None
|
||||
assert job.updated_at.tzinfo is not None
|
||||
@@ -137,3 +243,130 @@ class TestClassificationJobState:
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.confidence = 1.0
|
||||
assert job.confidence == 1.0
|
||||
|
||||
|
||||
class TestClassificationJobStatusMissing:
|
||||
"""ClassificationJobStatus._missing_ 兼容行为测试"""
|
||||
|
||||
def test_done_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_success_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_finished_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_complete_maps_to_completed(self):
|
||||
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_fail_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_error_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_err_maps_to_failed(self):
|
||||
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_unknown_maps_to_pending(self):
|
||||
assert ClassificationJobStatus("unknown_status") == ClassificationJobStatus.PENDING
|
||||
|
||||
def test_case_insensitive_mapping(self):
|
||||
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
|
||||
class TestClassificationJobExtended:
|
||||
"""ClassificationJob 深度补充测试"""
|
||||
|
||||
def test_id_is_hex(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
int(job.id, 16)
|
||||
|
||||
def test_empty_classification(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.classification == ""
|
||||
|
||||
def test_zero_confidence(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.confidence == 0.0
|
||||
|
||||
def test_high_confidence(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
job.confidence = 0.99
|
||||
assert job.confidence == pytest.approx(0.99)
|
||||
|
||||
def test_negative_confidence(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
job.confidence = -0.1
|
||||
assert job.confidence == pytest.approx(-0.1)
|
||||
|
||||
def test_confidence_over_one(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
job.confidence = 1.5
|
||||
assert job.confidence == pytest.approx(1.5)
|
||||
|
||||
def test_empty_error_message(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_long_error_message(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
long_msg = "error" * 100
|
||||
job.error_message = long_msg
|
||||
assert job.error_message == long_msg
|
||||
assert len(job.error_message) == 500
|
||||
|
||||
def test_status_with_string_assignment(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
job.status = "processing"
|
||||
assert job.status == ClassificationJobStatus.PROCESSING
|
||||
|
||||
|
||||
class TestAssetLibraryKindExtended:
|
||||
"""AssetLibraryKind 深度补充测试"""
|
||||
|
||||
def test_image_value(self):
|
||||
assert AssetLibraryKind.IMAGE == "image"
|
||||
|
||||
def test_all_three_kinds(self):
|
||||
assert len(AssetLibraryKind) == 3
|
||||
|
||||
def test_is_string_enum(self):
|
||||
assert isinstance(AssetLibraryKind.VIDEO, str)
|
||||
|
||||
def test_from_string(self):
|
||||
assert AssetLibraryKind("video") == AssetLibraryKind.VIDEO
|
||||
|
||||
|
||||
class TestIngestJobStatusExtended:
|
||||
"""IngestJobStatus 深度补充测试"""
|
||||
|
||||
def test_is_string_enum(self):
|
||||
assert isinstance(IngestJobStatus.PENDING, str)
|
||||
|
||||
def test_total_count(self):
|
||||
assert len(IngestJobStatus) == 4
|
||||
|
||||
def test_from_string(self):
|
||||
assert IngestJobStatus("pending") == IngestJobStatus.PENDING
|
||||
|
||||
|
||||
class TestAssetClassificationExtended:
|
||||
"""AssetClassification 深度补充测试"""
|
||||
|
||||
def test_total_count(self):
|
||||
assert len(AssetClassification) == 9
|
||||
|
||||
def test_is_string_enum(self):
|
||||
assert isinstance(AssetClassification.SCENIC, str)
|
||||
|
||||
def test_from_string(self):
|
||||
assert AssetClassification("scenic") == AssetClassification.SCENIC
|
||||
|
||||
def test_other_category(self):
|
||||
assert AssetClassification.OTHER == "other"
|
||||
|
||||
Executable
+539
@@ -0,0 +1,539 @@
|
||||
"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法.
|
||||
|
||||
通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.cover_generator import (
|
||||
CoverGenerator,
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_QUALITY,
|
||||
DEFAULT_COVER_TIME,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
SMART_COVER_FRAME_COUNT,
|
||||
)
|
||||
|
||||
|
||||
class TestCoverGeneratorConstants:
|
||||
"""常量默认值测试."""
|
||||
|
||||
def test_default_cover_time(self):
|
||||
"""默认抽帧时间为 1.0 秒."""
|
||||
assert DEFAULT_COVER_TIME == 1.0
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""默认封面尺寸 1080x1920 (竖屏)."""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
|
||||
def test_default_quality(self):
|
||||
"""默认质量为 5 (JPEG q:v, 越小越好)."""
|
||||
assert DEFAULT_COVER_QUALITY == 5
|
||||
|
||||
def test_smart_cover_frame_count(self):
|
||||
"""智能封面默认抽 3 帧."""
|
||||
assert SMART_COVER_FRAME_COUNT == 3
|
||||
|
||||
|
||||
class TestExtractFrameCommand:
|
||||
"""extract_frame 命令构建测试."""
|
||||
|
||||
def _probe_video_info_mock(self, duration=10.0):
|
||||
"""创建 probe_video_info 的 mock."""
|
||||
return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0}
|
||||
|
||||
def test_default_params_command(self, tmp_path):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
# 让 output_path 在 run_ffmpeg 后存在
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert result == Path(output_file)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
|
||||
assert "-y" in cmd
|
||||
assert "-vframes" in cmd
|
||||
assert cmd[cmd.index("-vframes") + 1] == "1"
|
||||
assert "-f" in cmd
|
||||
assert "mjpeg" in cmd[cmd.index("-f") + 1]
|
||||
|
||||
# 时间点
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001)
|
||||
|
||||
# 输入文件
|
||||
i_idx = cmd.index("-i")
|
||||
assert cmd[i_idx + 1] == str(video_file)
|
||||
|
||||
# 输出文件
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop 滤镜
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
assert "force_original_aspect_ratio=increase" in vf_value
|
||||
|
||||
def test_custom_time(self, tmp_path):
|
||||
"""自定义抽帧时间点."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=30.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001)
|
||||
|
||||
def test_custom_dimensions(self, tmp_path):
|
||||
"""自定义输出尺寸."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=1920:1080:" in vf_value
|
||||
assert "crop=1920:1080" in vf_value
|
||||
|
||||
def test_custom_quality(self, tmp_path):
|
||||
"""自定义 JPEG 质量."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
q_idx = cmd.index("-q:v")
|
||||
assert cmd[q_idx + 1] == "2"
|
||||
|
||||
def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""抽帧时间超过视频时长时,钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=5.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# 钳制到 duration/2 = 2.5
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001)
|
||||
|
||||
def test_negative_time_clamps_to_zero(self, tmp_path):
|
||||
"""负时间钳制到 0."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
def test_time_equals_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""时间点等于时长时钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_video(self, tmp_path):
|
||||
"""视频时长为 0 时的行为(不钳制,用原始时间)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=0.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001)
|
||||
|
||||
def test_video_not_found_raises(self, tmp_path):
|
||||
"""视频文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file))
|
||||
|
||||
def test_output_creates_parent_dir(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
out_dir = tmp_path / "deep" / "nested"
|
||||
output_file = out_dir / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_ffmpeg_failure_propagates(self, tmp_path):
|
||||
"""FFmpeg 失败时异常向上传递."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch(
|
||||
"video_processing.cover_generator.run_ffmpeg",
|
||||
side_effect=RuntimeError("FFmpeg error"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg error"):
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
|
||||
class TestSmartCoverTimePoints:
|
||||
"""智能封面时间点计算测试."""
|
||||
|
||||
def test_single_frame_falls_back_to_default(self, tmp_path):
|
||||
"""只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 20.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
# frame_count=1 时退化为普通抽帧
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1)
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_falls_back(self, tmp_path):
|
||||
"""视频时长为 0 时退化为普通抽帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 0.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_three_frames_uniform_distribution(self, tmp_path):
|
||||
"""3 帧均匀分布在 5%~95% 区间."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
# 记录抽帧时间
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
# 在输出路径写文件
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
# 不同文件大小,让第三帧"最清晰"
|
||||
idx = len(call_times) - 1
|
||||
size = 1000 * (idx + 1) # 递增的文件大小
|
||||
Path(output_arg).write_bytes(b"x" * size)
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 3 帧:5%、50%、95%
|
||||
assert len(call_times) == 3
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5%
|
||||
assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50%
|
||||
assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95%
|
||||
|
||||
def test_five_frames_distribution(self, tmp_path):
|
||||
"""5 帧均匀分布."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = len(call_times) - 1
|
||||
Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1)))
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5)
|
||||
|
||||
assert len(call_times) == 5
|
||||
# step = (95-5) / (5-1) = 22.5
|
||||
# times: 5, 27.5, 50, 72.5, 95
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1)
|
||||
assert call_times[1] == pytest.approx(27.5, abs=0.1)
|
||||
assert call_times[2] == pytest.approx(50.0, abs=0.1)
|
||||
assert call_times[3] == pytest.approx(72.5, abs=0.1)
|
||||
assert call_times[4] == pytest.approx(95.0, abs=0.1)
|
||||
|
||||
def test_selects_largest_file_as_best(self, tmp_path):
|
||||
"""选择文件最大的帧作为最佳封面(清晰度近似)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
sizes = [5000, 15000, 8000] # 第二帧最大
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
call_idx = [0]
|
||||
|
||||
def fake_run(cmd):
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = call_idx[0]
|
||||
Path(output_arg).write_bytes(b"x" * sizes[idx])
|
||||
call_idx[0] += 1
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 第二帧(索引1)应该是最佳
|
||||
assert result == output_file
|
||||
# 输出文件大小应等于第二帧大小
|
||||
assert output_file.stat().st_size == 15000
|
||||
|
||||
|
||||
class TestProcessCustomCover:
|
||||
"""自定义封面处理测试."""
|
||||
|
||||
def test_custom_cover_resize_command(self, tmp_path):
|
||||
"""自定义封面调整尺寸命令正确."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file))
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
assert "-i" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == str(input_file)
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
|
||||
def test_custom_cover_not_found_raises(self, tmp_path):
|
||||
"""自定义封面文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file))
|
||||
|
||||
def test_custom_cover_custom_dimensions(self, tmp_path):
|
||||
"""自定义封面自定义输出尺寸."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=800:600:" in vf_value
|
||||
assert "crop=800:600" in vf_value
|
||||
@@ -8,12 +8,68 @@ from unittest.mock import MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# 模块级mock cv2(dedup模块import时需要)
|
||||
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
|
||||
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
|
||||
|
||||
# cv2(视频处理依赖,纯算法测试不需要)
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
# 模块级mock worker_app.db(dedup模块import时会触发数据库初始化,纯算法测试不需要)
|
||||
sys.modules["worker_app.db"] = MagicMock()
|
||||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||||
|
||||
# celery 及其子模块
|
||||
_mock_celery = MagicMock()
|
||||
_mock_celery.Task = MagicMock
|
||||
_mock_celery.Celery = MagicMock
|
||||
sys.modules["celery"] = _mock_celery
|
||||
|
||||
# sqlalchemy 作为包结构 mock
|
||||
_mock_sqla = MagicMock()
|
||||
_mock_sqla.__path__ = []
|
||||
_mock_sqla.__package__ = "sqlalchemy"
|
||||
_mock_sqla_orm = MagicMock()
|
||||
_mock_sqla_orm.__path__ = []
|
||||
_mock_sqla_orm.Session = MagicMock
|
||||
_mock_sqla_engine = MagicMock()
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
|
||||
sys.modules["sqlalchemy.engine"] = _mock_sqla_engine
|
||||
sys.modules["sqlalchemy.ext"] = MagicMock()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = MagicMock()
|
||||
|
||||
# worker_app 及其子模块(避免导入时触发数据库连接)
|
||||
_mock_worker_app = MagicMock()
|
||||
_mock_worker_app.__path__ = []
|
||||
_mock_worker_db = MagicMock()
|
||||
_mock_worker_db.SessionLocal = MagicMock()
|
||||
_mock_worker_celery = MagicMock()
|
||||
_mock_worker_celery.celery_app = MagicMock()
|
||||
_mock_worker_core = MagicMock()
|
||||
_mock_worker_core.__path__ = []
|
||||
_mock_worker_config = MagicMock()
|
||||
_mock_worker_config.get_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["worker_app"] = _mock_worker_app
|
||||
sys.modules["worker_app.db"] = _mock_worker_db
|
||||
sys.modules["worker_app.celery_app"] = _mock_worker_celery
|
||||
sys.modules["worker_app.core"] = _mock_worker_core
|
||||
sys.modules["worker_app.core.config"] = _mock_worker_config
|
||||
|
||||
# packages.adapters.sqlalchemy_impl(整个包mock掉)
|
||||
_mock_sqla_impl = MagicMock()
|
||||
_mock_sqla_impl.__path__ = []
|
||||
sys.modules["packages.adapters.sqlalchemy_impl"] = _mock_sqla_impl
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = MagicMock()
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.schema_guard"] = MagicMock()
|
||||
|
||||
# packages.shared
|
||||
_mock_packages_shared = MagicMock()
|
||||
_mock_packages_shared.__path__ = []
|
||||
_mock_shared_config = MagicMock()
|
||||
_mock_shared_config.get_shared_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["packages.shared"] = _mock_packages_shared
|
||||
sys.modules["packages.shared.config"] = _mock_shared_config
|
||||
sys.modules["packages.shared.storage"] = MagicMock()
|
||||
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""领域层通用异常单元测试."""
|
||||
"""
|
||||
领域层异常类单元测试
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -14,118 +16,99 @@ class TestDomainError:
|
||||
"""DomainError 基类测试"""
|
||||
|
||||
def test_is_exception(self):
|
||||
"""DomainError 是 Exception 的子类"""
|
||||
assert issubclass(DomainError, Exception)
|
||||
|
||||
def test_can_raise_and_catch(self):
|
||||
"""可以抛出和捕获"""
|
||||
def test_raise_and_catch(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise DomainError("something went wrong")
|
||||
raise DomainError("test error")
|
||||
|
||||
def test_message(self):
|
||||
"""异常消息正确"""
|
||||
err = DomainError("test message")
|
||||
assert str(err) == "test message"
|
||||
def test_error_message(self):
|
||||
err = DomainError("something went wrong")
|
||||
assert str(err) == "something went wrong"
|
||||
|
||||
def test_empty_message(self):
|
||||
err = DomainError("")
|
||||
assert str(err) == ""
|
||||
|
||||
|
||||
class TestNotFoundError:
|
||||
"""NotFoundError 测试"""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
"""NotFoundError 继承自 DomainError"""
|
||||
assert issubclass(NotFoundError, DomainError)
|
||||
|
||||
def test_can_raise_as_domain_error(self):
|
||||
"""可以作为 DomainError 捕获"""
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise NotFoundError("resource not found")
|
||||
|
||||
def test_default_message(self):
|
||||
"""无参构造"""
|
||||
err = NotFoundError()
|
||||
assert isinstance(err, NotFoundError)
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(NotFoundError):
|
||||
raise NotFoundError("not found")
|
||||
|
||||
def test_custom_message(self):
|
||||
"""自定义消息"""
|
||||
def test_error_message(self):
|
||||
err = NotFoundError("user 123 not found")
|
||||
assert str(err) == "user 123 not found"
|
||||
assert "user 123 not found" in str(err)
|
||||
|
||||
|
||||
class TestValidationError:
|
||||
"""ValidationError 测试"""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
"""ValidationError 继承自 DomainError"""
|
||||
assert issubclass(ValidationError, DomainError)
|
||||
|
||||
def test_can_raise_as_domain_error(self):
|
||||
"""可以作为 DomainError 捕获"""
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise ValidationError("invalid input")
|
||||
|
||||
def test_custom_message(self):
|
||||
"""自定义消息"""
|
||||
err = ValidationError("duration must be positive")
|
||||
assert str(err) == "duration must be positive"
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(ValidationError):
|
||||
raise ValidationError("validation failed")
|
||||
|
||||
def test_error_message(self):
|
||||
err = ValidationError("name cannot be empty")
|
||||
assert "name cannot be empty" in str(err)
|
||||
|
||||
|
||||
class TestQuotaExceededError:
|
||||
"""QuotaExceededError 测试"""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
"""QuotaExceededError 继承自 DomainError"""
|
||||
assert issubclass(QuotaExceededError, DomainError)
|
||||
|
||||
def test_can_raise_as_domain_error(self):
|
||||
"""可以作为 DomainError 捕获"""
|
||||
with pytest.raises(DomainError):
|
||||
raise QuotaExceededError("storage", 1024.0, 2048.0)
|
||||
|
||||
def test_stores_dimension_limit_used(self):
|
||||
"""保存 dimension、limit、used 属性"""
|
||||
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
|
||||
assert err.dimension == "storage_mb"
|
||||
def test_constructor_sets_attributes(self):
|
||||
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
|
||||
assert err.dimension == "storage"
|
||||
assert err.limit == 1024.0
|
||||
assert err.used == 1500.0
|
||||
assert err.used == 2048.0
|
||||
|
||||
def test_error_message_format(self):
|
||||
"""异常消息格式正确"""
|
||||
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
|
||||
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
|
||||
msg = str(err)
|
||||
assert "storage_mb" in msg
|
||||
assert "1500.0" in msg
|
||||
assert "storage" in msg
|
||||
assert "2048.0" in msg
|
||||
assert "1024.0" in msg
|
||||
assert "Quota exceeded" in msg
|
||||
|
||||
def test_integer_values(self):
|
||||
"""整数值也能正常工作"""
|
||||
err = QuotaExceededError("projects", 10, 15)
|
||||
assert err.dimension == "projects"
|
||||
assert err.limit == 10
|
||||
assert err.used == 15
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise QuotaExceededError("projects", 10, 15)
|
||||
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(QuotaExceededError):
|
||||
raise QuotaExceededError("render", 5, 10)
|
||||
|
||||
def test_zero_limit(self):
|
||||
"""限制为 0 时也能正常工作"""
|
||||
err = QuotaExceededError("custom_templates", 0, 1)
|
||||
assert err.limit == 0
|
||||
assert err.used == 1
|
||||
err = QuotaExceededError(dimension="test", limit=0.0, used=1.0)
|
||||
assert err.limit == 0.0
|
||||
assert err.used == 1.0
|
||||
|
||||
def test_negative_values(self):
|
||||
"""负数也能存(领域层不做额外校验)"""
|
||||
err = QuotaExceededError(dimension="test", limit=-5.0, used=-3.0)
|
||||
assert err.limit == -5.0
|
||||
assert err.used == -3.0
|
||||
|
||||
class TestExceptionHierarchy:
|
||||
"""异常继承关系测试"""
|
||||
|
||||
def test_all_are_domain_errors(self):
|
||||
"""所有异常都可以作为 DomainError 捕获"""
|
||||
errors = [
|
||||
NotFoundError(),
|
||||
ValidationError("bad"),
|
||||
QuotaExceededError("x", 10.0, 20.0),
|
||||
]
|
||||
for err in errors:
|
||||
assert isinstance(err, DomainError)
|
||||
|
||||
def test_distinct_types(self):
|
||||
"""不同异常类型可以区分"""
|
||||
assert not issubclass(NotFoundError, ValidationError)
|
||||
assert not issubclass(ValidationError, QuotaExceededError)
|
||||
assert not issubclass(NotFoundError, QuotaExceededError)
|
||||
def test_large_values(self):
|
||||
err = QuotaExceededError(dimension="storage", limit=1e9, used=1.5e9)
|
||||
assert err.limit == 1e9
|
||||
assert err.used == 1.5e9
|
||||
|
||||
@@ -152,3 +152,103 @@ class TestEditTemplateBumpVersion:
|
||||
old_updated = template.updated_at
|
||||
template.bump_version()
|
||||
assert template.updated_at > old_updated or template.updated_at == old_updated
|
||||
|
||||
|
||||
class TestEditTemplateExtended:
|
||||
"""EditTemplate 深度补充测试"""
|
||||
|
||||
def test_id_is_hex(self):
|
||||
t = EditTemplate.create(name="test")
|
||||
int(t.id, 16)
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
t1 = EditTemplate.create(name="test1")
|
||||
t2 = EditTemplate.create(name="test2")
|
||||
assert t1.id != t2.id
|
||||
|
||||
def test_empty_description(self):
|
||||
t = EditTemplate.create(name="test", description="")
|
||||
assert t.description == ""
|
||||
|
||||
def test_long_description(self):
|
||||
desc = "描述" * 200
|
||||
t = EditTemplate.create(name="test", description=desc)
|
||||
assert t.description == desc
|
||||
assert len(t.description) == 400
|
||||
|
||||
def test_unicode_name(self):
|
||||
t = EditTemplate.create(name="🎬 口播 Vlog 模板")
|
||||
assert "🎬" in t.name
|
||||
assert "口播" in t.name
|
||||
|
||||
def test_special_characters_name(self):
|
||||
special = "模!@#$%板"
|
||||
t = EditTemplate.create(name=special)
|
||||
assert t.name == special
|
||||
|
||||
def test_long_name(self):
|
||||
long_name = "模板名称" * 50
|
||||
t = EditTemplate.create(name=long_name)
|
||||
assert t.name == long_name
|
||||
assert len(t.name) == 200
|
||||
|
||||
def test_config_independence(self):
|
||||
t1 = EditTemplate.create(name="test1")
|
||||
t2 = EditTemplate.create(name="test2")
|
||||
t1.config["key"] = "val"
|
||||
assert "key" not in t2.config
|
||||
|
||||
def test_sort_weight_negative(self):
|
||||
t = EditTemplate.create(name="test", sort_weight=-100)
|
||||
assert t.sort_weight == -100
|
||||
|
||||
def test_sort_weight_large(self):
|
||||
t = EditTemplate.create(name="test", sort_weight=99999)
|
||||
assert t.sort_weight == 99999
|
||||
|
||||
def test_sort_weight_zero(self):
|
||||
t = EditTemplate.create(name="test", sort_weight=0)
|
||||
assert t.sort_weight == 0
|
||||
|
||||
def test_preview_url_empty(self):
|
||||
t = EditTemplate.create(name="test", preview_url="")
|
||||
assert t.preview_url == ""
|
||||
|
||||
def test_version_zero(self):
|
||||
t = EditTemplate.create(name="test", version=0)
|
||||
assert t.version == 0
|
||||
|
||||
def test_version_large(self):
|
||||
t = EditTemplate.create(name="test", version=999)
|
||||
assert t.version == 999
|
||||
|
||||
def test_status_is_active_property(self):
|
||||
t = EditTemplate.create(name="test", status=EditTemplateStatus.ACTIVE)
|
||||
assert t.is_active is True
|
||||
t.deactivate()
|
||||
assert t.is_active is False
|
||||
t.activate()
|
||||
assert t.is_active is True
|
||||
|
||||
def test_bump_version_from_zero(self):
|
||||
t = EditTemplate.create(name="test", version=0)
|
||||
t.bump_version()
|
||||
assert t.version == 1
|
||||
|
||||
def test_template_type_custom(self):
|
||||
t = EditTemplate.create(name="test", template_type="custom_type")
|
||||
assert t.template_type == "custom_type"
|
||||
|
||||
def test_template_type_strips_and_default(self):
|
||||
"""空格的 template_type 回退到 default"""
|
||||
t = EditTemplate.create(name="test", template_type=" ")
|
||||
assert t.template_type == "default"
|
||||
|
||||
def test_create_with_empty_editing_mode_defaults(self):
|
||||
"""空字符串 editing_mode 回退到 one_take"""
|
||||
t = EditTemplate.create(name="test", editing_mode="")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_preview_url_strips_whitespace(self):
|
||||
t = EditTemplate.create(name="test", preview_url=" https://example.com/v.mp4 ")
|
||||
assert t.preview_url == "https://example.com/v.mp4"
|
||||
|
||||
@@ -38,3 +38,57 @@ class TestEditingMode:
|
||||
modes = list(EditingMode)
|
||||
assert len(modes) == 4
|
||||
assert EditingMode.ONE_TAKE in modes
|
||||
|
||||
|
||||
class TestEditingModeExtended:
|
||||
"""EditingMode 深度补充测试"""
|
||||
|
||||
def test_from_string_value(self):
|
||||
"""可以从字符串值构造枚举"""
|
||||
assert EditingMode("one_take") == EditingMode.ONE_TAKE
|
||||
assert EditingMode("pip") == EditingMode.PIP
|
||||
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
|
||||
assert EditingMode("voice_pip") == EditingMode.VOICE_PIP
|
||||
|
||||
def test_invalid_string_raises(self):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
EditingMode("invalid_mode")
|
||||
|
||||
def test_string_concatenation(self):
|
||||
"""StrEnum 支持字符串拼接"""
|
||||
result = "mode_" + EditingMode.ONE_TAKE
|
||||
assert result == "mode_one_take"
|
||||
|
||||
def test_dict_key_usage(self):
|
||||
"""可以作为字典 key 使用"""
|
||||
mapping = {
|
||||
EditingMode.ONE_TAKE: "顺序拼接",
|
||||
EditingMode.PIP: "画中画",
|
||||
}
|
||||
assert mapping[EditingMode.ONE_TAKE] == "顺序拼接"
|
||||
assert mapping[EditingMode.PIP] == "画中画"
|
||||
assert len(mapping) == 2
|
||||
|
||||
def test_value_lowercase(self):
|
||||
"""所有枚举值都是小写字母+下划线"""
|
||||
for mode in EditingMode:
|
||||
assert mode.value == mode.value.lower()
|
||||
assert " " not in mode.value
|
||||
|
||||
def test_unique_values(self):
|
||||
"""所有枚举值唯一"""
|
||||
values = [m.value for m in EditingMode]
|
||||
assert len(values) == len(set(values))
|
||||
|
||||
def test_membership_test(self):
|
||||
assert EditingMode.ONE_TAKE in EditingMode
|
||||
assert "one_take" in [m.value for m in EditingMode]
|
||||
|
||||
def test_comparison_with_string(self):
|
||||
"""和字符串直接比较"""
|
||||
mode = EditingMode.VOICE_OVER
|
||||
assert mode == "voice_over"
|
||||
assert mode != "pip"
|
||||
assert "voice_over" == mode
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
"""领域层异常类单元测试."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.exceptions import (
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
QuotaExceededError,
|
||||
)
|
||||
|
||||
|
||||
class TestDomainError:
|
||||
"""领域异常基类测试."""
|
||||
|
||||
def test_is_exception(self):
|
||||
"""DomainError 继承自 Exception."""
|
||||
err = DomainError("test")
|
||||
assert isinstance(err, Exception)
|
||||
|
||||
def test_message(self):
|
||||
"""可以设置错误消息."""
|
||||
err = DomainError("something wrong")
|
||||
assert str(err) == "something wrong"
|
||||
|
||||
def test_empty_message(self):
|
||||
"""支持空消息."""
|
||||
err = DomainError()
|
||||
assert str(err) == ""
|
||||
|
||||
def test_can_be_raised(self):
|
||||
"""可以被 raise 和 catch."""
|
||||
with pytest.raises(DomainError) as exc_info:
|
||||
raise DomainError("oops")
|
||||
assert str(exc_info.value) == "oops"
|
||||
|
||||
|
||||
class TestNotFoundError:
|
||||
"""资源不存在异常测试."""
|
||||
|
||||
def test_inherits_domain_error(self):
|
||||
"""NotFoundError 继承自 DomainError."""
|
||||
err = NotFoundError("user not found")
|
||||
assert isinstance(err, DomainError)
|
||||
assert isinstance(err, Exception)
|
||||
|
||||
def test_message(self):
|
||||
"""错误消息正确."""
|
||||
err = NotFoundError("project 123 not found")
|
||||
assert str(err) == "project 123 not found"
|
||||
assert "123" in str(err)
|
||||
|
||||
def test_can_catch_as_domain_error(self):
|
||||
"""可以用 DomainError 捕获."""
|
||||
with pytest.raises(DomainError):
|
||||
raise NotFoundError("not found")
|
||||
|
||||
|
||||
class TestValidationError:
|
||||
"""校验失败异常测试."""
|
||||
|
||||
def test_inherits_domain_error(self):
|
||||
"""ValidationError 继承自 DomainError."""
|
||||
err = ValidationError("invalid input")
|
||||
assert isinstance(err, DomainError)
|
||||
|
||||
def test_message(self):
|
||||
"""错误消息正确."""
|
||||
msg = "name must not be empty"
|
||||
err = ValidationError(msg)
|
||||
assert str(err) == msg
|
||||
|
||||
def test_not_not_found(self):
|
||||
"""ValidationError 不是 NotFoundError."""
|
||||
err = ValidationError("bad")
|
||||
assert not isinstance(err, NotFoundError)
|
||||
|
||||
|
||||
class TestQuotaExceededError:
|
||||
"""配额超限异常测试."""
|
||||
|
||||
def test_inherits_domain_error(self):
|
||||
"""QuotaExceededError 继承自 DomainError."""
|
||||
err = QuotaExceededError("storage", 100.0, 150.0)
|
||||
assert isinstance(err, DomainError)
|
||||
|
||||
def test_dimension_attribute(self):
|
||||
"""保存 dimension 属性."""
|
||||
err = QuotaExceededError("storage", 100.0, 150.0)
|
||||
assert err.dimension == "storage"
|
||||
|
||||
def test_limit_attribute(self):
|
||||
"""保存 limit 属性."""
|
||||
err = QuotaExceededError("storage", 100.0, 150.0)
|
||||
assert err.limit == 100.0
|
||||
|
||||
def test_used_attribute(self):
|
||||
"""保存 used 属性."""
|
||||
err = QuotaExceededError("storage", 100.0, 150.0)
|
||||
assert err.used == 150.0
|
||||
|
||||
def test_message_format(self):
|
||||
"""错误消息格式正确."""
|
||||
err = QuotaExceededError("credits", 50.0, 75.0)
|
||||
msg = str(err)
|
||||
assert "credits" in msg
|
||||
assert "50" in msg
|
||||
assert "75" in msg
|
||||
assert "Quota exceeded" in msg
|
||||
|
||||
def test_zero_limit(self):
|
||||
"""limit 为 0 的情况."""
|
||||
err = QuotaExceededError("test", 0.0, 1.0)
|
||||
assert err.limit == 0.0
|
||||
assert err.used == 1.0
|
||||
assert "0" in str(err)
|
||||
|
||||
def test_equal_limit_and_used(self):
|
||||
"""used 刚好等于 limit(边界情况)."""
|
||||
err = QuotaExceededError("test", 100.0, 100.0)
|
||||
assert err.used == 100.0
|
||||
assert err.limit == 100.0
|
||||
|
||||
def test_integer_values(self):
|
||||
"""整数值也能正常工作."""
|
||||
err = QuotaExceededError("count", 10, 20)
|
||||
assert err.dimension == "count"
|
||||
assert err.limit == 10
|
||||
assert err.used == 20
|
||||
|
||||
def test_can_catch_as_domain_error(self):
|
||||
"""可以用 DomainError 捕获."""
|
||||
with pytest.raises(DomainError):
|
||||
raise QuotaExceededError("x", 1.0, 2.0)
|
||||
|
||||
|
||||
class TestExceptionHierarchy:
|
||||
"""异常继承关系验证."""
|
||||
|
||||
def test_all_are_domain_errors(self):
|
||||
"""所有领域异常都是 DomainError."""
|
||||
errors = [
|
||||
NotFoundError("test"),
|
||||
ValidationError("test"),
|
||||
QuotaExceededError("test", 1, 2),
|
||||
]
|
||||
for err in errors:
|
||||
assert isinstance(err, DomainError)
|
||||
|
||||
def test_all_are_exceptions(self):
|
||||
"""所有领域异常都是 Exception."""
|
||||
errors = [
|
||||
DomainError("test"),
|
||||
NotFoundError("test"),
|
||||
ValidationError("test"),
|
||||
QuotaExceededError("test", 1, 2),
|
||||
]
|
||||
for err in errors:
|
||||
assert isinstance(err, Exception)
|
||||
|
||||
def test_not_found_is_not_validation(self):
|
||||
"""不同异常类型不能互相混淆."""
|
||||
assert not isinstance(NotFoundError("x"), ValidationError)
|
||||
assert not isinstance(ValidationError("x"), NotFoundError)
|
||||
assert not isinstance(QuotaExceededError("x", 1, 2), NotFoundError)
|
||||
assert not isinstance(QuotaExceededError("x", 1, 2), ValidationError)
|
||||
@@ -194,3 +194,117 @@ class TestGeneratedVideoProperties:
|
||||
fingerprint = {"phash": "abc123", "md5": "def456"}
|
||||
gv.video_fingerprint = fingerprint
|
||||
assert gv.video_fingerprint == fingerprint
|
||||
|
||||
|
||||
class TestGeneratedVideoExtended:
|
||||
"""GeneratedVideo 深度补充测试"""
|
||||
|
||||
def test_id_is_hex(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
int(v.id, 16)
|
||||
|
||||
def test_zero_file_size(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=0)
|
||||
assert v.file_size == 0
|
||||
|
||||
def test_large_file_size(self):
|
||||
large = 1024 * 1024 * 1024 # 1GB
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=large)
|
||||
assert v.file_size == large
|
||||
|
||||
def test_zero_duration(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=0.0)
|
||||
assert v.duration == 0.0
|
||||
|
||||
def test_large_duration(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=9999.99)
|
||||
assert v.duration == 9999.99
|
||||
|
||||
def test_zero_dimensions(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", width=0, height=0)
|
||||
assert v.width == 0
|
||||
assert v.height == 0
|
||||
|
||||
def test_4k_dimensions(self):
|
||||
v = GeneratedVideo.create(
|
||||
project_id="p", generation_task_id="t", name="n", file_url="u", width=3840, height=2160
|
||||
)
|
||||
assert v.width == 3840
|
||||
assert v.height == 2160
|
||||
|
||||
def test_zero_fps(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=0.0)
|
||||
assert v.fps == 0.0
|
||||
|
||||
def test_high_fps(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=120.0)
|
||||
assert v.fps == 120.0
|
||||
|
||||
def test_empty_user_id(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", user_id="")
|
||||
assert v.user_id == ""
|
||||
|
||||
def test_long_name(self):
|
||||
long_name = "视频" * 100
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=long_name, file_url="u")
|
||||
assert v.name == long_name
|
||||
assert len(v.name) == 200
|
||||
|
||||
def test_unicode_name(self):
|
||||
v = GeneratedVideo.create(
|
||||
project_id="p", generation_task_id="t", name="🎬 我的精彩视频 · 旅行vlog", file_url="u"
|
||||
)
|
||||
assert "🎬" in v.name
|
||||
assert "旅行vlog" in v.name
|
||||
|
||||
def test_special_characters_name(self):
|
||||
special = "视!@#$%^&*()频"
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=special, file_url="u")
|
||||
assert v.name == special
|
||||
|
||||
def test_thumbnail_url_none(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", thumbnail_url=None)
|
||||
assert v.thumbnail_url is None
|
||||
|
||||
def test_generation_params_complex(self):
|
||||
params = {
|
||||
"mode": "voice_over",
|
||||
"quality": "high",
|
||||
"resolution": {"width": 1920, "height": 1080},
|
||||
"effects": ["filter", "transition"],
|
||||
}
|
||||
v = GeneratedVideo.create(
|
||||
project_id="p", generation_task_id="t", name="n", file_url="u", generation_params=params
|
||||
)
|
||||
assert v.generation_params["mode"] == "voice_over"
|
||||
assert v.generation_params["resolution"]["width"] == 1920
|
||||
assert len(v.generation_params["effects"]) == 2
|
||||
|
||||
def test_generation_params_independence(self):
|
||||
v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1")
|
||||
v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2")
|
||||
v1.generation_params["key"] = "val"
|
||||
assert "key" not in v2.generation_params
|
||||
|
||||
def test_status_failed(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
v.status = "failed"
|
||||
assert v.status == "failed"
|
||||
|
||||
def test_review_status_approved(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
v.review_status = "approved"
|
||||
assert v.review_status == "approved"
|
||||
|
||||
def test_is_duplicate_default_false(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
assert v.is_duplicate is False
|
||||
assert v.duplicate_of is None
|
||||
|
||||
def test_fingerprint_none_default(self):
|
||||
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
assert v.video_fingerprint is None
|
||||
|
||||
def test_empty_file_url_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url=" ")
|
||||
|
||||
@@ -82,3 +82,102 @@ class TestRecipe:
|
||||
for itype in ["asset", "title", "voice"]:
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0)
|
||||
assert item.item_type == itype
|
||||
|
||||
def test_item_metadata_independence(self):
|
||||
"""不同 RecipeItem 的 metadata_ 互不影响"""
|
||||
item1 = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1")
|
||||
item2 = RecipeItem(id="i2", recipe_id="r1", item_type="asset", item_id="a2")
|
||||
item1.metadata_["key"] = "val"
|
||||
assert "key" not in item2.metadata_
|
||||
|
||||
def test_item_position_negative(self):
|
||||
"""负数 position 也能存"""
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=-5)
|
||||
assert item.position == -5
|
||||
|
||||
def test_item_position_large(self):
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=9999)
|
||||
assert item.position == 9999
|
||||
|
||||
def test_item_empty_item_id(self):
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="")
|
||||
assert item.item_id == ""
|
||||
|
||||
def test_item_type_voice(self):
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type="voice", item_id="v1")
|
||||
assert item.item_type == "voice"
|
||||
|
||||
def test_item_type_title(self):
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1")
|
||||
assert item.item_type == "title"
|
||||
|
||||
|
||||
class TestRecipeExtended:
|
||||
"""Recipe 深度补充测试"""
|
||||
|
||||
def test_items_order_preserved(self):
|
||||
items = [
|
||||
RecipeItem(id="i3", recipe_id="r1", item_type="asset", item_id="a1", position=2),
|
||||
RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1", position=0),
|
||||
RecipeItem(id="i2", recipe_id="r1", item_type="voice", item_id="v1", position=1),
|
||||
]
|
||||
r = Recipe(id="r1", user_id="u1", name="n", items=items)
|
||||
assert len(r.items) == 3
|
||||
assert r.items[0].position == 2
|
||||
assert r.items[1].position == 0
|
||||
assert r.items[2].position == 1
|
||||
|
||||
def test_empty_items_list(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n", items=[])
|
||||
assert r.items == []
|
||||
|
||||
def test_many_items(self):
|
||||
items = [
|
||||
RecipeItem(id=f"i{i}", recipe_id="r1", item_type="asset", item_id=f"a{i}", position=i) for i in range(50)
|
||||
]
|
||||
r = Recipe(id="r1", user_id="u1", name="n", items=items)
|
||||
assert len(r.items) == 50
|
||||
assert r.items[0].position == 0
|
||||
assert r.items[49].position == 49
|
||||
|
||||
def test_generation_params_independence(self):
|
||||
params = {"mode": "one_take", "duration": 30}
|
||||
r1 = Recipe(id="r1", user_id="u1", name="n1", generation_params=params)
|
||||
r2 = Recipe(id="r2", user_id="u1", name="n2")
|
||||
r1.generation_params["new_key"] = "new_val"
|
||||
# 传入同一个 dict 会共享,但默认生成的互不影响
|
||||
assert r2.generation_params == {}
|
||||
|
||||
def test_metadata_independence_default(self):
|
||||
r1 = Recipe(id="r1", user_id="u1", name="n1")
|
||||
r2 = Recipe(id="r2", user_id="u1", name="n2")
|
||||
r1.metadata_["key"] = "val"
|
||||
assert "key" not in r2.metadata_
|
||||
|
||||
def test_with_description(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n", description="这是一个测试配方")
|
||||
assert r.description == "这是一个测试配方"
|
||||
|
||||
def test_with_template_id(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n", template_id="tpl-123")
|
||||
assert r.template_id == "tpl-123"
|
||||
|
||||
def test_empty_name(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="")
|
||||
assert r.name == ""
|
||||
|
||||
def test_long_name(self):
|
||||
long_name = "配方" * 200
|
||||
r = Recipe(id="r1", user_id="u1", name=long_name)
|
||||
assert r.name == long_name
|
||||
assert len(r.name) == 400
|
||||
|
||||
def test_special_characters_in_name(self):
|
||||
special = "配!@#$%方"
|
||||
r = Recipe(id="r1", user_id="u1", name=special)
|
||||
assert r.name == special
|
||||
|
||||
def test_unicode_name(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="🎬 一键生成配方 · 美食探店")
|
||||
assert "🎬" in r.name
|
||||
assert "美食探店" in r.name
|
||||
|
||||
+457
-178
@@ -1,272 +1,551 @@
|
||||
"""字幕领域模型单元测试."""
|
||||
"""字幕领域模型单元测试 - 纯逻辑部分。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
import pytest
|
||||
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
"""SubtitleWord 测试."""
|
||||
"""单个词级别字幕单元。"""
|
||||
|
||||
def test_basic_properties(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=1.5)
|
||||
def test_basic_creation(self):
|
||||
word = SubtitleWord(text="你好", start=0.0, end=0.5)
|
||||
assert word.text == "你好"
|
||||
assert word.start == 1.0
|
||||
assert word.end == 1.5
|
||||
assert word.duration == 0.5
|
||||
assert word.start == 0.0
|
||||
assert word.end == 0.5
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
word = SubtitleWord(text="test", start=2.0, end=1.0)
|
||||
def test_duration_positive(self):
|
||||
word = SubtitleWord(text="test", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="x", start=3.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_zero_when_same_time(self):
|
||||
word = SubtitleWord(text="test", start=1.0, end=1.0)
|
||||
def test_duration_negative_returns_zero(self):
|
||||
"""end < start 时 duration 返回 0,不抛异常。"""
|
||||
word = SubtitleWord(text="x", start=5.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
"""SubtitleSegment 测试."""
|
||||
"""字幕段(一句话)。"""
|
||||
|
||||
def test_basic_properties(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
|
||||
assert seg.text == "大家好"
|
||||
def test_basic_creation(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
|
||||
assert seg.text == "你好世界"
|
||||
assert seg.start == 0.0
|
||||
assert seg.end == 2.0
|
||||
assert seg.duration == 2.0
|
||||
assert seg.char_count == 3
|
||||
assert seg.words == []
|
||||
|
||||
def test_duration_with_words(self):
|
||||
words = [
|
||||
SubtitleWord(text="大", start=0.0, end=0.5),
|
||||
SubtitleWord(text="家", start=0.5, end=1.0),
|
||||
SubtitleWord(text="好", start=1.0, end=1.5),
|
||||
]
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
|
||||
assert seg.duration == 1.5
|
||||
assert seg.char_count == 3
|
||||
assert len(seg.words) == 3
|
||||
def test_duration_positive(self):
|
||||
seg = SubtitleSegment(text="test", start=1.0, end=3.5)
|
||||
assert seg.duration == pytest.approx(2.5)
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
|
||||
def test_duration_zero(self):
|
||||
seg = SubtitleSegment(text="x", start=5.0, end=5.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
seg = SubtitleSegment(text="x", start=10.0, end=5.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0, end=1)
|
||||
assert seg.char_count == 4
|
||||
|
||||
def test_char_count_empty(self):
|
||||
seg = SubtitleSegment(text="", start=0, end=1)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_char_count_mixed_languages(self):
|
||||
seg = SubtitleSegment(text="你好hello世界", start=0, end=1)
|
||||
assert seg.char_count == 9 # 2中 + 5英 + 2中 = 9
|
||||
|
||||
def test_with_words(self):
|
||||
words = [
|
||||
SubtitleWord(text="你好", start=0.0, end=0.5),
|
||||
SubtitleWord(text="世界", start=0.5, end=1.0),
|
||||
]
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
|
||||
assert len(seg.words) == 2
|
||||
assert seg.words[0].text == "你好"
|
||||
|
||||
|
||||
class TestSubtitleTimelineBasics:
|
||||
"""SubtitleTimeline 基础属性测试."""
|
||||
"""字幕时间轴基础属性。"""
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
assert tl.total_chars == 0
|
||||
assert tl.segments == []
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
assert tl.segment_count == 0
|
||||
assert tl.total_chars == 0
|
||||
|
||||
def test_single_segment(self):
|
||||
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="你好", start=0, end=1)],
|
||||
language="zh",
|
||||
total_duration=1.0,
|
||||
)
|
||||
assert tl.segment_count == 1
|
||||
assert tl.total_chars == 2
|
||||
|
||||
def test_multiple_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="第一句", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二句", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="第三句", start=2.0, end=3.0),
|
||||
segments = [
|
||||
SubtitleSegment(text="第一句", start=0, end=1),
|
||||
SubtitleSegment(text="第二句更长一点", start=1, end=3),
|
||||
SubtitleSegment(text="第三句", start=3, end=4),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
|
||||
tl = SubtitleTimeline(segments=segments, total_duration=4.0)
|
||||
assert tl.segment_count == 3
|
||||
assert tl.total_chars == 9
|
||||
assert tl.total_duration == 3.0
|
||||
|
||||
def test_custom_language(self):
|
||||
tl = SubtitleTimeline(language="en")
|
||||
assert tl.language == "en"
|
||||
assert tl.total_chars == 3 + 7 + 3 # 13
|
||||
|
||||
|
||||
class TestSubtitleTimelineMergeShort:
|
||||
"""合并短字幕片段测试."""
|
||||
class TestMergeShortSegments:
|
||||
"""合并过短字幕片段。"""
|
||||
|
||||
def test_empty_or_single_no_change(self):
|
||||
def test_empty_timeline_unchanged(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
seg = SubtitleSegment(text="短", start=0.0, end=0.5)
|
||||
tl2 = SubtitleTimeline(segments=[seg])
|
||||
result2 = tl2.merge_short_segments()
|
||||
assert result2.segment_count == 1
|
||||
|
||||
def test_merge_short_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 1.0
|
||||
assert result.segments[1].text == "今天天气很好"
|
||||
|
||||
def test_merge_trailing_short_to_last(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="短", start=1.0, end=1.2),
|
||||
SubtitleSegment(text="尾", start=1.2, end=1.4),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "一二三四五六七八"=8字 → 保留
|
||||
# "短"+"尾"=2字 < 4 → 合并到上一段
|
||||
def test_single_segment_unchanged(self):
|
||||
tl = SubtitleTimeline(segments=[SubtitleSegment(text="短", start=0, end=0.5)])
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八短尾"
|
||||
assert result.segments[0].text == "短"
|
||||
|
||||
def test_merge_with_words(self):
|
||||
words1 = [SubtitleWord(text="你", start=0.0, end=0.25), SubtitleWord(text="好", start=0.25, end=0.5)]
|
||||
words2 = [SubtitleWord(text="世", start=0.5, end=0.75), SubtitleWord(text="界", start=0.75, end=1.0)]
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
|
||||
def test_all_short_merged_into_one(self):
|
||||
"""多个短片段合并成一个。"""
|
||||
segments = [
|
||||
SubtitleSegment(text="一", start=0, end=0.2),
|
||||
SubtitleSegment(text="二", start=0.2, end=0.4),
|
||||
SubtitleSegment(text="三", start=0.4, end=0.6),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
assert result.segments[0].text == "一二三"
|
||||
assert result.segments[0].start == 0
|
||||
assert result.segments[0].end == 0.6
|
||||
|
||||
def test_mixed_lengths(self):
|
||||
"""长短混合,中间短的会合并成一段。"""
|
||||
segments = [
|
||||
SubtitleSegment(text="这是比较长的第一句", start=0, end=2), # 10字
|
||||
SubtitleSegment(text="第一小段", start=2, end=2.4), # 4字
|
||||
SubtitleSegment(text="第二小段", start=2.4, end=2.8), # 4字
|
||||
SubtitleSegment(text="这是比较长的第四句", start=2.8, end=5), # 10字
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第一句10字够长单独保留;短1+短2=8字刚好够一段;第四句10字够长单独保留
|
||||
assert result.segment_count == 3
|
||||
assert result.segments[0].text == "这是比较长的第一句"
|
||||
assert result.segments[1].text == "第一小段第二小段"
|
||||
assert result.segments[2].text == "这是比较长的第四句"
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
segs = [SubtitleSegment(text="短", start=0.0, end=0.5)]
|
||||
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 0.5
|
||||
segments = [
|
||||
SubtitleSegment(text="a", start=0, end=0.1),
|
||||
SubtitleSegment(text="b", start=0.1, end=0.2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments, language="en", total_duration=10.0)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 10.0
|
||||
|
||||
|
||||
class TestSubtitleTimelineSplitLong:
|
||||
"""拆分长字幕片段测试."""
|
||||
|
||||
def test_short_segments_no_change(self):
|
||||
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
def test_last_short_merged_to_previous(self):
|
||||
"""最后剩余的短片段且不够min_chars,合并到上一段。"""
|
||||
segments = [
|
||||
SubtitleSegment(text="这是一句比较长的话", start=0, end=1.5), # 10字
|
||||
SubtitleSegment(text="尾", start=1.5, end=1.6), # 1字
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第一句够长(10>=8),但尾只有1字不够,合并到上一句
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "这是一句比较长的话尾"
|
||||
|
||||
def test_original_not_modified(self):
|
||||
segments = [SubtitleSegment(text="a", start=0, end=0.1)]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
tl.merge_short_segments(min_chars=5)
|
||||
assert len(tl.segments) == 1 # 原对象不变
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
"""拆分过长字幕片段。"""
|
||||
|
||||
def test_short_segments_unchanged(self):
|
||||
segments = [
|
||||
SubtitleSegment(text="短句", start=0, end=1),
|
||||
SubtitleSegment(text="另一句", start=1, end=2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "短句"
|
||||
|
||||
def test_split_by_sentence_punctuation(self):
|
||||
text = "今天天气很好。我们出去散步吧!"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
"""按句末标点拆分。"""
|
||||
text = "这是第一句话。这是第二句话!这是第三句话?"
|
||||
seg = SubtitleSegment(text=text, start=0, end=3.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert result.segment_count >= 2
|
||||
assert result.segments[0].text.endswith("。")
|
||||
assert result.total_chars == len(text)
|
||||
|
||||
def test_split_long_text_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert result.segment_count > 1
|
||||
# 所有片段都不超过 max_chars
|
||||
for s in result.segments:
|
||||
assert s.char_count <= 8
|
||||
# 合并起来应该等于原文
|
||||
merged_text = "".join(s.text for s in result.segments)
|
||||
assert merged_text == text
|
||||
|
||||
def test_split_time_proportional(self):
|
||||
text = "一二三四。五六七八。"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
"""拆分后的时间按字数比例分配。"""
|
||||
text = "一二三四五六七八。二二三四五六七八。"
|
||||
seg = SubtitleSegment(text=text, start=0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
assert result.segment_count >= 2
|
||||
# 总时长保持一致
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert len(result.segments) >= 2
|
||||
# 总时长不超过原时长
|
||||
assert result.segments[-1].end <= seg.end
|
||||
# 第一个片段的开始时间正确
|
||||
assert result.segments[0].start == 0.0
|
||||
|
||||
def test_split_with_words(self):
|
||||
words = [
|
||||
SubtitleWord(text="一", start=0.0, end=0.5),
|
||||
SubtitleWord(text="二", start=0.5, end=1.0),
|
||||
SubtitleWord(text="三", start=1.0, end=1.5),
|
||||
SubtitleWord(text="四", start=1.5, end=2.0),
|
||||
]
|
||||
text = "一二三四五六七八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
|
||||
def test_no_punctuation_hard_split(self):
|
||||
"""没有标点时硬切。"""
|
||||
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八十九二十"
|
||||
seg = SubtitleSegment(text=text, start=0, end=5.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
assert result.segment_count >= 2
|
||||
# 词的总数应该不变
|
||||
total_words = sum(len(s.words) for s in result.segments)
|
||||
assert total_words == 4
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert len(result.segments) >= 2
|
||||
merged = "".join(s.text for s in result.segments)
|
||||
assert merged == text
|
||||
|
||||
def test_split_preserves_language(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg], language="en")
|
||||
result = tl.split_long_segments(max_chars=2)
|
||||
def test_preserves_language_and_duration(self):
|
||||
seg = SubtitleSegment(text="a" * 30, start=0, end=5)
|
||||
tl = SubtitleTimeline(segments=[seg], language="en", total_duration=100.0)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 100.0
|
||||
|
||||
def test_empty_timeline_unchanged(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
"""标点拆分静态方法测试."""
|
||||
"""_split_text_by_punctuation 静态方法。"""
|
||||
|
||||
def test_short_text_unchanged(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好", 10)
|
||||
assert result == ["你好"]
|
||||
|
||||
def test_split_by_period(self):
|
||||
text = "这是第一句话。这是第二句话。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "这是第一句话。"
|
||||
assert result[1] == "这是第二句话。"
|
||||
|
||||
def test_split_by_exclamation(self):
|
||||
text = "你好世界大家好!再见世界朋友们!"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_split_by_question(self):
|
||||
text = "今天天气好不好呢?今天天气很好呀。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_split_by_comma_when_long(self):
|
||||
"""超过max_chars时,遇到逗号也会断开。"""
|
||||
text = "这是很长的一句话,中间有个逗号,后面还有内容继续。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_empty_text(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_split_by_period(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
|
||||
assert len(result) >= 2
|
||||
assert "。" in result[0]
|
||||
|
||||
def test_split_by_exclamation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_split_by_comma_when_long(self):
|
||||
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) > 1
|
||||
for part in result:
|
||||
assert len(part) <= 8
|
||||
|
||||
def test_sentence_end_triggers_split_when_half_max(self):
|
||||
# 句末标点在 max_chars//2 以上就拆分
|
||||
text = "你好世界。abcdefghij"
|
||||
text = "一二三四五六七八九十一二三四五六七八九十"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
# "你好世界。"=5字 < 10但>=5(half),应该拆分
|
||||
assert len(result) == 2
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
|
||||
def test_english_punctuation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("Hello, world! How are you?", 15)
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
"""_merge_segments 静态方法测试."""
|
||||
"""_merge_segments 静态方法。"""
|
||||
|
||||
def test_merge_empty(self):
|
||||
def test_merge_two_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 1.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
|
||||
def test_merge_single(self):
|
||||
def test_merge_single_segment(self):
|
||||
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
|
||||
result = SubtitleTimeline._merge_segments([seg])
|
||||
assert result.text == "test"
|
||||
assert result.start == 1.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_multiple(self):
|
||||
def test_merge_preserves_words(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="第一", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二", start=1.0, end=2.0),
|
||||
SubtitleSegment(
|
||||
text="你好",
|
||||
start=0.0,
|
||||
end=0.5,
|
||||
words=[SubtitleWord(text="你好", start=0.0, end=0.5)],
|
||||
),
|
||||
SubtitleSegment(
|
||||
text="世界",
|
||||
start=0.5,
|
||||
end=1.0,
|
||||
words=[SubtitleWord(text="世界", start=0.5, end=1.0)],
|
||||
),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "第一第二"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
assert len(result.words) == 2
|
||||
assert result.words[0].text == "你好"
|
||||
assert result.words[1].text == "世界"
|
||||
|
||||
|
||||
class TestMergeShortSegmentsEdgeCases:
|
||||
"""merge_short_segments 边界情况深度测试."""
|
||||
|
||||
def test_all_segments_too_short_merge_into_one(self):
|
||||
"""所有片段都很短,全部合并成一段."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="二", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="三", start=1.0, end=1.5),
|
||||
],
|
||||
total_duration=1.5,
|
||||
)
|
||||
result = timeline.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 1.5
|
||||
|
||||
def test_exactly_min_chars_no_merge(self):
|
||||
"""刚好等于 min_chars,不合并."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="一二三四五六七八", start=1.0, end=2.0),
|
||||
],
|
||||
total_duration=2.0,
|
||||
)
|
||||
result = timeline.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 2
|
||||
|
||||
def test_min_chars_one(self):
|
||||
"""min_chars=1 时每个都够,不合并."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="二", start=0.5, end=1.0),
|
||||
],
|
||||
total_duration=1.0,
|
||||
)
|
||||
result = timeline.merge_short_segments(min_chars=1)
|
||||
assert result.segment_count == 2
|
||||
|
||||
def test_merge_preserves_words_order(self):
|
||||
"""合并后词的顺序保持正确."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
text="你好",
|
||||
start=0.0,
|
||||
end=0.5,
|
||||
words=[
|
||||
SubtitleWord(text="你", start=0.0, end=0.25),
|
||||
SubtitleWord(text="好", start=0.25, end=0.5),
|
||||
],
|
||||
),
|
||||
SubtitleSegment(
|
||||
text="世界",
|
||||
start=0.5,
|
||||
end=1.0,
|
||||
words=[
|
||||
SubtitleWord(text="世", start=0.5, end=0.75),
|
||||
SubtitleWord(text="界", start=0.75, end=1.0),
|
||||
],
|
||||
),
|
||||
],
|
||||
total_duration=1.0,
|
||||
)
|
||||
result = timeline.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
words = result.segments[0].words
|
||||
assert len(words) == 4
|
||||
assert [w.text for w in words] == ["你", "好", "世", "界"]
|
||||
|
||||
def test_last_segment_short_merges_with_previous(self):
|
||||
"""最后一段太短,合并到前一段."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="九", start=1.0, end=1.2),
|
||||
],
|
||||
total_duration=1.2,
|
||||
)
|
||||
result = timeline.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八九"
|
||||
|
||||
|
||||
class TestSplitLongSegmentsEdgeCases:
|
||||
"""split_long_segments 边界情况深度测试."""
|
||||
|
||||
def test_mixed_long_and_short(self):
|
||||
"""长短片段混合,只拆分长的."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短", start=0.0, end=0.5),
|
||||
SubtitleSegment(
|
||||
text="这是一段非常长的字幕内容需要被拆分",
|
||||
start=0.5,
|
||||
end=3.0,
|
||||
),
|
||||
SubtitleSegment(text="短", start=3.0, end=3.5),
|
||||
],
|
||||
total_duration=3.5,
|
||||
)
|
||||
result = timeline.split_long_segments(max_chars=10)
|
||||
assert result.segment_count > 3 # 中间那段被拆分了
|
||||
assert result.segments[0].text == "短"
|
||||
assert result.segments[-1].text == "短"
|
||||
|
||||
def test_split_total_duration_preserved(self):
|
||||
"""拆分后总时长不变."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
text="一二三四五六七八九十一二三四五六七八九十",
|
||||
start=0.0,
|
||||
end=10.0,
|
||||
),
|
||||
],
|
||||
total_duration=10.0,
|
||||
)
|
||||
result = timeline.split_long_segments(max_chars=10)
|
||||
assert result.segment_count > 1
|
||||
assert result.segments[0].start == 0.0
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
|
||||
def test_max_chars_very_small(self):
|
||||
"""max_chars 很小,每个字都要拆."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三", start=0.0, end=3.0),
|
||||
],
|
||||
total_duration=3.0,
|
||||
)
|
||||
result = timeline.split_long_segments(max_chars=1)
|
||||
# 没有标点,硬切
|
||||
assert result.segment_count >= 3
|
||||
|
||||
def test_empty_segments_list(self):
|
||||
"""空片段列表不报错."""
|
||||
timeline = SubtitleTimeline(segments=[], total_duration=0.0)
|
||||
result = timeline.split_long_segments(max_chars=10)
|
||||
assert result.segment_count == 0
|
||||
|
||||
|
||||
class TestSplitTextByPunctuationDeep:
|
||||
"""_split_text_by_punctuation 深度测试."""
|
||||
|
||||
def test_multiple_punctuation_types(self):
|
||||
"""多种标点符号混合."""
|
||||
text = "你好!世界?测试,哈哈。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_consecutive_punctuation(self):
|
||||
"""连续标点符号."""
|
||||
text = "你好!!!测试。。。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 3)
|
||||
assert len(result) >= 1
|
||||
# 确保所有字符都保留
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_no_punctuation_long_text(self):
|
||||
"""长文本没有标点,硬切."""
|
||||
text = "一二三四五六七八九十一二三四五六七八九十"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
# 每段不超过 max_chars
|
||||
for part in result[:-1]: # 最后一段可能短一些
|
||||
assert len(part) <= 10
|
||||
|
||||
def test_punctuation_at_start(self):
|
||||
"""标点在开头."""
|
||||
text = ",你好世界"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_punctuation_at_end(self):
|
||||
"""标点在结尾."""
|
||||
text = "你好世界!"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert "".join(result) == text
|
||||
assert result[-1].endswith("!")
|
||||
|
||||
|
||||
class TestSubtitleTimelineProperties:
|
||||
"""SubtitleTimeline 属性计算深度测试."""
|
||||
|
||||
def test_total_chars_empty(self):
|
||||
"""空时间轴 total_chars 为 0."""
|
||||
timeline = SubtitleTimeline(segments=[])
|
||||
assert timeline.total_chars == 0
|
||||
|
||||
def test_total_chars_sum(self):
|
||||
"""total_chars 等于所有片段字数之和."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0, end=1),
|
||||
SubtitleSegment(text="世界", start=1, end=2),
|
||||
SubtitleSegment(text="123", start=2, end=3),
|
||||
],
|
||||
)
|
||||
assert timeline.total_chars == 2 + 2 + 3
|
||||
|
||||
def test_segment_count(self):
|
||||
"""segment_count 正确."""
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
SubtitleSegment(text="b", start=1, end=2),
|
||||
],
|
||||
)
|
||||
assert timeline.segment_count == 2
|
||||
|
||||
def test_empty_segment_char_count(self):
|
||||
"""空片段 char_count 为 0."""
|
||||
seg = SubtitleSegment(text="", start=0.0, end=1.0)
|
||||
assert seg.char_count == 0
|
||||
|
||||
@@ -176,3 +176,593 @@ class TestGenerateAssFromTimeline:
|
||||
assert "0:00:01.25" in content
|
||||
# 1:01:01.00 格式(3661秒 = 1小时1分1秒)
|
||||
assert "1:01:01.00" in content
|
||||
|
||||
|
||||
# ── _hex_to_ass_color 深度测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
"""_hex_to_ass_color 颜色转换测试"""
|
||||
|
||||
def test_standard_hex_with_hash(self):
|
||||
"""带#号的标准6位HEX"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
# #RRGGBB → &HBBGGRR
|
||||
assert _hex_to_ass_color("#FF0000") == "&H0000FF" # 红
|
||||
assert _hex_to_ass_color("#00FF00") == "&H00FF00" # 绿
|
||||
assert _hex_to_ass_color("#0000FF") == "&HFF0000" # 蓝
|
||||
|
||||
def test_standard_hex_without_hash(self):
|
||||
"""不带#号的6位HEX"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("FF0000") == "&H0000FF"
|
||||
assert _hex_to_ass_color("00FF00") == "&H00FF00"
|
||||
assert _hex_to_ass_color("0000FF") == "&HFF0000"
|
||||
|
||||
def test_white_and_black(self):
|
||||
"""白色和黑色"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" # 白
|
||||
assert _hex_to_ass_color("#000000") == "&H000000" # 黑
|
||||
|
||||
def test_lowercase_hex(self):
|
||||
"""小写字母HEX"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
result = _hex_to_ass_color("#aabbcc")
|
||||
# 输出是大写的
|
||||
assert result == "&HCCBBAA"
|
||||
|
||||
def test_mixed_case_hex(self):
|
||||
"""大小写混合HEX"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
result = _hex_to_ass_color("#AaBbCc")
|
||||
assert result == "&HCCBBAA"
|
||||
|
||||
def test_short_hex_returns_default(self):
|
||||
"""长度不足6位返回默认白色"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
|
||||
assert _hex_to_ass_color("FF") == "&H00FFFFFF" # 2位
|
||||
|
||||
def test_long_hex_returns_default(self):
|
||||
"""长度超过6位返回默认白色"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("#AABBCCDD") == "&H00FFFFFF"
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
"""空字符串返回默认白色"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("") == "&H00FFFFFF"
|
||||
|
||||
def test_gray_color(self):
|
||||
"""灰色调"""
|
||||
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
|
||||
|
||||
assert _hex_to_ass_color("#808080") == "&H808080"
|
||||
|
||||
|
||||
# ── _position_to_ass_alignment 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPositionToAssAlignment:
|
||||
"""_position_to_ass_alignment 位置映射测试"""
|
||||
|
||||
def test_top_maps_to_8(self):
|
||||
"""顶部 → 8"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("top") == 8
|
||||
|
||||
def test_center_maps_to_5(self):
|
||||
"""居中 → 5"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("center") == 5
|
||||
|
||||
def test_bottom_maps_to_2(self):
|
||||
"""底部 → 2"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("bottom") == 2
|
||||
|
||||
def test_unknown_position_defaults_to_bottom(self):
|
||||
"""未知位置默认底部(2)"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("left") == 2
|
||||
assert _position_to_ass_alignment("right") == 2
|
||||
assert _position_to_ass_alignment("middle") == 2
|
||||
|
||||
def test_empty_string_defaults_to_bottom(self):
|
||||
"""空字符串默认底部"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("") == 2
|
||||
|
||||
def test_uppercase_not_matched(self):
|
||||
"""大写不匹配,走默认"""
|
||||
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
|
||||
|
||||
assert _position_to_ass_alignment("TOP") == 2
|
||||
assert _position_to_ass_alignment("CENTER") == 2
|
||||
|
||||
|
||||
# ── _format_ass_time 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFormatAssTime:
|
||||
"""_format_ass_time 时间格式化测试"""
|
||||
|
||||
def test_zero_seconds(self):
|
||||
"""0秒"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(0.0) == "0:00:00.00"
|
||||
|
||||
def test_fractional_seconds(self):
|
||||
"""小数秒"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(0.5) == "0:00:00.50"
|
||||
assert _format_ass_time(1.25) == "0:00:01.25"
|
||||
|
||||
def test_whole_seconds(self):
|
||||
"""整秒"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(5.0) == "0:00:05.00"
|
||||
assert _format_ass_time(30.0) == "0:00:30.00"
|
||||
|
||||
def test_minutes_level(self):
|
||||
"""分钟级"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(60.0) == "0:01:00.00"
|
||||
assert _format_ass_time(90.5) == "0:01:30.50"
|
||||
assert _format_ass_time(599.0) == "0:09:59.00"
|
||||
|
||||
def test_hours_level(self):
|
||||
"""小时级"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(3600.0) == "1:00:00.00"
|
||||
assert _format_ass_time(3661.5) == "1:01:01.50"
|
||||
assert _format_ass_time(7384.0) == "2:03:04.00"
|
||||
|
||||
def test_centisecond_precision(self):
|
||||
"""百分秒精度"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
assert _format_ass_time(1.01) == "0:00:01.01"
|
||||
assert _format_ass_time(1.99) == "0:00:01.99"
|
||||
|
||||
def test_sub_centisecond_truncated(self):
|
||||
"""毫秒级精度会被格式化截断到百分秒"""
|
||||
from apps.worker.video_processing.subtitle_generator import _format_ass_time
|
||||
|
||||
# Python 的 %05.2f 会四舍五入
|
||||
result = _format_ass_time(1.123)
|
||||
assert result.startswith("0:00:01.")
|
||||
assert len(result.split(".")[-1]) == 2
|
||||
|
||||
|
||||
# ── _escape_ass_text 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
"""_escape_ass_text 转义测试"""
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
"""普通文本不改变"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("你好世界") == "你好世界"
|
||||
assert _escape_ass_text("Hello World") == "Hello World"
|
||||
|
||||
def test_newline_to_ass_newline(self):
|
||||
"""\\n 转 \\N"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("第一行\n第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_crlf_to_ass_newline(self):
|
||||
"""\\r\\n 转 \\N"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("第一行\r\n第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_cr_to_ass_newline(self):
|
||||
"""\\r 转 \\N"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("第一行\r第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_curly_braces_escaped(self):
|
||||
"""花括号转圆括号(防止ASS标签注入)"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("{text}") == "(text)"
|
||||
assert _escape_ass_text("{{double}}") == "((double))"
|
||||
|
||||
def test_mixed_escapes(self):
|
||||
"""混合转义"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
text = "你好\n世界{测试}\r\n结束"
|
||||
result = _escape_ass_text(text)
|
||||
assert "\\N" in result
|
||||
assert "(测试)" in result
|
||||
assert "\n" not in result
|
||||
assert "{" not in result
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("") == ""
|
||||
|
||||
def test_only_braces(self):
|
||||
"""只有花括号"""
|
||||
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
|
||||
|
||||
assert _escape_ass_text("{}") == "()"
|
||||
|
||||
|
||||
# ── _wrap_text 补充测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWrapTextMore:
|
||||
"""_wrap_text 补充边界测试"""
|
||||
|
||||
def test_empty_string_returns_empty_list(self):
|
||||
"""空字符串返回空列表"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
assert _wrap_text("", 10) == [""]
|
||||
|
||||
def test_single_character(self):
|
||||
"""单字符"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
assert _wrap_text("好", 10) == ["好"]
|
||||
|
||||
def test_max_chars_equals_one(self):
|
||||
"""max_chars=1 每个字一行"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
result = _wrap_text("一二三四", 1)
|
||||
assert len(result) == 4
|
||||
assert result[0] == "一"
|
||||
assert result[1] == "二"
|
||||
|
||||
def test_punctuation_in_middle(self):
|
||||
"""标点在正中间优先从标点断开"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
# 前10字内有句号,优先在句号后断开
|
||||
text = "一二三四五六七八九十。后面的内容继续写下去"
|
||||
result = _wrap_text(text, 15)
|
||||
# 第一行应该包含句号
|
||||
assert "。" in result[0]
|
||||
|
||||
def test_punctuation_at_start_ignored(self):
|
||||
"""标点在开头位置(前半部分)不会触发断开"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "。一二三四五六七八九十"
|
||||
result = _wrap_text(text, 10)
|
||||
# 标点在第0位,不会在max_chars//2到max_chars范围内
|
||||
assert len(result) == 2
|
||||
assert len(result[0]) == 10
|
||||
|
||||
def test_no_punctuation_long_text(self):
|
||||
"""完全没有标点的长文本硬切"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 3
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
assert len(result[2]) == 5
|
||||
|
||||
def test_exactly_two_lines(self):
|
||||
"""恰好两行"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "一" * 20
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 2
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
|
||||
def test_three_lines(self):
|
||||
"""三行"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "一" * 25
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 3
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
assert len(result[2]) == 5
|
||||
|
||||
def test_multiple_punctuation_points(self):
|
||||
"""多个标点,选择最后一个合适的"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "你好。再见。谢谢。抱歉。好的不行了"
|
||||
result = _wrap_text(text, 12)
|
||||
# 应该在最靠后的(在范围内的)标点处断开
|
||||
assert "。" in result[0]
|
||||
|
||||
def test_english_punctuation_wrap(self):
|
||||
"""英文标点也会触发换行"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "Hello world. This is a test sentence."
|
||||
result = _wrap_text(text, 20)
|
||||
assert len(result) >= 2
|
||||
assert result[0].endswith(".") or "." in result[0]
|
||||
|
||||
def test_total_length_preserved(self):
|
||||
"""换行后总字符数不变"""
|
||||
from apps.worker.video_processing.subtitle_generator import _wrap_text
|
||||
|
||||
text = "这是一段用于测试换行功能的中文文本,包含了各种标点符号。看看效果如何?"
|
||||
result = _wrap_text(text, 10)
|
||||
assert "".join(result) == text
|
||||
|
||||
|
||||
# ── generate_ass_from_timeline 补充测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateAssFromTimelineMore:
|
||||
"""generate_ass_from_timeline 补充测试"""
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段字幕"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="你好", start=0.0, end=1.0)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "single.ass"
|
||||
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Dialogue:" in content
|
||||
assert "你好" in content
|
||||
assert content.count("Dialogue:") == 1
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段字幕"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text=f"第{i}段", start=float(i), end=float(i + 1)) for i in range(5)],
|
||||
total_duration=5.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "multi.ass"
|
||||
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert content.count("Dialogue:") == 5
|
||||
|
||||
def test_long_text_auto_wrap(self):
|
||||
"""长字幕自动换行"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
long_text = "这是一段非常非常长的字幕文本,用来测试自动换行功能是否正常工作。"
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text=long_text, start=0, end=5)],
|
||||
total_duration=5.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "wrap.ass"
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
subtitle_config={"max_chars_per_line": 10},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 应该包含 \N 换行符
|
||||
assert "\\N" in content
|
||||
|
||||
def test_custom_color_hex(self):
|
||||
"""自定义颜色正确转换"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="红", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "red.ass"
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
subtitle_config={"color": "#FF0000"},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 红色 #FF0000 → &H0000FF (BBGGRR)
|
||||
assert "&H0000FF" in content
|
||||
|
||||
def test_position_top(self):
|
||||
"""顶部位置"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="顶部", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "top.ass"
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
subtitle_config={"position": "top"},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 顶部对齐是 \\an8 → Style 中 Alignment=8
|
||||
assert "1,1.5,0,8," in content or ",0,8," in content
|
||||
|
||||
def test_position_center(self):
|
||||
"""居中位置"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="居中", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "center.ass"
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
subtitle_config={"position": "center"},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 居中对齐是 Alignment=5
|
||||
assert ",0,5," in content
|
||||
|
||||
def test_custom_font_size(self):
|
||||
"""自定义字体大小"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="大", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "big.ass"
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
subtitle_config={"size": 48},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# Style 行中字号应该是48
|
||||
assert "Default,思源黑体,48," in content
|
||||
|
||||
def test_720p_resolution(self):
|
||||
"""720p分辨率"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="720p", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "720p.ass"
|
||||
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "PlayResX: 1280" in content
|
||||
assert "PlayResY: 720" in content
|
||||
|
||||
def test_special_characters_in_text(self):
|
||||
"""字幕文本含特殊字符(花括号、换行)"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="你好{tag}\n世界", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "special.ass"
|
||||
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 花括号被转义
|
||||
assert "(tag)" in content
|
||||
# 换行被转义成 \N
|
||||
assert "\\N" in content
|
||||
|
||||
def test_output_path_creates_parent_dirs(self):
|
||||
"""自动创建父目录"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="测试", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "nested" / "deep" / "out.ass"
|
||||
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
|
||||
assert result.exists()
|
||||
assert result.parent.exists()
|
||||
|
||||
def test_returns_output_path(self):
|
||||
"""返回值等于输出路径"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||||
|
||||
timeline = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="返回", start=0, end=1)],
|
||||
total_duration=1.0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "ret.ass"
|
||||
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
|
||||
assert result == output
|
||||
|
||||
@@ -32,3 +32,61 @@ class TestTagCreate:
|
||||
def test_create_has_created_at(self):
|
||||
tag = Tag.create(user_id="user-1", name="美食")
|
||||
assert tag.created_at is not None
|
||||
|
||||
|
||||
class TestTagExtended:
|
||||
"""Tag 深度补充测试"""
|
||||
|
||||
def test_create_id_is_unique(self):
|
||||
"""多次创建生成不同的 id"""
|
||||
tag1 = Tag.create(user_id="u1", name="标签1")
|
||||
tag2 = Tag.create(user_id="u1", name="标签2")
|
||||
assert tag1.id != tag2.id
|
||||
assert len(tag1.id) == 32
|
||||
assert len(tag2.id) == 32
|
||||
|
||||
def test_create_id_is_hex(self):
|
||||
"""id 是十六进制字符串"""
|
||||
tag = Tag.create(user_id="u1", name="测试")
|
||||
int(tag.id, 16) # 不抛错就是合法 hex
|
||||
|
||||
def test_slots_no_extra_attributes(self):
|
||||
"""slots=True 不能添加新属性"""
|
||||
import pytest
|
||||
|
||||
tag = Tag.create(user_id="u1", name="测试")
|
||||
with pytest.raises(AttributeError):
|
||||
tag.new_attr = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_create_very_long_name(self):
|
||||
long_name = "标签" * 50
|
||||
tag = Tag.create(user_id="u1", name=long_name)
|
||||
assert tag.name == long_name
|
||||
assert len(tag.name) == 100
|
||||
|
||||
def test_create_unicode_name(self):
|
||||
tag = Tag.create(user_id="u1", name="🔥热门标签✨")
|
||||
assert tag.name == "🔥热门标签✨"
|
||||
|
||||
def test_create_name_only_spaces_between_text(self):
|
||||
"""中间有空格的标签名正常保留"""
|
||||
tag = Tag.create(user_id="u1", name=" 美食 探店 ")
|
||||
assert tag.name == "美食 探店"
|
||||
|
||||
def test_create_different_user_ids(self):
|
||||
tag1 = Tag.create(user_id="user-001", name="标签")
|
||||
tag2 = Tag.create(user_id="user-999", name="标签")
|
||||
assert tag1.user_id == "user-001"
|
||||
assert tag2.user_id == "user-999"
|
||||
assert tag1.id != tag2.id
|
||||
|
||||
def test_name_is_string(self):
|
||||
tag = Tag.create(user_id="u1", name="12345")
|
||||
assert isinstance(tag.name, str)
|
||||
assert tag.name == "12345"
|
||||
|
||||
def test_equality(self):
|
||||
"""两个相同属性的 tag 不相等(id 不同)"""
|
||||
tag1 = Tag.create(user_id="u1", name="同名")
|
||||
tag2 = Tag.create(user_id="u1", name="同名")
|
||||
assert tag1 != tag2
|
||||
|
||||
Regular → Executable
+139
@@ -151,3 +151,142 @@ class TestTemplateCategory:
|
||||
def test_category_has_timestamp(self):
|
||||
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
|
||||
assert cat.created_at is not None
|
||||
|
||||
|
||||
class TestTemplateExtended:
|
||||
"""Template 深度补充测试"""
|
||||
|
||||
def test_tags_independence(self):
|
||||
"""不同模板的 tags 列表互不影响"""
|
||||
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
|
||||
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
|
||||
t1.tags.append("新标签")
|
||||
assert "新标签" not in t2.tags
|
||||
assert len(t2.tags) == 0
|
||||
|
||||
def test_title_config_independence(self):
|
||||
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
|
||||
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
|
||||
t1.title_config["key"] = "val"
|
||||
assert "key" not in t2.title_config
|
||||
|
||||
def test_subtitle_config_independence(self):
|
||||
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
|
||||
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
|
||||
t1.subtitle_config["key"] = "val"
|
||||
assert "key" not in t2.subtitle_config
|
||||
|
||||
def test_bgm_config_independence(self):
|
||||
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
|
||||
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
|
||||
t1.bgm_config["key"] = "val"
|
||||
assert "key" not in t2.bgm_config
|
||||
|
||||
def test_segments_independence(self):
|
||||
segs = [
|
||||
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=2),
|
||||
]
|
||||
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take", segments=segs)
|
||||
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
|
||||
assert len(t2.segments) == 0
|
||||
|
||||
def test_empty_segments(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=[])
|
||||
assert t.segments == []
|
||||
|
||||
def test_many_segments(self):
|
||||
segs = [
|
||||
TemplateSegment(id=f"s{i}", template_id="t1", segment_order=i, duration_min=1, duration_max=3)
|
||||
for i in range(30)
|
||||
]
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=segs)
|
||||
assert len(t.segments) == 30
|
||||
assert t.segments[0].segment_order == 0
|
||||
assert t.segments[29].segment_order == 29
|
||||
|
||||
def test_zero_estimated_duration(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=0.0)
|
||||
assert t.estimated_duration == 0.0
|
||||
|
||||
def test_negative_estimated_duration(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=-1.0)
|
||||
assert t.estimated_duration == -1.0
|
||||
|
||||
def test_large_estimated_duration(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=9999.9)
|
||||
assert t.estimated_duration == 9999.9
|
||||
|
||||
def test_empty_category(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="")
|
||||
assert t.category == ""
|
||||
|
||||
def test_custom_category(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="美食探店")
|
||||
assert t.category == "美食探店"
|
||||
|
||||
def test_empty_name(self):
|
||||
t = Template(id="t1", user_id="u1", name="", mode="one_take")
|
||||
assert t.name == ""
|
||||
|
||||
def test_unicode_name(self):
|
||||
t = Template(id="t1", user_id="u1", name="🎬 美食探店 · Vlog模板", mode="one_take")
|
||||
assert "🎬" in t.name
|
||||
assert "美食探店" in t.name
|
||||
|
||||
def test_special_characters_in_name(self):
|
||||
special = "模!@#$%板"
|
||||
t = Template(id="t1", user_id="u1", name=special, mode="one_take")
|
||||
assert t.name == special
|
||||
|
||||
def test_long_name(self):
|
||||
long_name = "模板" * 100
|
||||
t = Template(id="t1", user_id="u1", name=long_name, mode="one_take")
|
||||
assert t.name == long_name
|
||||
assert len(t.name) == 200
|
||||
|
||||
|
||||
class TestTemplateSegmentExtended:
|
||||
"""TemplateSegment 深度补充测试"""
|
||||
|
||||
def test_zero_duration(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=0)
|
||||
assert seg.duration_min == 0
|
||||
assert seg.duration_max == 0
|
||||
|
||||
def test_negative_duration_min(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=-1, duration_max=5)
|
||||
assert seg.duration_min == -1
|
||||
|
||||
def test_negative_duration_max(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=-5)
|
||||
assert seg.duration_max == -5
|
||||
|
||||
def test_large_duration(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=9999.9)
|
||||
assert seg.duration_max == 9999.9
|
||||
|
||||
def test_negative_order(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=-5, duration_min=1, duration_max=3)
|
||||
assert seg.segment_order == -5
|
||||
|
||||
def test_large_order(self):
|
||||
seg = TemplateSegment(id="s1", template_id="t1", segment_order=999, duration_min=1, duration_max=3)
|
||||
assert seg.segment_order == 999
|
||||
|
||||
def test_material_type_none(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=None
|
||||
)
|
||||
assert seg.material_type is None
|
||||
|
||||
def test_material_type_empty_string(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=""
|
||||
)
|
||||
assert seg.material_type == ""
|
||||
|
||||
def test_material_type_unicode(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type="风景"
|
||||
)
|
||||
assert seg.material_type == "风景"
|
||||
|
||||
Regular → Executable
+78
@@ -125,3 +125,81 @@ class TestEditTemplateVersionSlots:
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
with pytest.raises(AttributeError):
|
||||
v.nonexistent_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestEditTemplateVersionExtended:
|
||||
"""EditTemplateVersion 深度补充测试"""
|
||||
|
||||
def test_id_is_hex(self):
|
||||
"""id 是十六进制字符串"""
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
int(v.id, 16) # 不抛错就是合法 hex
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
"""不同版本的 id 不同"""
|
||||
v1 = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
v2 = EditTemplateVersion.create(template_id="t1", version=2)
|
||||
assert v1.id != v2.id
|
||||
|
||||
def test_zero_version(self):
|
||||
"""version=0 也能创建"""
|
||||
v = EditTemplateVersion.create(template_id="t1", version=0)
|
||||
assert v.version == 0
|
||||
|
||||
def test_negative_version(self):
|
||||
"""负数 version 也能创建(领域层不做业务校验)"""
|
||||
v = EditTemplateVersion.create(template_id="t1", version=-1)
|
||||
assert v.version == -1
|
||||
|
||||
def test_large_version(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=9999)
|
||||
assert v.version == 9999
|
||||
|
||||
def test_empty_template_id(self):
|
||||
v = EditTemplateVersion.create(template_id="", version=1)
|
||||
assert v.template_id == ""
|
||||
|
||||
def test_empty_name(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, name="")
|
||||
assert v.name == ""
|
||||
|
||||
def test_empty_change_note(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, change_note="")
|
||||
assert v.change_note == ""
|
||||
|
||||
def test_empty_published_by(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, published_by="")
|
||||
assert v.published_by == ""
|
||||
|
||||
def test_unicode_name(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, name="🎨 V5 优化版")
|
||||
assert "🎨" in v.name
|
||||
assert "V5" in v.name
|
||||
|
||||
def test_long_name(self):
|
||||
long_name = "版本" * 50
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, name=long_name)
|
||||
assert v.name == long_name
|
||||
assert len(v.name) == 100
|
||||
|
||||
def test_config_complex_nested(self):
|
||||
config = {
|
||||
"layer1": {
|
||||
"layer2": {
|
||||
"layer3": [1, 2, 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, config=config)
|
||||
assert v.config["layer1"]["layer2"]["layer3"] == [1, 2, 3]
|
||||
|
||||
def test_clip_configs_empty_list(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=[])
|
||||
assert v.clip_configs == []
|
||||
|
||||
def test_clip_configs_many(self):
|
||||
clips = [{"clip_id": i, "duration": float(i)} for i in range(50)]
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=clips)
|
||||
assert len(v.clip_configs) == 50
|
||||
assert v.clip_configs[0]["clip_id"] == 0
|
||||
assert v.clip_configs[49]["clip_id"] == 49
|
||||
|
||||
@@ -325,3 +325,88 @@ class TestSplitTextEdgeCases:
|
||||
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
|
||||
|
||||
@@ -76,3 +76,84 @@ class TestTitleLibraryItem:
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
|
||||
assert item.created_at is not None
|
||||
assert item.updated_at is not None
|
||||
|
||||
def test_tags_independence_between_instances(self):
|
||||
"""不同实例的 tags 列表互不影响"""
|
||||
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
|
||||
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
|
||||
item1.tags.append("新标签")
|
||||
assert "新标签" not in item2.tags
|
||||
assert len(item2.tags) == 0
|
||||
|
||||
def test_metadata_independence_between_instances(self):
|
||||
"""不同实例的 metadata_ 字典互不影响"""
|
||||
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
|
||||
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
|
||||
item1.metadata_["key"] = "value"
|
||||
assert "key" not in item2.metadata_
|
||||
|
||||
def test_zero_usage_count(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=0)
|
||||
assert item.usage_count == 0
|
||||
|
||||
def test_large_usage_count(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=999999)
|
||||
assert item.usage_count == 999999
|
||||
|
||||
def test_negative_usage_count(self):
|
||||
"""负数使用次数也能存(领域层不做业务校验)"""
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=-1)
|
||||
assert item.usage_count == -1
|
||||
|
||||
def test_empty_text(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="")
|
||||
assert item.text == ""
|
||||
|
||||
def test_long_text(self):
|
||||
long_text = "标题" * 1000
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text=long_text)
|
||||
assert item.text == long_text
|
||||
assert len(item.text) == 2000
|
||||
|
||||
def test_empty_name(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="", text="t")
|
||||
assert item.name == ""
|
||||
|
||||
def test_special_characters_in_name_and_text(self):
|
||||
special = "!@#$%^&*()_+-=[]{}|;':\",./<>?\n\t"
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name=special, text=special)
|
||||
assert item.name == special
|
||||
assert item.text == special
|
||||
|
||||
def test_unicode_in_name_and_text(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="中文标题🔥emoji",
|
||||
text="支持各种文字:中文、English、日本語、한국어",
|
||||
)
|
||||
assert "中文" in item.name
|
||||
assert "🔥" in item.name
|
||||
assert "日本語" in item.text
|
||||
|
||||
def test_many_tags(self):
|
||||
tags = [f"tag_{i}" for i in range(100)]
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", tags=tags)
|
||||
assert len(item.tags) == 100
|
||||
assert item.tags[0] == "tag_0"
|
||||
assert item.tags[99] == "tag_99"
|
||||
|
||||
def test_is_active_toggle(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", is_active=True)
|
||||
item.is_active = False
|
||||
assert item.is_active is False
|
||||
item.is_active = True
|
||||
assert item.is_active is True
|
||||
|
||||
def test_empty_description(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", description="")
|
||||
assert item.description == ""
|
||||
|
||||
def test_custom_category(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", category="自定义分类")
|
||||
assert item.category == "自定义分类"
|
||||
|
||||
@@ -202,3 +202,139 @@ class TestTtsConfigClamp:
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
|
||||
|
||||
class TestTtsConfigTextEdge:
|
||||
"""文本字段边界测试."""
|
||||
|
||||
def test_long_text_preserved(self):
|
||||
long_text = "配音文本" * 500
|
||||
data = {"enabled": True, "voice_id": "v1", "text": long_text}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == long_text
|
||||
assert len(config.text) == 2000
|
||||
|
||||
def test_unicode_text_preserved(self):
|
||||
data = {"enabled": True, "voice_id": "v1", "text": "こんにちは世界🎵"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == "こんにちは世界🎵"
|
||||
|
||||
def test_special_chars_text_preserved(self):
|
||||
data = {"enabled": True, "voice_id": "v1", "text": "line1\nline2\t tab <>&\"'"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == "line1\nline2\t tab <>&\"'"
|
||||
|
||||
def test_empty_text_ok(self):
|
||||
data = {"enabled": True, "voice_id": "v1", "text": ""}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == ""
|
||||
|
||||
def test_text_none_fallback(self):
|
||||
data = {"enabled": True, "voice_id": "v1", "text": None}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == ""
|
||||
|
||||
|
||||
class TestTtsConfigVoiceIdEdge:
|
||||
"""voice_id 边界测试."""
|
||||
|
||||
def test_very_long_voice_id_preserved(self):
|
||||
long_id = "voice_" + "x" * 200
|
||||
data = {"enabled": True, "voice_id": long_id}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.voice_id == long_id
|
||||
|
||||
def test_voice_id_empty_string_ok(self):
|
||||
data = {"enabled": True, "voice_id": ""}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_voice_id_unicode_ok(self):
|
||||
data = {"enabled": True, "voice_id": "音色_测试_001"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.voice_id == "音色_测试_001"
|
||||
|
||||
|
||||
class TestTtsConfigClampEdge:
|
||||
"""钳制边界附近值测试."""
|
||||
|
||||
def test_speed_just_below_min_clamped(self):
|
||||
data = {"enabled": True, "speed": 0.499}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_just_above_max_clamped(self):
|
||||
data = {"enabled": True, "speed": 2.001}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_pitch_just_below_min_clamped(self):
|
||||
data = {"enabled": True, "pitch": -12.1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_just_above_max_clamped(self):
|
||||
data = {"enabled": True, "pitch": 12.1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_volume_just_below_min_clamped(self):
|
||||
data = {"enabled": True, "volume": -0.001}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_just_above_max_clamped(self):
|
||||
data = {"enabled": True, "volume": 1.001}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_direct_construct_clamp_speed(self):
|
||||
config = TtsConfig(enabled=True, speed=0.1)
|
||||
config._clamp()
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_direct_construct_clamp_pitch_volume(self):
|
||||
config = TtsConfig(enabled=True, pitch=-20, volume=2.0)
|
||||
config._clamp()
|
||||
assert config.pitch == -12
|
||||
assert config.volume == 1.0
|
||||
|
||||
|
||||
class TestTtsConfigAlignOverlapEdge:
|
||||
"""对齐与叠加模式边界."""
|
||||
|
||||
def test_align_mode_empty_string_fallback(self):
|
||||
data = {"enabled": True, "align_mode": ""}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_overlap_mode_empty_string_fallback(self):
|
||||
data = {"enabled": True, "overlap_mode": ""}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_align_mode_case_sensitive(self):
|
||||
data = {"enabled": True, "align_mode": "SUBTITLE"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.align_mode == "full"
|
||||
|
||||
|
||||
class TestTtsConfigEquality:
|
||||
"""相等性与独立性测试."""
|
||||
|
||||
def test_same_config_equal(self):
|
||||
c1 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
|
||||
c2 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
|
||||
assert c1 == c2
|
||||
|
||||
def test_different_config_not_equal(self):
|
||||
c1 = TtsConfig(enabled=True, voice_id="v1")
|
||||
c2 = TtsConfig(enabled=True, voice_id="v2")
|
||||
assert c1 != c2
|
||||
|
||||
def test_modify_one_does_not_affect_other(self):
|
||||
c1 = TtsConfig(enabled=True, voice_id="v1")
|
||||
c2 = TtsConfig(enabled=True, voice_id="v1")
|
||||
c2.speed = 2.0
|
||||
assert c1.speed == 1.0
|
||||
assert c1 != c2
|
||||
|
||||
Regular → Executable
+85
@@ -166,3 +166,88 @@ class TestVerificationCodeTypes:
|
||||
vc = VerificationCode.create("test@example.com", code_type)
|
||||
assert vc.code_type == code_type
|
||||
assert vc.is_valid is True
|
||||
|
||||
|
||||
class TestVerificationCodeExtended:
|
||||
"""VerificationCode 深度补充测试"""
|
||||
|
||||
def test_id_is_hex(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
int(vc.id, 16)
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
vc1 = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc2 = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc1.id != vc2.id
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
import pytest
|
||||
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
with pytest.raises(AttributeError):
|
||||
vc.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_custom_code_non_numeric(self):
|
||||
"""自定义 code 可以是非数字"""
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="abcdef")
|
||||
assert vc.code == "abcdef"
|
||||
|
||||
def test_custom_code_short(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="12")
|
||||
assert vc.code == "12"
|
||||
|
||||
def test_custom_code_long(self):
|
||||
long_code = "1" * 20
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", custom_code=long_code)
|
||||
assert vc.code == long_code
|
||||
assert len(vc.code) == 20
|
||||
|
||||
def test_ttl_zero_expires_immediately(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=0)
|
||||
# ttl=0 时 expires_at = now,可能已过期或刚好
|
||||
assert vc.expires_at is not None
|
||||
|
||||
def test_very_long_ttl(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=86400)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_negative_ttl_expired(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-100)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_attempts_starts_at_zero(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_attempts_returns_none(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
result = vc.increment_attempts()
|
||||
assert result is None
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_mark_used_returns_none(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
result = vc.mark_used()
|
||||
assert result is None
|
||||
assert vc.is_used is True
|
||||
|
||||
def test_code_type_empty_string(self):
|
||||
vc = VerificationCode.create("test@example.com", "")
|
||||
assert vc.code_type == ""
|
||||
|
||||
def test_recipient_empty_string(self):
|
||||
vc = VerificationCode.create("", "email_bind")
|
||||
assert vc.recipient == ""
|
||||
|
||||
def test_created_at_equals_expires_minus_ttl(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=60)
|
||||
diff = (vc.expires_at - vc.created_at).total_seconds()
|
||||
assert abs(diff - 60) < 2
|
||||
|
||||
def test_multiple_mark_used_updates_time(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.mark_used()
|
||||
first = vc.used_at
|
||||
vc.mark_used()
|
||||
second = vc.used_at
|
||||
assert second >= first
|
||||
|
||||
Executable
+365
@@ -0,0 +1,365 @@
|
||||
"""VideoProcessor 纯逻辑单测 — 数据类 + 输入校验 + 解析逻辑.
|
||||
|
||||
通过 mock ffmpeg-python 库验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import fields
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.processor import VideoProcessor, VideoResult
|
||||
|
||||
|
||||
class TestVideoResultDataclass:
|
||||
"""VideoResult 数据类测试."""
|
||||
|
||||
def test_all_fields_exist(self):
|
||||
"""所有字段都存在."""
|
||||
field_names = {f.name for f in fields(VideoResult)}
|
||||
expected = {
|
||||
"output_path",
|
||||
"thumbnail_path",
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"file_size",
|
||||
}
|
||||
assert expected.issubset(field_names)
|
||||
|
||||
def test_default_construction(self):
|
||||
"""正常构造 VideoResult."""
|
||||
result = VideoResult(
|
||||
output_path="/tmp/out.mp4",
|
||||
thumbnail_path="/tmp/out.jpg",
|
||||
duration=10.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
file_size=1024000,
|
||||
)
|
||||
assert result.output_path == "/tmp/out.mp4"
|
||||
assert result.thumbnail_path == "/tmp/out.jpg"
|
||||
assert result.duration == 10.5
|
||||
assert result.width == 1920
|
||||
assert result.height == 1080
|
||||
assert result.fps == 25.0
|
||||
assert result.file_size == 1024000
|
||||
|
||||
def test_zero_values(self):
|
||||
"""零值/边界值构造."""
|
||||
result = VideoResult(
|
||||
output_path="",
|
||||
thumbnail_path="",
|
||||
duration=0.0,
|
||||
width=0,
|
||||
height=0,
|
||||
fps=0.0,
|
||||
file_size=0,
|
||||
)
|
||||
assert result.duration == 0.0
|
||||
assert result.file_size == 0
|
||||
|
||||
|
||||
class TestVideoProcessorInit:
|
||||
"""VideoProcessor 初始化测试."""
|
||||
|
||||
def test_default_temp_dir(self):
|
||||
"""默认使用系统临时目录."""
|
||||
import tempfile
|
||||
|
||||
vp = VideoProcessor()
|
||||
assert vp.temp_dir == tempfile.gettempdir()
|
||||
|
||||
def test_custom_temp_dir(self):
|
||||
"""自定义临时目录."""
|
||||
vp = VideoProcessor(temp_dir="/my/temp")
|
||||
assert vp.temp_dir == "/my/temp"
|
||||
|
||||
|
||||
class TestVideoProcessorConcatenateValidation:
|
||||
"""concatenate_videos 输入校验测试."""
|
||||
|
||||
def test_empty_input_raises(self):
|
||||
"""空输入列表抛出 ValueError."""
|
||||
vp = VideoProcessor()
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
vp.concatenate_videos([], "/tmp/output.mp4")
|
||||
|
||||
def test_none_input_raises(self):
|
||||
"""None 输入抛出异常."""
|
||||
vp = VideoProcessor()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
vp.concatenate_videos(None, "/tmp/output.mp4") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestVideoProcessorGetVideoInfoParsing:
|
||||
"""get_video_info 解析逻辑测试(mock ffmpeg.probe)."""
|
||||
|
||||
def _mock_probe(self, streams=None, fmt=None):
|
||||
"""创建 ffmpeg.probe 的 mock 返回值."""
|
||||
return {
|
||||
"streams": streams
|
||||
or [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1", "codec_name": "h264"}],
|
||||
"format": fmt or {"duration": "10.5", "bit_rate": "5000000"},
|
||||
}
|
||||
|
||||
def test_basic_info_parsing(self):
|
||||
"""基本视频信息解析正确."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["duration"] == 10.5
|
||||
assert info["width"] == 1920
|
||||
assert info["height"] == 1080
|
||||
assert info["fps"] == 25.0
|
||||
assert info["codec"] == "h264"
|
||||
assert info["bitrate"] == 5000000
|
||||
|
||||
def test_fps_fraction_parsing(self):
|
||||
"""分数帧率解析(如 30000/1001 = 29.97)."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[
|
||||
{
|
||||
"codec_type": "video",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"r_frame_rate": "30000/1001",
|
||||
"codec_name": "h264",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == pytest.approx(29.97, abs=0.01)
|
||||
|
||||
def test_fps_integer_string(self):
|
||||
"""整数字符串帧率(如 "60")."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "60", "codec_name": "h264"}]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == 60.0
|
||||
|
||||
def test_missing_r_frame_rate(self):
|
||||
"""缺少 r_frame_rate 时使用默认值."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "codec_name": "h264"}]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == 25.0
|
||||
|
||||
def test_no_video_stream(self):
|
||||
"""没有视频流时的行为."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = {
|
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||
"format": {"duration": "10.0", "bit_rate": "128000"},
|
||||
}
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
with pytest.raises(StopIteration):
|
||||
vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
def test_float_duration(self):
|
||||
"""浮点时长解析."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(fmt={"duration": "123.456", "bit_rate": "0"})
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["duration"] == pytest.approx(123.456, abs=0.001)
|
||||
|
||||
def test_bitrate_zero(self):
|
||||
"""码率为 0 时."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(fmt={"duration": "10.0", "bit_rate": "0"})
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["bitrate"] == 0
|
||||
|
||||
def test_ffmpeg_probe_error_raises(self):
|
||||
"""ffmpeg.probe 失败时抛出 RuntimeError."""
|
||||
vp = VideoProcessor()
|
||||
|
||||
import ffmpeg
|
||||
|
||||
with patch(
|
||||
"video_processing.processor.ffmpeg.probe",
|
||||
side_effect=ffmpeg.Error([], b"", b"No such file"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="probe error"):
|
||||
vp.get_video_info("/tmp/nonexistent.mp4")
|
||||
|
||||
|
||||
class TestVideoProcessorGenerateThumbnail:
|
||||
"""generate_thumbnail 测试."""
|
||||
|
||||
def _build_mock_chain(self):
|
||||
"""构建 ffmpeg.input → .output → .overwrite_output → .run 调用链."""
|
||||
mock_input_node = MagicMock()
|
||||
mock_output_node = MagicMock()
|
||||
mock_overwrite_node = MagicMock()
|
||||
mock_input_node.output.return_value = mock_output_node
|
||||
mock_output_node.overwrite_output.return_value = mock_overwrite_node
|
||||
return mock_input_node, mock_output_node, mock_overwrite_node
|
||||
|
||||
def test_default_output_path(self):
|
||||
"""默认输出路径为视频路径 + _thumb.jpg."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
result = vp.generate_thumbnail("/tmp/video.mp4")
|
||||
|
||||
assert result == "/tmp/video_thumb.jpg"
|
||||
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=1.0)
|
||||
mock_input_node.output.assert_called_once()
|
||||
# 验证输出路径和参数
|
||||
output_args = mock_input_node.output.call_args
|
||||
assert output_args[0][0] == "/tmp/video_thumb.jpg"
|
||||
assert output_args[1].get("vframes") == 1
|
||||
assert output_args[1].get("format") == "image2"
|
||||
assert output_args[1].get("vcodec") == "mjpeg"
|
||||
|
||||
def test_custom_output_path(self):
|
||||
"""自定义输出路径."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
result = vp.generate_thumbnail("/tmp/video.mp4", output_path="/custom/thumb.jpg")
|
||||
|
||||
assert result == "/custom/thumb.jpg"
|
||||
|
||||
def test_custom_timestamp(self):
|
||||
"""自定义截图时间点."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
vp.generate_thumbnail("/tmp/video.mp4", timestamp=3.5)
|
||||
|
||||
# 验证 ss 参数
|
||||
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=3.5)
|
||||
|
||||
def test_ffmpeg_error_raises_runtime(self):
|
||||
"""FFmpeg 失败时抛出 RuntimeError."""
|
||||
vp = VideoProcessor()
|
||||
|
||||
import ffmpeg
|
||||
|
||||
mock_input_node, mock_output, mock_overwrite = self._build_mock_chain()
|
||||
mock_overwrite.run.side_effect = ffmpeg.Error([], b"", b"Output file #0 does not contain any stream")
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node):
|
||||
with pytest.raises(RuntimeError, match="thumbnail error"):
|
||||
vp.generate_thumbnail("/tmp/video.mp4")
|
||||
|
||||
|
||||
class TestVideoProcessorConcatFileFormat:
|
||||
"""concat 临时文件格式验证."""
|
||||
|
||||
def test_concat_file_format(self, tmp_path):
|
||||
"""concat 临时文件格式符合 FFmpeg concat demuxer 规范."""
|
||||
import os
|
||||
|
||||
vp = VideoProcessor(temp_dir=str(tmp_path))
|
||||
|
||||
written_content = {}
|
||||
|
||||
def fake_input(path, *args, **kwargs):
|
||||
mock_node = MagicMock()
|
||||
mock_output = MagicMock()
|
||||
mock_overwrite = MagicMock()
|
||||
mock_node.output.return_value = mock_output
|
||||
mock_output.overwrite_output.return_value = mock_overwrite
|
||||
|
||||
if kwargs.get("format") == "concat":
|
||||
# 读取 concat 文件内容
|
||||
with open(path) as f:
|
||||
written_content["concat"] = f.read()
|
||||
return mock_node
|
||||
|
||||
mock_probe = MagicMock(
|
||||
return_value={
|
||||
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
|
||||
"format": {"duration": "5.0", "bit_rate": "1000000"},
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("video_processing.processor.ffmpeg.input", side_effect=fake_input),
|
||||
patch("video_processing.processor.ffmpeg.probe", mock_probe),
|
||||
patch("video_processing.processor.os.path.getsize", return_value=1024),
|
||||
):
|
||||
with patch.object(VideoProcessor, "generate_thumbnail", return_value="/tmp/thumb.jpg"):
|
||||
vp.concatenate_videos(
|
||||
["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"],
|
||||
str(tmp_path / "output.mp4"),
|
||||
)
|
||||
|
||||
# 验证 concat 文件格式
|
||||
assert "concat" in written_content
|
||||
lines = written_content["concat"].strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0].startswith("file '")
|
||||
assert "a.mp4'" in lines[0]
|
||||
assert "b.mp4'" in lines[1]
|
||||
assert "c.mp4'" in lines[2]
|
||||
# 使用绝对路径
|
||||
first_path = lines[0].replace("file '", "").rstrip("'")
|
||||
assert os.path.isabs(first_path)
|
||||
|
||||
def test_concat_creates_output_directory(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
vp = VideoProcessor(temp_dir=str(tmp_path))
|
||||
|
||||
out_dir = tmp_path / "deep" / "output"
|
||||
out_file = out_dir / "result.mp4"
|
||||
|
||||
mock_node = MagicMock()
|
||||
mock_output = MagicMock()
|
||||
mock_overwrite = MagicMock()
|
||||
mock_node.output.return_value = mock_output
|
||||
mock_output.overwrite_output.return_value = mock_overwrite
|
||||
|
||||
mock_probe = MagicMock(
|
||||
return_value={
|
||||
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
|
||||
"format": {"duration": "5.0", "bit_rate": "1000000"},
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("video_processing.processor.ffmpeg.input", return_value=mock_node),
|
||||
patch("video_processing.processor.ffmpeg.probe", mock_probe),
|
||||
patch("video_processing.processor.os.path.getsize", return_value=1024),
|
||||
):
|
||||
with patch.object(VideoProcessor, "generate_thumbnail", return_value=str(out_dir / "thumb.jpg")):
|
||||
vp.concatenate_videos(["/tmp/a.mp4"], str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
@@ -223,3 +223,100 @@ class TestVideoShareCounters:
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
assert share.is_accessible is False
|
||||
|
||||
|
||||
class TestGenerateShareTokenExtended:
|
||||
"""generate_share_token 深度补充测试"""
|
||||
|
||||
def test_zero_length(self):
|
||||
token = generate_share_token(0)
|
||||
assert token == ""
|
||||
|
||||
def test_length_one(self):
|
||||
token = generate_share_token(1)
|
||||
assert len(token) == 1
|
||||
|
||||
def test_very_long_token(self):
|
||||
token = generate_share_token(100)
|
||||
assert len(token) == 100
|
||||
|
||||
def test_no_special_characters(self):
|
||||
token = generate_share_token(50)
|
||||
assert token.isalnum()
|
||||
|
||||
def test_all_characters_from_alphabet(self):
|
||||
alphabet = set("abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
||||
token = generate_share_token(200)
|
||||
for c in token:
|
||||
assert c in alphabet
|
||||
|
||||
|
||||
class TestVideoShareExtended:
|
||||
"""VideoShare 深度补充测试"""
|
||||
|
||||
def test_zero_view_count(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.view_count == 0
|
||||
|
||||
def test_zero_download_count(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.download_count == 0
|
||||
|
||||
def test_large_view_count(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
for _ in range(1000):
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 1000
|
||||
|
||||
def test_revoke_idempotent(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
|
||||
def test_revoke_returns_none(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
result = share.revoke()
|
||||
assert result is None
|
||||
|
||||
def test_increment_view_returns_none(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
result = share.increment_view_count()
|
||||
assert result is None
|
||||
|
||||
def test_increment_download_returns_none(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
result = share.increment_download_count()
|
||||
assert result is None
|
||||
|
||||
def test_id_is_hex(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
int(share.id, 16)
|
||||
|
||||
def test_ids_are_unique(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_expires_at_boundary_exact_now(self):
|
||||
"""expires_at 恰好是现在,应该被认为过期"""
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
|
||||
assert share.is_expired is True
|
||||
|
||||
def test_expires_at_boundary_one_second_future(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_password_with_special_characters(self):
|
||||
special_pass = "pass!@#$%^&*()"
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password=special_pass)
|
||||
assert share.verify_password(special_pass) is True
|
||||
assert share.verify_password("wrong") is False
|
||||
|
||||
def test_password_unicode(self):
|
||||
unicode_pass = "密码🔐测试"
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password=unicode_pass)
|
||||
assert share.verify_password(unicode_pass) is True
|
||||
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
"""VoiceExtractor 纯逻辑单测 — 命令构建 + 边界用例.
|
||||
|
||||
通过 mock run_ffmpeg 验证 FFmpeg 命令参数是否正确,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from worker_app.tasks.voice_extraction import VoiceExtractor
|
||||
|
||||
|
||||
class TestVoiceExtractorExtractVoiceCommand:
|
||||
"""extract_voice 命令构建测试."""
|
||||
|
||||
def test_default_params_correct_command(self):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
result = extractor.extract_voice("/tmp/input.mp4", "/tmp/output.mp3")
|
||||
|
||||
assert result == "/tmp/output.mp3"
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构验证
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-y" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
||||
assert "-vn" in cmd # 无视频流
|
||||
assert cmd[-1] == "/tmp/output.mp3"
|
||||
|
||||
# 音频滤镜验证
|
||||
af_idx = cmd.index("-af")
|
||||
af_value = cmd[af_idx + 1]
|
||||
assert "highpass=f=200" in af_value
|
||||
assert "afftdn=bn=20" in af_value
|
||||
assert "bandpass=f=300:width_type=h:width=3000" in af_value
|
||||
assert "loudnorm" in af_value
|
||||
|
||||
# 编码验证
|
||||
assert "libmp3lame" in cmd
|
||||
assert "-q:a" in cmd
|
||||
assert cmd[cmd.index("-q:a") + 1] == "2"
|
||||
|
||||
def test_custom_highpass(self):
|
||||
"""自定义 highpass 频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "highpass=f=500" in af_value
|
||||
|
||||
def test_custom_bandpass_freq(self):
|
||||
"""自定义 bandpass 中心频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "bandpass=f=500:" in af_value
|
||||
|
||||
def test_custom_bandpass_width(self):
|
||||
"""自定义 bandpass 宽度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_width=5000)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "width=5000" in af_value
|
||||
|
||||
def test_custom_noise_reduction(self):
|
||||
"""自定义降噪强度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=30)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "afftdn=bn=30" in af_value
|
||||
|
||||
def test_filter_order_is_correct(self):
|
||||
"""滤镜顺序:highpass → 降噪 → bandpass → loudnorm."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
|
||||
hp_pos = af_value.index("highpass")
|
||||
dn_pos = af_value.index("afftdn")
|
||||
bp_pos = af_value.index("bandpass")
|
||||
ln_pos = af_value.index("loudnorm")
|
||||
|
||||
assert hp_pos < dn_pos < bp_pos < ln_pos
|
||||
|
||||
def test_creates_output_directory(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
out_dir = tmp_path / "nested" / "deep"
|
||||
out_file = out_dir / "voice.mp3"
|
||||
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
extractor.extract_voice("/tmp/in.mp4", str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_returns_output_path(self):
|
||||
"""返回值为输出路径."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
result = extractor.extract_voice("/tmp/in.mp4", "/tmp/voice.mp3")
|
||||
|
||||
assert result == "/tmp/voice.mp3"
|
||||
|
||||
|
||||
class TestVoiceExtractorExtractBackgroundCommand:
|
||||
"""extract_background 命令构建测试."""
|
||||
|
||||
def test_default_params_correct_command(self):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
result = extractor.extract_background("/tmp/input.mp4", "/tmp/output.mp3")
|
||||
|
||||
assert result == "/tmp/output.mp3"
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-y" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
||||
assert "-vn" in cmd
|
||||
assert cmd[-1] == "/tmp/output.mp3"
|
||||
|
||||
# 音频滤镜
|
||||
af_idx = cmd.index("-af")
|
||||
af_value = cmd[af_idx + 1]
|
||||
assert "lowpass=f=200" in af_value
|
||||
assert "loudnorm" in af_value
|
||||
|
||||
# 编码
|
||||
assert "libmp3lame" in cmd
|
||||
|
||||
def test_custom_lowpass_freq(self):
|
||||
"""自定义 lowpass 频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "lowpass=f=500" in af_value
|
||||
|
||||
def test_filter_order_background(self):
|
||||
"""背景音滤镜顺序:lowpass → loudnorm."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
|
||||
lp_pos = af_value.index("lowpass")
|
||||
ln_pos = af_value.index("loudnorm")
|
||||
assert lp_pos < ln_pos
|
||||
|
||||
def test_background_creates_output_directory(self, tmp_path):
|
||||
"""背景音输出目录不存在时自动创建."""
|
||||
out_dir = tmp_path / "bgm" / "tracks"
|
||||
out_file = out_dir / "bg.mp3"
|
||||
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
extractor.extract_background("/tmp/in.mp4", str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
|
||||
|
||||
class TestVoiceExtractorEdgeCases:
|
||||
"""边界情况测试."""
|
||||
|
||||
def test_zero_highpass(self):
|
||||
"""highpass=0 时的行为(极端低值)."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "highpass=f=0" in af_value
|
||||
|
||||
def test_zero_bandpass_freq(self):
|
||||
"""bandpass_freq=0 时的极端情况."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "bandpass=f=0:" in af_value
|
||||
|
||||
def test_very_high_noise_reduction(self):
|
||||
"""极高降噪强度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=100)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "afftdn=bn=100" in af_value
|
||||
|
||||
def test_negative_lowpass_allowed(self):
|
||||
"""lowpass 负值(由调用方保证合法性,函数不做校验)."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=-10)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "lowpass=f=-10" in af_value
|
||||
|
||||
def test_run_ffmpeg_propagates_error(self):
|
||||
"""_run_ffmpeg 抛出异常时向上传递."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=RuntimeError("FFmpeg failed")):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg failed"):
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
def test_voice_extractor_is_static_method(self):
|
||||
"""_run_ffmpeg 是静态方法,可在类上直接调用."""
|
||||
# 验证 VoiceExtractor 可以直接实例化(无需参数)
|
||||
extractor = VoiceExtractor()
|
||||
assert extractor is not None
|
||||
|
||||
def test_multiple_extractions_same_instance(self):
|
||||
"""同一个实例可多次执行提取."""
|
||||
extractor = VoiceExtractor()
|
||||
call_count = 0
|
||||
|
||||
def fake_run(cmd):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=fake_run):
|
||||
extractor.extract_voice("/tmp/a.mp4", "/tmp/a_voice.mp3")
|
||||
extractor.extract_background("/tmp/a.mp4", "/tmp/a_bg.mp3")
|
||||
|
||||
assert call_count == 2
|
||||
@@ -93,3 +93,93 @@ class TestVoiceLibraryItem:
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.created_at is not None
|
||||
assert item.updated_at is not None
|
||||
|
||||
|
||||
class TestVoiceLibraryItemExtended:
|
||||
"""VoiceLibraryItem 深度补充测试"""
|
||||
|
||||
def test_zero_duration(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=0)
|
||||
assert item.duration == 0
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负数 duration 领域层不校验"""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=-1.5)
|
||||
assert item.duration == -1.5
|
||||
|
||||
def test_large_duration(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=9999.99)
|
||||
assert item.duration == 9999.99
|
||||
|
||||
def test_zero_file_size(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=0)
|
||||
assert item.file_size == 0
|
||||
|
||||
def test_large_file_size(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=10**9)
|
||||
assert item.file_size == 10**9
|
||||
|
||||
def test_empty_audio_url(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", audio_url="")
|
||||
assert item.audio_url == ""
|
||||
|
||||
def test_tags_independence(self):
|
||||
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
|
||||
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
|
||||
item1.tags.append("新标签")
|
||||
assert "新标签" not in item2.tags
|
||||
assert len(item2.tags) == 0
|
||||
|
||||
def test_metadata_independence(self):
|
||||
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
|
||||
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
|
||||
item1.metadata_["key"] = "val"
|
||||
assert "key" not in item2.metadata_
|
||||
|
||||
def test_many_tags(self):
|
||||
tags = [f"tag_{i}" for i in range(30)]
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=tags)
|
||||
assert len(item.tags) == 30
|
||||
assert item.tags[0] == "tag_0"
|
||||
|
||||
def test_empty_text(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text="")
|
||||
assert item.text == ""
|
||||
|
||||
def test_long_text(self):
|
||||
long_text = "这是一段很长的配音文本" * 100
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text=long_text)
|
||||
assert item.text == long_text
|
||||
assert len(item.text) == 1100
|
||||
|
||||
def test_empty_voice_id(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", voice_id="")
|
||||
assert item.voice_id == ""
|
||||
|
||||
def test_empty_project_id(self):
|
||||
"""project_id 默认是 None 不是空字符串"""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.project_id is None
|
||||
|
||||
def test_project_id_with_string(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", project_id="")
|
||||
# 传空字符串的话就是空字符串
|
||||
assert item.project_id == ""
|
||||
|
||||
def test_status_empty_string(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="")
|
||||
assert item.status == ""
|
||||
|
||||
def test_unicode_name(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="🎙️ 专业配音 · 龙小淳")
|
||||
assert "🎙️" in item.name
|
||||
assert "龙小淳" in item.name
|
||||
|
||||
def test_special_characters_in_name(self):
|
||||
special = "配!@#$%^&*()音"
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name=special)
|
||||
assert item.name == special
|
||||
|
||||
def test_empty_user_id(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="", name="n")
|
||||
assert item.user_id == ""
|
||||
|
||||
Executable
+839
@@ -0,0 +1,839 @@
|
||||
"""第76波:TTSJob + Job + Tag 领域纯逻辑单测。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.job import Job, JobStatus, JobType
|
||||
from packages.domain.tag import Tag
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
# ============================================================
|
||||
# Tag.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTagCreate:
|
||||
def test_create_basic(self):
|
||||
tag = Tag.create(user_id="u1", name="风景")
|
||||
assert tag.id
|
||||
assert tag.user_id == "u1"
|
||||
assert tag.name == "风景"
|
||||
assert isinstance(tag.created_at, datetime)
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="u1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="u1", name=" ")
|
||||
|
||||
def test_create_name_stripped(self):
|
||||
tag = Tag.create(user_id="u1", name=" 美食 ")
|
||||
assert tag.name == "美食"
|
||||
|
||||
def test_create_id_is_unique(self):
|
||||
tag1 = Tag.create(user_id="u1", name="a")
|
||||
tag2 = Tag.create(user_id="u1", name="b")
|
||||
assert tag1.id != tag2.id
|
||||
|
||||
def test_create_uses_utc_timezone(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
tag = Tag.create(user_id="u1", name="t")
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= tag.created_at <= after
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSJob.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSJobCreate:
|
||||
def test_create_minimal(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="你好世界")
|
||||
assert job.id
|
||||
assert job.user_id == "u1"
|
||||
assert job.input_text == "你好世界"
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.format == "mp3"
|
||||
assert job.sample_rate == 22050
|
||||
assert job.error_message == ""
|
||||
assert isinstance(job.created_at, datetime)
|
||||
assert isinstance(job.updated_at, datetime)
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id 不能为空"):
|
||||
TTSJob.create(user_id=" ", input_text="hi")
|
||||
|
||||
def test_create_empty_input_text_raises(self):
|
||||
with pytest.raises(ValueError, match="input_text 不能为空"):
|
||||
TTSJob.create(user_id="u1", input_text="")
|
||||
|
||||
def test_create_whitespace_input_raises(self):
|
||||
with pytest.raises(ValueError, match="input_text 不能为空"):
|
||||
TTSJob.create(user_id="u1", input_text=" \n\t ")
|
||||
|
||||
def test_create_too_long_input_raises(self):
|
||||
long_text = "a" * 10001
|
||||
with pytest.raises(ValueError, match="长度不能超过"):
|
||||
TTSJob.create(user_id="u1", input_text=long_text)
|
||||
|
||||
def test_create_boundary_length_ok(self):
|
||||
text = "a" * 10000
|
||||
job = TTSJob.create(user_id="u1", input_text=text)
|
||||
assert len(job.input_text) == 10000
|
||||
|
||||
def test_create_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError, match="不支持的输出格式"):
|
||||
TTSJob.create(user_id="u1", input_text="hi", format="flac")
|
||||
|
||||
def test_create_valid_formats(self):
|
||||
for fmt in ("mp3", "wav", "pcm"):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", format=fmt)
|
||||
assert job.format == fmt
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
job = TTSJob.create(
|
||||
user_id=" u1 ",
|
||||
input_text=" hello ",
|
||||
voice_id=" v1 ",
|
||||
voice_model=" cosyvoice ",
|
||||
project_id=" p1 ",
|
||||
voice_clone_profile_id=" vp1 ",
|
||||
)
|
||||
assert job.user_id == "u1"
|
||||
assert job.input_text == "hello"
|
||||
assert job.voice_id == "v1"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "p1"
|
||||
assert job.voice_clone_profile_id == "vp1"
|
||||
|
||||
def test_create_custom_params(self):
|
||||
job = TTSJob.create(
|
||||
user_id="u1",
|
||||
input_text="hi",
|
||||
voice_id="voice_001",
|
||||
voice_model="cosyvoice",
|
||||
project_id="proj_001",
|
||||
voice_clone_profile_id="vcp_001",
|
||||
sample_rate=16000,
|
||||
format="wav",
|
||||
max_retries=5,
|
||||
metadata={"key": "val"},
|
||||
)
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.voice_clone_profile_id == "vcp_001"
|
||||
assert job.sample_rate == 16000
|
||||
assert job.format == "wav"
|
||||
assert job.max_retries == 5
|
||||
assert job.metadata == {"key": "val"}
|
||||
|
||||
def test_create_metadata_default_empty_dict(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert job.metadata == {}
|
||||
|
||||
def test_create_id_unique(self):
|
||||
j1 = TTSJob.create(user_id="u1", input_text="a")
|
||||
j2 = TTSJob.create(user_id="u1", input_text="b")
|
||||
assert j1.id != j2.id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSJob 状态属性测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSJobStatusProperties:
|
||||
def test_is_terminal_pending_false(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_processing_false(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_completed_true(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("http://out.mp3")
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_failed_true(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_cancelled_true(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_cancelled()
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err")
|
||||
assert job.is_retryable
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err")
|
||||
job.retry_count = 2
|
||||
assert not job.is_retryable
|
||||
|
||||
def test_is_retryable_pending_false(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
assert not job.is_retryable
|
||||
|
||||
def test_is_completed_needs_url(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.status = TTSJobStatus.COMPLETED
|
||||
job.output_audio_url = ""
|
||||
assert not job.is_completed
|
||||
job.output_audio_url = "http://x.mp3"
|
||||
assert job.is_completed
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSJob 状态转换测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSJobTransitions:
|
||||
def test_pending_to_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_pending_to_completed_invalid(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
|
||||
def test_processing_to_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
job.retry_count = 0
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
|
||||
def test_completed_to_anything_invalid(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
with pytest.raises(ValueError):
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
|
||||
def test_transition_to_accepts_string(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.transition_to("processing")
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
job.transition_to("invalid_state")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
before = job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.updated_at >= before
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSJob 业务方法测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSJobBusinessMethods:
|
||||
def test_mark_processing_sets_started_at(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
assert job.started_at is not None
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_processing_clears_error(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.error_message = "old error"
|
||||
job.mark_processing()
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_completed_sets_fields(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
"http://out.mp3",
|
||||
output_audio_key="oss://key",
|
||||
duration=10.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "http://out.mp3"
|
||||
assert job.output_audio_key == "oss://key"
|
||||
assert job.duration == 10.5
|
||||
assert job.file_size == 102400
|
||||
assert job.completed_at is not None
|
||||
assert job.is_completed
|
||||
|
||||
def test_mark_completed_empty_url_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
|
||||
job.mark_completed(" ")
|
||||
|
||||
def test_mark_failed_sets_error(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_failed("网络超时")
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert job.error_message == "网络超时"
|
||||
|
||||
def test_mark_cancelled_from_pending(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_cancelled()
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_mark_cancelled_from_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_cancelled()
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_prepare_retry_success(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err")
|
||||
assert job.retry_count == 0
|
||||
job.prepare_retry()
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
def test_prepare_retry_multiple_times(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
|
||||
for i in range(3):
|
||||
job.mark_processing()
|
||||
job.mark_failed(f"err{i}")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == i + 1
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
|
||||
def test_prepare_retry_exceed_max_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
|
||||
job.mark_processing()
|
||||
job.mark_failed("err1")
|
||||
job.prepare_retry()
|
||||
job.mark_processing()
|
||||
job.mark_failed("err2")
|
||||
job.prepare_retry()
|
||||
# 第3次失败后 retry_count=2 == max_retries=2,不可重试
|
||||
job.mark_processing()
|
||||
job.mark_failed("err3")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_prepare_retry_pending_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
job = TTSJob.create(
|
||||
user_id="u1",
|
||||
input_text="hi",
|
||||
voice_id="v1",
|
||||
project_id="p1",
|
||||
metadata={"k": "v"},
|
||||
)
|
||||
d = job.to_dict()
|
||||
expected_keys = {
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"voice_clone_profile_id",
|
||||
"status",
|
||||
"input_text",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"output_audio_url",
|
||||
"output_audio_key",
|
||||
"duration",
|
||||
"file_size",
|
||||
"sample_rate",
|
||||
"format",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"is_retryable",
|
||||
"is_completed",
|
||||
"metadata",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert set(d.keys()) == expected_keys
|
||||
assert d["status"] == "pending"
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_completed"] is False
|
||||
assert d["metadata"] == {"k": "v"}
|
||||
# 时间字段应为 ISO 字符串或 None
|
||||
assert isinstance(d["created_at"], str)
|
||||
assert d["started_at"] is None
|
||||
|
||||
def test_to_dict_completed_state(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="hi")
|
||||
job.mark_processing()
|
||||
job.mark_completed("http://out.mp3")
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "completed"
|
||||
assert d["is_completed"] is True
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Job.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJobCreate:
|
||||
def test_create_minimal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.id
|
||||
assert job.project_id == "p1"
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.progress == 0.0
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.payload == {}
|
||||
assert job.result == {}
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
|
||||
|
||||
def test_create_string_job_type(self):
|
||||
job = Job.create(project_id="p1", job_type="video_compose")
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
|
||||
def test_create_invalid_job_type_string_raises(self):
|
||||
with pytest.raises(ValueError, match="不支持的任务类型"):
|
||||
Job.create(project_id="p1", job_type="unknown_type")
|
||||
|
||||
def test_create_strips_project_id(self):
|
||||
job = Job.create(project_id=" p1 ", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.project_id == "p1"
|
||||
|
||||
def test_create_full_params(self):
|
||||
job = Job.create(
|
||||
project_id="p1",
|
||||
job_type=JobType.RENDER_EDIT_PLAN,
|
||||
payload={"edit_plan_id": "ep1"},
|
||||
source_id="src_001",
|
||||
created_by_user_id="u1",
|
||||
max_retries=5,
|
||||
)
|
||||
assert job.job_type == JobType.RENDER_EDIT_PLAN
|
||||
assert job.payload == {"edit_plan_id": "ep1"}
|
||||
assert job.source_id == "src_001"
|
||||
assert job.created_by_user_id == "u1"
|
||||
assert job.max_retries == 5
|
||||
|
||||
def test_create_all_job_types(self):
|
||||
for jt in JobType:
|
||||
job = Job.create(project_id="p1", job_type=jt)
|
||||
assert job.job_type == jt
|
||||
|
||||
def test_create_id_unique(self):
|
||||
j1 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
j2 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert j1.id != j2.id
|
||||
|
||||
def test_create_strips_source_and_user(self):
|
||||
job = Job.create(
|
||||
project_id="p1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
source_id=" s1 ",
|
||||
created_by_user_id=" u1 ",
|
||||
)
|
||||
assert job.source_id == "s1"
|
||||
assert job.created_by_user_id == "u1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Job 状态属性测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJobStatusProperties:
|
||||
def test_is_terminal_pending_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_running_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_success_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success()
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_failed_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_failed("err")
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_cancelled_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_cancelled()
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.mark_running()
|
||||
job.mark_failed("err")
|
||||
assert job.is_retryable
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
|
||||
job.mark_running()
|
||||
job.mark_failed("err")
|
||||
job.retry_count = 2
|
||||
assert not job.is_retryable
|
||||
|
||||
def test_is_retryable_success_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success()
|
||||
assert not job.is_retryable
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Job 状态转换测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJobTransitions:
|
||||
def test_pending_to_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.status == JobStatus.RUNNING
|
||||
|
||||
def test_pending_to_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
|
||||
def test_pending_to_failed_invalid(self):
|
||||
"""pending 不能直接到 failed,必须经过 running 或直接 success/cancelled。"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
|
||||
def test_running_to_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
|
||||
def test_running_to_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.status == JobStatus.FAILED
|
||||
|
||||
def test_running_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
assert job.status == JobStatus.PENDING
|
||||
|
||||
def test_success_to_pending_invalid(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
with pytest.raises(ValueError):
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
|
||||
def test_transition_to_accepts_string(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to("running")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
job.transition_to("bogus")
|
||||
|
||||
def test_transition_running_sets_started_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.started_at is None
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.started_at is not None
|
||||
|
||||
def test_transition_success_sets_completed_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.completed_at is None
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_transition_failed_sets_completed_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_started_at_not_overwritten_on_second_running_transition(self):
|
||||
"""通过 transition_to 再次到 RUNNING 时,如果 started_at 已有值不覆盖。"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
first = job.started_at
|
||||
# 走个 retry 流程再回来
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
job.started_at = None # prepare_retry 会清掉,这里模拟
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
second = job.started_at
|
||||
# started_at 被重置后应该重新设置
|
||||
assert second is not None
|
||||
# 时间可能相同(精度问题),但逻辑上第二次 running 会重新设置
|
||||
assert isinstance(second, datetime)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Job 业务方法测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJobBusinessMethods:
|
||||
def test_mark_running_with_stage(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running("视频合成中")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == "视频合成中"
|
||||
|
||||
def test_mark_running_without_stage(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.current_stage = "之前"
|
||||
job.mark_running()
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == "之前" # 不传 stage 不修改
|
||||
|
||||
def test_mark_success_with_result(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success({"output_url": "http://x.mp4"})
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.progress == 100.0
|
||||
assert job.current_stage == "完成"
|
||||
assert job.result == {"output_url": "http://x.mp4"}
|
||||
|
||||
def test_mark_success_without_result(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success()
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.progress == 100.0
|
||||
assert job.result == {}
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_failed("渲染失败")
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert job.error_message == "渲染失败"
|
||||
assert job.current_stage == "失败"
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_cancelled()
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
assert job.current_stage == "已取消"
|
||||
|
||||
def test_update_progress_normal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(50.0, "合成中")
|
||||
assert job.progress == 50.0
|
||||
assert job.current_stage == "合成中"
|
||||
|
||||
def test_update_progress_boundary_zero(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(0.0)
|
||||
assert job.progress == 0.0
|
||||
|
||||
def test_update_progress_boundary_hundred(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(100.0)
|
||||
assert job.progress == 100.0
|
||||
|
||||
def test_update_progress_negative_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(-1.0)
|
||||
|
||||
def test_update_progress_over_100_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(100.1)
|
||||
|
||||
def test_update_progress_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
before = job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.update_progress(30.0)
|
||||
assert job.updated_at >= before
|
||||
|
||||
def test_prepare_retry_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.mark_running("阶段1")
|
||||
job.mark_failed("错误1")
|
||||
job.celery_task_id = "celery-123"
|
||||
job.prepare_retry()
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.progress == 0.0
|
||||
assert "第 1 次重试" in job.current_stage
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
assert job.celery_task_id == ""
|
||||
|
||||
def test_prepare_retry_multiple_times(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
for i in range(3):
|
||||
job.mark_running()
|
||||
job.mark_failed(f"err{i}")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == i + 1
|
||||
assert job.status == JobStatus.PENDING
|
||||
|
||||
def test_prepare_retry_exceed_max_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
|
||||
job.mark_running()
|
||||
job.mark_failed("err1")
|
||||
job.prepare_retry()
|
||||
job.mark_running()
|
||||
job.mark_failed("err2")
|
||||
job.prepare_retry()
|
||||
job.mark_running()
|
||||
job.mark_failed("err3")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_prepare_retry_pending_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
job = Job.create(
|
||||
project_id="p1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
payload={"k": "v"},
|
||||
source_id="s1",
|
||||
created_by_user_id="u1",
|
||||
)
|
||||
d = job.to_dict()
|
||||
expected_keys = {
|
||||
"id",
|
||||
"project_id",
|
||||
"job_type",
|
||||
"status",
|
||||
"progress",
|
||||
"current_stage",
|
||||
"payload",
|
||||
"result",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"celery_task_id",
|
||||
"source_id",
|
||||
"created_by_user_id",
|
||||
"is_retryable",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert set(d.keys()) == expected_keys
|
||||
assert d["status"] == "pending"
|
||||
assert d["job_type"] == "video_compose"
|
||||
assert d["progress"] == 0.0
|
||||
assert d["payload"] == {"k": "v"}
|
||||
assert d["is_retryable"] is False
|
||||
assert d["started_at"] is None
|
||||
assert isinstance(d["created_at"], str)
|
||||
|
||||
def test_to_dict_success_state(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success({"output": "ok"})
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "success"
|
||||
assert d["progress"] == 100.0
|
||||
assert d["result"] == {"output": "ok"}
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
Executable
+466
@@ -0,0 +1,466 @@
|
||||
"""第77波:GenerationTask 领域模型纯逻辑单测。"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
# ============================================================
|
||||
# GenerationTaskStatus._missing_ 兼容枚举测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskStatusMissing:
|
||||
def test_completed_aliases(self):
|
||||
for val in ("done", "success", "finished", "complete", "completed"):
|
||||
assert GenerationTaskStatus(val) == GenerationTaskStatus.COMPLETED
|
||||
assert GenerationTaskStatus(val.upper()) == GenerationTaskStatus.COMPLETED
|
||||
|
||||
def test_failed_aliases(self):
|
||||
for val in ("fail", "failed", "error", "err"):
|
||||
assert GenerationTaskStatus(val) == GenerationTaskStatus.FAILED
|
||||
|
||||
def test_running_aliases(self):
|
||||
for val in ("process", "processing", "run", "running", "in_progress"):
|
||||
assert GenerationTaskStatus(val) == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_cancelled_aliases(self):
|
||||
for val in ("cancel", "cancelled", "canceled"):
|
||||
assert GenerationTaskStatus(val) == GenerationTaskStatus.CANCELLED
|
||||
|
||||
def test_unknown_defaults_to_pending(self):
|
||||
assert GenerationTaskStatus("unknown_state") == GenerationTaskStatus.PENDING
|
||||
assert GenerationTaskStatus("") == GenerationTaskStatus.PENDING
|
||||
|
||||
def test_whitespace_and_case_insensitive(self):
|
||||
assert GenerationTaskStatus(" DONE ") == GenerationTaskStatus.COMPLETED
|
||||
assert GenerationTaskStatus("Failed") == GenerationTaskStatus.FAILED
|
||||
|
||||
def test_non_string_falls_back_to_pending(self):
|
||||
assert GenerationTaskStatus(None) == GenerationTaskStatus.PENDING
|
||||
assert GenerationTaskStatus(123) == GenerationTaskStatus.PENDING
|
||||
|
||||
def test_normal_values_still_work(self):
|
||||
assert GenerationTaskStatus("pending") == GenerationTaskStatus.PENDING
|
||||
assert GenerationTaskStatus("running") == GenerationTaskStatus.RUNNING
|
||||
assert GenerationTaskStatus("completed") == GenerationTaskStatus.COMPLETED
|
||||
assert GenerationTaskStatus("failed") == GenerationTaskStatus.FAILED
|
||||
assert GenerationTaskStatus("cancelled") == GenerationTaskStatus.CANCELLED
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GenerationTask.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskCreate:
|
||||
def test_create_minimal(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert task.id
|
||||
assert task.project_id == "p1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.progress == 0.0
|
||||
assert task.result_count == 0
|
||||
assert task.retry_count == 0
|
||||
assert task.asset_ids == []
|
||||
assert task.title_ids == []
|
||||
assert task.voice_ids == []
|
||||
assert task.bgm_config == {}
|
||||
assert task.logs == "[]"
|
||||
|
||||
def test_create_requires_project_or_template(self):
|
||||
"""project_id 和 template_id 至少需要一个。"""
|
||||
with pytest.raises(ValueError, match="至少需要提供一个"):
|
||||
GenerationTask.create(project_id="", asset_library_id="lib1", template_id="")
|
||||
|
||||
def test_create_template_id_only(self):
|
||||
task = GenerationTask.create(project_id="", template_id="tpl1", asset_library_id="lib1")
|
||||
assert task.template_id == "tpl1"
|
||||
assert task.project_id == ""
|
||||
|
||||
def test_create_requires_library_or_assets(self):
|
||||
"""asset_library_id 和 asset_ids/title_ids/voice_ids 至少需要一个。"""
|
||||
with pytest.raises(ValueError, match="至少需要提供一个"):
|
||||
GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="",
|
||||
asset_ids=None,
|
||||
title_ids=None,
|
||||
voice_ids=None,
|
||||
)
|
||||
|
||||
def test_create_asset_ids_only(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="", asset_ids=["a1", "a2"])
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
assert task.asset_library_id == ""
|
||||
|
||||
def test_create_title_ids_only(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="", title_ids=["t1"])
|
||||
assert task.title_ids == ["t1"]
|
||||
|
||||
def test_create_voice_ids_only(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="", voice_ids=["v1"])
|
||||
assert task.voice_ids == ["v1"]
|
||||
|
||||
def test_create_full_params(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="lib1",
|
||||
strategy_id="s1",
|
||||
voice_library_id="vlib1",
|
||||
template_id="tpl1",
|
||||
asset_ids=["a1", "a2"],
|
||||
title_ids=["t1"],
|
||||
voice_ids=["v1"],
|
||||
created_by_user_id="u1",
|
||||
source_edit_plan_id="ep1",
|
||||
asset_select_mode="random",
|
||||
batch_id="batch_001",
|
||||
video_title="测试视频",
|
||||
resolution="1080p",
|
||||
bgm_config={"volume": 0.5},
|
||||
auto_retry_enabled=True,
|
||||
auto_retry_max=3,
|
||||
)
|
||||
assert task.strategy_id == "s1"
|
||||
assert task.voice_library_id == "vlib1"
|
||||
assert task.template_id == "tpl1"
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
assert task.title_ids == ["t1"]
|
||||
assert task.voice_ids == ["v1"]
|
||||
assert task.created_by_user_id == "u1"
|
||||
assert task.source_edit_plan_id == "ep1"
|
||||
assert task.asset_select_mode == "random"
|
||||
assert task.batch_id == "batch_001"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080p"
|
||||
assert task.bgm_config == {"volume": 0.5}
|
||||
assert task.auto_retry_enabled is True
|
||||
assert task.auto_retry_max == 3
|
||||
|
||||
def test_create_strips_string_fields(self):
|
||||
task = GenerationTask.create(
|
||||
project_id=" p1 ",
|
||||
asset_library_id=" lib1 ",
|
||||
strategy_id=" s1 ",
|
||||
template_id=" tpl1 ",
|
||||
created_by_user_id=" u1 ",
|
||||
video_title=" 测试 ",
|
||||
resolution=" 1080p ",
|
||||
)
|
||||
assert task.project_id == "p1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.strategy_id == "s1"
|
||||
assert task.template_id == "tpl1"
|
||||
assert task.created_by_user_id == "u1"
|
||||
assert task.video_title == "测试"
|
||||
assert task.resolution == "1080p"
|
||||
|
||||
def test_create_asset_ids_is_copy(self):
|
||||
ids = ["a1", "a2"]
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=ids)
|
||||
ids.append("a3")
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
|
||||
def test_create_bgm_config_is_copy(self):
|
||||
cfg = {"vol": 0.5}
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", bgm_config=cfg)
|
||||
cfg["vol"] = 0.8
|
||||
assert task.bgm_config == {"vol": 0.5}
|
||||
|
||||
def test_create_id_unique(self):
|
||||
t1 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
t2 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert t1.id != t2.id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GenerationTask 状态查询测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskStatusQuery:
|
||||
def test_is_terminal_pending_false(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert not task.is_terminal
|
||||
|
||||
def test_is_terminal_running_false(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
assert not task.is_terminal
|
||||
|
||||
def test_is_terminal_completed_true(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
assert task.is_terminal
|
||||
|
||||
def test_is_terminal_failed_true(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("err")
|
||||
assert task.is_terminal
|
||||
|
||||
def test_is_terminal_cancelled_true(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_cancelled()
|
||||
assert task.is_terminal
|
||||
|
||||
def test_is_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert not task.is_completed
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
assert task.is_completed
|
||||
|
||||
def test_is_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert not task.is_failed
|
||||
task.mark_processing()
|
||||
task.mark_failed("err")
|
||||
assert task.is_failed
|
||||
|
||||
def test_is_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert not task.is_running
|
||||
task.mark_processing()
|
||||
assert task.is_running
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GenerationTask 状态转换测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskTransitions:
|
||||
def test_pending_to_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.FAILED)
|
||||
assert task.status == GenerationTaskStatus.FAILED
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
assert task.status == GenerationTaskStatus.CANCELLED
|
||||
|
||||
def test_pending_to_completed_invalid(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to(GenerationTaskStatus.COMPLETED)
|
||||
|
||||
def test_running_to_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
task.transition_to(GenerationTaskStatus.COMPLETED)
|
||||
assert task.status == GenerationTaskStatus.COMPLETED
|
||||
|
||||
def test_running_to_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
task.transition_to(GenerationTaskStatus.FAILED)
|
||||
assert task.status == GenerationTaskStatus.FAILED
|
||||
|
||||
def test_running_to_cancelled(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
task.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
assert task.status == GenerationTaskStatus.CANCELLED
|
||||
|
||||
def test_failed_to_pending(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
task.transition_to(GenerationTaskStatus.FAILED)
|
||||
task.transition_to(GenerationTaskStatus.PENDING)
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
|
||||
def test_completed_to_pending_invalid(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to(GenerationTaskStatus.RUNNING)
|
||||
task.transition_to(GenerationTaskStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
task.transition_to(GenerationTaskStatus.PENDING)
|
||||
|
||||
def test_transition_to_accepts_string(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.transition_to("running")
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_transition_to_unknown_string_defaults_pending(self):
|
||||
"""_missing_ 兜底:未知字符串映射为 PENDING,再走状态机校验。"""
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
# 当前已经是 pending,pending→pending 不合法
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to("bogus_status")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GenerationTask 业务方法测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskBusinessMethods:
|
||||
def test_mark_processing_sets_started_at(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert task.started_at is None
|
||||
task.mark_processing()
|
||||
assert task.started_at is not None
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_mark_processing_clears_error(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.error_message = "old error"
|
||||
task.mark_processing()
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_mark_completed_default_count(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
assert task.status == GenerationTaskStatus.COMPLETED
|
||||
assert task.progress == 100.0
|
||||
assert task.result_count == 1
|
||||
assert task.completed_at is not None
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_mark_completed_custom_count(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_completed(result_count=5)
|
||||
assert task.result_count == 5
|
||||
|
||||
def test_mark_failed_with_message(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("网络超时")
|
||||
assert task.status == GenerationTaskStatus.FAILED
|
||||
assert task.error_message == "网络超时"
|
||||
assert task.completed_at is not None
|
||||
|
||||
def test_mark_failed_with_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
info = {"error_type": "NetworkError", "stage": "download"}
|
||||
task.mark_failed("超时", error_info=info)
|
||||
assert task.error_info == info
|
||||
|
||||
def test_mark_failed_default_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("未知错误")
|
||||
assert "error_type" in task.error_info
|
||||
assert task.error_info["message"] == "未知错误"
|
||||
assert "failed_at" in task.error_info
|
||||
|
||||
def test_mark_cancelled_from_pending(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_cancelled()
|
||||
assert task.status == GenerationTaskStatus.CANCELLED
|
||||
assert task.completed_at is not None
|
||||
|
||||
def test_mark_cancelled_from_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_cancelled()
|
||||
assert task.status == GenerationTaskStatus.CANCELLED
|
||||
|
||||
def test_mark_pending_from_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("err")
|
||||
task.mark_pending_from_failed()
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.error_message == ""
|
||||
assert task.error_info == {}
|
||||
assert task.started_at is None
|
||||
assert task.completed_at is None
|
||||
assert task.progress == 0.0
|
||||
assert task.result_count == 0
|
||||
assert task.retry_count == 1
|
||||
|
||||
def test_mark_pending_from_failed_multiple_retries(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
for i in range(3):
|
||||
task.mark_processing()
|
||||
task.mark_failed(f"err{i}")
|
||||
task.mark_pending_from_failed()
|
||||
assert task.retry_count == i + 1
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
|
||||
def test_mark_pending_from_failed_wrong_status_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
with pytest.raises(ValueError, match="只有 failed 状态"):
|
||||
task.mark_pending_from_failed()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GenerationTask 日志系统测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerationTaskLogs:
|
||||
def test_append_log_basic(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.append_log("初始化", "任务创建成功")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["stage"] == "初始化"
|
||||
assert logs[0]["message"] == "任务创建成功"
|
||||
assert logs[0]["level"] == "INFO"
|
||||
assert "ts" in logs[0]
|
||||
|
||||
def test_append_log_with_level(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.append_log("渲染", "渲染失败", level="ERROR")
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["level"] == "ERROR"
|
||||
|
||||
def test_append_log_with_extra_fields(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.append_log("下载", "下载完成", asset_id="a1", duration=10.5)
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["asset_id"] == "a1"
|
||||
assert logs[0]["duration"] == 10.5
|
||||
|
||||
def test_append_multiple_logs(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
for i in range(5):
|
||||
task.append_log(f"阶段{i}", f"消息{i}")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 5
|
||||
assert logs[0]["stage"] == "阶段0"
|
||||
assert logs[4]["stage"] == "阶段4"
|
||||
|
||||
def test_get_logs_empty(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_get_logs_corrupted_json(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.logs = "not valid json"
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_append_log_respects_max_limit(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
# _MAX_LOGS = 200,超过后保留最新的 200 条
|
||||
for i in range(250):
|
||||
task.append_log("阶段", f"消息{i}")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 200
|
||||
assert logs[0]["message"] == "消息50" # 前50条被截掉
|
||||
assert logs[-1]["message"] == "消息249"
|
||||
|
||||
def test_logs_persisted_as_json_string(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
|
||||
task.append_log("阶段", "消息")
|
||||
assert isinstance(task.logs, str)
|
||||
# 可以被 json.loads 解析
|
||||
parsed = json.loads(task.logs)
|
||||
assert isinstance(parsed, list)
|
||||
assert len(parsed) == 1
|
||||
Executable
+742
@@ -0,0 +1,742 @@
|
||||
"""第78波:Duplication 查重领域模型纯逻辑单测。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
# ============================================================
|
||||
# DuplicateSegment.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDuplicateSegmentCreate:
|
||||
def test_create_basic(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=20.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="匹配视频",
|
||||
matched_start=5.0,
|
||||
matched_end=15.0,
|
||||
similarity=85.5,
|
||||
)
|
||||
assert seg.id
|
||||
assert seg.source_start == 10.0
|
||||
assert seg.source_end == 20.0
|
||||
assert seg.matched_video_id == "mv1"
|
||||
assert seg.matched_video_name == "匹配视频"
|
||||
assert seg.matched_start == 5.0
|
||||
assert seg.matched_end == 15.0
|
||||
assert seg.similarity == 85.5
|
||||
|
||||
def test_create_invalid_source_start_negative(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=-1.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_invalid_source_end_lte_start(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=20.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_source_end_equal_start_invalid(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_invalid_matched_start_negative(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=-1.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_invalid_matched_end_lte_start(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=15.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_zero(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=0.0,
|
||||
)
|
||||
assert seg.similarity == 0.0
|
||||
|
||||
def test_create_similarity_hundred(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=100.0,
|
||||
)
|
||||
assert seg.similarity == 100.0
|
||||
|
||||
def test_create_similarity_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=-1.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_over_100_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="mv1",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=101.0,
|
||||
)
|
||||
|
||||
def test_create_id_unique(self):
|
||||
s1 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
|
||||
s2 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
|
||||
assert s1.id != s2.id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DuplicationRecord.create 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDuplicationRecordCreate:
|
||||
def test_create_minimal(self):
|
||||
rec = DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=102400,
|
||||
storage_key="oss://key",
|
||||
)
|
||||
assert rec.id
|
||||
assert rec.user_id == "u1"
|
||||
assert rec.filename == "test.mp4"
|
||||
assert rec.file_size == 102400
|
||||
assert rec.storage_key == "oss://key"
|
||||
assert rec.duration_seconds == 0.0
|
||||
assert rec.status == "pending"
|
||||
assert rec.duplicate_rate is None
|
||||
assert rec.duplicate_count == 0
|
||||
assert rec.segments == []
|
||||
assert rec.error_message == ""
|
||||
|
||||
def test_create_with_duration(self):
|
||||
rec = DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=1024,
|
||||
storage_key="oss://k",
|
||||
duration_seconds=120.5,
|
||||
)
|
||||
assert rec.duration_seconds == 120.5
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id=" ",
|
||||
filename="t.mp4",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_empty_filename_raises(self):
|
||||
with pytest.raises(ValueError, match="filename cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_zero_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="t.mp4",
|
||||
file_size=0,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_negative_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="t.mp4",
|
||||
file_size=-100,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
rec = DuplicationRecord.create(
|
||||
user_id=" u1 ",
|
||||
filename=" test.mp4 ",
|
||||
file_size=100,
|
||||
storage_key=" oss://k ",
|
||||
)
|
||||
assert rec.user_id == "u1"
|
||||
assert rec.filename == "test.mp4"
|
||||
# storage_key 不确定有没有 strip,看源码是直接赋值
|
||||
assert rec.storage_key == " oss://k "
|
||||
|
||||
def test_create_id_unique(self):
|
||||
r1 = DuplicationRecord.create("u1", "a.mp4", 100, "k1")
|
||||
r2 = DuplicationRecord.create("u1", "b.mp4", 200, "k2")
|
||||
assert r1.id != r2.id
|
||||
|
||||
def test_create_default_segments_empty_list(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
assert rec.segments == []
|
||||
assert isinstance(rec.segments, list)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DuplicationRecord 状态流转测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDuplicationRecordStatusFlow:
|
||||
def test_mark_processing(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
before = rec.updated_at
|
||||
rec.mark_processing()
|
||||
assert rec.status == "processing"
|
||||
assert rec.updated_at >= before
|
||||
|
||||
def test_mark_completed(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 90.0)
|
||||
rec.mark_completed(
|
||||
duplicate_rate=35.5,
|
||||
duplicate_count=1,
|
||||
segments=[seg],
|
||||
)
|
||||
assert rec.status == "completed"
|
||||
assert rec.duplicate_rate == 35.5
|
||||
assert rec.duplicate_count == 1
|
||||
assert len(rec.segments) == 1
|
||||
assert rec.segments[0].id == seg.id
|
||||
|
||||
def test_mark_completed_zero_rate(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_completed(0.0, 0, [])
|
||||
assert rec.status == "completed"
|
||||
assert rec.duplicate_rate == 0.0
|
||||
assert rec.duplicate_count == 0
|
||||
assert rec.segments == []
|
||||
|
||||
def test_mark_completed_full_rate(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
seg = DuplicateSegment.create(0, 10, "m", "n", 0, 10, 100.0)
|
||||
rec.mark_completed(100.0, 1, [seg])
|
||||
assert rec.duplicate_rate == 100.0
|
||||
|
||||
def test_mark_completed_invalid_rate_negative(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
rec.mark_completed(-1.0, 0, [])
|
||||
|
||||
def test_mark_completed_invalid_rate_over_100(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
rec.mark_completed(100.1, 0, [])
|
||||
|
||||
def test_mark_failed(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_failed("网络超时")
|
||||
assert rec.status == "failed"
|
||||
assert rec.error_message == "网络超时"
|
||||
|
||||
def test_mark_failed_from_processing(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_processing()
|
||||
rec.mark_failed("解析失败")
|
||||
assert rec.status == "failed"
|
||||
assert rec.error_message == "解析失败"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DuplicationRecord 重试机制测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDuplicationRecordRetry:
|
||||
def test_can_retry_failed_true(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_failed("err")
|
||||
assert rec.can_retry() is True
|
||||
|
||||
def test_can_retry_pending_false(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
assert rec.can_retry() is False
|
||||
|
||||
def test_can_retry_processing_false(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_processing()
|
||||
assert rec.can_retry() is False
|
||||
|
||||
def test_can_retry_completed_false(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.mark_completed(10.0, 0, [])
|
||||
assert rec.can_retry() is False
|
||||
|
||||
def test_reset_for_retry_clears_all(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 80.0)
|
||||
rec.mark_completed(30.0, 1, [seg])
|
||||
# 先设为 failed 再 reset
|
||||
rec.status = "failed"
|
||||
rec.reset_for_retry()
|
||||
assert rec.status == "pending"
|
||||
assert rec.duplicate_rate is None
|
||||
assert rec.duplicate_count == 0
|
||||
assert rec.error_message == ""
|
||||
assert rec.segments == []
|
||||
assert rec.video_fingerprint is None
|
||||
|
||||
def test_reset_for_retry_updates_timestamp(self):
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
rec.status = "failed"
|
||||
before = rec.updated_at
|
||||
rec.reset_for_retry()
|
||||
assert rec.updated_at >= before
|
||||
|
||||
def test_full_retry_flow(self):
|
||||
"""完整的 创建→失败→重置→再处理→完成 流程。"""
|
||||
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
|
||||
assert rec.status == "pending"
|
||||
|
||||
rec.mark_processing()
|
||||
assert rec.status == "processing"
|
||||
|
||||
rec.mark_failed("超时")
|
||||
assert rec.can_retry()
|
||||
|
||||
rec.reset_for_retry()
|
||||
assert rec.status == "pending"
|
||||
assert rec.error_message == ""
|
||||
|
||||
rec.mark_processing()
|
||||
rec.mark_completed(0.0, 0, [])
|
||||
assert rec.status == "completed"
|
||||
assert not rec.can_retry()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaTier 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQuotaTier:
|
||||
def test_get_limit_defined(self):
|
||||
from packages.domain.quota import QUOTA_TIERS, QuotaTier
|
||||
|
||||
free = QUOTA_TIERS["free"]
|
||||
assert free.get_limit("storage_gb") == 2
|
||||
assert free.get_limit("videos_per_month") == 5
|
||||
|
||||
def test_get_limit_undefined_returns_zero(self):
|
||||
from packages.domain.quota import QuotaTier
|
||||
|
||||
tier = QuotaTier(name="test", limits={"a": 10})
|
||||
assert tier.get_limit("nonexistent") == 0
|
||||
|
||||
def test_is_unlimited_inf(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
premium = QUOTA_TIERS["premium"]
|
||||
assert premium.is_unlimited("max_templates") is True
|
||||
|
||||
def test_is_unlimited_finite(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
free = QUOTA_TIERS["free"]
|
||||
assert free.is_unlimited("storage_gb") is False
|
||||
|
||||
def test_is_unlimited_undefined_returns_true(self):
|
||||
"""未定义的维度 limits.get 默认 inf,is_unlimited 返回 True。"""
|
||||
from packages.domain.quota import QuotaTier
|
||||
|
||||
tier = QuotaTier(name="test", limits={})
|
||||
assert tier.is_unlimited("unknown") is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QUOTA_TIERS 三档套餐验证
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQuotaTiers:
|
||||
def test_free_tier_storage(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["free"].get_limit("storage_gb") == 2
|
||||
|
||||
def test_free_tier_no_ai_voice(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["free"].get_limit("ai_voice_enabled") == 0
|
||||
|
||||
def test_basic_tier_storage(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["basic"].get_limit("storage_gb") == 20
|
||||
|
||||
def test_basic_tier_has_ai_voice(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["basic"].get_limit("ai_voice_enabled") == 1
|
||||
|
||||
def test_basic_tier_ai_voice_credits(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["basic"].get_limit("ai_voice_credits") == 100
|
||||
|
||||
def test_premium_tier_storage(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["premium"].get_limit("storage_gb") == 100
|
||||
|
||||
def test_premium_tier_unlimited_templates(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["premium"].is_unlimited("max_templates")
|
||||
|
||||
def test_premium_tier_multi_platform(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["premium"].get_limit("multi_platform_enabled") == 1
|
||||
|
||||
def test_basic_no_multi_platform(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert QUOTA_TIERS["basic"].get_limit("multi_platform_enabled") == 0
|
||||
|
||||
def test_all_tiers_exist(self):
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert set(QUOTA_TIERS.keys()) == {"free", "basic", "premium"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaCheckResult 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQuotaCheckResult:
|
||||
def test_usage_percent_normal(self):
|
||||
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
|
||||
|
||||
r = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=50,
|
||||
remaining=50,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert r.usage_percent == 50.0
|
||||
|
||||
def test_usage_percent_capped_at_100(self):
|
||||
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
|
||||
|
||||
r = QuotaCheckResult(
|
||||
allowed=False,
|
||||
dimension="x",
|
||||
limit=10,
|
||||
used=15,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.EXCEEDED,
|
||||
)
|
||||
assert r.usage_percent == 100.0
|
||||
|
||||
def test_usage_percent_unlimited(self):
|
||||
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
|
||||
|
||||
r = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="x",
|
||||
limit=float("inf"),
|
||||
used=999,
|
||||
remaining=float("inf"),
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert r.usage_percent == 0.0
|
||||
|
||||
def test_usage_percent_zero_limit_with_usage(self):
|
||||
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
|
||||
|
||||
r = QuotaCheckResult(
|
||||
allowed=False,
|
||||
dimension="x",
|
||||
limit=0,
|
||||
used=5,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.EXCEEDED,
|
||||
)
|
||||
assert r.usage_percent == 100.0
|
||||
|
||||
def test_usage_percent_zero_limit_no_usage(self):
|
||||
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
|
||||
|
||||
r = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="x",
|
||||
limit=0,
|
||||
used=0,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert r.usage_percent == 0.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaWarningLevel 计算测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWarningLevel:
|
||||
def test_normal_below_80(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_warning_at_80(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_warning_below_95(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(90, 100) == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_critical_at_95(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_critical_below_100(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_exceeded_at_100(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_exceeded_over_100(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_unlimited_always_normal(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(99999, float("inf")) == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_zero_limit_with_usage_is_exceeded(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(1, 0) == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_zero_limit_no_usage_is_normal(self):
|
||||
from packages.domain.quota import QuotaWarningLevel, get_warning_level
|
||||
|
||||
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaChecker 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQuotaChecker:
|
||||
def test_check_allowed(self):
|
||||
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("free", "storage_gb", 1)
|
||||
assert r.allowed is True
|
||||
assert r.limit == 2
|
||||
assert r.used == 1
|
||||
assert r.remaining == 1
|
||||
assert r.warning_level == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_check_exceeded(self):
|
||||
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("free", "storage_gb", 5)
|
||||
assert r.allowed is False
|
||||
assert r.remaining == 0
|
||||
assert r.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_at_limit_not_allowed(self):
|
||||
"""used == limit 时 allowed 为 False(严格小于才算允许)。"""
|
||||
from packages.domain.quota import QuotaChecker
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("free", "storage_gb", 2)
|
||||
assert r.allowed is False
|
||||
|
||||
def test_check_unlimited_always_allowed(self):
|
||||
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("premium", "max_templates", 9999)
|
||||
assert r.allowed is True
|
||||
assert r.remaining == float("inf")
|
||||
assert r.warning_level == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_check_unknown_plan_returns_zero(self):
|
||||
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("unknown_plan", "storage_gb", 1)
|
||||
assert r.allowed is False
|
||||
assert r.limit == 0
|
||||
assert r.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_warning_level_80_percent(self):
|
||||
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
|
||||
|
||||
checker = QuotaChecker()
|
||||
r = checker.check("basic", "storage_gb", 16) # 20 * 0.8 = 16
|
||||
assert r.warning_level == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_check_multiple(self):
|
||||
from packages.domain.quota import QuotaChecker
|
||||
|
||||
checker = QuotaChecker()
|
||||
results = checker.check_multiple(
|
||||
"free",
|
||||
{"storage_gb": 1, "videos_per_month": 10, "max_templates": 2},
|
||||
)
|
||||
assert len(results) == 3
|
||||
dims = {r.dimension: r for r in results}
|
||||
assert dims["storage_gb"].allowed is True
|
||||
assert dims["videos_per_month"].allowed is False
|
||||
assert dims["max_templates"].allowed is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaRegistry 测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestQuotaRegistry:
|
||||
def test_list_dimensions_includes_builtin(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
dims = reg.list_dimensions()
|
||||
assert "storage_gb" in dims
|
||||
assert "videos_per_month" in dims
|
||||
assert "ai_voice_enabled" in dims
|
||||
|
||||
def test_list_tiers(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
tiers = reg.list_tiers()
|
||||
assert set(tiers) == {"free", "basic", "premium"}
|
||||
|
||||
def test_register_new_dimension(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
reg.register_dimension("custom_dim", "自定义维度", {"free": 5, "basic": 20})
|
||||
dims = reg.list_dimensions()
|
||||
assert "custom_dim" in dims
|
||||
assert dims["custom_dim"] == "自定义维度"
|
||||
assert reg.get_limit("free", "custom_dim") == 5
|
||||
assert reg.get_limit("basic", "custom_dim") == 20
|
||||
|
||||
def test_register_dimension_idempotent(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
reg.register_dimension("custom_dim", "v1", {"free": 5})
|
||||
reg.register_dimension("custom_dim", "v2", {"free": 10})
|
||||
# 第二次应该被忽略(幂等),描述和限制都保持第一次
|
||||
assert reg.list_dimensions()["custom_dim"] == "v1"
|
||||
assert reg.get_limit("free", "custom_dim") == 5
|
||||
|
||||
def test_register_without_defaults_defaults_to_zero(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
reg.register_dimension("new_dim", "新维度")
|
||||
assert reg.get_limit("free", "new_dim") == 0
|
||||
assert reg.get_limit("basic", "new_dim") == 0
|
||||
assert reg.get_limit("premium", "new_dim") == 0
|
||||
|
||||
def test_get_tier_exists(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
tier = reg.get_tier("free")
|
||||
assert tier is not None
|
||||
assert tier.name == "free"
|
||||
|
||||
def test_get_tier_not_exists(self):
|
||||
from packages.domain.quota import QuotaRegistry
|
||||
|
||||
reg = QuotaRegistry()
|
||||
assert reg.get_tier("nonexistent") is None
|
||||
|
||||
def test_global_registry_instance(self):
|
||||
from packages.domain.quota import quota_checker, quota_registry
|
||||
|
||||
assert quota_registry is not None
|
||||
assert quota_checker is not None
|
||||
assert quota_registry.get_limit("free", "storage_gb") == 2
|
||||
Reference in New Issue
Block a user