Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4642130a04 | |||
| 66e7faa06a | |||
| 2f92398212 | |||
| c9449a1dbd | |||
| 675945f4fb | |||
| 5e74d0b565 | |||
| ebe68429bc | |||
| 18f534bbd6 | |||
| f36aaea374 | |||
| 80bc63d58b | |||
| 73a566c621 | |||
| 9057ba25c8 | |||
| 35b38ed48c | |||
| 15142e3168 | |||
| 953dd9a6e6 | |||
| 060307197c | |||
| 6930d4543f | |||
| 7b2f35ad3d | |||
| 5256bd0d8b | |||
| 33270dd026 | |||
| 0f27bfa999 | |||
| f15bc2f2a2 | |||
| 2273fb329f | |||
| 4bd3da73e3 | |||
| 7674a04a33 | |||
| 9d97e8aacc | |||
| 89639a6d3e | |||
| c4144d9e3f | |||
| de201436ea | |||
| 77a49e3365 | |||
| d7e362a637 | |||
| 028c6613ce | |||
| 696cdda87b | |||
| a85167a529 | |||
| e0b95e69d4 | |||
| 63d4048354 | |||
| e811516c6e | |||
| f9a106f36b | |||
| e902fbd65e | |||
| e8352227af | |||
| ef7596739c | |||
| c2329f3f98 | |||
| 0a0914322c | |||
| 042cae7a61 | |||
| be885eb8f0 | |||
| fd61800be9 | |||
| 183681c07d | |||
| 8d906bac72 | |||
| 8f925e6c65 | |||
| 9732cc51ee | |||
| 815b3758a7 | |||
| eb73eeab69 | |||
| 2325ffbc57 |
@@ -1777,6 +1777,7 @@ jobs:
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"integration-tests:$RESULT_INTEGRATION"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityLevel } from "../utils"
|
||||
|
||||
export interface AssetCardProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showBatchSelect: boolean
|
||||
onToggleSelect: (e: React.MouseEvent) => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showBatchSelect,
|
||||
onToggleSelect,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={onToggleSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityColor } from "../utils"
|
||||
|
||||
export interface AssetListItemProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showBatchSelect: boolean
|
||||
onToggleSelect: (e: React.MouseEvent) => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
export const AssetListItem: React.FC<AssetListItemProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showBatchSelect,
|
||||
onToggleSelect,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={onToggleSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list"
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 格式化文件大小 */
|
||||
export const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
export const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
export const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
|
||||
/** 类型筛选选项 */
|
||||
export const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import "./PageHead.css"
|
||||
|
||||
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||
|
||||
const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/dashboard": "首页",
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
"/app/profile": "个人设置",
|
||||
"/app/editing-planner": "模板制作",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
|
||||
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
+1
@@ -159,3 +159,4 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 路由 → 标题映射(用于自动生成面包屑) */
|
||||
export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/dashboard": "首页",
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
"/app/profile": "个人设置",
|
||||
"/app/editing-planner": "模板制作",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import type { PageHeadProps } from "./types"
|
||||
import { generateBreadcrumb } from "./utils"
|
||||
import "./PageHead.css"
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
export type { BreadcrumbItem, PageHeadProps } from "./types"
|
||||
@@ -0,0 +1,23 @@
|
||||
import type React from "react"
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ROUTE_TITLE_MAP } from "./constants"
|
||||
import type { BreadcrumbItem } from "./types"
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
export const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -1,238 +1,8 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "./constants"
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
||||
*/
|
||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
||||
export { useBatchTag } from "./batch/useBatchTag"
|
||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
||||
export { useBatchMark } from "./batch/useBatchMark"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batchOperations"
|
||||
} from "./asset-operations/batch-operations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
Regular → Executable
+5
-75
@@ -2,11 +2,9 @@
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
@@ -121,78 +119,10 @@ const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StickerPreview sticker={sticker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
|
||||
if (!titleSettings.title.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
/* 步骤 */
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
|
||||
/* 模板 */
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
materialMode: "manual" | "auto"
|
||||
setMaterialMode: (mode: "manual" | "auto") => void
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
setSelectedVoice: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
setSelectedClonedVoice: (id: string) => void
|
||||
presetVoices: PresetVoiceItem[]
|
||||
|
||||
/* 克隆弹窗 */
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
|
||||
/* URL 参数 */
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板(从 API 加载) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr])
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useTemplateSelection } from "./useTemplateSelection"
|
||||
import { useTitleCoverSync } from "./useTitleCoverSync"
|
||||
import { useVoiceState } from "./useVoiceState"
|
||||
import { usePlanConfigLoader } from "./usePlanConfigLoader"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
/* 步骤 */
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
|
||||
/* 模板 */
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
materialMode: "manual" | "auto"
|
||||
setMaterialMode: (mode: "manual" | "auto") => void
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
setSelectedVoice: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
setSelectedClonedVoice: (id: string) => void
|
||||
presetVoices: PresetVoiceItem[]
|
||||
|
||||
/* 克隆弹窗 */
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
|
||||
/* URL 参数 */
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* ── 模板切换时同步标题/封面 ── */
|
||||
useTitleCoverSync({
|
||||
selectedTemplate,
|
||||
userTemplates,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
})
|
||||
|
||||
/* ── 配音状态 ── */
|
||||
const {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
} = useVoiceState()
|
||||
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/* ── 从 URL / 编辑计划加载配置 ── */
|
||||
usePlanConfigLoader({
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
setSelectedMaterials,
|
||||
})
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
interface UsePlanConfigLoaderOptions {
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 参数或编辑计划 ID 加载表单配置
|
||||
*/
|
||||
export function usePlanConfigLoader({
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
setSelectedMaterials,
|
||||
}: UsePlanConfigLoaderOptions) {
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr, setTitleSettings, setSelectedMaterials])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr, setTitleSettings, setCoverSettings, setSelectedMaterials])
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
export function useTemplateSelection() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
return { selectedTemplate, setSelectedTemplate, userTemplates }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseTitleCoverSyncOptions {
|
||||
selectedTemplate: string
|
||||
userTemplates: EditingTemplate[]
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 当选中模板变化时,自动同步标题和封面配置
|
||||
*/
|
||||
export function useTitleCoverSync({
|
||||
selectedTemplate,
|
||||
userTemplates,
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
}: UseTitleCoverSyncOptions) {
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
|
||||
export function useVoiceState() {
|
||||
const [selectedVoice, setSelectedVoice] = useState("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||
|
||||
/* 预置音色 API */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
}
|
||||
}
|
||||
@@ -9,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
])
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
PlayCircleOutlined,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "../types"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
import { useVideoPlayer } from "../hooks/useVideoPlayer"
|
||||
|
||||
interface VideoPlayerProps {
|
||||
product: ProductItem
|
||||
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
const {
|
||||
videoRef,
|
||||
progressRef,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
} = useVideoPlayer()
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
const displayDuration = duration || product.duration
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
<button className="xx-player-play-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>{formatTime(displayDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import { mapApiProduct } from "../../utils"
|
||||
import { useProductFiltering } from "./useProductFiltering"
|
||||
import { useBatchSelection } from "./useBatchSelection"
|
||||
|
||||
export type { Filters } from "./useProductFiltering"
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
}),
|
||||
[apiProducts],
|
||||
)
|
||||
|
||||
/* 筛选 */
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
filteredProducts,
|
||||
projectOptions,
|
||||
} = useProductFiltering(products)
|
||||
|
||||
/* 批量选择 */
|
||||
const {
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
} = useBatchSelection(filteredProducts)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { ProductItem } from "../../types"
|
||||
|
||||
export const useBatchSelection = (filteredProducts: ProductItem[]) => {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
// 仅取消选中当前可见的项,保留筛选外的选中状态
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredProducts.forEach((p) => next.delete(p.id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// 选中所有当前可见项
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredProducts.forEach((p) => next.add(p.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [allSelected, filteredProducts])
|
||||
|
||||
const handleToggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
+3
-78
@@ -1,8 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { mapApiProduct } from "../utils"
|
||||
import type { ProductItem } from "../../types"
|
||||
|
||||
/** 筛选选项类型 */
|
||||
export interface Filters {
|
||||
@@ -27,32 +24,7 @@ const getProjectOptions = (products: ProductItem[]) =>
|
||||
label: name as string,
|
||||
}))
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* 筛选 */
|
||||
export const useProductFiltering = (products: ProductItem[]) => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
@@ -60,12 +32,6 @@ export const useProductList = () => {
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量选择 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
@@ -142,42 +108,7 @@ export const useProductList = () => {
|
||||
|
||||
const projectOptions = useMemo(() => getProjectOptions(products), [products])
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
@@ -190,13 +121,7 @@ export const useProductList = () => {
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
filteredProducts,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+5
-123
@@ -1,11 +1,8 @@
|
||||
import React from "react"
|
||||
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
|
||||
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import type { TaskItem, TaskStatus } from "@/api/tasks"
|
||||
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
|
||||
import { formatDuration, formatTime } from "../utils"
|
||||
import { Table } from "antd"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { TaskErrorDetail } from "./TaskErrorDetail"
|
||||
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
|
||||
|
||||
interface TaskTableProps {
|
||||
dataSource: TaskItem[]
|
||||
@@ -24,7 +21,6 @@ interface TaskTableProps {
|
||||
|
||||
/**
|
||||
* 任务列表表格
|
||||
* 含列定义、分页、展开行
|
||||
*/
|
||||
export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
dataSource,
|
||||
@@ -40,116 +36,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
onRetry,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} className="task-status-tag">
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => <span className="task-step">{step || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => onRetry(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryLoading}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => onViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
|
||||
|
||||
return (
|
||||
<Table
|
||||
@@ -178,12 +65,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
emptyText: <TaskEmptyState />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react"
|
||||
import { ClockCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
export const TaskEmptyState: React.FC = () => (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useTaskTableColumns } from "./useTaskTableColumns"
|
||||
export { TaskEmptyState } from "./TaskEmptyState"
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Tag, Button, Popconfirm, Tooltip } from "antd"
|
||||
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import type { TaskItem, TaskStatus } from "@/api/tasks"
|
||||
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
|
||||
import { formatDuration, formatTime } from "../../utils"
|
||||
|
||||
interface UseTaskTableColumnsOptions {
|
||||
retryLoading: boolean
|
||||
onRetry: (id: string) => void
|
||||
onViewDetail: (record: TaskItem) => void
|
||||
}
|
||||
|
||||
export function useTaskTableColumns({
|
||||
retryLoading,
|
||||
onRetry,
|
||||
onViewDetail,
|
||||
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
|
||||
return [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} className="task-status-tag">
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => <span className="task-step">{step || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => onRetry(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryLoading}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => onViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useMemo, useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
|
||||
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
|
||||
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
|
||||
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
|
||||
|
||||
export const useTitleLibrary = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* 分类 */
|
||||
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
|
||||
|
||||
/* 数据获取 */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
||||
|
||||
/* 动态派生分类 */
|
||||
const categories: CategoryItem[] = useMemo(() => {
|
||||
const cats = new Map<string, number>()
|
||||
apiTitles.forEach((t) => {
|
||||
const cat = t.category || "未分类"
|
||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
||||
})
|
||||
return [
|
||||
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
|
||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||
id: `cat-${name}`,
|
||||
name,
|
||||
count,
|
||||
})),
|
||||
]
|
||||
}, [apiTitles])
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
})
|
||||
|
||||
/* 筛选状态 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
||||
|
||||
/* 派生:筛选后的标题列表 */
|
||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
||||
|
||||
const filteredTitles = useMemo(() => {
|
||||
let list = titles
|
||||
|
||||
/* 按分类过滤 */
|
||||
if (activeCatId !== ALL_CATEGORY_ID) {
|
||||
const catName = activeCategory?.name || ""
|
||||
if (catName) {
|
||||
list = list.filter((t) => t.category === catName)
|
||||
}
|
||||
}
|
||||
|
||||
/* 按类型筛选 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((t) => t.type === filterType)
|
||||
}
|
||||
|
||||
/* 按行业筛选 */
|
||||
if (filterIndustry !== "all") {
|
||||
list = list.filter((t) => t.industry === filterIndustry)
|
||||
}
|
||||
|
||||
/* 按使用频率筛选 */
|
||||
if (filterFrequency !== "all") {
|
||||
switch (filterFrequency) {
|
||||
case "high":
|
||||
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
|
||||
break
|
||||
case "medium":
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
|
||||
t.usageCount < FREQUENCY_THRESHOLDS.high,
|
||||
)
|
||||
break
|
||||
case "low":
|
||||
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
||||
|
||||
/* 操作:收藏 */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线")
|
||||
}, [])
|
||||
|
||||
/* 操作:复制 */
|
||||
const handleCopy = useCallback(async (title: TitleData) => {
|
||||
const ok = await copyToClipboard(title.content)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 操作:删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
message.success("标题已删除")
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
/* 状态 */
|
||||
titles,
|
||||
categories,
|
||||
activeCatId,
|
||||
activeCategory,
|
||||
filteredTitles,
|
||||
searchText,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
/* mutations */
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
/* setters */
|
||||
setActiveCatId,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterIndustry,
|
||||
setFilterFrequency,
|
||||
/* handlers */
|
||||
handleToggleFavorite,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTitleFilters } from "./useTitleFilters"
|
||||
import { useTitleMutations } from "./useTitleMutations"
|
||||
import { useTitleData } from "./useTitleData"
|
||||
import { useTitleActions } from "./useTitleActions"
|
||||
|
||||
export const useTitleLibrary = () => {
|
||||
/* 数据获取与派生 */
|
||||
const { titles, categories, activeCatId, activeCategory, setActiveCatId } = useTitleData()
|
||||
|
||||
/* 筛选 */
|
||||
const {
|
||||
searchText,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterIndustry,
|
||||
setFilterFrequency,
|
||||
filteredTitles,
|
||||
} = useTitleFilters(titles, categories, activeCatId, activeCategory)
|
||||
|
||||
/* CRUD mutations */
|
||||
const { createMutation, updateMutation, deleteMutation } = useTitleMutations()
|
||||
|
||||
/* 操作 handlers */
|
||||
const { handleToggleFavorite, handleCopy, handleDelete } = useTitleActions(deleteMutation)
|
||||
|
||||
return {
|
||||
/* 状态 */
|
||||
titles,
|
||||
categories,
|
||||
activeCatId,
|
||||
activeCategory,
|
||||
filteredTitles,
|
||||
searchText,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
/* mutations */
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
/* setters */
|
||||
setActiveCatId,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterIndustry,
|
||||
setFilterFrequency,
|
||||
/* handlers */
|
||||
handleToggleFavorite,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import type { TitleData } from "../../types/titleLibrary"
|
||||
import { copyToClipboard } from "../../utils/titleLibrary"
|
||||
|
||||
export const useTitleActions = (
|
||||
deleteMutation: UseMutationResult<void, Error, string, unknown>,
|
||||
) => {
|
||||
/* 操作:收藏 */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线")
|
||||
}, [])
|
||||
|
||||
/* 操作:复制 */
|
||||
const handleCopy = useCallback(async (title: TitleData) => {
|
||||
const ok = await copyToClipboard(title.content)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 操作:删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
return { handleToggleFavorite, handleCopy, handleDelete }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import type { TitleData, CategoryItem } from "../../types/titleLibrary"
|
||||
import { toTitleData } from "../../utils/titleLibrary"
|
||||
import { ALL_CATEGORY_ID } from "../../constants/titleLibrary"
|
||||
|
||||
export const useTitleData = () => {
|
||||
/* 分类 */
|
||||
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
|
||||
|
||||
/* 数据获取 */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
||||
|
||||
/* 动态派生分类 */
|
||||
const categories: CategoryItem[] = useMemo(() => {
|
||||
const cats = new Map<string, number>()
|
||||
apiTitles.forEach((t) => {
|
||||
const cat = t.category || "未分类"
|
||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
||||
})
|
||||
return [
|
||||
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
|
||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||
id: `cat-${name}`,
|
||||
name,
|
||||
count,
|
||||
})),
|
||||
]
|
||||
}, [apiTitles])
|
||||
|
||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
||||
|
||||
return {
|
||||
titles,
|
||||
categories,
|
||||
activeCatId,
|
||||
activeCategory,
|
||||
setActiveCatId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import type { TitleData, CategoryItem, Frequency } from "../../types/titleLibrary"
|
||||
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../../constants/titleLibrary"
|
||||
|
||||
export const useTitleFilters = (
|
||||
titles: TitleData[],
|
||||
_categories: CategoryItem[],
|
||||
activeCatId: string,
|
||||
activeCategory: CategoryItem | undefined,
|
||||
) => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
||||
|
||||
/* 派生:筛选后的标题列表 */
|
||||
const filteredTitles = useMemo(() => {
|
||||
let list = titles
|
||||
|
||||
/* 按分类过滤 */
|
||||
if (activeCatId !== ALL_CATEGORY_ID) {
|
||||
const catName = activeCategory?.name || ""
|
||||
if (catName) {
|
||||
list = list.filter((t) => t.category === catName)
|
||||
}
|
||||
}
|
||||
|
||||
/* 按类型筛选 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((t) => t.type === filterType)
|
||||
}
|
||||
|
||||
/* 按行业筛选 */
|
||||
if (filterIndustry !== "all") {
|
||||
list = list.filter((t) => t.industry === filterIndustry)
|
||||
}
|
||||
|
||||
/* 按使用频率筛选 */
|
||||
if (filterFrequency !== "all") {
|
||||
switch (filterFrequency) {
|
||||
case "high":
|
||||
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
|
||||
break
|
||||
case "medium":
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
|
||||
t.usageCount < FREQUENCY_THRESHOLDS.high,
|
||||
)
|
||||
break
|
||||
case "low":
|
||||
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
||||
|
||||
return {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterIndustry,
|
||||
setFilterIndustry,
|
||||
filterFrequency,
|
||||
setFilterFrequency,
|
||||
filteredTitles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { createTitle, updateTitle, deleteTitle } from "@/api/titles"
|
||||
|
||||
export const useTitleMutations = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
message.success("标题已删除")
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
})
|
||||
|
||||
return { createMutation, updateMutation, deleteMutation }
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Auth API 测试
|
||||
* 对应 api/auth/ 目录化后的模块
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
@@ -10,7 +14,6 @@ import {
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
@@ -36,6 +36,10 @@ import "@/pages/assets/hooks/useLibraryManagement"
|
||||
import "@/pages/assets/hooks/useAssetUpload"
|
||||
import "@/pages/assets/hooks/useAssetSelection"
|
||||
import "@/pages/assets/hooks/useAssetOperations"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
|
||||
|
||||
describe("AssetLibrary module smoke test", () => {
|
||||
it("should load all asset modules", () => {
|
||||
|
||||
Regular → Executable
+2
@@ -61,6 +61,8 @@ import "@/pages/editing-planner/components/pip-config/LayerConfig"
|
||||
import "@/pages/editing-planner/components/sticker/StickerLibrary"
|
||||
import "@/pages/editing-planner/components/sticker/StickerList"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPreview"
|
||||
import "@/pages/editing-planner/components/sticker/TextStickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
|
||||
@@ -38,7 +38,13 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTitleCoverSync"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader"
|
||||
import "@/pages/generate/hooks/generate-video/types"
|
||||
import "@/pages/generate/hooks/generate-video/phase"
|
||||
import "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
import "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
import "@/pages/generate/hooks/generate-video/buildPayload"
|
||||
|
||||
@@ -21,7 +21,10 @@ import "@/pages/products/components/VideoPlayer"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/products/hooks/useProductList"
|
||||
import "@/pages/products/hooks/useProductList/useProductFiltering"
|
||||
import "@/pages/products/hooks/useProductList/useBatchSelection"
|
||||
import "@/pages/products/hooks/useProductActions"
|
||||
import "@/pages/products/hooks/useVideoPlayer"
|
||||
|
||||
describe("ProductLibrary module smoke test", () => {
|
||||
it("should load all product modules", () => {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Tasks 模块 smoke test
|
||||
* 建立依赖链,确保 vitest related 能匹配到 tasks 目录下的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/tasks/components/TaskTable"
|
||||
import "@/pages/tasks/components/task-table/useTaskTableColumns"
|
||||
import "@/pages/tasks/components/task-table/TaskEmptyState"
|
||||
import "@/pages/tasks/components/TaskFilterBar"
|
||||
import "@/pages/tasks/components/TaskErrorDetail"
|
||||
|
||||
describe("Tasks module smoke test", () => {
|
||||
it("should load all task modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TitleLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* titles 目录下所有文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/titles/TitleLibrary"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/titles/hooks/useTitleLibrary"
|
||||
import "@/pages/titles/hooks/useTitleLibrary/useTitleData"
|
||||
import "@/pages/titles/hooks/useTitleLibrary/useTitleFilters"
|
||||
import "@/pages/titles/hooks/useTitleLibrary/useTitleMutations"
|
||||
import "@/pages/titles/hooks/useTitleLibrary/useTitleActions"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/titles/types/titleLibrary"
|
||||
import "@/pages/titles/constants/titleLibrary"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/titles/utils/titleLibrary"
|
||||
|
||||
describe("TitleLibrary module smoke test", () => {
|
||||
it("should load all title modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,23 @@ class InMemoryUserRepository(UserRepository):
|
||||
|
||||
def save(self, user: User) -> None:
|
||||
"""保存用户"""
|
||||
# 如果是更新,先清理旧索引
|
||||
old = self._users.get(user.id)
|
||||
if old:
|
||||
self._email_index.pop(old.email.lower(), None)
|
||||
if old.username:
|
||||
self._username_index.pop(old.username.lower(), None)
|
||||
if old.email_verification_token:
|
||||
self._verification_token_index.pop(old.email_verification_token, None)
|
||||
if old.password_reset_token:
|
||||
self._reset_token_index.pop(old.password_reset_token, None)
|
||||
if old.wechat_openid:
|
||||
self._wechat_openid_index.pop(old.wechat_openid, None)
|
||||
if old.wechat_unionid:
|
||||
self._wechat_unionid_index.pop(old.wechat_unionid, None)
|
||||
if old.phone:
|
||||
self._phone_index.pop(old.phone, None)
|
||||
|
||||
self._users[user.id] = user
|
||||
self._email_index[user.email.lower()] = user.id
|
||||
if user.username:
|
||||
|
||||
+13
-10
@@ -32,7 +32,13 @@ def api_request(token, repo, endpoint, method="GET", data=None):
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read().decode()), resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
body = e.read().decode()
|
||||
if body:
|
||||
try:
|
||||
return json.loads(body), e.code
|
||||
except json.JSONDecodeError:
|
||||
return {"error": body}, e.code
|
||||
return {"error": str(e)}, e.code
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
@@ -269,13 +275,9 @@ def main():
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)",
|
||||
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
|
||||
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
@@ -284,7 +286,8 @@ def main():
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
@@ -301,7 +304,7 @@ def main():
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
base_ref = pr.get("base", {}).get("ref", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
"""asset / asset_library 兼容层单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.asset import Asset, AssetStatus, AssetType, ClassificationStatus
|
||||
from domain.asset_library import AssetLibrary, AssetLibraryKind, LibraryKind
|
||||
|
||||
|
||||
class TestAssetType:
|
||||
"""AssetType 常量类测试."""
|
||||
|
||||
def test_video_value(self):
|
||||
assert AssetType.VIDEO == "video"
|
||||
|
||||
def test_image_value(self):
|
||||
assert AssetType.IMAGE == "image"
|
||||
|
||||
def test_audio_value(self):
|
||||
assert AssetType.AUDIO == "audio"
|
||||
|
||||
def test_three_types(self):
|
||||
assert AssetType.VIDEO
|
||||
assert AssetType.IMAGE
|
||||
assert AssetType.AUDIO
|
||||
|
||||
|
||||
class TestAssetReexports:
|
||||
"""asset.py 重导出测试."""
|
||||
|
||||
def test_asset_reexported(self):
|
||||
# Asset 类从 entities 转发,确认可访问
|
||||
assert Asset is not None
|
||||
|
||||
def test_asset_status_reexported(self):
|
||||
assert AssetStatus is not None
|
||||
|
||||
def test_classification_status_reexported(self):
|
||||
assert ClassificationStatus is not None
|
||||
|
||||
|
||||
class TestLibraryKind:
|
||||
"""LibraryKind 常量类测试."""
|
||||
|
||||
def test_video_value(self):
|
||||
assert LibraryKind.VIDEO == AssetLibraryKind.VIDEO
|
||||
|
||||
def test_voice_value(self):
|
||||
assert LibraryKind.VOICE == AssetLibraryKind.VOICE
|
||||
|
||||
def test_image_value(self):
|
||||
assert LibraryKind.IMAGE == AssetLibraryKind.IMAGE
|
||||
|
||||
|
||||
class TestAssetLibraryReexports:
|
||||
"""asset_library.py 重导出测试."""
|
||||
|
||||
def test_asset_library_reexported(self):
|
||||
assert AssetLibrary is not None
|
||||
|
||||
def test_asset_library_kind_reexported(self):
|
||||
assert AssetLibraryKind is not None
|
||||
Executable
+521
@@ -0,0 +1,521 @@
|
||||
"""audio_track_config 多轨道音频配置单测."""
|
||||
|
||||
import pytest
|
||||
from domain.audio_track_config import (
|
||||
ALLOWED_AUDIO_EXTENSIONS,
|
||||
DEFAULT_VOLUMES,
|
||||
MAX_AUDIO_TRACKS,
|
||||
TRACK_TYPE_AMBIENT,
|
||||
TRACK_TYPE_BGM,
|
||||
TRACK_TYPE_MAIN,
|
||||
TRACK_TYPE_SFX,
|
||||
TRACK_TYPE_VOICEOVER,
|
||||
AudioTrack,
|
||||
MultiTrackMixConfig,
|
||||
clamp_volume,
|
||||
is_valid_audio_extension,
|
||||
)
|
||||
|
||||
# ── 常量测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""模块常量"""
|
||||
|
||||
def test_track_type_constants(self):
|
||||
assert TRACK_TYPE_MAIN == "main"
|
||||
assert TRACK_TYPE_BGM == "bgm"
|
||||
assert TRACK_TYPE_VOICEOVER == "voiceover"
|
||||
assert TRACK_TYPE_SFX == "sfx"
|
||||
assert TRACK_TYPE_AMBIENT == "ambient"
|
||||
|
||||
def test_max_tracks(self):
|
||||
assert MAX_AUDIO_TRACKS == 8
|
||||
|
||||
def test_default_volumes(self):
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
|
||||
|
||||
def test_allowed_extensions(self):
|
||||
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
|
||||
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
# ── AudioTrack ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAudioTrackDefaults:
|
||||
"""AudioTrack 默认值"""
|
||||
|
||||
def test_default_values(self):
|
||||
t = AudioTrack()
|
||||
assert t.track_id == ""
|
||||
assert t.track_type == TRACK_TYPE_SFX
|
||||
assert t.audio_path == ""
|
||||
assert t.volume == 1.0
|
||||
assert t.fade_in == 0.0
|
||||
assert t.fade_out == 0.0
|
||||
assert t.start_time == 0.0
|
||||
assert t.duration == 0.0
|
||||
assert t.enabled is True
|
||||
|
||||
def test_custom_track(self):
|
||||
t = AudioTrack(
|
||||
track_id="bgm_001",
|
||||
track_type=TRACK_TYPE_BGM,
|
||||
audio_path="/music/bgm.mp3",
|
||||
volume=0.5,
|
||||
fade_in=1.5,
|
||||
fade_out=2.0,
|
||||
start_time=3.0,
|
||||
duration=30.0,
|
||||
enabled=False,
|
||||
)
|
||||
assert t.track_id == "bgm_001"
|
||||
assert t.track_type == TRACK_TYPE_BGM
|
||||
assert t.audio_path == "/music/bgm.mp3"
|
||||
assert t.volume == 0.5
|
||||
assert t.fade_in == 1.5
|
||||
assert t.start_time == 3.0
|
||||
assert t.duration == 30.0
|
||||
assert t.enabled is False
|
||||
|
||||
|
||||
class TestAudioTrackFromDict:
|
||||
"""AudioTrack.from_dict"""
|
||||
|
||||
def test_empty_dict(self):
|
||||
t = AudioTrack.from_dict({})
|
||||
assert t.track_type == TRACK_TYPE_SFX
|
||||
assert t.audio_path == ""
|
||||
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
|
||||
assert t.enabled is True
|
||||
|
||||
def test_full_dict(self):
|
||||
t = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": 0.8,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
"start_time": 5.0,
|
||||
"duration": 60.0,
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
assert t.track_id == "t1"
|
||||
assert t.track_type == "bgm"
|
||||
assert t.volume == 0.8
|
||||
assert t.fade_in == 1.0
|
||||
assert t.duration == 60.0
|
||||
|
||||
def test_volume_clamped_to_zero(self):
|
||||
t = AudioTrack.from_dict({"volume": -0.5})
|
||||
assert t.volume == 0.0
|
||||
|
||||
def test_volume_clamped_to_two(self):
|
||||
t = AudioTrack.from_dict({"volume": 3.0})
|
||||
assert t.volume == 2.0
|
||||
|
||||
def test_invalid_volume_falls_back_to_default(self):
|
||||
t = AudioTrack.from_dict({"track_type": "bgm", "volume": "abc"})
|
||||
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
|
||||
|
||||
def test_invalid_fade_in_falls_back(self):
|
||||
t = AudioTrack.from_dict({"fade_in": "bad"})
|
||||
assert t.fade_in == 0.0
|
||||
|
||||
def test_negative_fade_in_clamped(self):
|
||||
t = AudioTrack.from_dict({"fade_in": -1.0})
|
||||
assert t.fade_in == 0.0
|
||||
|
||||
def test_invalid_fade_out_falls_back(self):
|
||||
t = AudioTrack.from_dict({"fade_out": None})
|
||||
assert t.fade_out == 0.0
|
||||
|
||||
def test_negative_start_time_clamped(self):
|
||||
t = AudioTrack.from_dict({"start_time": -5.0})
|
||||
assert t.start_time == 0.0
|
||||
|
||||
def test_invalid_duration_falls_back(self):
|
||||
t = AudioTrack.from_dict({"duration": "long"})
|
||||
assert t.duration == 0.0
|
||||
|
||||
def test_bgm_default_volume(self):
|
||||
t = AudioTrack.from_dict({"track_type": "bgm"})
|
||||
assert t.volume == 0.3
|
||||
|
||||
def test_main_default_volume(self):
|
||||
t = AudioTrack.from_dict({"track_type": "main"})
|
||||
assert t.volume == 1.0
|
||||
|
||||
def test_voiceover_default_volume(self):
|
||||
t = AudioTrack.from_dict({"track_type": "voiceover"})
|
||||
assert t.volume == 1.0
|
||||
|
||||
def test_ambient_default_volume(self):
|
||||
t = AudioTrack.from_dict({"track_type": "ambient"})
|
||||
assert t.volume == 0.2
|
||||
|
||||
def test_unknown_type_default_volume(self):
|
||||
t = AudioTrack.from_dict({"track_type": "unknown_type"})
|
||||
assert t.volume == 1.0
|
||||
|
||||
def test_enabled_false(self):
|
||||
t = AudioTrack.from_dict({"enabled": False})
|
||||
assert t.enabled is False
|
||||
|
||||
|
||||
class TestAudioTrackValidate:
|
||||
"""AudioTrack.validate"""
|
||||
|
||||
def test_empty_path_invalid(self):
|
||||
t = AudioTrack(audio_path="")
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "audio_path" in msg
|
||||
|
||||
def test_valid_track(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", volume=0.5)
|
||||
valid, msg = t.validate()
|
||||
assert valid is True
|
||||
assert msg == ""
|
||||
|
||||
def test_volume_below_zero_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", volume=-0.1)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "volume" in msg
|
||||
|
||||
def test_volume_above_two_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", volume=2.1)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "volume" in msg
|
||||
|
||||
def test_volume_zero_valid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", volume=0.0)
|
||||
valid, _ = t.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_volume_two_valid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", volume=2.0)
|
||||
valid, _ = t.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_negative_fade_in_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", fade_in=-1.0)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "fade_in" in msg
|
||||
|
||||
def test_negative_fade_out_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", fade_out=-1.0)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "fade_out" in msg
|
||||
|
||||
def test_negative_start_time_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", start_time=-0.5)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "start_time" in msg
|
||||
|
||||
def test_negative_duration_invalid(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", duration=-1.0)
|
||||
valid, msg = t.validate()
|
||||
assert valid is False
|
||||
assert "duration" in msg
|
||||
|
||||
|
||||
class TestAudioTrackIsEffective:
|
||||
"""AudioTrack.is_effective 属性"""
|
||||
|
||||
def test_enabled_with_path_effective(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", enabled=True)
|
||||
assert t.is_effective is True
|
||||
|
||||
def test_disabled_not_effective(self):
|
||||
t = AudioTrack(audio_path="/a.mp3", enabled=False)
|
||||
assert t.is_effective is False
|
||||
|
||||
def test_no_path_not_effective(self):
|
||||
t = AudioTrack(audio_path="", enabled=True)
|
||||
assert t.is_effective is False
|
||||
|
||||
def test_disabled_no_path_not_effective(self):
|
||||
t = AudioTrack(audio_path="", enabled=False)
|
||||
assert t.is_effective is False
|
||||
|
||||
|
||||
# ── MultiTrackMixConfig ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiTrackMixConfigDefaults:
|
||||
"""MultiTrackMixConfig 默认值"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = MultiTrackMixConfig()
|
||||
assert c.tracks == []
|
||||
assert c.master_volume == 1.0
|
||||
assert c.normalize is True
|
||||
assert c.max_output_volume == 1.5
|
||||
|
||||
def test_custom_config(self):
|
||||
t1 = AudioTrack(track_id="t1", audio_path="/a.mp3")
|
||||
c = MultiTrackMixConfig(
|
||||
tracks=[t1],
|
||||
master_volume=0.8,
|
||||
normalize=False,
|
||||
max_output_volume=2.0,
|
||||
)
|
||||
assert len(c.tracks) == 1
|
||||
assert c.master_volume == 0.8
|
||||
assert c.normalize is False
|
||||
assert c.max_output_volume == 2.0
|
||||
|
||||
|
||||
class TestMultiTrackFromConfigDict:
|
||||
"""MultiTrackMixConfig.from_config_dict"""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(None)
|
||||
assert len(c.tracks) == 0
|
||||
assert c.master_volume == 1.0
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({})
|
||||
assert len(c.tracks) == 0
|
||||
|
||||
def test_non_dict_returns_default(self):
|
||||
c = MultiTrackMixConfig.from_config_dict("not a dict")
|
||||
assert len(c.tracks) == 0
|
||||
|
||||
def test_single_track(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "track_type": "bgm", "audio_path": "/bgm.mp3", "volume": 0.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.tracks) == 1
|
||||
assert c.tracks[0].track_id == "t1"
|
||||
assert c.tracks[0].volume == 0.5
|
||||
|
||||
def test_multiple_tracks(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "track_type": "main", "audio_path": "/main.wav"},
|
||||
{"track_id": "t2", "track_type": "bgm", "audio_path": "/bgm.mp3"},
|
||||
{"track_id": "t3", "track_type": "sfx", "audio_path": "/sfx.wav"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.tracks) == 3
|
||||
assert c.tracks[0].track_type == "main"
|
||||
assert c.tracks[1].track_type == "bgm"
|
||||
assert c.tracks[2].track_type == "sfx"
|
||||
|
||||
def test_skip_disabled_tracks(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
|
||||
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
|
||||
{"track_id": "t3", "audio_path": "/c.mp3"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.tracks) == 2
|
||||
ids = [t.track_id for t in c.tracks]
|
||||
assert "t1" in ids
|
||||
assert "t2" not in ids
|
||||
assert "t3" in ids
|
||||
|
||||
def test_skip_no_path_tracks(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
{"track_id": "t2", "audio_path": ""},
|
||||
{"track_id": "t3"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.tracks) == 1
|
||||
assert c.tracks[0].track_id == "t1"
|
||||
|
||||
def test_skip_non_dict_tracks(self):
|
||||
c = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
"not a dict",
|
||||
123,
|
||||
None,
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.tracks) == 1
|
||||
|
||||
def test_master_volume_clamped(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"master_volume": 3.0})
|
||||
assert c.master_volume == 2.0
|
||||
|
||||
def test_master_volume_negative_clamped(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"master_volume": -1.0})
|
||||
assert c.master_volume == 0.0
|
||||
|
||||
def test_invalid_master_volume_falls_back(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"master_volume": "high"})
|
||||
assert c.master_volume == 1.0
|
||||
|
||||
def test_normalize_false(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"normalize": False})
|
||||
assert c.normalize is False
|
||||
|
||||
def test_max_output_volume_custom(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
|
||||
assert c.max_output_volume == 2.0
|
||||
|
||||
def test_invalid_max_output_volume_falls_back(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": "big"})
|
||||
assert c.max_output_volume == 1.5
|
||||
|
||||
def test_tracks_not_list_ignored(self):
|
||||
c = MultiTrackMixConfig.from_config_dict({"tracks": "not a list"})
|
||||
assert len(c.tracks) == 0
|
||||
|
||||
|
||||
class TestMultiTrackProperties:
|
||||
"""MultiTrackMixConfig 属性方法"""
|
||||
|
||||
def _make_config(self):
|
||||
return MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "m1", "track_type": "main", "audio_path": "/m.wav"},
|
||||
{"track_id": "b1", "track_type": "bgm", "audio_path": "/b1.mp3"},
|
||||
{"track_id": "b2", "track_type": "bgm", "audio_path": "/b2.mp3", "enabled": False},
|
||||
{"track_id": "s1", "track_type": "sfx", "audio_path": "/s.wav"},
|
||||
{"track_id": "x", "track_type": "ambient", "audio_path": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_has_effect_true(self):
|
||||
c = self._make_config()
|
||||
assert c.has_effect is True
|
||||
|
||||
def test_has_effect_false(self):
|
||||
c = MultiTrackMixConfig()
|
||||
assert c.has_effect is False
|
||||
|
||||
def test_effective_track_count(self):
|
||||
c = self._make_config()
|
||||
# m1 + b1 + s1 = 3个有效(b2禁用,x无路径)
|
||||
assert c.effective_track_count == 3
|
||||
|
||||
def test_main_tracks(self):
|
||||
c = self._make_config()
|
||||
mains = c.main_tracks
|
||||
assert len(mains) == 1
|
||||
assert mains[0].track_id == "m1"
|
||||
|
||||
def test_bgm_tracks(self):
|
||||
c = self._make_config()
|
||||
bgms = c.bgm_tracks
|
||||
assert len(bgms) == 1 # 只有b1有效
|
||||
assert bgms[0].track_id == "b1"
|
||||
|
||||
def test_empty_tracks(self):
|
||||
c = MultiTrackMixConfig()
|
||||
assert c.effective_track_count == 0
|
||||
assert c.main_tracks == []
|
||||
assert c.bgm_tracks == []
|
||||
|
||||
|
||||
# ── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsValidAudioExtension:
|
||||
"""is_valid_audio_extension 函数"""
|
||||
|
||||
def test_mp3(self):
|
||||
assert is_valid_audio_extension("song.mp3") is True
|
||||
|
||||
def test_wav(self):
|
||||
assert is_valid_audio_extension("sound.wav") is True
|
||||
|
||||
def test_aac(self):
|
||||
assert is_valid_audio_extension("audio.aac") is True
|
||||
|
||||
def test_ogg(self):
|
||||
assert is_valid_audio_extension("music.ogg") is True
|
||||
|
||||
def test_flac(self):
|
||||
assert is_valid_audio_extension("lossless.flac") is True
|
||||
|
||||
def test_m4a(self):
|
||||
assert is_valid_audio_extension("apple.m4a") is True
|
||||
|
||||
def test_wma(self):
|
||||
assert is_valid_audio_extension("windows.wma") is True
|
||||
|
||||
def test_uppercase_extension(self):
|
||||
assert is_valid_audio_extension("SONG.MP3") is True
|
||||
|
||||
def test_mixed_case_extension(self):
|
||||
assert is_valid_audio_extension("song.Mp3") is True
|
||||
|
||||
def test_mp4_not_valid(self):
|
||||
assert is_valid_audio_extension("video.mp4") is False
|
||||
|
||||
def test_txt_not_valid(self):
|
||||
assert is_valid_audio_extension("notes.txt") is False
|
||||
|
||||
def test_no_extension(self):
|
||||
assert is_valid_audio_extension("README") is False
|
||||
|
||||
def test_full_path(self):
|
||||
assert is_valid_audio_extension("/home/user/music/song.mp3") is True
|
||||
|
||||
|
||||
class TestClampVolume:
|
||||
"""clamp_volume 函数"""
|
||||
|
||||
def test_within_range(self):
|
||||
assert clamp_volume(0.5) == 0.5
|
||||
|
||||
def test_exact_min(self):
|
||||
assert clamp_volume(0.0) == 0.0
|
||||
|
||||
def test_exact_max(self):
|
||||
assert clamp_volume(2.0) == 2.0
|
||||
|
||||
def test_below_min(self):
|
||||
assert clamp_volume(-1.0) == 0.0
|
||||
|
||||
def test_above_max(self):
|
||||
assert clamp_volume(3.0) == 2.0
|
||||
|
||||
def test_custom_bounds(self):
|
||||
assert clamp_volume(5.0, min_vol=1.0, max_vol=10.0) == 5.0
|
||||
|
||||
def test_custom_below_min(self):
|
||||
assert clamp_volume(0.5, min_vol=1.0) == 1.0
|
||||
|
||||
def test_custom_above_max(self):
|
||||
assert clamp_volume(15.0, max_vol=10.0) == 10.0
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
"""Auth ports (ABC接口) 单元测试.
|
||||
|
||||
验证抽象接口定义正确:不能直接实例化,子类必须实现所有抽象方法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
|
||||
import pytest
|
||||
from domain.auth.email_service import EmailServicePort
|
||||
from domain.auth.jwt_service import JWTServicePort
|
||||
from domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
|
||||
from domain.auth.session_store import SessionStorePort
|
||||
from domain.auth.sms_service import SmsService
|
||||
|
||||
|
||||
class TestSessionStorePort:
|
||||
"""SessionStorePort 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(SessionStorePort, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
SessionStorePort() # type: ignore[misc]
|
||||
|
||||
def test_has_abstract_methods(self):
|
||||
abstract_methods = SessionStorePort.__abstractmethods__
|
||||
expected = {
|
||||
"save_session",
|
||||
"get_session",
|
||||
"get_session_by_refresh_token",
|
||||
"get_refresh_token",
|
||||
"update_last_active",
|
||||
"delete_session",
|
||||
"get_user_sessions",
|
||||
"delete_all_user_sessions",
|
||||
"session_exists",
|
||||
}
|
||||
assert expected.issubset(abstract_methods)
|
||||
|
||||
def test_concrete_subclass_works(self):
|
||||
class ConcreteStore(SessionStorePort):
|
||||
def save_session(self, **kwargs): # type: ignore[override]
|
||||
return True
|
||||
|
||||
def get_session(self, session_id): # type: ignore[override]
|
||||
return None
|
||||
|
||||
def get_session_by_refresh_token(self, token): # type: ignore[override]
|
||||
return None
|
||||
|
||||
def get_refresh_token(self, session_id): # type: ignore[override]
|
||||
return None
|
||||
|
||||
def update_last_active(self, session_id): # type: ignore[override]
|
||||
return True
|
||||
|
||||
def delete_session(self, session_id): # type: ignore[override]
|
||||
return True
|
||||
|
||||
def get_user_sessions(self, user_id): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def delete_all_user_sessions(self, user_id): # type: ignore[override]
|
||||
return 0
|
||||
|
||||
def session_exists(self, session_id): # type: ignore[override]
|
||||
return False
|
||||
|
||||
store = ConcreteStore()
|
||||
assert isinstance(store, SessionStorePort)
|
||||
assert store.session_exists("s1") is False
|
||||
assert store.delete_session("s1") is True
|
||||
|
||||
|
||||
class TestEmailServicePort:
|
||||
"""EmailServicePort 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(EmailServicePort, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
EmailServicePort() # type: ignore[misc]
|
||||
|
||||
def test_has_abstract_methods(self):
|
||||
abstract_methods = EmailServicePort.__abstractmethods__
|
||||
expected = {"send_email", "send_verification_email", "send_password_reset_email"}
|
||||
assert expected.issubset(abstract_methods)
|
||||
|
||||
|
||||
class TestJWTServicePort:
|
||||
"""JWTServicePort 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(JWTServicePort, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
JWTServicePort() # type: ignore[misc]
|
||||
|
||||
def test_has_abstract_methods(self):
|
||||
abstract_methods = JWTServicePort.__abstractmethods__
|
||||
expected = {
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"verify_token",
|
||||
"verify_access_token",
|
||||
"verify_refresh_token",
|
||||
}
|
||||
assert expected.issubset(abstract_methods)
|
||||
|
||||
|
||||
class TestPasswordHasherPort:
|
||||
"""PasswordHasherPort 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(PasswordHasherPort, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
PasswordHasherPort() # type: ignore[misc]
|
||||
|
||||
def test_has_abstract_methods(self):
|
||||
abstract_methods = PasswordHasherPort.__abstractmethods__
|
||||
expected = {"hash_password", "verify_password", "needs_rehash"}
|
||||
assert expected.issubset(abstract_methods)
|
||||
|
||||
|
||||
class TestPasswordValidatorPort:
|
||||
"""PasswordValidatorPort 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(PasswordValidatorPort, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
PasswordValidatorPort() # type: ignore[misc]
|
||||
|
||||
def test_has_validate_method(self):
|
||||
assert "validate" in PasswordValidatorPort.__abstractmethods__
|
||||
|
||||
|
||||
class TestSmsService:
|
||||
"""SmsService 接口测试."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
assert issubclass(SmsService, ABC)
|
||||
|
||||
def test_cannot_instantiate(self):
|
||||
with pytest.raises(TypeError):
|
||||
SmsService() # type: ignore[misc]
|
||||
|
||||
def test_has_abstract_methods(self):
|
||||
abstract_methods = SmsService.__abstractmethods__
|
||||
expected = {"send_verification_code", "send_template_sms"}
|
||||
assert expected.issubset(abstract_methods)
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
"""bgm_utils 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.bgm_utils import merge_bgm_config
|
||||
|
||||
|
||||
class TestMergeBgmConfigEmptyInputs:
|
||||
"""空输入测试."""
|
||||
|
||||
def test_both_empty(self):
|
||||
result = merge_bgm_config({}, {})
|
||||
assert result == {}
|
||||
|
||||
def test_user_empty_returns_template_copy(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(template, {})
|
||||
assert result == {"enabled": True, "volume": 0.5}
|
||||
# 返回的是副本不是同一个对象
|
||||
assert result is not template
|
||||
|
||||
def test_template_empty_returns_user_copy(self):
|
||||
user = {"enabled": False, "volume": 0.8}
|
||||
result = merge_bgm_config({}, user)
|
||||
assert result == {"enabled": False, "volume": 0.8}
|
||||
assert result is not user
|
||||
|
||||
def test_user_none_returns_template(self):
|
||||
template = {"enabled": True}
|
||||
result = merge_bgm_config(template, None) # type: ignore[arg-type]
|
||||
assert result == template
|
||||
|
||||
def test_template_none_returns_user(self):
|
||||
user = {"enabled": True}
|
||||
result = merge_bgm_config(None, user) # type: ignore[arg-type]
|
||||
assert result == user
|
||||
|
||||
|
||||
class TestMergeBgmConfigBasicMerge:
|
||||
"""基础合并测试."""
|
||||
|
||||
def test_user_overrides_template_field(self):
|
||||
template = {"volume": 0.5, "fade_in": 1.0}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.8
|
||||
assert result["fade_in"] == 1.0
|
||||
|
||||
def test_user_adds_new_field(self):
|
||||
template = {"volume": 0.5}
|
||||
user = {"fade_out": 2.0}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_out"] == 2.0
|
||||
|
||||
def test_all_fields_overridden(self):
|
||||
template = {"enabled": True, "volume": 0.5, "track_id": "t1"}
|
||||
user = {"enabled": False, "volume": 1.0, "track_id": "t2"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result == {"enabled": False, "volume": 1.0, "track_id": "t2"}
|
||||
|
||||
|
||||
class TestMergeBgmConfigEnabledSpecial:
|
||||
"""enabled 特殊处理测试."""
|
||||
|
||||
def test_user_no_enabled_keeps_template_enabled_true(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_user_no_enabled_keeps_template_enabled_false(self):
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_user_explicit_enabled_true_overrides_template_false(self):
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"enabled": True, "volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_user_explicit_enabled_false_overrides_template_true(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"enabled": False}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_template_no_enabled_user_no_enabled(self):
|
||||
template = {"volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert "enabled" not in result
|
||||
assert result["volume"] == 0.8
|
||||
|
||||
def test_template_no_enabled_user_has_enabled(self):
|
||||
template = {"volume": 0.5}
|
||||
user = {"enabled": True, "volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
|
||||
class TestMergeBgmConfigDoesNotMutate:
|
||||
"""不修改原字典测试."""
|
||||
|
||||
def test_template_not_mutated(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
original = dict(template)
|
||||
user = {"volume": 0.8, "fade": 1.0}
|
||||
merge_bgm_config(template, user)
|
||||
assert template == original
|
||||
|
||||
def test_user_not_mutated(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
original = dict(user)
|
||||
merge_bgm_config(template, user)
|
||||
assert user == original
|
||||
|
||||
|
||||
class TestMergeBgmConfigNestedDict:
|
||||
"""嵌套字典合并测试(简单合并,非深合并)."""
|
||||
|
||||
def test_nested_dict_user_overrides(self):
|
||||
template = {"effects": {"fade_in": 1.0, "fade_out": 1.0}}
|
||||
user = {"effects": {"fade_in": 2.0}}
|
||||
result = merge_bgm_config(template, user)
|
||||
# 简单合并,用户effects整个覆盖模板的
|
||||
assert result["effects"] == {"fade_in": 2.0}
|
||||
|
||||
def test_nested_dict_preserved_when_no_user_override(self):
|
||||
template = {"effects": {"fade_in": 1.0}}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["effects"] == {"fade_in": 1.0}
|
||||
Executable
+825
@@ -0,0 +1,825 @@
|
||||
"""config_schemas 模块单测.
|
||||
|
||||
覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from domain.config_schemas import (
|
||||
DEFAULT_EDIT_PLAN_CONFIG,
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG,
|
||||
BGMConfig,
|
||||
BGMSource,
|
||||
CoverConfig,
|
||||
CoverType,
|
||||
EditPlanConfigSchema,
|
||||
EditTemplateConfigSchema,
|
||||
ExportConfig,
|
||||
FilterConfig,
|
||||
ShadowConfig,
|
||||
StrokeConfig,
|
||||
SubtitleConfig,
|
||||
TextAnimation,
|
||||
TextPosition,
|
||||
TitleConfig,
|
||||
normalize_plan_config,
|
||||
normalize_template_config,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverType:
|
||||
"""CoverType 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert CoverType.AI_FRAME.value == "ai_frame"
|
||||
assert CoverType.MANUAL.value == "manual"
|
||||
assert CoverType.UPLOAD.value == "upload"
|
||||
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(CoverType.AI_FRAME, str)
|
||||
assert CoverType.AI_FRAME == "ai_frame"
|
||||
|
||||
def test_from_string(self):
|
||||
assert CoverType("ai_frame") == CoverType.AI_FRAME
|
||||
assert CoverType("manual") == CoverType.MANUAL
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
CoverType("invalid")
|
||||
|
||||
|
||||
class TestTextPosition:
|
||||
"""TextPosition 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextPosition.TOP.value == "top"
|
||||
assert TextPosition.CENTER.value == "center"
|
||||
assert TextPosition.BOTTOM.value == "bottom"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextPosition("top") == TextPosition.TOP
|
||||
assert TextPosition("bottom") == TextPosition.BOTTOM
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextPosition("left")
|
||||
|
||||
|
||||
class TestTextAnimation:
|
||||
"""TextAnimation 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert TextAnimation.NONE.value == "none"
|
||||
assert TextAnimation.FADE_IN.value == "fade_in"
|
||||
assert TextAnimation.SLIDE_UP.value == "slide_up"
|
||||
assert TextAnimation.SLIDE_DOWN.value == "slide_down"
|
||||
assert TextAnimation.SCALE.value == "scale"
|
||||
|
||||
def test_from_string(self):
|
||||
assert TextAnimation("fade_in") == TextAnimation.FADE_IN
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextAnimation("bounce")
|
||||
|
||||
|
||||
class TestBGMSource:
|
||||
"""BGMSource 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert BGMSource.LIBRARY.value == "library"
|
||||
assert BGMSource.UPLOAD.value == "upload"
|
||||
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
|
||||
|
||||
def test_from_string(self):
|
||||
assert BGMSource("library") == BGMSource.LIBRARY
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
BGMSource("spotify")
|
||||
|
||||
|
||||
# ── StrokeConfig / ShadowConfig ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStrokeConfig:
|
||||
"""StrokeConfig 描边配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = StrokeConfig()
|
||||
assert s.enabled is False
|
||||
assert s.color == "#000000"
|
||||
assert s.width == 1
|
||||
|
||||
def test_custom_values(self):
|
||||
s = StrokeConfig(enabled=True, color="#ff0000", width=5)
|
||||
assert s.enabled is True
|
||||
assert s.color == "#ff0000"
|
||||
assert s.width == 5
|
||||
|
||||
def test_width_min_boundary(self):
|
||||
s = StrokeConfig(width=1)
|
||||
assert s.width == 1
|
||||
|
||||
def test_width_max_boundary(self):
|
||||
s = StrokeConfig(width=10)
|
||||
assert s.width == 10
|
||||
|
||||
def test_width_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=0)
|
||||
|
||||
def test_width_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=11)
|
||||
|
||||
|
||||
class TestShadowConfig:
|
||||
"""ShadowConfig 阴影配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = ShadowConfig()
|
||||
assert s.enabled is False
|
||||
assert s.blur == 4
|
||||
assert s.offset_x == 2
|
||||
assert s.offset_y == 2
|
||||
|
||||
def test_custom_values(self):
|
||||
s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5)
|
||||
assert s.enabled is True
|
||||
assert s.blur == 10
|
||||
assert s.offset_x == 5
|
||||
assert s.offset_y == 5
|
||||
|
||||
def test_blur_min_boundary(self):
|
||||
s = ShadowConfig(blur=0)
|
||||
assert s.blur == 0
|
||||
|
||||
def test_blur_max_boundary(self):
|
||||
s = ShadowConfig(blur=20)
|
||||
assert s.blur == 20
|
||||
|
||||
def test_blur_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ShadowConfig(blur=21)
|
||||
|
||||
|
||||
# ── CoverConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoverConfig:
|
||||
"""CoverConfig 封面配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = CoverConfig()
|
||||
assert c.type == CoverType.AI_FRAME
|
||||
assert c.image_url == ""
|
||||
assert c.frame_time is None
|
||||
|
||||
def test_manual_type_with_frame_time(self):
|
||||
c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5)
|
||||
assert c.type == CoverType.MANUAL
|
||||
assert c.frame_time == 5.5
|
||||
|
||||
def test_upload_type_with_image_url(self):
|
||||
c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg")
|
||||
assert c.type == CoverType.UPLOAD
|
||||
assert c.image_url == "https://example.com/cover.jpg"
|
||||
|
||||
def test_frame_time_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CoverConfig(frame_time=-1.0)
|
||||
|
||||
def test_frame_time_zero_valid(self):
|
||||
c = CoverConfig(frame_time=0.0)
|
||||
assert c.frame_time == 0.0
|
||||
|
||||
def test_from_dict_with_string_enum(self):
|
||||
c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""})
|
||||
assert c.type == CoverType.AI_REGENERATE
|
||||
|
||||
|
||||
# ── TitleConfig ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTitleConfig:
|
||||
"""TitleConfig 标题配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
t = TitleConfig()
|
||||
assert t.enabled is True
|
||||
assert t.ai_auto is True
|
||||
assert t.text == ""
|
||||
assert t.position == TextPosition.TOP
|
||||
assert t.font == "思源黑体"
|
||||
assert t.color == "#ffffff"
|
||||
assert t.size == 48
|
||||
assert t.bold is True
|
||||
assert t.italic is False
|
||||
assert isinstance(t.stroke, StrokeConfig)
|
||||
assert isinstance(t.shadow, ShadowConfig)
|
||||
|
||||
def test_custom_title(self):
|
||||
t = TitleConfig(
|
||||
enabled=True,
|
||||
ai_auto=False,
|
||||
text="我的视频标题",
|
||||
position=TextPosition.CENTER,
|
||||
font="微软雅黑",
|
||||
color="#000000",
|
||||
size=36,
|
||||
bold=False,
|
||||
italic=True,
|
||||
)
|
||||
assert t.text == "我的视频标题"
|
||||
assert t.position == TextPosition.CENTER
|
||||
assert t.size == 36
|
||||
assert t.bold is False
|
||||
assert t.italic is True
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
t = TitleConfig(size=12)
|
||||
assert t.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
t = TitleConfig(size=120)
|
||||
assert t.size == 120
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=11)
|
||||
|
||||
def test_size_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=121)
|
||||
|
||||
def test_stroke_nested_config(self):
|
||||
t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3})
|
||||
assert t.stroke.enabled is True
|
||||
assert t.stroke.color == "#ff0000"
|
||||
assert t.stroke.width == 3
|
||||
|
||||
def test_shadow_nested_config(self):
|
||||
t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3})
|
||||
assert t.shadow.enabled is True
|
||||
assert t.shadow.blur == 8
|
||||
|
||||
|
||||
# ── SubtitleConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleConfig:
|
||||
"""SubtitleConfig 字幕配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = SubtitleConfig()
|
||||
assert s.enabled is True
|
||||
assert s.position == TextPosition.BOTTOM
|
||||
assert s.font == "思源黑体"
|
||||
assert s.color == "#ffffff"
|
||||
assert s.size == 24
|
||||
assert s.animation == TextAnimation.FADE_IN
|
||||
assert s.auto_generated is False
|
||||
assert s.language == ""
|
||||
assert s.max_chars_per_line == 20
|
||||
assert s.min_chars_per_segment == 8
|
||||
|
||||
def test_custom_subtitle(self):
|
||||
s = SubtitleConfig(
|
||||
enabled=False,
|
||||
position=TextPosition.TOP,
|
||||
size=32,
|
||||
animation=TextAnimation.SLIDE_UP,
|
||||
auto_generated=True,
|
||||
language="zh",
|
||||
max_chars_per_line=30,
|
||||
min_chars_per_segment=10,
|
||||
)
|
||||
assert s.enabled is False
|
||||
assert s.position == TextPosition.TOP
|
||||
assert s.size == 32
|
||||
assert s.animation == TextAnimation.SLIDE_UP
|
||||
assert s.auto_generated is True
|
||||
assert s.language == "zh"
|
||||
|
||||
def test_size_min_boundary(self):
|
||||
s = SubtitleConfig(size=12)
|
||||
assert s.size == 12
|
||||
|
||||
def test_size_max_boundary(self):
|
||||
s = SubtitleConfig(size=60)
|
||||
assert s.size == 60
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(size=11)
|
||||
|
||||
def test_max_chars_min_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=8)
|
||||
assert s.max_chars_per_line == 8
|
||||
|
||||
def test_max_chars_max_boundary(self):
|
||||
s = SubtitleConfig(max_chars_per_line=40)
|
||||
assert s.max_chars_per_line == 40
|
||||
|
||||
def test_max_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(max_chars_per_line=41)
|
||||
|
||||
def test_min_chars_min_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=2)
|
||||
assert s.min_chars_per_segment == 2
|
||||
|
||||
def test_min_chars_max_boundary(self):
|
||||
s = SubtitleConfig(min_chars_per_segment=20)
|
||||
assert s.min_chars_per_segment == 20
|
||||
|
||||
def test_min_chars_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(min_chars_per_segment=1)
|
||||
|
||||
|
||||
# ── BGMConfig ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGMConfig BGM配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
b = BGMConfig()
|
||||
assert b.enabled is False
|
||||
assert b.source == BGMSource.LIBRARY
|
||||
assert b.asset_id == ""
|
||||
assert b.preset_id == ""
|
||||
assert b.audio_url == ""
|
||||
assert b.volume == 0.3
|
||||
assert b.fade_in == 0.0
|
||||
assert b.fade_out == 0.0
|
||||
assert b.loop_enabled is True
|
||||
assert b.sidechain_enabled is False
|
||||
assert b.sidechain_ratio == 0.3
|
||||
assert b.sidechain_attack == 0.02
|
||||
assert b.sidechain_release == 0.5
|
||||
assert b.sidechain_threshold == -25.0
|
||||
|
||||
def test_custom_bgm(self):
|
||||
b = BGMConfig(
|
||||
enabled=True,
|
||||
source=BGMSource.UPLOAD,
|
||||
asset_id="bgm_123",
|
||||
volume=0.5,
|
||||
fade_in=2.0,
|
||||
fade_out=3.0,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
)
|
||||
assert b.enabled is True
|
||||
assert b.source == BGMSource.UPLOAD
|
||||
assert b.volume == 0.5
|
||||
assert b.sidechain_enabled is True
|
||||
assert b.sidechain_ratio == 0.5
|
||||
|
||||
def test_volume_range(self):
|
||||
b = BGMConfig(volume=0.0)
|
||||
assert b.volume == 0.0
|
||||
b = BGMConfig(volume=1.0)
|
||||
assert b.volume == 1.0
|
||||
|
||||
def test_volume_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=1.1)
|
||||
|
||||
def test_fade_in_range(self):
|
||||
b = BGMConfig(fade_in=30.0)
|
||||
assert b.fade_in == 30.0
|
||||
|
||||
def test_fade_in_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(fade_in=31.0)
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
b = BGMConfig(sidechain_attack=0.001)
|
||||
assert b.sidechain_attack == 0.001
|
||||
|
||||
def test_sidechain_attack_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_attack=0.0001)
|
||||
|
||||
def test_sidechain_threshold_range(self):
|
||||
b = BGMConfig(sidechain_threshold=-60.0)
|
||||
assert b.sidechain_threshold == -60.0
|
||||
b = BGMConfig(sidechain_threshold=0.0)
|
||||
assert b.sidechain_threshold == 0.0
|
||||
|
||||
def test_sidechain_threshold_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=-61.0)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=1.0)
|
||||
|
||||
|
||||
# ── ExportConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExportConfig:
|
||||
"""ExportConfig 导出配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
e = ExportConfig()
|
||||
assert e.resolution == "1080x1920"
|
||||
assert e.fps == 30
|
||||
assert e.video_bitrate == 8000
|
||||
assert e.audio_bitrate == 128
|
||||
assert e.format == "mp4"
|
||||
assert e.quality_preset == "balanced"
|
||||
assert e.watermark_enabled is False
|
||||
assert e.watermark_text == ""
|
||||
|
||||
def test_custom_export(self):
|
||||
e = ExportConfig(
|
||||
resolution="720x1280",
|
||||
fps=60,
|
||||
video_bitrate=5000,
|
||||
audio_bitrate=192,
|
||||
format="mov",
|
||||
quality_preset="high",
|
||||
watermark_enabled=True,
|
||||
watermark_text="我的水印",
|
||||
)
|
||||
assert e.resolution == "720x1280"
|
||||
assert e.fps == 60
|
||||
assert e.format == "mov"
|
||||
assert e.watermark_enabled is True
|
||||
|
||||
def test_fps_min_boundary(self):
|
||||
e = ExportConfig(fps=15)
|
||||
assert e.fps == 15
|
||||
|
||||
def test_fps_max_boundary(self):
|
||||
e = ExportConfig(fps=60)
|
||||
assert e.fps == 60
|
||||
|
||||
def test_fps_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=14)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=61)
|
||||
|
||||
def test_video_bitrate_range(self):
|
||||
e = ExportConfig(video_bitrate=1000)
|
||||
assert e.video_bitrate == 1000
|
||||
e = ExportConfig(video_bitrate=20000)
|
||||
assert e.video_bitrate == 20000
|
||||
|
||||
def test_video_bitrate_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=999)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=20001)
|
||||
|
||||
def test_audio_bitrate_range(self):
|
||||
e = ExportConfig(audio_bitrate=64)
|
||||
assert e.audio_bitrate == 64
|
||||
e = ExportConfig(audio_bitrate=320)
|
||||
assert e.audio_bitrate == 320
|
||||
|
||||
|
||||
# ── FilterConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterConfig:
|
||||
"""FilterConfig 滤镜配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
f = FilterConfig()
|
||||
assert f.enabled is False
|
||||
assert f.preset_id == "filter_none"
|
||||
assert f.intensity == 100
|
||||
assert f.brightness == 0.0
|
||||
assert f.contrast == 1.0
|
||||
assert f.saturation == 1.0
|
||||
assert f.warmth == 0.0
|
||||
|
||||
def test_custom_filter(self):
|
||||
f = FilterConfig(
|
||||
enabled=True,
|
||||
preset_id="vintage",
|
||||
intensity=50,
|
||||
brightness=0.3,
|
||||
contrast=1.5,
|
||||
saturation=2.0,
|
||||
warmth=-0.5,
|
||||
)
|
||||
assert f.enabled is True
|
||||
assert f.preset_id == "vintage"
|
||||
assert f.intensity == 50
|
||||
assert f.brightness == 0.3
|
||||
|
||||
def test_intensity_range(self):
|
||||
f = FilterConfig(intensity=0)
|
||||
assert f.intensity == 0
|
||||
f = FilterConfig(intensity=100)
|
||||
assert f.intensity == 100
|
||||
|
||||
def test_intensity_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=-1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=101)
|
||||
|
||||
def test_brightness_range(self):
|
||||
f = FilterConfig(brightness=-1.0)
|
||||
assert f.brightness == -1.0
|
||||
f = FilterConfig(brightness=1.0)
|
||||
assert f.brightness == 1.0
|
||||
|
||||
def test_contrast_range(self):
|
||||
f = FilterConfig(contrast=0.0)
|
||||
assert f.contrast == 0.0
|
||||
f = FilterConfig(contrast=2.0)
|
||||
assert f.contrast == 2.0
|
||||
|
||||
def test_saturation_range(self):
|
||||
f = FilterConfig(saturation=0.0)
|
||||
assert f.saturation == 0.0
|
||||
f = FilterConfig(saturation=3.0)
|
||||
assert f.saturation == 3.0
|
||||
|
||||
def test_warmth_range(self):
|
||||
f = FilterConfig(warmth=-1.0)
|
||||
assert f.warmth == -1.0
|
||||
f = FilterConfig(warmth=1.0)
|
||||
assert f.warmth == 1.0
|
||||
|
||||
def test_brightness_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=-1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=1.1)
|
||||
|
||||
|
||||
# ── 完整 Schema 模型 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditPlanConfigSchema:
|
||||
"""EditPlanConfigSchema 完整计划配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditPlanConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert isinstance(s.title, TitleConfig)
|
||||
assert isinstance(s.subtitle, SubtitleConfig)
|
||||
assert isinstance(s.bgm, BGMConfig)
|
||||
assert isinstance(s.export, ExportConfig)
|
||||
assert isinstance(s.filter, FilterConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
|
||||
def test_partial_update_via_dict(self):
|
||||
s = EditPlanConfigSchema(
|
||||
**{
|
||||
"cover": {"type": "manual", "frame_time": 10.0},
|
||||
"title": {"text": "自定义标题", "size": 60},
|
||||
"editing_mode": "template",
|
||||
}
|
||||
)
|
||||
assert s.cover.type == CoverType.MANUAL
|
||||
assert s.cover.frame_time == 10.0
|
||||
assert s.title.text == "自定义标题"
|
||||
assert s.title.size == 60
|
||||
assert s.editing_mode == "template"
|
||||
|
||||
def test_full_config_dict_roundtrip(self):
|
||||
data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
data["title"]["text"] = "测试标题"
|
||||
data["bgm"]["enabled"] = True
|
||||
s = EditPlanConfigSchema(**data)
|
||||
assert s.title.text == "测试标题"
|
||||
assert s.bgm.enabled is True
|
||||
# 默认字段保留
|
||||
assert s.subtitle.size == 24
|
||||
assert s.export.fps == 30
|
||||
|
||||
def test_invalid_subfield_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EditPlanConfigSchema(**{"title": {"size": 999}})
|
||||
|
||||
|
||||
class TestEditTemplateConfigSchema:
|
||||
"""EditTemplateConfigSchema 模板配置"""
|
||||
|
||||
def test_default_values(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert isinstance(s.cover, CoverConfig)
|
||||
assert s.editing_mode == "one_take"
|
||||
assert s.transition_enabled is True
|
||||
|
||||
def test_custom_transition_enabled(self):
|
||||
s = EditTemplateConfigSchema(transition_enabled=False)
|
||||
assert s.transition_enabled is False
|
||||
|
||||
def test_has_all_plan_fields(self):
|
||||
s = EditTemplateConfigSchema()
|
||||
assert hasattr(s, "cover")
|
||||
assert hasattr(s, "title")
|
||||
assert hasattr(s, "subtitle")
|
||||
assert hasattr(s, "bgm")
|
||||
assert hasattr(s, "export")
|
||||
assert hasattr(s, "filter")
|
||||
assert hasattr(s, "editing_mode")
|
||||
assert hasattr(s, "transition_enabled")
|
||||
|
||||
|
||||
# ── 默认值常量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultConfigs:
|
||||
"""默认配置常量"""
|
||||
|
||||
def test_default_plan_config_structure(self):
|
||||
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
|
||||
def test_default_template_config_extra_field(self):
|
||||
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
|
||||
|
||||
def test_template_config_inherits_plan(self):
|
||||
# 模板配置应该包含计划配置的所有字段
|
||||
for key in DEFAULT_EDIT_PLAN_CONFIG:
|
||||
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
|
||||
def test_defaults_are_valid_for_schema(self):
|
||||
# 默认值应该能通过 schema 校验
|
||||
plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG)
|
||||
assert plan.editing_mode == "one_take"
|
||||
template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert template.transition_enabled is True
|
||||
|
||||
def test_mutation_does_not_affect_original(self):
|
||||
# 修改返回的 dict 不应该影响常量
|
||||
d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
d["cover"]["type"] = "upload"
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame"
|
||||
|
||||
|
||||
# ── normalize_plan_config ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePlanConfig:
|
||||
"""normalize_plan_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_plan_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_plan_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
def test_partial_cover_update(self):
|
||||
result = normalize_plan_config({"cover": {"type": "manual"}})
|
||||
assert result["cover"]["type"] == "manual"
|
||||
# 其他 cover 字段保留默认
|
||||
assert result["cover"]["image_url"] == ""
|
||||
assert result["cover"]["frame_time"] is None
|
||||
|
||||
def test_partial_title_update(self):
|
||||
result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}})
|
||||
assert result["title"]["text"] == "我的标题"
|
||||
assert result["title"]["size"] == 36
|
||||
assert result["title"]["font"] == "思源黑体"
|
||||
|
||||
def test_partial_subtitle_update(self):
|
||||
result = normalize_plan_config({"subtitle": {"size": 28}})
|
||||
assert result["subtitle"]["size"] == 28
|
||||
assert result["subtitle"]["position"] == "bottom"
|
||||
|
||||
def test_partial_bgm_update(self):
|
||||
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}})
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["bgm"]["volume"] == 0.5
|
||||
assert result["bgm"]["source"] == "library"
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_plan_config({"editing_mode": "template"})
|
||||
assert result["editing_mode"] == "template"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"})
|
||||
assert result["generation_task_id"] == "task_123"
|
||||
assert result["custom_field"] == "value"
|
||||
# 标准字段也保留
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_plan_config(
|
||||
{
|
||||
"cover": {"type": "upload", "image_url": "http://x.com/c.jpg"},
|
||||
"title": {"text": "标题", "size": 60},
|
||||
"bgm": {"enabled": True},
|
||||
"editing_mode": "smart",
|
||||
"extra_key": "extra_value",
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "upload"
|
||||
assert result["title"]["text"] == "标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["editing_mode"] == "smart"
|
||||
assert result["extra_key"] == "extra_value"
|
||||
|
||||
def test_non_dict_section_ignored(self):
|
||||
result = normalize_plan_config({"cover": "not_a_dict"})
|
||||
# cover 应该还是默认值
|
||||
assert result["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_editing_mode_non_string_ignored(self):
|
||||
result = normalize_plan_config({"editing_mode": 123})
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
raw = {"cover": {"type": "manual"}, "extra": "value"}
|
||||
raw_copy = copy.deepcopy(raw)
|
||||
normalize_plan_config(raw)
|
||||
assert raw == raw_copy
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
normalize_plan_config({"cover": {"type": "upload"}})
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG == original
|
||||
|
||||
|
||||
# ── normalize_template_config ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
"""normalize_template_config 工具函数"""
|
||||
|
||||
def test_none_returns_full_defaults(self):
|
||||
result = normalize_template_config(None)
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
|
||||
def test_empty_dict_returns_defaults(self):
|
||||
result = normalize_template_config({})
|
||||
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_partial_sections(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"title": {"text": "模板标题"},
|
||||
"bgm": {"enabled": True},
|
||||
}
|
||||
)
|
||||
assert result["title"]["text"] == "模板标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
|
||||
def test_transition_enabled_update(self):
|
||||
result = normalize_template_config({"transition_enabled": False})
|
||||
assert result["transition_enabled"] is False
|
||||
|
||||
def test_transition_enabled_non_bool_ignored(self):
|
||||
result = normalize_template_config({"transition_enabled": "yes"})
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_editing_mode_update(self):
|
||||
result = normalize_template_config({"editing_mode": "story"})
|
||||
assert result["editing_mode"] == "story"
|
||||
|
||||
def test_extra_fields_preserved(self):
|
||||
result = normalize_template_config({"template_version": "v2", "author": "test"})
|
||||
assert result["template_version"] == "v2"
|
||||
assert result["author"] == "test"
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_combined_update(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"cover": {"type": "ai_regenerate"},
|
||||
"subtitle": {"size": 20},
|
||||
"transition_enabled": False,
|
||||
"editing_mode": "vlog",
|
||||
"tags": ["travel", "food"],
|
||||
}
|
||||
)
|
||||
assert result["cover"]["type"] == "ai_regenerate"
|
||||
assert result["subtitle"]["size"] == 20
|
||||
assert result["transition_enabled"] is False
|
||||
assert result["editing_mode"] == "vlog"
|
||||
assert result["tags"] == ["travel", "food"]
|
||||
|
||||
def test_does_not_mutate_defaults(self):
|
||||
original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
normalize_template_config({"transition_enabled": False})
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG == original
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
"""edit_template 剪辑模板实体单测."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from domain.editing_mode import EditingMode
|
||||
|
||||
# ── EditTemplateStatus 枚举 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatus:
|
||||
"""EditTemplateStatus 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert EditTemplateStatus.ACTIVE.value == "active"
|
||||
assert EditTemplateStatus.INACTIVE.value == "inactive"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(EditTemplateStatus.ACTIVE, str)
|
||||
assert EditTemplateStatus.ACTIVE == "active"
|
||||
|
||||
def test_from_string(self):
|
||||
assert EditTemplateStatus("active") == EditTemplateStatus.ACTIVE
|
||||
assert EditTemplateStatus("inactive") == EditTemplateStatus.INACTIVE
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplateStatus("deleted")
|
||||
|
||||
|
||||
# ── EditTemplate.create 工厂方法 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateCreate:
|
||||
"""EditTemplate.create 工厂方法"""
|
||||
|
||||
def test_minimal_create(self):
|
||||
t = EditTemplate.create("测试模板")
|
||||
assert t.id is not None
|
||||
assert len(t.id) == 32 # uuid4 hex
|
||||
assert t.name == "测试模板"
|
||||
assert t.description == ""
|
||||
assert t.template_type == "default"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {}
|
||||
assert t.preview_url == ""
|
||||
assert t.sort_weight == 0
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.version == 1
|
||||
|
||||
def test_unique_ids(self):
|
||||
t1 = EditTemplate.create("模板A")
|
||||
t2 = EditTemplate.create("模板B")
|
||||
assert t1.id != t2.id
|
||||
|
||||
def test_custom_fields(self):
|
||||
t = EditTemplate.create(
|
||||
"自定义模板",
|
||||
description="这是一个自定义模板",
|
||||
template_type="story",
|
||||
editing_mode="one_take",
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.mp4",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=2,
|
||||
)
|
||||
assert t.name == "自定义模板"
|
||||
assert t.description == "这是一个自定义模板"
|
||||
assert t.template_type == "story"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.mp4"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 2
|
||||
|
||||
def test_name_stripped(self):
|
||||
t = EditTemplate.create(" 带空格的模板 ")
|
||||
assert t.name == "带空格的模板"
|
||||
|
||||
def test_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称"):
|
||||
EditTemplate.create("")
|
||||
|
||||
def test_whitespace_only_name_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplate.create(" ")
|
||||
|
||||
def test_invalid_editing_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="editing_mode"):
|
||||
EditTemplate.create("测试", editing_mode="invalid_mode")
|
||||
|
||||
def test_empty_editing_mode_falls_back_to_default(self):
|
||||
t = EditTemplate.create("测试", editing_mode="")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_whitespace_editing_mode_falls_back(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_editing_mode_stripped(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" one_take ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_description_stripped(self):
|
||||
t = EditTemplate.create("测试", description=" 描述 ")
|
||||
assert t.description == "描述"
|
||||
|
||||
def test_template_type_stripped(self):
|
||||
t = EditTemplate.create("测试", template_type=" vlog ")
|
||||
assert t.template_type == "vlog"
|
||||
|
||||
def test_empty_template_type_falls_back(self):
|
||||
t = EditTemplate.create("测试", template_type="")
|
||||
assert t.template_type == "default"
|
||||
|
||||
def test_none_config_becomes_empty_dict(self):
|
||||
t = EditTemplate.create("测试", config=None)
|
||||
assert t.config == {}
|
||||
assert isinstance(t.config, dict)
|
||||
|
||||
def test_preview_url_stripped(self):
|
||||
t = EditTemplate.create("测试", preview_url=" https://x.com/a.mp4 ")
|
||||
assert t.preview_url == "https://x.com/a.mp4"
|
||||
|
||||
def test_timestamps_are_utc(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.created_at.tzinfo is not None
|
||||
assert t.updated_at.tzinfo is not None
|
||||
|
||||
def test_created_at_equals_updated_at_on_create(self):
|
||||
t = EditTemplate.create("测试")
|
||||
# 创建时两个时间应该非常接近
|
||||
diff = abs((t.updated_at - t.created_at).total_seconds())
|
||||
assert diff < 1.0
|
||||
|
||||
|
||||
# ── 状态操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatusOperations:
|
||||
"""EditTemplate 状态操作"""
|
||||
|
||||
def test_activate_sets_active(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
t.activate()
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.is_active is True
|
||||
|
||||
def test_deactivate_sets_inactive(self):
|
||||
t = EditTemplate.create("测试")
|
||||
t.deactivate()
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.is_active is False
|
||||
|
||||
def test_is_active_true(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.is_active is True
|
||||
|
||||
def test_is_active_false(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
assert t.is_active is False
|
||||
|
||||
def test_activate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
old_updated = t.updated_at
|
||||
t.activate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
def test_deactivate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.deactivate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── 版本操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateVersion:
|
||||
"""EditTemplate 版本操作"""
|
||||
|
||||
def test_bump_version_increments(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.version == 1
|
||||
t.bump_version()
|
||||
assert t.version == 2
|
||||
|
||||
def test_bump_version_multiple(self):
|
||||
t = EditTemplate.create("测试", version=5)
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
assert t.version == 8
|
||||
|
||||
def test_bump_version_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.bump_version()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateBasics:
|
||||
"""EditTemplate 基础特性"""
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
t = EditTemplate.create("测试")
|
||||
with pytest.raises(AttributeError):
|
||||
t.nonexistent_field = "value"
|
||||
|
||||
def test_direct_construction_minimal(self):
|
||||
# 最小构造:仅必填字段 + 状态,其余走默认值
|
||||
t = EditTemplate(
|
||||
id="custom_id",
|
||||
name="直接构造",
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
assert t.id == "custom_id"
|
||||
assert t.name == "直接构造"
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
# 默认值检查
|
||||
assert t.description == ""
|
||||
assert t.config == {}
|
||||
assert t.version == 1
|
||||
assert t.editing_mode == EditingMode.ONE_TAKE.value
|
||||
assert isinstance(t.created_at, datetime)
|
||||
assert isinstance(t.updated_at, datetime)
|
||||
|
||||
def test_direct_construction_full(self):
|
||||
# 完整构造:所有字段都传
|
||||
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
t = EditTemplate(
|
||||
id="full_id",
|
||||
name="完整构造",
|
||||
description="测试描述",
|
||||
template_type="custom",
|
||||
editing_mode=EditingMode.PIP.value,
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.jpg",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=3,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
assert t.id == "full_id"
|
||||
assert t.name == "完整构造"
|
||||
assert t.description == "测试描述"
|
||||
assert t.template_type == "custom"
|
||||
assert t.editing_mode == EditingMode.PIP.value
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.jpg"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 3
|
||||
assert t.created_at == now
|
||||
assert t.updated_at == now
|
||||
|
||||
def test_config_is_independent(self):
|
||||
# 不同实例的 config 应该是独立的 dict
|
||||
t1 = EditTemplate.create("模板1")
|
||||
t2 = EditTemplate.create("模板2")
|
||||
t1.config["key"] = "value"
|
||||
assert "key" not in t2.config
|
||||
|
||||
def test_equality(self):
|
||||
# 两个不同实例即使内容相同也不等(id不同)
|
||||
t1 = EditTemplate.create("同名模板")
|
||||
t2 = EditTemplate.create("同名模板")
|
||||
assert t1 != t2
|
||||
|
||||
def test_same_id_equal(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
t1 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
t2 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
assert t1 == t2
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
"""EmailConfig 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from domain.auth.email_service import EmailConfig
|
||||
|
||||
|
||||
class TestEmailConfigDefaults:
|
||||
"""默认值测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
config = EmailConfig()
|
||||
assert config.smtp_host == "smtp.gmail.com"
|
||||
assert config.smtp_port == 587
|
||||
assert config.smtp_user == ""
|
||||
assert config.smtp_password == ""
|
||||
assert config.from_email == ""
|
||||
assert config.from_name == "小虾 SaaS"
|
||||
assert config.use_tls is True
|
||||
|
||||
def test_custom_construction(self):
|
||||
config = EmailConfig(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=465,
|
||||
smtp_user="user@example.com",
|
||||
smtp_password="secret",
|
||||
from_email="no-reply@example.com",
|
||||
from_name="Example App",
|
||||
use_tls=False,
|
||||
)
|
||||
assert config.smtp_host == "smtp.example.com"
|
||||
assert config.smtp_port == 465
|
||||
assert config.smtp_user == "user@example.com"
|
||||
assert config.smtp_password == "secret"
|
||||
assert config.from_email == "no-reply@example.com"
|
||||
assert config.from_name == "Example App"
|
||||
assert config.use_tls is False
|
||||
|
||||
def test_is_dataclass(self):
|
||||
# 可重复创建相同配置
|
||||
c1 = EmailConfig(smtp_host="h.com", smtp_port=25)
|
||||
c2 = EmailConfig(smtp_host="h.com", smtp_port=25)
|
||||
assert c1 == c2
|
||||
|
||||
|
||||
class TestEmailConfigEquality:
|
||||
"""相等性测试."""
|
||||
|
||||
def test_equal_same_values(self):
|
||||
c1 = EmailConfig()
|
||||
c2 = EmailConfig()
|
||||
assert c1 == c2
|
||||
|
||||
def test_not_equal_different_host(self):
|
||||
c1 = EmailConfig(smtp_host="a.com")
|
||||
c2 = EmailConfig(smtp_host="b.com")
|
||||
assert c1 != c2
|
||||
|
||||
def test_not_equal_different_port(self):
|
||||
c1 = EmailConfig(smtp_port=587)
|
||||
c2 = EmailConfig(smtp_port=465)
|
||||
assert c1 != c2
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
"""domain exceptions 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from domain.exceptions import (
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
QuotaExceededError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
class TestDomainError:
|
||||
"""DomainError 基类测试."""
|
||||
|
||||
def test_is_exception(self):
|
||||
assert issubclass(DomainError, Exception)
|
||||
|
||||
def test_raise_and_catch(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise DomainError("something went wrong")
|
||||
|
||||
def test_message(self):
|
||||
err = DomainError("test message")
|
||||
assert str(err) == "test message"
|
||||
|
||||
def test_empty_message(self):
|
||||
err = DomainError()
|
||||
assert str(err) == ""
|
||||
|
||||
|
||||
class TestNotFoundError:
|
||||
"""NotFoundError 测试."""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
assert issubclass(NotFoundError, DomainError)
|
||||
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise NotFoundError("user not found")
|
||||
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(NotFoundError):
|
||||
raise NotFoundError("user not found")
|
||||
|
||||
def test_message(self):
|
||||
err = NotFoundError("resource not found")
|
||||
assert str(err) == "resource not found"
|
||||
|
||||
|
||||
class TestValidationError:
|
||||
"""ValidationError 测试."""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
assert issubclass(ValidationError, DomainError)
|
||||
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise ValidationError("invalid input")
|
||||
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(ValidationError):
|
||||
raise ValidationError("invalid input")
|
||||
|
||||
def test_message(self):
|
||||
err = ValidationError("bad data")
|
||||
assert str(err) == "bad data"
|
||||
|
||||
|
||||
class TestQuotaExceededError:
|
||||
"""QuotaExceededError 测试."""
|
||||
|
||||
def test_is_domain_error(self):
|
||||
assert issubclass(QuotaExceededError, DomainError)
|
||||
|
||||
def test_constructor_stores_fields(self):
|
||||
err = QuotaExceededError("storage", limit=100.0, used=150.0)
|
||||
assert err.dimension == "storage"
|
||||
assert err.limit == 100.0
|
||||
assert err.used == 150.0
|
||||
|
||||
def test_message_format(self):
|
||||
err = QuotaExceededError("storage", limit=100.0, used=150.0)
|
||||
assert "storage" in str(err)
|
||||
assert "150.0" in str(err)
|
||||
assert "100.0" in str(err)
|
||||
assert "Quota exceeded" in str(err)
|
||||
|
||||
def test_raise_and_catch_as_domain(self):
|
||||
with pytest.raises(DomainError):
|
||||
raise QuotaExceededError("api_calls", limit=1000, used=2000)
|
||||
|
||||
def test_raise_and_catch_specific(self):
|
||||
with pytest.raises(QuotaExceededError):
|
||||
raise QuotaExceededError("api_calls", limit=1000, used=2000)
|
||||
|
||||
def test_int_values(self):
|
||||
err = QuotaExceededError("count", limit=100, used=150)
|
||||
assert err.limit == 100
|
||||
assert err.used == 150
|
||||
assert "150/100" in str(err)
|
||||
|
||||
def test_float_values(self):
|
||||
err = QuotaExceededError("size", limit=10.5, used=20.3)
|
||||
assert err.limit == 10.5
|
||||
assert err.used == 20.3
|
||||
Executable
+593
@@ -0,0 +1,593 @@
|
||||
"""intro_outro_config 片头片尾配置单测."""
|
||||
|
||||
import pytest
|
||||
from domain.intro_outro_config import (
|
||||
INTRO_OUTRO_TYPE_FOLLOW,
|
||||
INTRO_OUTRO_TYPE_NONE,
|
||||
INTRO_OUTRO_TYPE_TEXT,
|
||||
INTRO_OUTRO_TYPE_VIDEO,
|
||||
TRANSITION_FADE,
|
||||
TRANSITION_SLIDE,
|
||||
TRANSITION_WIPE,
|
||||
IntroOutroConfig,
|
||||
)
|
||||
|
||||
# ── 常量测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""模块常量"""
|
||||
|
||||
def test_type_constants(self):
|
||||
assert INTRO_OUTRO_TYPE_NONE == "none"
|
||||
assert INTRO_OUTRO_TYPE_VIDEO == "video"
|
||||
assert INTRO_OUTRO_TYPE_TEXT == "text"
|
||||
assert INTRO_OUTRO_TYPE_FOLLOW == "follow"
|
||||
|
||||
def test_transition_constants(self):
|
||||
assert TRANSITION_FADE == "fade"
|
||||
assert TRANSITION_SLIDE == "slide"
|
||||
assert TRANSITION_WIPE == "wipe"
|
||||
|
||||
|
||||
# ── 默认值与基础属性 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
"""IntroOutroConfig 默认值"""
|
||||
|
||||
def test_default_not_enabled(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.enabled is False
|
||||
|
||||
def test_default_intro(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
|
||||
assert c.intro_video_path == ""
|
||||
assert c.intro_duration == 3.0
|
||||
assert c.intro_background == "#000000"
|
||||
assert c.intro_title == ""
|
||||
assert c.intro_subtitle == ""
|
||||
assert c.intro_title_color == "white"
|
||||
assert c.intro_title_size == 48
|
||||
assert c.intro_subtitle_color == "gray"
|
||||
assert c.intro_subtitle_size == 24
|
||||
|
||||
def test_default_outro(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
|
||||
assert c.outro_video_path == ""
|
||||
assert c.outro_duration == 3.0
|
||||
assert c.outro_background == "#000000"
|
||||
assert c.outro_title == "感谢观看"
|
||||
assert c.outro_subtitle == "点赞关注不迷路"
|
||||
assert c.outro_title_color == "white"
|
||||
assert c.outro_title_size == 48
|
||||
assert c.outro_subtitle_color == "gray"
|
||||
assert c.outro_subtitle_size == 24
|
||||
|
||||
def test_default_transition(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.transition_effect == TRANSITION_FADE
|
||||
assert c.transition_duration == 0.5
|
||||
|
||||
|
||||
# ── from_dict 构造 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFromDict:
|
||||
"""from_dict 工厂方法"""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
c = IntroOutroConfig.from_dict(None)
|
||||
assert c.enabled is False
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
c = IntroOutroConfig.from_dict({})
|
||||
assert c.enabled is False
|
||||
|
||||
def test_enabled_false_returns_default(self):
|
||||
c = IntroOutroConfig.from_dict({"enabled": False})
|
||||
assert c.enabled is False
|
||||
|
||||
def test_minimal_enabled(self):
|
||||
c = IntroOutroConfig.from_dict({"enabled": True})
|
||||
assert c.enabled is True
|
||||
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
|
||||
|
||||
def test_intro_video(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/intro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert c.intro_type == INTRO_OUTRO_TYPE_VIDEO
|
||||
assert c.intro_video_path == "/tmp/intro.mp4"
|
||||
assert c.intro_duration == 5.0
|
||||
|
||||
def test_intro_text(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "欢迎来到",
|
||||
"subtitle": "我的频道",
|
||||
"background": "#ffffff",
|
||||
"title_color": "black",
|
||||
"title_size": 64,
|
||||
"subtitle_color": "darkgray",
|
||||
"subtitle_size": 32,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert c.intro_type == INTRO_OUTRO_TYPE_TEXT
|
||||
assert c.intro_title == "欢迎来到"
|
||||
assert c.intro_subtitle == "我的频道"
|
||||
assert c.intro_background == "#ffffff"
|
||||
assert c.intro_title_size == 64
|
||||
assert c.intro_subtitle_size == 32
|
||||
|
||||
def test_outro_text(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "text",
|
||||
"title": "再见",
|
||||
"subtitle": "下次见",
|
||||
"title_size": 56,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_TEXT
|
||||
assert c.outro_title == "再见"
|
||||
assert c.outro_subtitle == "下次见"
|
||||
assert c.outro_title_size == 56
|
||||
|
||||
def test_outro_follow_type(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {"type": "follow", "title": "关注我"},
|
||||
}
|
||||
)
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_FOLLOW
|
||||
assert c.outro_title == "关注我"
|
||||
|
||||
def test_outro_video(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 4.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_VIDEO
|
||||
assert c.outro_video_path == "/tmp/outro.mp4"
|
||||
assert c.outro_duration == 4.0
|
||||
|
||||
def test_transition_config(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"transition": "slide",
|
||||
"transition_duration": 1.0,
|
||||
}
|
||||
)
|
||||
assert c.transition_effect == TRANSITION_SLIDE
|
||||
assert c.transition_duration == 1.0
|
||||
|
||||
def test_video_field_alias(self):
|
||||
# video 字段兼容(video_path 和 video 都能用)
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {"type": "video", "video": "old_path.mp4"},
|
||||
}
|
||||
)
|
||||
assert c.intro_video_path == "old_path.mp4"
|
||||
|
||||
def test_video_path_preferred_over_video(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {"type": "video", "video_path": "new.mp4", "video": "old.mp4"},
|
||||
}
|
||||
)
|
||||
assert c.intro_video_path == "new.mp4"
|
||||
|
||||
def test_invalid_duration_falls_back(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {"duration": "abc"},
|
||||
}
|
||||
)
|
||||
assert c.intro_duration == 3.0
|
||||
|
||||
def test_invalid_size_falls_back(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {"title_size": "not_a_number"},
|
||||
}
|
||||
)
|
||||
assert c.intro_title_size == 48
|
||||
|
||||
def test_none_intro_outro(self):
|
||||
c = IntroOutroConfig.from_dict({"enabled": True, "intro": None, "outro": None})
|
||||
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
|
||||
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
|
||||
|
||||
def test_empty_title_defaults_for_outro(self):
|
||||
# outro title 为空时回退到默认值
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {"type": "text", "title": ""},
|
||||
}
|
||||
)
|
||||
assert c.outro_title == "感谢观看"
|
||||
|
||||
def test_empty_subtitle_defaults_for_outro(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {"subtitle": ""},
|
||||
}
|
||||
)
|
||||
assert c.outro_subtitle == "点赞关注不迷路"
|
||||
|
||||
def test_combined_full_config(self):
|
||||
c = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "片头标题",
|
||||
"subtitle": "片头副标题",
|
||||
"background": "#123456",
|
||||
"duration": 2.5,
|
||||
"title_size": 72,
|
||||
},
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/outro.mp4",
|
||||
"duration": 4.0,
|
||||
},
|
||||
"transition": "wipe",
|
||||
"transition_duration": 0.8,
|
||||
}
|
||||
)
|
||||
assert c.intro_title == "片头标题"
|
||||
assert c.intro_duration == 2.5
|
||||
assert c.outro_type == "video"
|
||||
assert c.outro_video_path == "/outro.mp4"
|
||||
assert c.transition_effect == "wipe"
|
||||
assert c.transition_duration == 0.8
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
data = {"enabled": True, "intro": {"type": "text", "title": "test"}}
|
||||
data_copy = {
|
||||
"enabled": True,
|
||||
"intro": {"type": "text", "title": "test"},
|
||||
}
|
||||
IntroOutroConfig.from_dict(data)
|
||||
assert data == data_copy
|
||||
|
||||
|
||||
# ── has_intro / has_outro 属性 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHasIntroOutro:
|
||||
"""has_intro / has_outro 属性"""
|
||||
|
||||
def test_disabled_no_intro(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.has_intro is False
|
||||
|
||||
def test_disabled_no_outro(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.has_outro is False
|
||||
|
||||
def test_enabled_none_type_no_intro(self):
|
||||
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_NONE)
|
||||
assert c.has_intro is False
|
||||
|
||||
def test_video_intro_has_intro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
intro_video_path="/x.mp4",
|
||||
)
|
||||
assert c.has_intro is True
|
||||
|
||||
def test_text_intro_has_intro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
)
|
||||
assert c.has_intro is True
|
||||
|
||||
def test_video_outro_has_outro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
outro_video_path="/x.mp4",
|
||||
)
|
||||
assert c.has_outro is True
|
||||
|
||||
def test_text_outro_has_outro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
)
|
||||
assert c.has_outro is True
|
||||
|
||||
def test_follow_outro_has_outro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
|
||||
outro_title="关注",
|
||||
)
|
||||
assert c.has_outro is True
|
||||
|
||||
def test_follow_type_no_intro(self):
|
||||
# follow 只是片尾类型,片头不支持
|
||||
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_FOLLOW)
|
||||
assert c.has_intro is False
|
||||
|
||||
|
||||
# ── total_extra_duration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTotalExtraDuration:
|
||||
"""total_extra_duration 属性"""
|
||||
|
||||
def test_disabled_zero(self):
|
||||
c = IntroOutroConfig()
|
||||
assert c.total_extra_duration == 0.0
|
||||
|
||||
def test_only_intro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_duration=3.0,
|
||||
)
|
||||
assert c.total_extra_duration == 3.0
|
||||
|
||||
def test_only_outro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
outro_duration=4.0,
|
||||
)
|
||||
assert c.total_extra_duration == 4.0
|
||||
|
||||
def test_both_intro_outro(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
intro_video_path="/i.mp4",
|
||||
intro_duration=2.5,
|
||||
outro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
outro_video_path="/o.mp4",
|
||||
outro_duration=3.5,
|
||||
)
|
||||
assert c.total_extra_duration == 6.0
|
||||
|
||||
def test_zero_duration_not_counted(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_duration=0.0,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
outro_duration=0.0,
|
||||
)
|
||||
assert c.total_extra_duration == 0.0
|
||||
|
||||
def test_negative_duration_not_counted(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_duration=-1.0,
|
||||
)
|
||||
assert c.total_extra_duration == 0.0
|
||||
|
||||
|
||||
# ── validate 校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""validate 方法"""
|
||||
|
||||
def test_disabled_always_valid(self):
|
||||
c = IntroOutroConfig()
|
||||
valid, msg = c.validate()
|
||||
assert valid is True
|
||||
assert msg == ""
|
||||
|
||||
def test_none_types_valid(self):
|
||||
c = IntroOutroConfig(enabled=True)
|
||||
valid, msg = c.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_invalid_intro_type(self):
|
||||
c = IntroOutroConfig(enabled=True, intro_type="invalid")
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "片头类型" in msg
|
||||
|
||||
def test_invalid_outro_type(self):
|
||||
c = IntroOutroConfig(enabled=True, outro_type="invalid")
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "片尾类型" in msg
|
||||
|
||||
def test_video_intro_no_path(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
intro_video_path="",
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_video_intro_with_path_valid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
intro_video_path="/path.mp4",
|
||||
)
|
||||
valid, _ = c.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_text_intro_no_title(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="",
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_text_intro_with_title_valid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
)
|
||||
valid, _ = c.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_video_outro_no_path(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_VIDEO,
|
||||
outro_video_path="",
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_text_outro_no_title(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="",
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_follow_outro_no_title(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
|
||||
outro_title="",
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_follow_outro_with_title_valid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
|
||||
outro_title="关注",
|
||||
)
|
||||
valid, _ = c.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_zero_intro_duration_invalid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_duration=0.0,
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "片头时长" in msg
|
||||
|
||||
def test_negative_outro_duration_invalid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
outro_duration=-1.0,
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "片尾时长" in msg
|
||||
|
||||
def test_negative_transition_duration_invalid(self):
|
||||
c = IntroOutroConfig(enabled=True, transition_duration=-0.5)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "转场" in msg
|
||||
|
||||
def test_zero_transition_valid(self):
|
||||
c = IntroOutroConfig(enabled=True, transition_duration=0.0)
|
||||
valid, _ = c.validate()
|
||||
assert valid is True
|
||||
|
||||
def test_zero_title_size_invalid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_title_size=0,
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "标题字号" in msg
|
||||
|
||||
def test_negative_subtitle_size_invalid(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
outro_subtitle_size=-1,
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is False
|
||||
assert "副标题字号" in msg
|
||||
|
||||
def test_full_valid_config(self):
|
||||
c = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
intro_title="Hi",
|
||||
intro_duration=3.0,
|
||||
intro_title_size=48,
|
||||
intro_subtitle_size=24,
|
||||
outro_type=INTRO_OUTRO_TYPE_TEXT,
|
||||
outro_title="Bye",
|
||||
outro_duration=3.0,
|
||||
outro_title_size=48,
|
||||
outro_subtitle_size=24,
|
||||
transition_duration=0.5,
|
||||
)
|
||||
valid, msg = c.validate()
|
||||
assert valid is True
|
||||
assert msg == ""
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
"""media_validation 媒体文件校验单测."""
|
||||
|
||||
import pytest
|
||||
from domain.media_validation import (
|
||||
MIN_AUDIO_FILE_SIZE,
|
||||
MIN_IMAGE_FILE_SIZE,
|
||||
MIN_VIDEO_FILE_SIZE,
|
||||
SUPPORTED_VIDEO_CODECS,
|
||||
is_valid_media,
|
||||
safe_parse_fps,
|
||||
)
|
||||
|
||||
# ── 常量测试 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""模块常量"""
|
||||
|
||||
def test_min_sizes(self):
|
||||
assert MIN_VIDEO_FILE_SIZE == 1024
|
||||
assert MIN_AUDIO_FILE_SIZE == 100
|
||||
assert MIN_IMAGE_FILE_SIZE == 100
|
||||
|
||||
def test_supported_codecs_is_frozenset(self):
|
||||
assert isinstance(SUPPORTED_VIDEO_CODECS, frozenset)
|
||||
|
||||
def test_supported_codecs_includes_common(self):
|
||||
assert "h264" in SUPPORTED_VIDEO_CODECS
|
||||
assert "hevc" in SUPPORTED_VIDEO_CODECS
|
||||
assert "vp9" in SUPPORTED_VIDEO_CODECS
|
||||
assert "av1" in SUPPORTED_VIDEO_CODECS
|
||||
assert "mpeg4" in SUPPORTED_VIDEO_CODECS
|
||||
assert "prores" in SUPPORTED_VIDEO_CODECS
|
||||
|
||||
def test_supported_codecs_count(self):
|
||||
assert len(SUPPORTED_VIDEO_CODECS) >= 20
|
||||
|
||||
|
||||
# ── safe_parse_fps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeParseFps:
|
||||
"""safe_parse_fps 函数"""
|
||||
|
||||
def test_simple_decimal(self):
|
||||
assert safe_parse_fps("30.0") == 30.0
|
||||
|
||||
def test_integer_string(self):
|
||||
assert safe_parse_fps("24") == 24.0
|
||||
|
||||
def test_fraction_format(self):
|
||||
assert abs(safe_parse_fps("30000/1001") - 29.97) < 0.01
|
||||
|
||||
def test_simple_fraction(self):
|
||||
assert safe_parse_fps("30/1") == 30.0
|
||||
|
||||
def test_24fps_fraction(self):
|
||||
assert safe_parse_fps("24/1") == 24.0
|
||||
|
||||
def test_60fps_fraction(self):
|
||||
assert safe_parse_fps("60000/1001") == pytest.approx(59.94, abs=0.01)
|
||||
|
||||
def test_zero_denominator_returns_zero(self):
|
||||
assert safe_parse_fps("30/0") == 0.0
|
||||
|
||||
def test_empty_string_returns_zero(self):
|
||||
assert safe_parse_fps("") == 0.0
|
||||
|
||||
def test_invalid_string_returns_zero(self):
|
||||
assert safe_parse_fps("invalid") == 0.0
|
||||
|
||||
def test_none_numerator_fraction(self):
|
||||
assert safe_parse_fps("abc/1001") == 0.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
assert safe_parse_fps("-30") == -30.0
|
||||
|
||||
def test_very_high_fps(self):
|
||||
assert safe_parse_fps("240/1") == 240.0
|
||||
|
||||
def test_multiple_slashes(self):
|
||||
# 只按第一个 / 分割
|
||||
# "30/1/2" → num="30", den="1/2" → float("1/2") 抛异常 → 返回 0
|
||||
assert safe_parse_fps("30/1/2") == 0.0
|
||||
|
||||
def test_float_fraction(self):
|
||||
result = safe_parse_fps("29.97/1")
|
||||
assert result == pytest.approx(29.97)
|
||||
|
||||
def test_zero_fps(self):
|
||||
assert safe_parse_fps("0") == 0.0
|
||||
|
||||
def test_zero_numerator(self):
|
||||
assert safe_parse_fps("0/1000") == 0.0
|
||||
|
||||
|
||||
# ── is_valid_media - video ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsValidMediaVideo:
|
||||
"""is_valid_media 视频校验"""
|
||||
|
||||
def test_valid_video(self):
|
||||
metadata = {
|
||||
"size_bytes": 1024 * 1024, # 1MB
|
||||
"duration": 10.0,
|
||||
"codec": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_small_file_invalid(self):
|
||||
metadata = {"size_bytes": 100, "duration": 10.0}
|
||||
assert is_valid_media(metadata, "video") is False
|
||||
|
||||
def test_exact_min_size_valid(self):
|
||||
metadata = {"size_bytes": MIN_VIDEO_FILE_SIZE, "duration": 1.0}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_zero_duration_invalid(self):
|
||||
metadata = {"size_bytes": 1024 * 1024, "duration": 0}
|
||||
assert is_valid_media(metadata, "video") is False
|
||||
|
||||
def test_negative_duration_invalid(self):
|
||||
metadata = {"size_bytes": 1024 * 1024, "duration": -1.0}
|
||||
assert is_valid_media(metadata, "video") is False
|
||||
|
||||
def test_unsupported_codec_still_valid(self):
|
||||
# 非白名单编码仍允许通过(不做严格拦截)
|
||||
metadata = {
|
||||
"size_bytes": 1024 * 1024,
|
||||
"duration": 10.0,
|
||||
"codec": "unknown_codec_xyz",
|
||||
}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_empty_codec_valid(self):
|
||||
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0, "codec": ""}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_no_codec_valid(self):
|
||||
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_hevc_codec_valid(self):
|
||||
metadata = {
|
||||
"size_bytes": 1024 * 1024,
|
||||
"duration": 10.0,
|
||||
"codec": "hevc",
|
||||
}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_codec_case_insensitive(self):
|
||||
metadata = {
|
||||
"size_bytes": 1024 * 1024,
|
||||
"duration": 10.0,
|
||||
"codec": "H264",
|
||||
}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_missing_size_invalid(self):
|
||||
metadata = {"duration": 10.0}
|
||||
assert is_valid_media(metadata, "video") is False
|
||||
|
||||
def test_missing_duration_invalid(self):
|
||||
metadata = {"size_bytes": 1024 * 1024}
|
||||
assert is_valid_media(metadata, "video") is False
|
||||
|
||||
def test_empty_metadata_invalid(self):
|
||||
assert is_valid_media({}, "video") is False
|
||||
|
||||
|
||||
# ── is_valid_media - audio ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsValidMediaAudio:
|
||||
"""is_valid_media 音频校验"""
|
||||
|
||||
def test_valid_audio(self):
|
||||
metadata = {"size_bytes": 1024, "duration": 30.0, "codec": "aac"}
|
||||
assert is_valid_media(metadata, "audio") is True
|
||||
|
||||
def test_small_audio_invalid(self):
|
||||
metadata = {"size_bytes": 50, "duration": 30.0}
|
||||
assert is_valid_media(metadata, "audio") is False
|
||||
|
||||
def test_exact_min_size_valid(self):
|
||||
metadata = {"size_bytes": MIN_AUDIO_FILE_SIZE, "duration": 1.0}
|
||||
assert is_valid_media(metadata, "audio") is True
|
||||
|
||||
def test_zero_duration_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "duration": 0}
|
||||
assert is_valid_media(metadata, "audio") is False
|
||||
|
||||
def test_negative_duration_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "duration": -5.0}
|
||||
assert is_valid_media(metadata, "audio") is False
|
||||
|
||||
def test_empty_metadata_invalid(self):
|
||||
assert is_valid_media({}, "audio") is False
|
||||
|
||||
def test_very_short_audio_valid(self):
|
||||
metadata = {"size_bytes": 200, "duration": 0.5}
|
||||
assert is_valid_media(metadata, "audio") is True
|
||||
|
||||
|
||||
# ── is_valid_media - image ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsValidMediaImage:
|
||||
"""is_valid_media 图片校验"""
|
||||
|
||||
def test_valid_image(self):
|
||||
metadata = {"size_bytes": 1024, "width": 1920, "height": 1080}
|
||||
assert is_valid_media(metadata, "image") is True
|
||||
|
||||
def test_small_image_invalid(self):
|
||||
metadata = {"size_bytes": 50, "width": 1920, "height": 1080}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_exact_min_size_valid(self):
|
||||
metadata = {
|
||||
"size_bytes": MIN_IMAGE_FILE_SIZE,
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
}
|
||||
assert is_valid_media(metadata, "image") is True
|
||||
|
||||
def test_zero_width_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "width": 0, "height": 1080}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_zero_height_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "width": 1920, "height": 0}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_negative_width_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "width": -1, "height": 1080}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_small_image_valid(self):
|
||||
metadata = {"size_bytes": 200, "width": 10, "height": 10}
|
||||
assert is_valid_media(metadata, "image") is True
|
||||
|
||||
def test_missing_width_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "height": 1080}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_missing_height_invalid(self):
|
||||
metadata = {"size_bytes": 1024, "width": 1920}
|
||||
assert is_valid_media(metadata, "image") is False
|
||||
|
||||
def test_empty_metadata_invalid(self):
|
||||
assert is_valid_media({}, "image") is False
|
||||
|
||||
|
||||
# ── is_valid_media - edge cases ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsValidMediaEdgeCases:
|
||||
"""is_valid_media 边界情况"""
|
||||
|
||||
def test_invalid_media_type(self):
|
||||
metadata = {"size_bytes": 1024, "duration": 10.0}
|
||||
assert is_valid_media(metadata, "document") is False
|
||||
|
||||
def test_empty_media_type(self):
|
||||
metadata = {"size_bytes": 1024}
|
||||
assert is_valid_media(metadata, "") is False
|
||||
|
||||
def test_string_size_converted(self):
|
||||
metadata = {"size_bytes": "2048", "duration": "5.0"}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_video_size_as_string(self):
|
||||
metadata = {"size_bytes": "1000000", "duration": "30"}
|
||||
assert is_valid_media(metadata, "video") is True
|
||||
|
||||
def test_invalid_size_string_raises(self):
|
||||
# int("abc") 会抛 ValueError
|
||||
metadata = {"size_bytes": "abc", "duration": 10.0}
|
||||
with pytest.raises(ValueError):
|
||||
is_valid_media(metadata, "video")
|
||||
|
||||
def test_none_size_raises(self):
|
||||
# int(None) 会抛 TypeError
|
||||
metadata = {"size_bytes": None, "duration": 10.0}
|
||||
with pytest.raises(TypeError):
|
||||
is_valid_media(metadata, "video")
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
"""preset_bgm 预设BGM库单测."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from domain.preset_bgm import (
|
||||
BGM_STYLES,
|
||||
PRESET_BGM_LIBRARY,
|
||||
PresetBGM,
|
||||
get_preset_bgm,
|
||||
list_preset_bgm_by_style,
|
||||
search_preset_bgm,
|
||||
)
|
||||
|
||||
# ── PresetBGM dataclass ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetBGM:
|
||||
"""PresetBGM dataclass"""
|
||||
|
||||
def test_minimal_creation(self):
|
||||
b = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=60.0)
|
||||
assert b.id == "test_001"
|
||||
assert b.name == "测试音乐"
|
||||
assert b.style == "upbeat"
|
||||
assert b.duration == 60.0
|
||||
assert b.artist == ""
|
||||
assert b.description == ""
|
||||
assert b.tags == []
|
||||
assert b.audio_url == ""
|
||||
|
||||
def test_full_creation(self):
|
||||
b = PresetBGM(
|
||||
id="bgm_001",
|
||||
name="阳光清晨",
|
||||
style="upbeat",
|
||||
duration=120.5,
|
||||
artist="音乐人A",
|
||||
description="轻快明亮的吉他",
|
||||
tags=["轻快", "阳光"],
|
||||
audio_url="https://cdn.example.com/bgm.mp3",
|
||||
)
|
||||
assert b.id == "bgm_001"
|
||||
assert b.artist == "音乐人A"
|
||||
assert b.description == "轻快明亮的吉他"
|
||||
assert b.tags == ["轻快", "阳光"]
|
||||
assert b.audio_url == "https://cdn.example.com/bgm.mp3"
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
b = PresetBGM(id="test", name="Test", style="relax", duration=100.0)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
b.name = "NewName"
|
||||
|
||||
def test_equality(self):
|
||||
b1 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
|
||||
b2 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
|
||||
assert b1 == b2
|
||||
|
||||
def test_inequality(self):
|
||||
b1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0)
|
||||
b2 = PresetBGM(id="b", name="B", style="relax", duration=90.0)
|
||||
assert b1 != b2
|
||||
|
||||
def test_not_hashable_due_to_list_tags(self):
|
||||
# 包含 list 字段(tags)的 frozen dataclass 不可哈希
|
||||
b = PresetBGM(id="test", name="Test", style="tech", duration=60.0)
|
||||
with pytest.raises(TypeError):
|
||||
hash(b)
|
||||
|
||||
|
||||
# ── PRESET_BGM_LIBRARY 清单 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetBGMLibrary:
|
||||
"""预设BGM库清单"""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(PRESET_BGM_LIBRARY) > 0
|
||||
|
||||
def test_all_are_preset_bgm(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert isinstance(bgm, PresetBGM)
|
||||
|
||||
def test_unique_ids(self):
|
||||
ids = [b.id for b in PRESET_BGM_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_all_have_required_fields(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert bgm.id != ""
|
||||
assert bgm.name != ""
|
||||
assert bgm.style != ""
|
||||
assert bgm.duration > 0
|
||||
|
||||
def test_upbeat_style_count(self):
|
||||
upbeats = [b for b in PRESET_BGM_LIBRARY if b.style == "upbeat"]
|
||||
assert len(upbeats) >= 3
|
||||
|
||||
def test_relax_style_count(self):
|
||||
relax = [b for b in PRESET_BGM_LIBRARY if b.style == "relax"]
|
||||
assert len(relax) >= 3
|
||||
|
||||
def test_tech_style_count(self):
|
||||
tech = [b for b in PRESET_BGM_LIBRARY if b.style == "tech"]
|
||||
assert len(tech) >= 2
|
||||
|
||||
def test_commerce_style_count(self):
|
||||
commerce = [b for b in PRESET_BGM_LIBRARY if b.style == "commerce"]
|
||||
assert len(commerce) >= 2
|
||||
|
||||
def test_sunny_morning_preset(self):
|
||||
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_upbeat_001")
|
||||
assert b.name == "阳光清晨"
|
||||
assert b.style == "upbeat"
|
||||
assert b.duration == 120.0
|
||||
assert "吉他" in b.description
|
||||
assert "vlog" in b.tags
|
||||
|
||||
def test_quiet_time_preset(self):
|
||||
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_relax_001")
|
||||
assert b.name == "静谧时光"
|
||||
assert b.style == "relax"
|
||||
assert b.duration == 180.0
|
||||
|
||||
def test_future_tech_preset(self):
|
||||
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_tech_001")
|
||||
assert b.name == "未来科技"
|
||||
assert b.style == "tech"
|
||||
|
||||
def test_all_durations_positive(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert bgm.duration > 0
|
||||
|
||||
def test_all_tags_are_lists(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert isinstance(bgm.tags, list)
|
||||
|
||||
|
||||
# ── BGM_STYLES 风格字典 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMStyles:
|
||||
"""BGM_STYLES 风格分类字典"""
|
||||
|
||||
def test_styles_exist(self):
|
||||
assert "upbeat" in BGM_STYLES
|
||||
assert "relax" in BGM_STYLES
|
||||
assert "tech" in BGM_STYLES
|
||||
assert "commerce" in BGM_STYLES
|
||||
assert "emotional" in BGM_STYLES
|
||||
assert "cinematic" in BGM_STYLES
|
||||
|
||||
def test_style_names_chinese(self):
|
||||
assert BGM_STYLES["upbeat"] == "轻快"
|
||||
assert BGM_STYLES["relax"] == "治愈"
|
||||
assert BGM_STYLES["tech"] == "科技"
|
||||
assert BGM_STYLES["commerce"] == "电商"
|
||||
assert BGM_STYLES["emotional"] == "情感"
|
||||
assert BGM_STYLES["cinematic"] == "电影"
|
||||
|
||||
def test_library_styles_are_defined(self):
|
||||
# 库中的所有风格都应该在 BGM_STYLES 中有定义
|
||||
styles_in_library = {b.style for b in PRESET_BGM_LIBRARY}
|
||||
for style in styles_in_library:
|
||||
assert style in BGM_STYLES, f"style {style} not defined in BGM_STYLES"
|
||||
|
||||
|
||||
# ── get_preset_bgm ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPresetBGM:
|
||||
"""get_preset_bgm 函数"""
|
||||
|
||||
def test_get_existing(self):
|
||||
b = get_preset_bgm("bgm_upbeat_001")
|
||||
assert b is not None
|
||||
assert b.id == "bgm_upbeat_001"
|
||||
assert b.name == "阳光清晨"
|
||||
|
||||
def test_get_relax(self):
|
||||
b = get_preset_bgm("bgm_relax_002")
|
||||
assert b is not None
|
||||
assert b.style == "relax"
|
||||
|
||||
def test_get_nonexistent_returns_none(self):
|
||||
b = get_preset_bgm("nonexistent_id")
|
||||
assert b is None
|
||||
|
||||
def test_get_empty_string_returns_none(self):
|
||||
b = get_preset_bgm("")
|
||||
assert b is None
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
b1 = get_preset_bgm("bgm_upbeat_001")
|
||||
b2 = get_preset_bgm("bgm_upbeat_001")
|
||||
assert b1 is b2
|
||||
|
||||
|
||||
# ── list_preset_bgm_by_style ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPresetBGMByStyle:
|
||||
"""list_preset_bgm_by_style 函数"""
|
||||
|
||||
def test_upbeat_style(self):
|
||||
result = list_preset_bgm_by_style("upbeat")
|
||||
assert len(result) >= 3
|
||||
for b in result:
|
||||
assert b.style == "upbeat"
|
||||
|
||||
def test_relax_style(self):
|
||||
result = list_preset_bgm_by_style("relax")
|
||||
assert len(result) >= 3
|
||||
for b in result:
|
||||
assert b.style == "relax"
|
||||
|
||||
def test_tech_style(self):
|
||||
result = list_preset_bgm_by_style("tech")
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_commerce_style(self):
|
||||
result = list_preset_bgm_by_style("commerce")
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_unknown_style_empty(self):
|
||||
result = list_preset_bgm_by_style("nonexistent_style")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_empty_string_empty(self):
|
||||
result = list_preset_bgm_by_style("")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_returns_new_list(self):
|
||||
# 修改返回值不应影响原始列表
|
||||
result = list_preset_bgm_by_style("upbeat")
|
||||
result.clear()
|
||||
assert len(list_preset_bgm_by_style("upbeat")) >= 3
|
||||
|
||||
|
||||
# ── search_preset_bgm ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearchPresetBGM:
|
||||
"""search_preset_bgm 函数"""
|
||||
|
||||
def test_search_by_name(self):
|
||||
result = search_preset_bgm("阳光")
|
||||
assert len(result) >= 1
|
||||
assert any("阳光" in b.name for b in result)
|
||||
|
||||
def test_search_by_description(self):
|
||||
result = search_preset_bgm("钢琴")
|
||||
assert len(result) >= 1
|
||||
# 应该匹配描述里有钢琴的
|
||||
|
||||
def test_search_by_tag(self):
|
||||
result = search_preset_bgm("vlog")
|
||||
assert len(result) >= 1
|
||||
assert any("vlog" in b.tags for b in result)
|
||||
|
||||
def test_search_tech_keyword(self):
|
||||
result = search_preset_bgm("科技")
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_search_case_insensitive(self):
|
||||
r1 = search_preset_bgm("UPBEAT")
|
||||
r2 = search_preset_bgm("upbeat")
|
||||
assert len(r1) == len(r2)
|
||||
|
||||
def test_search_no_match(self):
|
||||
result = search_preset_bgm("完全不存在的关键词_xyz123")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_search_empty_string(self):
|
||||
# 空字符串应该匹配所有(因为 "" in any string 是 True)
|
||||
result = search_preset_bgm("")
|
||||
assert len(result) == len(PRESET_BGM_LIBRARY)
|
||||
|
||||
def test_search_electronic(self):
|
||||
result = search_preset_bgm("电子")
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_order_preserved(self):
|
||||
# 搜索结果应该保持原列表顺序
|
||||
result = search_preset_bgm("bgm")
|
||||
ids = [b.id for b in result]
|
||||
all_ids = [b.id for b in PRESET_BGM_LIBRARY]
|
||||
# 验证相对顺序
|
||||
pos_in_result = {bgm_id: i for i, bgm_id in enumerate(ids)}
|
||||
prev_pos = -1
|
||||
for bgm_id in all_ids:
|
||||
if bgm_id in pos_in_result:
|
||||
assert pos_in_result[bgm_id] > prev_pos
|
||||
prev_pos = pos_in_result[bgm_id]
|
||||
|
||||
def test_search_partial_tag_match(self):
|
||||
# 关键词是标签的子串也能匹配
|
||||
result = search_preset_bgm("吉他")
|
||||
assert len(result) >= 1
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
"""preset_voices 预置音色配置单测."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from domain.preset_voices import (
|
||||
PRESET_VOICES,
|
||||
PresetVoice,
|
||||
get_preset_voice_by_id,
|
||||
get_preset_voices,
|
||||
is_preset_voice,
|
||||
)
|
||||
|
||||
# ── PresetVoice dataclass ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetVoice:
|
||||
"""PresetVoice dataclass"""
|
||||
|
||||
def test_minimal_creation(self):
|
||||
v = PresetVoice(
|
||||
voice_id="test_v1",
|
||||
name="测试音色",
|
||||
description="测试描述",
|
||||
gender="female",
|
||||
)
|
||||
assert v.voice_id == "test_v1"
|
||||
assert v.name == "测试音色"
|
||||
assert v.description == "测试描述"
|
||||
assert v.gender == "female"
|
||||
assert v.language == "zh-CN"
|
||||
assert v.preview_url == ""
|
||||
assert v.tags is None
|
||||
|
||||
def test_full_creation(self):
|
||||
v = PresetVoice(
|
||||
voice_id="test_v2",
|
||||
name="完整音色",
|
||||
description="完整描述",
|
||||
gender="male",
|
||||
language="en-US",
|
||||
preview_url="https://example.com/preview.mp3",
|
||||
tags=["沉稳", "男声"],
|
||||
)
|
||||
assert v.gender == "male"
|
||||
assert v.language == "en-US"
|
||||
assert v.preview_url == "https://example.com/preview.mp3"
|
||||
assert v.tags == ["沉稳", "男声"]
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
v.name = "NewName"
|
||||
|
||||
def test_equality(self):
|
||||
v1 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
|
||||
v2 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
|
||||
assert v1 == v2
|
||||
|
||||
def test_inequality(self):
|
||||
v1 = PresetVoice(voice_id="a", name="A", description="da", gender="female")
|
||||
v2 = PresetVoice(voice_id="b", name="B", description="db", gender="male")
|
||||
assert v1 != v2
|
||||
|
||||
def test_to_dict(self):
|
||||
v = PresetVoice(
|
||||
voice_id="test_v1",
|
||||
name="测试音色",
|
||||
description="测试描述",
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="https://x.com/a.mp3",
|
||||
tags=["温柔", "女声"],
|
||||
)
|
||||
d = v.to_dict()
|
||||
assert isinstance(d, dict)
|
||||
assert d["voice_id"] == "test_v1"
|
||||
assert d["name"] == "测试音色"
|
||||
assert d["description"] == "测试描述"
|
||||
assert d["gender"] == "female"
|
||||
assert d["language"] == "zh-CN"
|
||||
assert d["preview_url"] == "https://x.com/a.mp3"
|
||||
assert d["tags"] == ["温柔", "女声"]
|
||||
|
||||
def test_to_dict_none_tags_becomes_empty_list(self):
|
||||
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
|
||||
d = v.to_dict()
|
||||
assert d["tags"] == []
|
||||
assert isinstance(d["tags"], list)
|
||||
|
||||
def test_to_dict_has_all_keys(self):
|
||||
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
|
||||
d = v.to_dict()
|
||||
assert set(d.keys()) == {
|
||||
"voice_id",
|
||||
"name",
|
||||
"description",
|
||||
"gender",
|
||||
"language",
|
||||
"preview_url",
|
||||
"tags",
|
||||
}
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
|
||||
# frozen + slots dataclass 不允许动态添加属性
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
v.nonexistent_field = "value"
|
||||
|
||||
|
||||
# ── PRESET_VOICES 列表 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetVoicesList:
|
||||
"""PRESET_VOICES 预置音色列表"""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(PRESET_VOICES) > 0
|
||||
|
||||
def test_count(self):
|
||||
assert len(PRESET_VOICES) == 8
|
||||
|
||||
def test_all_are_preset_voice(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert isinstance(v, PresetVoice)
|
||||
|
||||
def test_unique_voice_ids(self):
|
||||
ids = [v.voice_id for v in PRESET_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_unique_names(self):
|
||||
names = [v.name for v in PRESET_VOICES]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_all_have_required_fields(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert v.voice_id != ""
|
||||
assert v.name != ""
|
||||
assert v.description != ""
|
||||
assert v.gender in ("male", "female")
|
||||
|
||||
def test_all_chinese(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_longxiaochun_voice(self):
|
||||
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochun_v3")
|
||||
assert v.name == "龙小淳"
|
||||
assert v.gender == "female"
|
||||
assert "温柔" in v.description
|
||||
|
||||
def test_longxiaochen_voice(self):
|
||||
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochen_v3")
|
||||
assert v.name == "龙小晨"
|
||||
assert v.gender == "male"
|
||||
|
||||
def test_male_voices_count(self):
|
||||
males = [v for v in PRESET_VOICES if v.gender == "male"]
|
||||
assert len(males) == 3 # 龙小晨/龙书/龙博
|
||||
|
||||
def test_female_voices_count(self):
|
||||
females = [v for v in PRESET_VOICES if v.gender == "female"]
|
||||
assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜
|
||||
|
||||
def test_all_have_tags(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert v.tags is not None
|
||||
assert len(v.tags) > 0
|
||||
|
||||
def test_voice_id_pattern(self):
|
||||
# 所有音色 ID 都以 _v3 结尾
|
||||
for v in PRESET_VOICES:
|
||||
assert v.voice_id.endswith("_v3")
|
||||
|
||||
|
||||
# ── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPresetVoices:
|
||||
"""get_preset_voices 函数"""
|
||||
|
||||
def test_returns_full_list(self):
|
||||
result = get_preset_voices()
|
||||
assert len(result) == len(PRESET_VOICES)
|
||||
assert result is PRESET_VOICES # 返回同一列表引用
|
||||
|
||||
def test_all_are_preset_voice(self):
|
||||
result = get_preset_voices()
|
||||
for v in result:
|
||||
assert isinstance(v, PresetVoice)
|
||||
|
||||
|
||||
class TestGetPresetVoiceById:
|
||||
"""get_preset_voice_by_id 函数"""
|
||||
|
||||
def test_get_existing_female(self):
|
||||
v = get_preset_voice_by_id("longxiaochun_v3")
|
||||
assert v is not None
|
||||
assert v.voice_id == "longxiaochun_v3"
|
||||
assert v.name == "龙小淳"
|
||||
|
||||
def test_get_existing_male(self):
|
||||
v = get_preset_voice_by_id("longxiaochen_v3")
|
||||
assert v is not None
|
||||
assert v.gender == "male"
|
||||
|
||||
def test_get_nonexistent_returns_none(self):
|
||||
v = get_preset_voice_by_id("nonexistent_voice")
|
||||
assert v is None
|
||||
|
||||
def test_get_empty_string_returns_none(self):
|
||||
v = get_preset_voice_by_id("")
|
||||
assert v is None
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
v1 = get_preset_voice_by_id("longyue_v3")
|
||||
v2 = get_preset_voice_by_id("longyue_v3")
|
||||
assert v1 is v2
|
||||
|
||||
def test_all_voices_reachable(self):
|
||||
for v in PRESET_VOICES:
|
||||
found = get_preset_voice_by_id(v.voice_id)
|
||||
assert found is not None
|
||||
assert found.voice_id == v.voice_id
|
||||
|
||||
|
||||
class TestIsPresetVoice:
|
||||
"""is_preset_voice 函数"""
|
||||
|
||||
def test_existing_voice_true(self):
|
||||
assert is_preset_voice("longxiaochun_v3") is True
|
||||
|
||||
def test_all_existing_are_true(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert is_preset_voice(v.voice_id) is True
|
||||
|
||||
def test_nonexistent_voice_false(self):
|
||||
assert is_preset_voice("fake_voice") is False
|
||||
|
||||
def test_empty_string_false(self):
|
||||
assert is_preset_voice("") is False
|
||||
|
||||
def test_consistent_with_get_by_id(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert is_preset_voice(v.voice_id) == (get_preset_voice_by_id(v.voice_id) is not None)
|
||||
Executable
+453
@@ -0,0 +1,453 @@
|
||||
"""speed_config 调速配置领域模型单测."""
|
||||
|
||||
import pytest
|
||||
from domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
adjust_duration,
|
||||
build_audio_filter,
|
||||
build_clip_speed_filter,
|
||||
build_video_filter,
|
||||
resolve_clip_speed,
|
||||
)
|
||||
|
||||
# ── 常量测试 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""模块常量"""
|
||||
|
||||
def test_speed_limits(self):
|
||||
assert MIN_SPEED == 0.25
|
||||
assert MAX_SPEED == 4.0
|
||||
assert DEFAULT_SPEED == 1.0
|
||||
|
||||
|
||||
# ── SpeedConfig 默认值与基础 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigDefaults:
|
||||
"""SpeedConfig 默认值"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = SpeedConfig()
|
||||
assert c.speed == 1.0
|
||||
assert c.pitch_correct is True
|
||||
|
||||
def test_custom_values(self):
|
||||
c = SpeedConfig(speed=2.0, pitch_correct=False)
|
||||
assert c.speed == 2.0
|
||||
assert c.pitch_correct is False
|
||||
|
||||
|
||||
# ── SpeedConfig.parse ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigParse:
|
||||
"""SpeedConfig.parse 工厂方法"""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
c = SpeedConfig.parse(None)
|
||||
assert c.speed == 1.0
|
||||
assert c.pitch_correct is True
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
c = SpeedConfig.parse({})
|
||||
assert c.speed == 1.0
|
||||
|
||||
def test_not_dict_returns_default(self):
|
||||
c = SpeedConfig.parse("not a dict")
|
||||
assert c.speed == 1.0
|
||||
|
||||
def test_valid_speed(self):
|
||||
c = SpeedConfig.parse({"speed": 2.0})
|
||||
assert c.speed == 2.0
|
||||
|
||||
def test_valid_speed_int(self):
|
||||
c = SpeedConfig.parse({"speed": 2})
|
||||
assert c.speed == 2.0
|
||||
assert isinstance(c.speed, float)
|
||||
|
||||
def test_pitch_correct_false(self):
|
||||
c = SpeedConfig.parse({"pitch_correct": False})
|
||||
assert c.pitch_correct is False
|
||||
|
||||
def test_pitch_correct_non_bool_falls_back(self):
|
||||
c = SpeedConfig.parse({"pitch_correct": "true"})
|
||||
assert c.pitch_correct is True
|
||||
|
||||
def test_invalid_speed_string_falls_back(self):
|
||||
c = SpeedConfig.parse({"speed": "fast"})
|
||||
assert c.speed == 1.0
|
||||
|
||||
def test_speed_below_min_clamped(self):
|
||||
c = SpeedConfig.parse({"speed": 0.1})
|
||||
assert c.speed == MIN_SPEED
|
||||
|
||||
def test_speed_above_max_clamped(self):
|
||||
c = SpeedConfig.parse({"speed": 10.0})
|
||||
assert c.speed == MAX_SPEED
|
||||
|
||||
def test_zero_speed_falls_back_to_default(self):
|
||||
c = SpeedConfig.parse({"speed": 0})
|
||||
assert c.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_speed_falls_back(self):
|
||||
c = SpeedConfig.parse({"speed": -1.0})
|
||||
assert c.speed == DEFAULT_SPEED
|
||||
|
||||
def test_min_speed_boundary(self):
|
||||
c = SpeedConfig.parse({"speed": 0.25})
|
||||
assert c.speed == 0.25
|
||||
|
||||
def test_max_speed_boundary(self):
|
||||
c = SpeedConfig.parse({"speed": 4.0})
|
||||
assert c.speed == 4.0
|
||||
|
||||
|
||||
# ── SpeedConfig.clamp ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigClamp:
|
||||
"""SpeedConfig.clamp 方法"""
|
||||
|
||||
def test_normal_speed_no_change(self):
|
||||
c = SpeedConfig(speed=1.5)
|
||||
c.clamp()
|
||||
assert c.speed == 1.5
|
||||
|
||||
def test_zero_speed_reset_default(self):
|
||||
c = SpeedConfig(speed=0.0)
|
||||
c.clamp()
|
||||
assert c.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_speed_reset_default(self):
|
||||
c = SpeedConfig(speed=-0.5)
|
||||
c.clamp()
|
||||
assert c.speed == DEFAULT_SPEED
|
||||
|
||||
def test_below_min_clamped(self):
|
||||
c = SpeedConfig(speed=0.1)
|
||||
c.clamp()
|
||||
assert c.speed == MIN_SPEED
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
c = SpeedConfig(speed=5.0)
|
||||
c.clamp()
|
||||
assert c.speed == MAX_SPEED
|
||||
|
||||
def test_exact_min_unchanged(self):
|
||||
c = SpeedConfig(speed=MIN_SPEED)
|
||||
c.clamp()
|
||||
assert c.speed == MIN_SPEED
|
||||
|
||||
def test_exact_max_unchanged(self):
|
||||
c = SpeedConfig(speed=MAX_SPEED)
|
||||
c.clamp()
|
||||
assert c.speed == MAX_SPEED
|
||||
|
||||
|
||||
# ── SpeedConfig 属性方法 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigProperties:
|
||||
"""SpeedConfig 属性方法"""
|
||||
|
||||
def test_is_original_true(self):
|
||||
c = SpeedConfig(speed=1.0)
|
||||
assert c.is_original is True
|
||||
|
||||
def test_is_original_very_close(self):
|
||||
c = SpeedConfig(speed=1.0 + 1e-7)
|
||||
assert c.is_original is True
|
||||
|
||||
def test_is_original_false_fast(self):
|
||||
c = SpeedConfig(speed=1.5)
|
||||
assert c.is_original is False
|
||||
|
||||
def test_is_original_false_slow(self):
|
||||
c = SpeedConfig(speed=0.8)
|
||||
assert c.is_original is False
|
||||
|
||||
def test_is_fast_true(self):
|
||||
c = SpeedConfig(speed=2.0)
|
||||
assert c.is_fast is True
|
||||
|
||||
def test_is_fast_false(self):
|
||||
c = SpeedConfig(speed=0.5)
|
||||
assert c.is_fast is False
|
||||
|
||||
def test_is_fast_at_one(self):
|
||||
c = SpeedConfig(speed=1.0)
|
||||
assert c.is_fast is False
|
||||
|
||||
def test_is_slow_true(self):
|
||||
c = SpeedConfig(speed=0.5)
|
||||
assert c.is_slow is True
|
||||
|
||||
def test_is_slow_false(self):
|
||||
c = SpeedConfig(speed=2.0)
|
||||
assert c.is_slow is False
|
||||
|
||||
def test_is_slow_at_one(self):
|
||||
c = SpeedConfig(speed=1.0)
|
||||
assert c.is_slow is False
|
||||
|
||||
|
||||
# ── build_video_filter ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildVideoFilter:
|
||||
"""build_video_filter 视频滤镜构建"""
|
||||
|
||||
def test_original_speed_empty(self):
|
||||
c = SpeedConfig(speed=1.0)
|
||||
assert build_video_filter(c) == ""
|
||||
|
||||
def test_double_speed(self):
|
||||
c = SpeedConfig(speed=2.0)
|
||||
result = build_video_filter(c)
|
||||
assert "setpts=PTS/2.0" in result
|
||||
|
||||
def test_half_speed(self):
|
||||
c = SpeedConfig(speed=0.5)
|
||||
result = build_video_filter(c)
|
||||
assert "setpts=PTS/0.5" in result
|
||||
|
||||
def test_format_precision(self):
|
||||
c = SpeedConfig(speed=1.5)
|
||||
result = build_video_filter(c)
|
||||
# 应该是 4 位小数
|
||||
assert "1.5000" in result
|
||||
|
||||
def test_min_speed(self):
|
||||
c = SpeedConfig(speed=0.25)
|
||||
result = build_video_filter(c)
|
||||
assert result.startswith("setpts=PTS/")
|
||||
|
||||
def test_max_speed(self):
|
||||
c = SpeedConfig(speed=4.0)
|
||||
result = build_video_filter(c)
|
||||
assert "4.0000" in result
|
||||
|
||||
|
||||
# ── build_audio_filter / atempo 拆分 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAudioFilter:
|
||||
"""build_audio_filter 音频滤镜构建"""
|
||||
|
||||
def test_original_speed_empty(self):
|
||||
c = SpeedConfig(speed=1.0)
|
||||
assert build_audio_filter(c) == ""
|
||||
|
||||
def test_within_range_single_stage(self):
|
||||
c = SpeedConfig(speed=1.5)
|
||||
result = build_audio_filter(c)
|
||||
assert result == "atempo=1.5000"
|
||||
|
||||
def test_05_speed_single_stage(self):
|
||||
c = SpeedConfig(speed=0.5)
|
||||
result = build_audio_filter(c)
|
||||
assert result == "atempo=0.5000"
|
||||
|
||||
def test_20_speed_single_stage(self):
|
||||
c = SpeedConfig(speed=2.0)
|
||||
result = build_audio_filter(c)
|
||||
assert result == "atempo=2.0000"
|
||||
|
||||
def test_4x_speed_two_stages(self):
|
||||
c = SpeedConfig(speed=4.0)
|
||||
result = build_audio_filter(c)
|
||||
# 2.0 * 2.0 = 4.0
|
||||
assert result == "atempo=2.0000,atempo=2.0000"
|
||||
|
||||
def test_025_speed_two_stages(self):
|
||||
c = SpeedConfig(speed=0.25)
|
||||
result = build_audio_filter(c)
|
||||
# 0.5 * 0.5 = 0.25
|
||||
assert result == "atempo=0.5000,atempo=0.5000"
|
||||
|
||||
def test_3x_speed_two_stages(self):
|
||||
c = SpeedConfig(speed=3.0)
|
||||
result = build_audio_filter(c)
|
||||
# 2.0 * 1.5 = 3.0
|
||||
stages = result.split(",")
|
||||
assert len(stages) == 2
|
||||
assert "atempo=2.0000" in stages[0]
|
||||
assert "atempo=1.5000" in stages[1]
|
||||
|
||||
def test_03_speed_two_stages(self):
|
||||
c = SpeedConfig(speed=0.3)
|
||||
result = build_audio_filter(c)
|
||||
stages = result.split(",")
|
||||
assert len(stages) == 2
|
||||
# 0.5 * 0.6 = 0.3
|
||||
assert "atempo=0.5000" in stages[0]
|
||||
|
||||
def test_format_each_stage(self):
|
||||
c = SpeedConfig(speed=1.2345)
|
||||
result = build_audio_filter(c)
|
||||
assert "atempo=1.2345" in result
|
||||
|
||||
|
||||
class TestAtempoStages:
|
||||
"""atempo 多级拆分逻辑验证"""
|
||||
|
||||
def _extract_speeds(self, filter_str: str) -> list[float]:
|
||||
"""从 atempo 滤镜字符串中提取速度值."""
|
||||
import re
|
||||
|
||||
return [float(m) for m in re.findall(r"atempo=([\d.]+)", filter_str)]
|
||||
|
||||
def test_product_equals_speed_fast_3x(self):
|
||||
c = SpeedConfig(speed=3.0)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
product = 1.0
|
||||
for s in speeds:
|
||||
product *= s
|
||||
assert abs(product - 3.0) < 1e-4
|
||||
|
||||
def test_product_equals_speed_4x(self):
|
||||
c = SpeedConfig(speed=4.0)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
product = 1.0
|
||||
for s in speeds:
|
||||
product *= s
|
||||
assert abs(product - 4.0) < 1e-4
|
||||
|
||||
def test_product_equals_speed_slow_025(self):
|
||||
c = SpeedConfig(speed=0.25)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
product = 1.0
|
||||
for s in speeds:
|
||||
product *= s
|
||||
assert abs(product - 0.25) < 1e-4
|
||||
|
||||
def test_product_equals_speed_slow_03(self):
|
||||
c = SpeedConfig(speed=0.3)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
product = 1.0
|
||||
for s in speeds:
|
||||
product *= s
|
||||
assert abs(product - 0.3) < 1e-4
|
||||
|
||||
def test_each_stage_in_range_fast(self):
|
||||
c = SpeedConfig(speed=3.5)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
for s in speeds:
|
||||
assert 0.5 <= s <= 2.0
|
||||
|
||||
def test_each_stage_in_range_slow(self):
|
||||
c = SpeedConfig(speed=0.35)
|
||||
speeds = self._extract_speeds(build_audio_filter(c))
|
||||
for s in speeds:
|
||||
assert 0.5 <= s <= 2.0
|
||||
|
||||
|
||||
# ── adjust_duration ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdjustDuration:
|
||||
"""adjust_duration 时长计算"""
|
||||
|
||||
def test_original_speed_no_change(self):
|
||||
assert adjust_duration(10.0, SpeedConfig(speed=1.0)) == 10.0
|
||||
|
||||
def test_double_speed_half_duration(self):
|
||||
assert adjust_duration(10.0, SpeedConfig(speed=2.0)) == 5.0
|
||||
|
||||
def test_half_speed_double_duration(self):
|
||||
assert adjust_duration(10.0, SpeedConfig(speed=0.5)) == 20.0
|
||||
|
||||
def test_zero_duration_unchanged(self):
|
||||
assert adjust_duration(0.0, SpeedConfig(speed=2.0)) == 0.0
|
||||
|
||||
def test_negative_duration_unchanged(self):
|
||||
assert adjust_duration(-1.0, SpeedConfig(speed=2.0)) == -1.0
|
||||
|
||||
def test_original_with_zero_duration(self):
|
||||
assert adjust_duration(0.0, SpeedConfig(speed=1.0)) == 0.0
|
||||
|
||||
def test_triple_speed(self):
|
||||
assert adjust_duration(30.0, SpeedConfig(speed=3.0)) == 10.0
|
||||
|
||||
def test_quarter_speed(self):
|
||||
assert adjust_duration(10.0, SpeedConfig(speed=0.25)) == 40.0
|
||||
|
||||
|
||||
# ── build_clip_speed_filter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildClipSpeedFilter:
|
||||
"""build_clip_speed_filter 便捷方法"""
|
||||
|
||||
def test_returns_tuple_of_three(self):
|
||||
result = build_clip_speed_filter(1.5)
|
||||
assert len(result) == 3
|
||||
video_filter, audio_filter, config = result
|
||||
assert isinstance(video_filter, str)
|
||||
assert isinstance(audio_filter, str)
|
||||
assert isinstance(config, SpeedConfig)
|
||||
|
||||
def test_normal_speed(self):
|
||||
video_filter, audio_filter, config = build_clip_speed_filter(1.0)
|
||||
assert video_filter == ""
|
||||
assert audio_filter == ""
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_double_speed(self):
|
||||
video_filter, audio_filter, config = build_clip_speed_filter(2.0)
|
||||
assert "setpts" in video_filter
|
||||
assert "atempo" in audio_filter
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_clamps_speed(self):
|
||||
_, _, config = build_clip_speed_filter(10.0)
|
||||
assert config.speed == MAX_SPEED
|
||||
|
||||
def test_pitch_correct_false(self):
|
||||
# pitch_correct=False 时仍然生成滤镜(实际使用中可能换其他算法,但接口返回不变)
|
||||
video_filter, audio_filter, config = build_clip_speed_filter(2.0, pitch_correct=False)
|
||||
assert config.pitch_correct is False
|
||||
assert "setpts" in video_filter
|
||||
|
||||
|
||||
# ── resolve_clip_speed ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveClipSpeed:
|
||||
"""resolve_clip_speed 片段速度解析"""
|
||||
|
||||
def test_none_config_uses_global(self):
|
||||
assert resolve_clip_speed(None, 1.5) == 1.5
|
||||
|
||||
def test_zero_speed_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
|
||||
|
||||
def test_missing_key_uses_global(self):
|
||||
assert resolve_clip_speed({}, 2.0) == 2.0
|
||||
|
||||
def test_valid_speed(self):
|
||||
assert resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
|
||||
|
||||
def test_negative_speed_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": -1.0}, 1.0) == 1.0
|
||||
|
||||
def test_invalid_type_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
|
||||
|
||||
def test_default_global_is_one(self):
|
||||
assert resolve_clip_speed({"playback_speed": 0}) == 1.0
|
||||
|
||||
def test_int_speed(self):
|
||||
result = resolve_clip_speed({"playback_speed": 2})
|
||||
assert result == 2.0
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_very_small_positive_uses_it(self):
|
||||
# 只要 > 0 就用
|
||||
result = resolve_clip_speed({"playback_speed": 0.1})
|
||||
assert result == 0.1
|
||||
Executable
+497
@@ -0,0 +1,497 @@
|
||||
"""subtitle 字幕时间轴领域模型单测."""
|
||||
|
||||
import pytest
|
||||
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
# ── SubtitleWord ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
"""SubtitleWord 词级字幕单元"""
|
||||
|
||||
def test_basic(self):
|
||||
w = SubtitleWord(text="你好", start=1.0, end=1.5)
|
||||
assert w.text == "你好"
|
||||
assert w.start == 1.0
|
||||
assert w.end == 1.5
|
||||
|
||||
def test_duration(self):
|
||||
w = SubtitleWord(text="test", start=0.0, end=2.5)
|
||||
assert w.duration == 2.5
|
||||
|
||||
def test_duration_zero(self):
|
||||
w = SubtitleWord(text="x", start=5.0, end=5.0)
|
||||
assert w.duration == 0.0
|
||||
|
||||
def test_duration_negative_becomes_zero(self):
|
||||
w = SubtitleWord(text="x", start=3.0, end=2.0)
|
||||
assert w.duration == 0.0
|
||||
|
||||
|
||||
# ── SubtitleSegment ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
"""SubtitleSegment 字幕片段"""
|
||||
|
||||
def test_basic(self):
|
||||
s = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
|
||||
assert s.text == "你好世界"
|
||||
assert s.start == 0.0
|
||||
assert s.end == 2.0
|
||||
assert s.words == []
|
||||
|
||||
def test_with_words(self):
|
||||
words = [
|
||||
SubtitleWord("你好", 0.0, 0.5),
|
||||
SubtitleWord("世界", 0.5, 1.0),
|
||||
]
|
||||
s = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
|
||||
assert len(s.words) == 2
|
||||
assert s.words[0].text == "你好"
|
||||
|
||||
def test_duration(self):
|
||||
s = SubtitleSegment(text="test", start=1.5, end=3.5)
|
||||
assert s.duration == 2.0
|
||||
|
||||
def test_duration_negative_becomes_zero(self):
|
||||
s = SubtitleSegment(text="test", start=5.0, end=3.0)
|
||||
assert s.duration == 0.0
|
||||
|
||||
def test_char_count(self):
|
||||
s = SubtitleSegment(text="你好世界", start=0, end=1)
|
||||
assert s.char_count == 4
|
||||
|
||||
def test_char_count_empty(self):
|
||||
s = SubtitleSegment(text="", start=0, end=1)
|
||||
assert s.char_count == 0
|
||||
|
||||
def test_char_count_mixed(self):
|
||||
s = SubtitleSegment(text="Hello 世界", start=0, end=1)
|
||||
assert s.char_count == 8 # H-e-l-l-o- -世-界
|
||||
|
||||
|
||||
# ── SubtitleTimeline 基础 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleTimelineBasics:
|
||||
"""SubtitleTimeline 基础属性"""
|
||||
|
||||
def test_defaults(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segments == []
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_custom_language(self):
|
||||
tl = SubtitleTimeline(language="en")
|
||||
assert tl.language == "en"
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a", 0, 1),
|
||||
SubtitleSegment("b", 1, 2),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 2
|
||||
|
||||
def test_segment_count_empty(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0, 1),
|
||||
SubtitleSegment("世界", 1, 2),
|
||||
]
|
||||
)
|
||||
assert tl.total_chars == 4
|
||||
|
||||
def test_total_chars_empty(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.total_chars == 0
|
||||
|
||||
|
||||
# ── merge_short_segments ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeShortSegments:
|
||||
"""merge_short_segments 合并过短片段"""
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_single_segment(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短", 0, 1),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "短"
|
||||
|
||||
def test_two_short_segments_merged(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0, 1), # 2
|
||||
SubtitleSegment("世界", 1, 2), # 2
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 2.0
|
||||
|
||||
def test_multiple_short_merged(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一", 0, 0.5), # 1
|
||||
SubtitleSegment("二", 0.5, 1.0), # 1
|
||||
SubtitleSegment("三", 1.0, 1.5), # 1
|
||||
SubtitleSegment("四", 1.5, 2.0), # 1
|
||||
SubtitleSegment("五", 2.0, 2.5), # 1
|
||||
SubtitleSegment("六七八", 2.5, 3.5), # 3
|
||||
SubtitleSegment("八九十", 3.5, 4.5), # 3
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
# 一二三四五 5个=5 → 合并为1段
|
||||
# 六七八+八九十 3+3=6 → 合并为1段
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "一二三四五"
|
||||
assert result.segments[1].text == "六七八八九十"
|
||||
|
||||
def test_long_segment_stays_alone(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("这是一段很长的字幕内容", 0, 2), # 11
|
||||
SubtitleSegment("短", 2, 2.5), # 1
|
||||
SubtitleSegment("语", 2.5, 3.0), # 1
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第一段11字>=8,单独输出;后两段加起来2字<8,合并到上一段
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "这是一段很长的字幕内容短语"
|
||||
|
||||
def test_tail_short_merged_with_previous(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一二三四五六七八", 0, 2), # 8
|
||||
SubtitleSegment("尾", 2, 2.5), # 1,太短了
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八尾"
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment("a", 0, 1)],
|
||||
language="en",
|
||||
total_duration=10.0,
|
||||
)
|
||||
result = tl.merge_short_segments()
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 10.0
|
||||
|
||||
def test_default_min_chars_is_8(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一二三四五", 0, 1), # 5 < 8
|
||||
SubtitleSegment("六七八", 1, 2), # 3 → 5+3=8
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_merges_words(self):
|
||||
words1 = [SubtitleWord("你", 0.0, 0.3), SubtitleWord("好", 0.3, 0.6)]
|
||||
words2 = [SubtitleWord("世", 1.0, 1.3), SubtitleWord("界", 1.3, 1.6)]
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0.0, 0.6, words=words1),
|
||||
SubtitleSegment("世界", 1.0, 1.6, words=words2),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
assert result.segments[0].words[0].text == "你"
|
||||
assert result.segments[0].words[3].text == "界"
|
||||
|
||||
def test_does_not_mutate_original(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a", 0, 1),
|
||||
SubtitleSegment("b", 1, 2),
|
||||
]
|
||||
)
|
||||
original_count = tl.segment_count
|
||||
tl.merge_short_segments(min_chars=5)
|
||||
assert tl.segment_count == original_count
|
||||
|
||||
|
||||
# ── split_long_segments ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
"""split_long_segments 拆分过长片段"""
|
||||
|
||||
def test_short_segment_no_split(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短文本", 0, 1),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "短文本"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_split_by_sentence_end(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
"这是第一句话。这是第二句话。这是第三句话。",
|
||||
start=0.0,
|
||||
end=9.0,
|
||||
),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 第一句应该是完整的
|
||||
assert result.segments[0].text.endswith("。")
|
||||
|
||||
def test_split_preserves_total_text(self):
|
||||
original = "这是第一句话。这是第二句话。这是第三句话,很长的一句话。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(original, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
# 拆分后所有片段拼起来应该等于原文
|
||||
combined = "".join(s.text for s in result.segments)
|
||||
assert combined == original
|
||||
|
||||
def test_split_time_proportional(self):
|
||||
text = "一二三四五六七八九十。一二三四五六七八九十。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=12)
|
||||
assert result.segment_count >= 2
|
||||
# 第一段结束时间应该早于总时长
|
||||
assert result.segments[0].end < 10.0
|
||||
# 最后一段结束应该等于原结束时间
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3
|
||||
combined = "".join(s.text for s in result.segments)
|
||||
assert combined == text
|
||||
|
||||
def test_multiple_mixed_segments(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短", 0, 1),
|
||||
SubtitleSegment("这是一段非常非常长的字幕文本内容需要拆分", 1, 5),
|
||||
SubtitleSegment("短的", 5, 6),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 第一个和第三个保持不变,中间被拆分
|
||||
assert result.segment_count > 3
|
||||
assert result.segments[0].text == "短"
|
||||
assert result.segments[-1].text == "短的"
|
||||
|
||||
def test_preserves_language_and_total_duration(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment("a" * 30, 0, 10)],
|
||||
language="ja",
|
||||
total_duration=20.0,
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 20.0
|
||||
|
||||
def test_default_max_chars_is_20(self):
|
||||
text = "一" * 25
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=5),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count >= 2
|
||||
|
||||
def test_split_with_words(self):
|
||||
words = [SubtitleWord(f"w{i}", i * 0.5, i * 0.5 + 0.4) for i in range(20)]
|
||||
text = "".join(w.text for w in words)
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0, words=words),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 所有片段的词数之和应该等于原词数
|
||||
total_words = sum(len(s.words) for s in result.segments)
|
||||
assert total_words <= len(words) + 1 # 可能有边界误差
|
||||
|
||||
def test_does_not_mutate_original(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a" * 30, 0, 10),
|
||||
]
|
||||
)
|
||||
original_count = tl.segment_count
|
||||
tl.split_long_segments(max_chars=10)
|
||||
assert tl.segment_count == original_count
|
||||
|
||||
|
||||
# ── _split_text_by_punctuation 静态方法 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
"""_split_text_by_punctuation 静态方法"""
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("短文本", max_chars=20)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "短文本"
|
||||
|
||||
def test_sentence_end_punctuation_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"第一句。第二句。第三句。",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
assert result[0] == "第一句。"
|
||||
|
||||
def test_clause_pause_punctuation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"今天天气很好,阳光明媚,适合出去玩。",
|
||||
max_chars=8,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_exclamation_mark(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"太精彩了!真的很棒!",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_question_mark(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"你是谁?从哪里来?",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_english_punctuation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"Hello, world! How are you?",
|
||||
max_chars=10,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
text = "一" * 25
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, max_chars=10)
|
||||
assert len(result) >= 3
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_empty_string(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", max_chars=10)
|
||||
assert len(result) == 0 or (len(result) == 1 and result[0] == "")
|
||||
|
||||
def test_semicolon_colon(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"注意事项:第一,要认真;第二,要仔细。",
|
||||
max_chars=8,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
# ── _merge_segments 静态方法 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeSegmentsStatic:
|
||||
"""_merge_segments 静态方法"""
|
||||
|
||||
def test_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
|
||||
def test_single_segment(self):
|
||||
seg = SubtitleSegment("hello", 1.0, 2.0)
|
||||
result = SubtitleTimeline._merge_segments([seg])
|
||||
assert result.text == "hello"
|
||||
assert result.start == 1.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_two_segments(self):
|
||||
s1 = SubtitleSegment("你好", 0.0, 1.0)
|
||||
s2 = SubtitleSegment("世界", 1.0, 2.0)
|
||||
result = SubtitleTimeline._merge_segments([s1, s2])
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merges_words(self):
|
||||
w1 = [SubtitleWord("你", 0, 0.5)]
|
||||
w2 = [SubtitleWord("好", 0.5, 1.0)]
|
||||
s1 = SubtitleSegment("你", 0, 0.5, words=w1)
|
||||
s2 = SubtitleSegment("好", 0.5, 1.0, words=w2)
|
||||
result = SubtitleTimeline._merge_segments([s1, s2])
|
||||
assert len(result.words) == 2
|
||||
assert result.words[0].text == "你"
|
||||
assert result.words[1].text == "好"
|
||||
|
||||
|
||||
# ── 端到端:先合并再拆分 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeAndSplit:
|
||||
"""合并和拆分组合使用"""
|
||||
|
||||
def test_merge_then_split_roundtrip(self):
|
||||
# 很多短句先合并,再按合理长度拆分
|
||||
segments = [
|
||||
SubtitleSegment("你好", 0, 0.5),
|
||||
SubtitleSegment("我是小明", 0.5, 1.5),
|
||||
SubtitleSegment("今天天气真好。", 1.5, 3.0),
|
||||
SubtitleSegment("我们出去玩吧。", 3.0, 5.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
merged = tl.merge_short_segments(min_chars=5)
|
||||
split = merged.split_long_segments(max_chars=15)
|
||||
# 结果应该合理(不保证完全一样,但文本应该完整)
|
||||
original_text = "".join(s.text for s in segments)
|
||||
result_text = "".join(s.text for s in split.segments)
|
||||
assert original_text == result_text
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
"""EditTemplateVersion 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from domain.template_version import EditTemplateVersion
|
||||
|
||||
|
||||
class TestEditTemplateVersionCreate:
|
||||
"""create() 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
v = EditTemplateVersion.create("tmpl_001", 1)
|
||||
assert v.id is not None
|
||||
assert len(v.id) == 32
|
||||
assert v.template_id == "tmpl_001"
|
||||
assert v.version == 1
|
||||
assert v.name == ""
|
||||
assert v.editing_mode == "one_take"
|
||||
assert v.config == {}
|
||||
assert v.clip_configs == []
|
||||
assert v.change_note == ""
|
||||
assert v.published_by == ""
|
||||
assert v.created_at is not None
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
v = EditTemplateVersion.create(
|
||||
"tmpl_001",
|
||||
3,
|
||||
name="第三版",
|
||||
editing_mode="pip",
|
||||
config={"bgm": True},
|
||||
clip_configs=[{"id": "c1", "type": "video"}],
|
||||
change_note="优化剪辑逻辑",
|
||||
published_by="user_123",
|
||||
)
|
||||
assert v.template_id == "tmpl_001"
|
||||
assert v.version == 3
|
||||
assert v.name == "第三版"
|
||||
assert v.editing_mode == "pip"
|
||||
assert v.config == {"bgm": True}
|
||||
assert v.clip_configs == [{"id": "c1", "type": "video"}]
|
||||
assert v.change_note == "优化剪辑逻辑"
|
||||
assert v.published_by == "user_123"
|
||||
|
||||
def test_create_config_none_defaults_to_empty(self):
|
||||
v = EditTemplateVersion.create("t1", 1, config=None)
|
||||
assert v.config == {}
|
||||
|
||||
def test_create_clip_configs_none_defaults_to_empty(self):
|
||||
v = EditTemplateVersion.create("t1", 1, clip_configs=None)
|
||||
assert v.clip_configs == []
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
v1 = EditTemplateVersion.create("t1", 1)
|
||||
v2 = EditTemplateVersion.create("t1", 1)
|
||||
assert v1.id != v2.id
|
||||
|
||||
def test_create_sets_created_at(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
v = EditTemplateVersion.create("t1", 1)
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= v.created_at <= after
|
||||
|
||||
|
||||
class TestEditTemplateVersionConstruction:
|
||||
"""直接构造测试."""
|
||||
|
||||
def test_direct_construction(self):
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
v = EditTemplateVersion(
|
||||
id="v1",
|
||||
template_id="t1",
|
||||
version=5,
|
||||
name="v5",
|
||||
editing_mode="voice_over",
|
||||
config={"key": "value"},
|
||||
clip_configs=[{"a": 1}, {"b": 2}],
|
||||
change_note="test",
|
||||
published_by="admin",
|
||||
created_at=now,
|
||||
)
|
||||
assert v.id == "v1"
|
||||
assert v.template_id == "t1"
|
||||
assert v.version == 5
|
||||
assert v.name == "v5"
|
||||
assert v.editing_mode == "voice_over"
|
||||
assert v.config == {"key": "value"}
|
||||
assert v.clip_configs == [{"a": 1}, {"b": 2}]
|
||||
assert v.change_note == "test"
|
||||
assert v.published_by == "admin"
|
||||
assert v.created_at == now
|
||||
|
||||
def test_default_values(self):
|
||||
v = EditTemplateVersion(id="v1", template_id="t1", version=1)
|
||||
assert v.name == ""
|
||||
assert v.editing_mode == "one_take"
|
||||
assert v.config == {}
|
||||
assert v.clip_configs == []
|
||||
assert v.change_note == ""
|
||||
assert v.published_by == ""
|
||||
|
||||
|
||||
class TestEditTemplateVersionSlots:
|
||||
"""slots 测试."""
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
v = EditTemplateVersion.create("t1", 1)
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
v.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestEditTemplateVersionEquality:
|
||||
"""相等性测试."""
|
||||
|
||||
def test_equal_same_id_and_version(self):
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
|
||||
v2 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
|
||||
assert v1 == v2
|
||||
|
||||
def test_not_equal_different_id(self):
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
v1 = EditTemplateVersion(id="v1", template_id="t1", version=1, created_at=now)
|
||||
v2 = EditTemplateVersion(id="v2", template_id="t1", version=1, created_at=now)
|
||||
assert v1 != v2
|
||||
|
||||
def test_not_equal_different_version(self):
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
|
||||
v2 = EditTemplateVersion(id="same", template_id="t1", version=2, created_at=now)
|
||||
assert v1 != v2
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
"""TtsConfig 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
"""默认值测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
assert config.text == ""
|
||||
assert config.align_mode == "full"
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_custom_construction(self):
|
||||
config = TtsConfig(
|
||||
enabled=True,
|
||||
voice_id="voice_001",
|
||||
speed=1.5,
|
||||
pitch=3.0,
|
||||
volume=0.9,
|
||||
text="hello",
|
||||
align_mode="subtitle",
|
||||
overlap_mode="mix",
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 3.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "hello"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
config = TtsConfig()
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
config.new_attr = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_values(self):
|
||||
a = TtsConfig(enabled=True, voice_id="v1")
|
||||
b = TtsConfig(enabled=True, voice_id="v1")
|
||||
assert a == b
|
||||
|
||||
def test_equality_different_values(self):
|
||||
a = TtsConfig(enabled=True)
|
||||
b = TtsConfig(enabled=False)
|
||||
assert a != b
|
||||
|
||||
|
||||
class TestTtsConfigParseNoneAndEmpty:
|
||||
"""parse 空输入测试."""
|
||||
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_string(self):
|
||||
config = TtsConfig.parse("not a dict") # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_list(self):
|
||||
config = TtsConfig.parse([]) # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_number(self):
|
||||
config = TtsConfig.parse(123) # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
|
||||
class TestTtsConfigParseDisabled:
|
||||
"""parse disabled 场景."""
|
||||
|
||||
def test_parse_enabled_false_returns_default(self):
|
||||
config = TtsConfig.parse({"enabled": False})
|
||||
assert config.enabled is False
|
||||
assert config.speed == 1.0
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_enabled_false_ignores_other_fields(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": False,
|
||||
"voice_id": "v1",
|
||||
"speed": 1.5,
|
||||
}
|
||||
)
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_enabled_non_bool_falls_to_false(self):
|
||||
config = TtsConfig.parse({"enabled": "true"})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_enabled_int_falls_to_false(self):
|
||||
config = TtsConfig.parse({"enabled": 1})
|
||||
assert config.enabled is False
|
||||
|
||||
|
||||
class TestTtsConfigParseNormal:
|
||||
"""parse 正常数据测试."""
|
||||
|
||||
def test_parse_full_data(self):
|
||||
data = {
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.5,
|
||||
"volume": 0.7,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.5
|
||||
assert config.volume == 0.7
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_int_speed_becomes_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2})
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_int_pitch_becomes_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -3})
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == -3.0
|
||||
|
||||
|
||||
class TestTtsConfigParseTypeFallback:
|
||||
"""parse 类型错误回退测试."""
|
||||
|
||||
def test_parse_voice_id_non_string_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_non_string_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 456})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_voice_id_list_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": ["v1"]})
|
||||
assert config.voice_id == ""
|
||||
|
||||
|
||||
class TestTtsConfigParseClamp:
|
||||
"""parse 边界钳制测试."""
|
||||
|
||||
def test_parse_speed_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_parse_speed_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_speed_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_parse_speed_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_pitch_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_parse_pitch_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_parse_pitch_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -12})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_parse_pitch_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 12})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_parse_volume_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_parse_volume_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_parse_volume_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_parse_volume_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
|
||||
assert config.volume == 1.0
|
||||
|
||||
|
||||
class TestTtsConfigParseAlignMode:
|
||||
"""align_mode 解析测试."""
|
||||
|
||||
def test_parse_align_mode_subtitle(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
|
||||
assert config.align_mode == "subtitle"
|
||||
|
||||
def test_parse_align_mode_full(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_align_mode_invalid_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "auto"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_align_mode_empty_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": ""})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
|
||||
class TestTtsConfigParseOverlapMode:
|
||||
"""overlap_mode 解析测试."""
|
||||
|
||||
def test_parse_overlap_mode_replace(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_mix(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_overlap_mode_invalid_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "add"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_empty_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": ""})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
"""_clamp 直接调用测试."""
|
||||
|
||||
def test_clamp_speed_low(self):
|
||||
config = TtsConfig(enabled=True, speed=0.1)
|
||||
config._clamp()
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_clamp_speed_high(self):
|
||||
config = TtsConfig(enabled=True, speed=5.0)
|
||||
config._clamp()
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_clamp_speed_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, speed=1.2)
|
||||
config._clamp()
|
||||
assert config.speed == 1.2
|
||||
|
||||
def test_clamp_pitch_low(self):
|
||||
config = TtsConfig(enabled=True, pitch=-20)
|
||||
config._clamp()
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_clamp_pitch_high(self):
|
||||
config = TtsConfig(enabled=True, pitch=20)
|
||||
config._clamp()
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_clamp_pitch_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, pitch=5.0)
|
||||
config._clamp()
|
||||
assert config.pitch == 5.0
|
||||
|
||||
def test_clamp_volume_low(self):
|
||||
config = TtsConfig(enabled=True, volume=-1.0)
|
||||
config._clamp()
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_clamp_volume_high(self):
|
||||
config = TtsConfig(enabled=True, volume=2.0)
|
||||
config._clamp()
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_clamp_volume_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, volume=0.5)
|
||||
config._clamp()
|
||||
assert config.volume == 0.5
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
"""VerificationCode 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
"""create() 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.id is not None
|
||||
assert len(vc.id) == 32
|
||||
assert vc.recipient == "test@example.com"
|
||||
assert vc.code_type == "email_login"
|
||||
assert len(vc.code) == 6
|
||||
assert vc.code.isdigit()
|
||||
assert vc.used_at is None
|
||||
assert vc.attempts == 0
|
||||
assert vc.created_at is not None
|
||||
assert vc.expires_at > vc.created_at
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
vc = VerificationCode.create(" test@example.com ", "email_login")
|
||||
assert vc.recipient == "test@example.com"
|
||||
|
||||
def test_create_custom_code(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
|
||||
assert vc.code == "123456"
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=60)
|
||||
|
||||
def test_create_default_ttl_300(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=300)
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
vc1 = VerificationCode.create("a@b.com", "email_login")
|
||||
vc2 = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc1.id != vc2.id
|
||||
|
||||
def test_create_unique_codes(self):
|
||||
codes = set()
|
||||
for _ in range(20):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
codes.add(vc.code)
|
||||
# 20个随机6位码几乎肯定不都一样
|
||||
assert len(codes) > 1
|
||||
|
||||
def test_create_phone_recipient(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_login")
|
||||
assert vc.recipient == "13800138000"
|
||||
assert vc.code_type == "phone_login"
|
||||
|
||||
def test_create_all_code_types(self):
|
||||
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
|
||||
vc = VerificationCode.create("test@example.com", ct)
|
||||
assert vc.code_type == ct
|
||||
|
||||
|
||||
class TestVerificationCodeIsExpired:
|
||||
"""is_expired 属性测试."""
|
||||
|
||||
def test_not_expired_future(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_expired_past(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_expired_boundary_exact(self):
|
||||
# 用mock固定时间,expires_at等于当前时间不算过期
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=fixed_now,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
|
||||
class TestVerificationCodeIsUsed:
|
||||
"""is_used 属性测试."""
|
||||
|
||||
def test_not_used_default(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.is_used is False
|
||||
|
||||
def test_is_used_after_mark(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
assert vc.is_used is True
|
||||
|
||||
|
||||
class TestVerificationCodeIsValid:
|
||||
"""is_valid 属性测试."""
|
||||
|
||||
def test_valid_fresh(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_valid is True
|
||||
|
||||
def test_invalid_expired(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_used(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_expired_and_used(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
|
||||
class TestVerificationCodeMarkUsed:
|
||||
"""mark_used 方法测试."""
|
||||
|
||||
def test_mark_used_sets_timestamp(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.used_at is None
|
||||
before = datetime.now(timezone.utc)
|
||||
vc.mark_used()
|
||||
after = datetime.now(timezone.utc)
|
||||
assert vc.used_at is not None
|
||||
assert before <= vc.used_at <= after
|
||||
|
||||
def test_mark_used_twice_overwrites(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
first = vc.used_at
|
||||
# 时间足够短,一般不会不同,但确保可以重复调用
|
||||
vc.mark_used()
|
||||
assert vc.used_at is not None
|
||||
|
||||
|
||||
class TestVerificationCodeIncrementAttempts:
|
||||
"""increment_attempts 方法测试."""
|
||||
|
||||
def test_default_zero(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_once(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_increment_multiple(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
for _i in range(5):
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 5
|
||||
|
||||
|
||||
class TestVerificationCodeBasics:
|
||||
"""基础构造和 slots 测试."""
|
||||
|
||||
def test_direct_construction(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc = VerificationCode(
|
||||
id="abc123",
|
||||
recipient="test@test.com",
|
||||
code="000000",
|
||||
code_type="email_bind",
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
used_at=None,
|
||||
attempts=0,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc.id == "abc123"
|
||||
assert vc.recipient == "test@test.com"
|
||||
assert vc.code == "000000"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
vc.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_id(self):
|
||||
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc1 == vc2
|
||||
|
||||
def test_equality_different_id(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="id1",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="id2",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
assert vc1 != vc2
|
||||
Executable
+392
@@ -0,0 +1,392 @@
|
||||
"""video_concat 视频拼接配置单测."""
|
||||
|
||||
import pytest
|
||||
from domain.video_concat import (
|
||||
ALLOWED_VIDEO_EXTENSIONS,
|
||||
CONCAT_DEMUXER_REQUIRED_PARAMS,
|
||||
MAX_CONCAT_SEGMENTS,
|
||||
ConcatConfig,
|
||||
ConcatSegment,
|
||||
)
|
||||
|
||||
# ── 常量测试 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""模块常量"""
|
||||
|
||||
def test_max_concat_segments(self):
|
||||
assert MAX_CONCAT_SEGMENTS == 50
|
||||
|
||||
def test_allowed_extensions(self):
|
||||
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".flv" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".wmv" in ALLOWED_VIDEO_EXTENSIONS
|
||||
|
||||
def test_concat_demuxer_params(self):
|
||||
params = CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "codec_name" in params
|
||||
assert "width" in params
|
||||
assert "height" in params
|
||||
assert "r_frame_rate" in params
|
||||
assert "pix_fmt" in params
|
||||
assert "sample_rate" in params
|
||||
assert "channels" in params
|
||||
assert "audio_codec" in params
|
||||
assert len(params) == 8
|
||||
|
||||
|
||||
# ── ConcatSegment ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConcatSegmentDefaults:
|
||||
"""ConcatSegment 默认值"""
|
||||
|
||||
def test_required_path(self):
|
||||
s = ConcatSegment(video_path="/video.mp4")
|
||||
assert s.video_path == "/video.mp4"
|
||||
assert s.start_time == 0.0
|
||||
assert s.duration == 0.0
|
||||
assert s.has_audio is True
|
||||
|
||||
def test_all_custom(self):
|
||||
s = ConcatSegment(
|
||||
video_path="/clip.mp4",
|
||||
start_time=5.0,
|
||||
duration=10.0,
|
||||
has_audio=False,
|
||||
)
|
||||
assert s.video_path == "/clip.mp4"
|
||||
assert s.start_time == 5.0
|
||||
assert s.duration == 10.0
|
||||
assert s.has_audio is False
|
||||
|
||||
|
||||
class TestConcatSegmentFromDict:
|
||||
"""ConcatSegment.from_dict"""
|
||||
|
||||
def test_none_returns_empty_path(self):
|
||||
s = ConcatSegment.from_dict(None)
|
||||
assert s.video_path == ""
|
||||
assert s.is_valid is False
|
||||
|
||||
def test_empty_dict(self):
|
||||
s = ConcatSegment.from_dict({})
|
||||
assert s.video_path == ""
|
||||
|
||||
def test_not_dict(self):
|
||||
s = ConcatSegment.from_dict("not a dict")
|
||||
assert s.video_path == ""
|
||||
|
||||
def test_full_dict(self):
|
||||
s = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/clip.mp4",
|
||||
"start_time": 2.5,
|
||||
"duration": 15.0,
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert s.video_path == "/clip.mp4"
|
||||
assert s.start_time == 2.5
|
||||
assert s.duration == 15.0
|
||||
assert s.has_audio is False
|
||||
|
||||
def test_invalid_start_time_falls_back(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": "bad"})
|
||||
assert s.start_time == 0.0
|
||||
|
||||
def test_negative_start_time_clamped(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": -5.0})
|
||||
assert s.start_time == 0.0
|
||||
|
||||
def test_invalid_duration_falls_back(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": None})
|
||||
assert s.duration == 0.0
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": -10.0})
|
||||
assert s.duration == 0.0
|
||||
|
||||
def test_has_audio_default_true(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4"})
|
||||
assert s.has_audio is True
|
||||
|
||||
def test_has_audio_false(self):
|
||||
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "has_audio": False})
|
||||
assert s.has_audio is False
|
||||
|
||||
def test_path_is_string(self):
|
||||
s = ConcatSegment.from_dict({"video_path": 123})
|
||||
assert s.video_path == "123"
|
||||
|
||||
|
||||
class TestConcatSegmentProperties:
|
||||
"""ConcatSegment 属性方法"""
|
||||
|
||||
def test_is_valid_true(self):
|
||||
s = ConcatSegment(video_path="/a.mp4")
|
||||
assert s.is_valid is True
|
||||
|
||||
def test_is_valid_false_empty(self):
|
||||
s = ConcatSegment(video_path="")
|
||||
assert s.is_valid is False
|
||||
|
||||
def test_effective_duration_positive(self):
|
||||
s = ConcatSegment(video_path="/a.mp4", duration=10.0)
|
||||
assert s.effective_duration == 10.0
|
||||
|
||||
def test_effective_duration_zero(self):
|
||||
s = ConcatSegment(video_path="/a.mp4", duration=0.0)
|
||||
assert s.effective_duration == 0.0
|
||||
|
||||
def test_effective_duration_negative(self):
|
||||
s = ConcatSegment(video_path="/a.mp4", duration=-5.0)
|
||||
assert s.effective_duration == 0.0
|
||||
|
||||
|
||||
# ── ConcatConfig ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConcatConfigDefaults:
|
||||
"""ConcatConfig 默认值"""
|
||||
|
||||
def test_default_values(self):
|
||||
c = ConcatConfig()
|
||||
assert c.segments == []
|
||||
assert c.output_width == 0
|
||||
assert c.output_height == 0
|
||||
assert c.output_fps == 0.0
|
||||
assert c.force_reencode is False
|
||||
assert c.transition == "none"
|
||||
assert c.transition_duration == 0.3
|
||||
|
||||
|
||||
class TestConcatConfigFromDict:
|
||||
"""ConcatConfig.from_config_dict"""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
c = ConcatConfig.from_config_dict(None)
|
||||
assert c.segments == []
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
c = ConcatConfig.from_config_dict({})
|
||||
assert c.segments == []
|
||||
|
||||
def test_not_dict_returns_default(self):
|
||||
c = ConcatConfig.from_config_dict("config")
|
||||
assert c.segments == []
|
||||
|
||||
def test_single_segment(self):
|
||||
c = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4", "duration": 10.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.segments) == 1
|
||||
assert c.segments[0].video_path == "/a.mp4"
|
||||
|
||||
def test_multiple_segments(self):
|
||||
c = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4", "duration": 10.0},
|
||||
{"video_path": "/b.mp4", "duration": 20.0},
|
||||
{"video_path": "/c.mp4", "duration": 15.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.segments) == 3
|
||||
|
||||
def test_skip_no_path_segments(self):
|
||||
c = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"duration": 5.0}, # 没有 video_path
|
||||
{"video_path": "/b.mp4"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.segments) == 2
|
||||
|
||||
def test_skip_non_dict_segments(self):
|
||||
c = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
"not a dict",
|
||||
123,
|
||||
None,
|
||||
{"video_path": "/b.mp4"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(c.segments) == 2
|
||||
|
||||
def test_output_resolution(self):
|
||||
c = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
}
|
||||
)
|
||||
assert c.output_width == 1920
|
||||
assert c.output_height == 1080
|
||||
|
||||
def test_output_width_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"output_width": -100})
|
||||
assert c.output_width == 0
|
||||
|
||||
def test_invalid_output_width_falls_back(self):
|
||||
c = ConcatConfig.from_config_dict({"output_width": "wide"})
|
||||
assert c.output_width == 0
|
||||
|
||||
def test_output_fps(self):
|
||||
c = ConcatConfig.from_config_dict({"output_fps": 30.0})
|
||||
assert c.output_fps == 30.0
|
||||
|
||||
def test_output_fps_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"output_fps": -1.0})
|
||||
assert c.output_fps == 0.0
|
||||
|
||||
def test_invalid_output_fps_falls_back(self):
|
||||
c = ConcatConfig.from_config_dict({"output_fps": "fast"})
|
||||
assert c.output_fps == 0.0
|
||||
|
||||
def test_force_reencode_true(self):
|
||||
c = ConcatConfig.from_config_dict({"force_reencode": True})
|
||||
assert c.force_reencode is True
|
||||
|
||||
def test_transition_crossfade(self):
|
||||
c = ConcatConfig.from_config_dict({"transition": "crossfade"})
|
||||
assert c.transition == "crossfade"
|
||||
|
||||
def test_transition_duration(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 1.0})
|
||||
assert c.transition_duration == 1.0
|
||||
|
||||
def test_transition_duration_min_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 0.01})
|
||||
# max(0.1, 0.01) = 0.1
|
||||
assert c.transition_duration == 0.1
|
||||
# 代码里 transition_duration = max(0.1, ...),默认 0.3
|
||||
# 0.01 < 0.1 ,所以被钳制到 0.1
|
||||
|
||||
def test_invalid_transition_duration_falls_back(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": "long"})
|
||||
assert c.transition_duration == 0.3
|
||||
|
||||
def test_segments_not_list_ignored(self):
|
||||
c = ConcatConfig.from_config_dict({"segments": "not a list"})
|
||||
assert c.segments == []
|
||||
|
||||
|
||||
class TestConcatConfigProperties:
|
||||
"""ConcatConfig 属性方法"""
|
||||
|
||||
def _make_config(self, n=3):
|
||||
return ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": f"/s{i}.mp4", "duration": 10.0 + i} for i in range(n)],
|
||||
}
|
||||
)
|
||||
|
||||
def test_has_effect_true(self):
|
||||
c = self._make_config(3)
|
||||
assert c.has_effect is True
|
||||
|
||||
def test_has_effect_false_one_segment(self):
|
||||
c = self._make_config(1)
|
||||
assert c.has_effect is False
|
||||
|
||||
def test_has_effect_false_empty(self):
|
||||
c = ConcatConfig()
|
||||
assert c.has_effect is False
|
||||
|
||||
def test_valid_segment_count(self):
|
||||
c = self._make_config(5)
|
||||
assert c.valid_segment_count == 5
|
||||
|
||||
def test_total_segments_alias(self):
|
||||
c = self._make_config(4)
|
||||
assert c.total_segments == 4
|
||||
assert c.total_segments == c.valid_segment_count
|
||||
|
||||
def test_first_valid_segment(self):
|
||||
c = self._make_config(3)
|
||||
first = c.first_valid_segment
|
||||
assert first is not None
|
||||
assert first.video_path == "/s0.mp4"
|
||||
|
||||
def test_first_valid_segment_empty(self):
|
||||
c = ConcatConfig()
|
||||
assert c.first_valid_segment is None
|
||||
|
||||
def test_estimated_total_duration(self):
|
||||
c = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment("/a.mp4", duration=10.0),
|
||||
ConcatSegment("/b.mp4", duration=20.0),
|
||||
ConcatSegment("/c.mp4", duration=0.0), # 不计入
|
||||
]
|
||||
)
|
||||
assert c.estimated_total_duration == 30.0
|
||||
|
||||
def test_estimated_total_duration_empty(self):
|
||||
c = ConcatConfig()
|
||||
assert c.estimated_total_duration == 0.0
|
||||
|
||||
def test_clamp_segments_within_limit(self):
|
||||
c = self._make_config(10)
|
||||
original = len(c.segments)
|
||||
c.clamp_segments(max_segments=50)
|
||||
assert len(c.segments) == original
|
||||
|
||||
def test_clamp_segments_over_limit(self):
|
||||
c = self._make_config(10)
|
||||
c.clamp_segments(max_segments=3)
|
||||
assert len(c.segments) == 3
|
||||
assert c.segments[0].video_path == "/s0.mp4"
|
||||
assert c.segments[2].video_path == "/s2.mp4"
|
||||
|
||||
def test_clamp_segments_default_max(self):
|
||||
# 默认应该是 MAX_CONCAT_SEGMENTS
|
||||
c = ConcatConfig(segments=[ConcatSegment(f"/s{i}.mp4") for i in range(100)])
|
||||
c.clamp_segments()
|
||||
assert len(c.segments) == MAX_CONCAT_SEGMENTS
|
||||
|
||||
|
||||
class TestTransitionDurationClamp:
|
||||
"""transition_duration 钳制边界"""
|
||||
|
||||
def test_min_boundary_01(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 0.1})
|
||||
assert c.transition_duration == 0.1
|
||||
|
||||
def test_below_min_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 0.05})
|
||||
# max(0.1, 0.05) = 0.1
|
||||
assert c.transition_duration == 0.1
|
||||
|
||||
def test_large_duration_ok(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 5.0})
|
||||
assert c.transition_duration == 5.0
|
||||
|
||||
def test_zero_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": 0.0})
|
||||
# max(0.1, 0.0) = 0.1
|
||||
assert c.transition_duration == 0.1
|
||||
|
||||
def test_negative_clamped(self):
|
||||
c = ConcatConfig.from_config_dict({"transition_duration": -1.0})
|
||||
# max(0.1, -1.0) = 0.1
|
||||
assert c.transition_duration == 0.1
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
"""视频分享领域模型单元测试 — wave215"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.video_share import (
|
||||
VideoShare,
|
||||
_hash_password,
|
||||
generate_share_token,
|
||||
)
|
||||
|
||||
# ── 密码哈希 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHashPassword:
|
||||
def test_empty_password_returns_empty(self):
|
||||
assert _hash_password("") == ""
|
||||
|
||||
def test_same_password_same_hash(self):
|
||||
h1 = _hash_password("secret123")
|
||||
h2 = _hash_password("secret123")
|
||||
assert h1 == h2
|
||||
assert h1 != ""
|
||||
|
||||
def test_different_password_different_hash(self):
|
||||
h1 = _hash_password("pass1")
|
||||
h2 = _hash_password("pass2")
|
||||
assert h1 != h2
|
||||
|
||||
def test_hash_is_sha256_hex(self):
|
||||
h = _hash_password("test")
|
||||
assert len(h) == 64
|
||||
assert re.match(r"^[0-9a-f]{64}$", h)
|
||||
|
||||
def test_hash_contains_salt(self):
|
||||
# 直接SHA-256("test") vs 加盐后的结果应该不同
|
||||
import hashlib
|
||||
|
||||
direct = hashlib.sha256(b"test").hexdigest()
|
||||
salted = _hash_password("test")
|
||||
assert direct != salted
|
||||
|
||||
|
||||
# ── Token 生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateShareToken:
|
||||
def test_default_length_12(self):
|
||||
token = generate_share_token()
|
||||
assert len(token) == 12
|
||||
|
||||
def test_custom_length(self):
|
||||
token = generate_share_token(20)
|
||||
assert len(token) == 20
|
||||
|
||||
def test_url_friendly_no_ambiguous_chars(self):
|
||||
# 不应包含容易混淆的字符:i, l, o, I, L, O, 0, 1
|
||||
token = generate_share_token(100)
|
||||
for ch in "ilO01":
|
||||
assert ch not in token
|
||||
|
||||
def test_alphanumeric_only(self):
|
||||
token = generate_share_token(50)
|
||||
assert token.isalnum()
|
||||
|
||||
def test_two_tokens_different(self):
|
||||
# 随机生成的两个token应该不同
|
||||
t1 = generate_share_token()
|
||||
t2 = generate_share_token()
|
||||
assert t1 != t2
|
||||
|
||||
|
||||
# ── VideoShare.create ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareCreate:
|
||||
def test_basic_create(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.id is not None
|
||||
assert share.video_id == "v1"
|
||||
assert share.user_id == "u1"
|
||||
assert share.share_token is not None
|
||||
assert len(share.share_token) == 12
|
||||
assert share.password_hash is None
|
||||
assert share.expires_at is None
|
||||
assert share.view_count == 0
|
||||
assert share.download_count == 0
|
||||
assert share.is_active is True
|
||||
assert share.created_at is not None
|
||||
assert share.updated_at is not None
|
||||
|
||||
def test_create_with_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret")
|
||||
assert share.password_hash is not None
|
||||
assert share.password_hash != "secret"
|
||||
assert len(share.password_hash) == 64
|
||||
|
||||
def test_create_with_empty_password_no_hash(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="")
|
||||
assert share.password_hash is None
|
||||
|
||||
def test_create_with_expires_at(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
||||
assert share.expires_at == future
|
||||
|
||||
def test_create_past_expires_at_raises(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
with pytest.raises(ValueError, match="expires_at cannot be in the past"):
|
||||
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
|
||||
|
||||
def test_create_empty_video_id_raises(self):
|
||||
with pytest.raises(ValueError, match="video_id cannot be empty"):
|
||||
VideoShare.create(video_id="", user_id="u1")
|
||||
|
||||
def test_create_whitespace_video_id_raises(self):
|
||||
with pytest.raises(ValueError, match="video_id cannot be empty"):
|
||||
VideoShare.create(video_id=" ", user_id="u1")
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
VideoShare.create(video_id="v1", user_id="")
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
share = VideoShare.create(video_id=" v1 ", user_id=" u1 ")
|
||||
assert share.video_id == "v1"
|
||||
assert share.user_id == "u1"
|
||||
|
||||
def test_create_unique_id_each_time(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_create_unique_token_each_time(self):
|
||||
s1 = VideoShare.create(video_id="v1", user_id="u1")
|
||||
s2 = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert s1.share_token != s2.share_token
|
||||
|
||||
|
||||
# ── has_password ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareHasPassword:
|
||||
def test_no_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.has_password is False
|
||||
|
||||
def test_with_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="pass")
|
||||
assert share.has_password is True
|
||||
|
||||
def test_empty_password_none(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="")
|
||||
assert share.has_password is False
|
||||
|
||||
|
||||
# ── is_expired ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareIsExpired:
|
||||
def test_no_expiry_never_expired(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_future_expiry_not_expired(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_past_expiry_is_expired(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 True
|
||||
|
||||
|
||||
# ── is_accessible ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareIsAccessible:
|
||||
def test_active_no_expiry_accessible(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_accessible is True
|
||||
|
||||
def test_revoked_not_accessible(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.is_active = False
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_expired_not_accessible(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_revoked_and_expired_not_accessible(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.is_active = False
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert share.is_accessible is False
|
||||
|
||||
|
||||
# ── verify_password ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareVerifyPassword:
|
||||
def test_no_password_any_pass_ok(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.verify_password("anything") is True
|
||||
assert share.verify_password("") is True
|
||||
|
||||
def test_no_password_none_ok(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.verify_password("") is True
|
||||
|
||||
def test_correct_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
||||
assert share.verify_password("mysecret") is True
|
||||
|
||||
def test_wrong_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
||||
assert share.verify_password("wrong") is False
|
||||
|
||||
def test_empty_password_with_protection(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
||||
assert share.verify_password("") is False
|
||||
|
||||
def test_password_case_sensitive(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="Secret")
|
||||
assert share.verify_password("secret") is False
|
||||
assert share.verify_password("Secret") is True
|
||||
|
||||
|
||||
# ── 计数方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareCounters:
|
||||
def test_increment_view(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.view_count == 0
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 1
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 2
|
||||
|
||||
def test_increment_download(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.download_count == 0
|
||||
share.increment_download_count()
|
||||
assert share.download_count == 1
|
||||
share.increment_download_count()
|
||||
assert share.download_count == 2
|
||||
|
||||
def test_counters_independent(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.increment_view_count()
|
||||
share.increment_view_count()
|
||||
share.increment_download_count()
|
||||
assert share.view_count == 2
|
||||
assert share.download_count == 1
|
||||
|
||||
|
||||
# ── revoke ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoShareRevoke:
|
||||
def test_revoke_sets_inactive(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_active is True
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
|
||||
def test_revoke_makes_inaccessible(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.revoke()
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_revoke_idempotent(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.revoke()
|
||||
share.revoke() # 第二次也不报错
|
||||
assert share.is_active is False
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
"""voice_presets 配音音色预设模块单测."""
|
||||
|
||||
import pytest
|
||||
from domain.voice_presets import (
|
||||
MOCK_VOICES,
|
||||
VoiceGender,
|
||||
VoicePreset,
|
||||
VoiceStyle,
|
||||
get_default_voice,
|
||||
get_voice,
|
||||
list_voices,
|
||||
)
|
||||
|
||||
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoiceGender:
|
||||
"""VoiceGender 音色性别枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert VoiceGender.MALE.value == "male"
|
||||
assert VoiceGender.FEMALE.value == "female"
|
||||
assert VoiceGender.CHILD.value == "child"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(VoiceGender.MALE, str)
|
||||
assert VoiceGender.FEMALE == "female"
|
||||
|
||||
def test_from_string(self):
|
||||
assert VoiceGender("male") == VoiceGender.MALE
|
||||
assert VoiceGender("child") == VoiceGender.CHILD
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VoiceGender("unknown")
|
||||
|
||||
|
||||
class TestVoiceStyle:
|
||||
"""VoiceStyle 音色风格枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert VoiceStyle.STABLE.value == "stable"
|
||||
assert VoiceStyle.LIVELY.value == "lively"
|
||||
assert VoiceStyle.CUSTOMER_SERVICE.value == "customer_service"
|
||||
assert VoiceStyle.NARRATION.value == "narration"
|
||||
assert VoiceStyle.NEWS.value == "news"
|
||||
assert VoiceStyle.STORY.value == "story"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(VoiceStyle.NARRATION, str)
|
||||
assert VoiceStyle.STORY == "story"
|
||||
|
||||
def test_from_string(self):
|
||||
assert VoiceStyle("news") == VoiceStyle.NEWS
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
VoiceStyle("rock")
|
||||
|
||||
|
||||
# ── VoicePreset dataclass ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoicePreset:
|
||||
"""VoicePreset 音色预设 dataclass"""
|
||||
|
||||
def test_minimal_creation(self):
|
||||
v = VoicePreset(voice_id="test_voice", name="测试音色")
|
||||
assert v.voice_id == "test_voice"
|
||||
assert v.name == "测试音色"
|
||||
# 默认值
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.NARRATION
|
||||
assert v.description == ""
|
||||
assert v.provider == "mock"
|
||||
assert v.provider_voice_id == ""
|
||||
assert v.default_speed == 1.0
|
||||
assert v.default_pitch == 0.0
|
||||
assert v.sample_rate == 22050
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_full_creation(self):
|
||||
v = VoicePreset(
|
||||
voice_id="male_deep",
|
||||
name="深沉男声",
|
||||
gender=VoiceGender.MALE,
|
||||
style=VoiceStyle.STABLE,
|
||||
description="非常深沉的男声",
|
||||
provider="aliyun",
|
||||
provider_voice_id="zhiyuan",
|
||||
default_speed=0.8,
|
||||
default_pitch=-1.0,
|
||||
sample_rate=16000,
|
||||
language="zh-CN",
|
||||
)
|
||||
assert v.voice_id == "male_deep"
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
assert v.provider == "aliyun"
|
||||
assert v.default_speed == 0.8
|
||||
assert v.sample_rate == 16000
|
||||
|
||||
def test_str_gender_creation(self):
|
||||
# 用字符串值创建也可以(因为是 StrEnum)
|
||||
v = VoicePreset(voice_id="v1", name="V1", gender="male")
|
||||
assert v.gender == VoiceGender.MALE
|
||||
|
||||
def test_str_style_creation(self):
|
||||
v = VoicePreset(voice_id="v1", name="V1", style="news")
|
||||
assert v.style == VoiceStyle.NEWS
|
||||
|
||||
def test_equality(self):
|
||||
v1 = VoicePreset(voice_id="same", name="同名")
|
||||
v2 = VoicePreset(voice_id="same", name="同名")
|
||||
assert v1 == v2
|
||||
|
||||
def test_inequality(self):
|
||||
v1 = VoicePreset(voice_id="a", name="A")
|
||||
v2 = VoicePreset(voice_id="b", name="B")
|
||||
assert v1 != v2
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
v = VoicePreset(voice_id="test", name="Test")
|
||||
with pytest.raises(AttributeError):
|
||||
v.nonexistent_field = "value"
|
||||
|
||||
|
||||
# ── MOCK_VOICES 列表 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMockVoices:
|
||||
"""Mock 音色预设列表"""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(MOCK_VOICES) > 0
|
||||
|
||||
def test_count(self):
|
||||
assert len(MOCK_VOICES) == 8
|
||||
|
||||
def test_all_are_voice_preset(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert isinstance(v, VoicePreset)
|
||||
|
||||
def test_unique_voice_ids(self):
|
||||
ids = [v.voice_id for v in MOCK_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_female_warm_preset(self):
|
||||
v = next(v for v in MOCK_VOICES if v.voice_id == "female_warm")
|
||||
assert v.name == "温暖女声"
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.NARRATION
|
||||
assert v.default_speed == 1.0
|
||||
assert "温柔" in v.description
|
||||
|
||||
def test_male_stable_preset(self):
|
||||
v = next(v for v in MOCK_VOICES if v.voice_id == "male_stable")
|
||||
assert v.name == "沉稳男声"
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
assert v.default_speed == 0.9
|
||||
|
||||
def test_female_lively_preset(self):
|
||||
v = next(v for v in MOCK_VOICES if v.voice_id == "female_lively")
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.LIVELY
|
||||
assert v.default_speed == 1.2
|
||||
assert v.default_pitch == 2.0
|
||||
|
||||
def test_child_cute_preset(self):
|
||||
v = next(v for v in MOCK_VOICES if v.voice_id == "child_cute")
|
||||
assert v.gender == VoiceGender.CHILD
|
||||
assert v.style == VoiceStyle.STORY
|
||||
assert v.default_pitch == 4.0
|
||||
|
||||
def test_all_mock_provider(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.provider == "mock"
|
||||
|
||||
def test_all_have_provider_voice_id(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.provider_voice_id != ""
|
||||
|
||||
def test_all_chinese(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
|
||||
# ── get_voice ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetVoice:
|
||||
"""get_voice 函数"""
|
||||
|
||||
def test_get_existing_voice(self):
|
||||
v = get_voice("female_warm")
|
||||
assert v is not None
|
||||
assert v.voice_id == "female_warm"
|
||||
assert v.name == "温暖女声"
|
||||
|
||||
def test_get_male_stable(self):
|
||||
v = get_voice("male_stable")
|
||||
assert v is not None
|
||||
assert v.gender == VoiceGender.MALE
|
||||
|
||||
def test_get_child_cute(self):
|
||||
v = get_voice("child_cute")
|
||||
assert v is not None
|
||||
assert v.gender == VoiceGender.CHILD
|
||||
|
||||
def test_get_nonexistent_returns_none(self):
|
||||
v = get_voice("nonexistent_voice")
|
||||
assert v is None
|
||||
|
||||
def test_get_empty_string_returns_none(self):
|
||||
v = get_voice("")
|
||||
assert v is None
|
||||
|
||||
def test_non_mock_provider_returns_none(self):
|
||||
v = get_voice("female_warm", provider="aliyun")
|
||||
assert v is None
|
||||
|
||||
def test_non_mock_provider_nonexistent(self):
|
||||
v = get_voice("whatever", provider="xunfei")
|
||||
assert v is None
|
||||
|
||||
def test_mock_provider_explicit(self):
|
||||
v = get_voice("female_warm", provider="mock")
|
||||
assert v is not None
|
||||
assert v.voice_id == "female_warm"
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
# 应该返回同一个对象(缓存的)
|
||||
v1 = get_voice("female_warm")
|
||||
v2 = get_voice("female_warm")
|
||||
assert v1 is v2
|
||||
|
||||
|
||||
# ── list_voices ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListVoices:
|
||||
"""list_voices 函数"""
|
||||
|
||||
def test_no_filters_returns_all(self):
|
||||
result = list_voices()
|
||||
assert len(result) == len(MOCK_VOICES)
|
||||
assert len(result) == 8
|
||||
|
||||
def test_filter_by_gender_male(self):
|
||||
result = list_voices(gender="male")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.MALE
|
||||
|
||||
def test_filter_by_gender_female(self):
|
||||
result = list_voices(gender="female")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
|
||||
def test_filter_by_gender_child(self):
|
||||
result = list_voices(gender="child")
|
||||
assert len(result) == 1
|
||||
assert result[0].voice_id == "child_cute"
|
||||
|
||||
def test_filter_by_gender_invalid_returns_empty(self):
|
||||
result = list_voices(gender="alien")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_filter_by_style_stable(self):
|
||||
result = list_voices(style="stable")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
|
||||
def test_filter_by_style_lively(self):
|
||||
result = list_voices(style="lively")
|
||||
assert len(result) == 1
|
||||
assert result[0].voice_id == "female_lively"
|
||||
|
||||
def test_filter_by_style_story(self):
|
||||
result = list_voices(style="story")
|
||||
assert len(result) >= 2
|
||||
for v in result:
|
||||
assert v.style == VoiceStyle.STORY
|
||||
|
||||
def test_filter_by_style_invalid_returns_empty(self):
|
||||
result = list_voices(style="punk")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_filter_by_provider_mock(self):
|
||||
result = list_voices(provider="mock")
|
||||
assert len(result) == len(MOCK_VOICES)
|
||||
|
||||
def test_filter_by_provider_other_returns_empty(self):
|
||||
result = list_voices(provider="aliyun")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
result = list_voices(keyword="女声")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert "女声" in v.name or "女声" in v.description or "女声" in v.voice_id
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
result = list_voices(keyword="商务")
|
||||
assert len(result) > 0
|
||||
# 沉稳男声描述里有"商务"
|
||||
|
||||
def test_filter_by_keyword_voice_id(self):
|
||||
result = list_voices(keyword="male_stable")
|
||||
assert len(result) == 1
|
||||
assert result[0].voice_id == "male_stable"
|
||||
|
||||
def test_filter_by_keyword_case_insensitive(self):
|
||||
result1 = list_voices(keyword="Female")
|
||||
result2 = list_voices(keyword="female")
|
||||
assert len(result1) == len(result2)
|
||||
|
||||
def test_filter_by_keyword_nonexistent(self):
|
||||
result = list_voices(keyword="不存在的关键词999")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_combined_gender_and_style(self):
|
||||
result = list_voices(gender="female", style="lively")
|
||||
assert len(result) == 1
|
||||
assert result[0].voice_id == "female_lively"
|
||||
|
||||
def test_combined_gender_style_keyword(self):
|
||||
result = list_voices(gender="male", style="story", keyword="磁性")
|
||||
assert len(result) == 1
|
||||
assert result[0].voice_id == "male_magnetic"
|
||||
|
||||
def test_combined_no_match(self):
|
||||
result = list_voices(gender="child", style="news")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_returns_new_list(self):
|
||||
# 修改返回值不应影响原始列表
|
||||
result = list_voices()
|
||||
result.clear()
|
||||
assert len(MOCK_VOICES) == 8
|
||||
|
||||
|
||||
# ── get_default_voice ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetDefaultVoice:
|
||||
"""get_default_voice 函数"""
|
||||
|
||||
def test_returns_voice_preset(self):
|
||||
v = get_default_voice()
|
||||
assert isinstance(v, VoicePreset)
|
||||
|
||||
def test_returns_first_mock_voice(self):
|
||||
v = get_default_voice()
|
||||
assert v == MOCK_VOICES[0]
|
||||
|
||||
def test_default_is_female_warm(self):
|
||||
v = get_default_voice()
|
||||
assert v.voice_id == "female_warm"
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
|
||||
def test_multiple_calls_same(self):
|
||||
v1 = get_default_voice()
|
||||
v2 = get_default_voice()
|
||||
assert v1 is v2
|
||||
+292
-466
@@ -1,9 +1,6 @@
|
||||
"""BGM 混音纯逻辑单元测试."""
|
||||
"""bgm_mixer_pure 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.bgm_mixer_pure import (
|
||||
from apps.worker.video_processing.bgm_mixer_pure import (
|
||||
BGMPureConfig,
|
||||
build_bgm_filter_chain,
|
||||
build_sidechain_mix_filter,
|
||||
@@ -17,408 +14,286 @@ from video_processing.bgm_mixer_pure import (
|
||||
validate_bgm_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# should_loop_bgm 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── BGMPureConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShouldLoopBGM:
|
||||
"""BGM 循环判断测试."""
|
||||
class TestBGMPureConfig:
|
||||
def test_default_values(self):
|
||||
cfg = BGMPureConfig()
|
||||
assert cfg.volume == 0.3
|
||||
assert cfg.fade_in == 0.0
|
||||
assert cfg.fade_out == 0.0
|
||||
assert cfg.loop_enabled is True
|
||||
assert cfg.sidechain_enabled is False
|
||||
assert cfg.sidechain_ratio == 0.3
|
||||
assert cfg.sidechain_attack == 0.02
|
||||
assert cfg.sidechain_release == 0.5
|
||||
assert cfg.sidechain_threshold == -25.0
|
||||
|
||||
def test_need_loop_when_much_shorter(self):
|
||||
"""BGM 远短于目标时长,需要循环."""
|
||||
assert should_loop_bgm(10, 100, True) is True
|
||||
def test_custom_values(self):
|
||||
cfg = BGMPureConfig(
|
||||
volume=0.5,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=False,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
)
|
||||
assert cfg.volume == 0.5
|
||||
assert cfg.loop_enabled is False
|
||||
assert cfg.sidechain_enabled is True
|
||||
assert cfg.sidechain_ratio == 0.5
|
||||
|
||||
def test_no_loop_when_long_enough(self):
|
||||
"""BGM 够长,不需要循环."""
|
||||
assert should_loop_bgm(100, 100, True) is False
|
||||
|
||||
def test_no_loop_when_just_slightly_shorter(self):
|
||||
"""BGM 只差一点点(>90%),不循环."""
|
||||
assert should_loop_bgm(95, 100, True) is False
|
||||
# ── should_loop_bgm ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_threshold_90_percent(self):
|
||||
"""刚好 90% 阈值,不循环(<90% 才循环)."""
|
||||
assert should_loop_bgm(90, 100, True) is False
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""略低于 90%,需要循环."""
|
||||
assert should_loop_bgm(89, 100, True) is True
|
||||
class TestShouldLoopBgm:
|
||||
def test_loop_enabled_much_shorter(self):
|
||||
# BGM 10秒,目标60秒 → 需要循环
|
||||
assert should_loop_bgm(10, 60) is True
|
||||
|
||||
def test_loop_disabled(self):
|
||||
"""禁用循环,即使 BGM 很短也不循环."""
|
||||
assert should_loop_bgm(10, 100, False) is False
|
||||
assert should_loop_bgm(10, 60, loop_enabled=False) is False
|
||||
|
||||
def test_bgm_longer_than_target(self):
|
||||
# BGM 100秒,目标60秒 → 不需要循环
|
||||
assert should_loop_bgm(100, 60) is False
|
||||
|
||||
def test_bgm_slightly_shorter_no_loop(self):
|
||||
# BGM 58秒,目标60秒 → 58 > 60*0.9=54,不需要循环
|
||||
assert should_loop_bgm(58, 60) is False
|
||||
|
||||
def test_bgm_significantly_shorter_loops(self):
|
||||
# BGM 50秒,目标60秒 → 50 < 54,需要循环
|
||||
assert should_loop_bgm(50, 60) is True
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,不循环."""
|
||||
assert should_loop_bgm(0, 100, True) is False
|
||||
assert should_loop_bgm(0, 60) is False
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,不循环."""
|
||||
assert should_loop_bgm(-5, 100, True) is False
|
||||
assert should_loop_bgm(-1, 60) is False
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,不循环."""
|
||||
assert should_loop_bgm(10, 0, True) is False
|
||||
assert should_loop_bgm(10, 0) is False
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,不循环."""
|
||||
assert should_loop_bgm(10, -10, True) is False
|
||||
assert should_loop_bgm(10, -1) is False
|
||||
|
||||
def test_exact_90_percent_no_loop(self):
|
||||
# 边界:bgm == target * 0.9 → 不小于,不循环
|
||||
assert should_loop_bgm(54, 60) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_loop_count 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── calculate_loop_count ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateLoopCount:
|
||||
"""循环次数计算测试."""
|
||||
def test_exact_fit_returns_1(self):
|
||||
assert calculate_loop_count(60, 60) == 1
|
||||
|
||||
def test_exact_multiple(self):
|
||||
"""刚好整数倍."""
|
||||
# 100/10 = 10, +2 = 12
|
||||
assert calculate_loop_count(10, 100) == 12
|
||||
def test_bgm_longer_returns_1(self):
|
||||
assert calculate_loop_count(100, 60) == 1
|
||||
|
||||
def test_not_exact_multiple(self):
|
||||
"""不是整数倍."""
|
||||
# 100/30 = 3, +2 = 5
|
||||
assert calculate_loop_count(30, 100) == 5
|
||||
def test_needs_3_loops_plus_2_margin(self):
|
||||
# 60/20 = 3 + 2 = 5
|
||||
assert calculate_loop_count(20, 60) == 5
|
||||
|
||||
def test_bgm_longer_than_target(self):
|
||||
"""BGM 比目标长,至少 1 次."""
|
||||
assert calculate_loop_count(200, 100) == 1
|
||||
def test_needs_2_loops_plus_2_margin(self):
|
||||
# 60/30 = 2 + 2 = 4
|
||||
assert calculate_loop_count(30, 60) == 4
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,返回 1."""
|
||||
assert calculate_loop_count(0, 100) == 1
|
||||
assert calculate_loop_count(0, 60) == 1
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,返回 1."""
|
||||
assert calculate_loop_count(-5, 100) == 1
|
||||
assert calculate_loop_count(-1, 60) == 1
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,返回 1."""
|
||||
assert calculate_loop_count(10, 0) == 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,返回 1."""
|
||||
assert calculate_loop_count(10, -10) == 1
|
||||
assert calculate_loop_count(10, -1) == 1
|
||||
|
||||
def test_very_short_bgm(self):
|
||||
"""非常短的 BGM,循环次数多."""
|
||||
# 100/1 = 100, +2 = 102
|
||||
assert calculate_loop_count(1, 100) == 102
|
||||
def test_minimum_is_1(self):
|
||||
assert calculate_loop_count(10, 5) == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_bgm_filter_chain 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_bgm_filter_chain ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBGMFilterChain:
|
||||
"""BGM 预处理滤镜链构建测试."""
|
||||
class TestBuildBgmFilterChain:
|
||||
def test_basic_structure(self):
|
||||
result = build_bgm_filter_chain(100, 60)
|
||||
parts = result.split(",")
|
||||
# 至少有 atrim + asetpts
|
||||
assert any("atrim=" in p for p in parts)
|
||||
assert "asetpts=N/SR/TB" in parts
|
||||
|
||||
def test_basic_volume_only(self):
|
||||
"""只有音量调节."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.5,
|
||||
)
|
||||
def test_volume_filter_applied(self):
|
||||
result = build_bgm_filter_chain(100, 60, volume=0.5)
|
||||
assert "volume=0.500" in result
|
||||
assert "aloop" not in result
|
||||
assert "afade=t=in" not in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "atrim=0:100.000" in result
|
||||
assert "asetpts=N/SR/TB" in result
|
||||
|
||||
def test_with_loop(self):
|
||||
"""需要循环的情况."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=True,
|
||||
)
|
||||
assert "aloop=loop=" in result
|
||||
assert "volume=0.300" in result
|
||||
def test_volume_one_omitted(self):
|
||||
result = build_bgm_filter_chain(100, 60, volume=1.0)
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_no_loop_when_disabled(self):
|
||||
"""禁用循环,即使 BGM 短也不循环."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=False,
|
||||
)
|
||||
assert "aloop" not in result
|
||||
def test_volume_clamped(self):
|
||||
# volume=2.0钳制到1.0,1.0等于默认值所以被跳过
|
||||
result = build_bgm_filter_chain(100, 60, volume=2.0)
|
||||
assert "volume=" not in result # 钳制到1.0后与默认相同,跳过
|
||||
# 用0.5验证音量过滤器本身存在
|
||||
result2 = build_bgm_filter_chain(100, 60, volume=0.5)
|
||||
assert "volume=0.500" in result2
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""只有淡入."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=2.5,
|
||||
)
|
||||
assert "afade=t=in:st=0:d=2.500" in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "volume=" not in result # volume=1.0 不加
|
||||
def test_volume_zero(self):
|
||||
result = build_bgm_filter_chain(100, 60, volume=0.0)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""只有淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_out=3.0,
|
||||
)
|
||||
assert "afade=t=out:st=97.000:d=3.000" in result
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=1.5,
|
||||
fade_out=2.0,
|
||||
)
|
||||
def test_fade_in_applied(self):
|
||||
result = build_bgm_filter_chain(100, 60, fade_in=1.5)
|
||||
assert "afade=t=in:st=0:d=1.500" in result
|
||||
assert "afade=t=out:st=98.000:d=2.000" in result
|
||||
|
||||
def test_volume_1_0_skipped(self):
|
||||
"""音量为 1.0 时不添加 volume 滤镜."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
)
|
||||
assert "volume=" not in result
|
||||
def test_fade_in_zero_skipped(self):
|
||||
result = build_bgm_filter_chain(100, 60, fade_in=0)
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_volume_0(self):
|
||||
"""音量为 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.0,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
def test_fade_out_applied(self):
|
||||
result = build_bgm_filter_chain(100, 60, fade_out=2.0)
|
||||
assert "afade=t=out:st=58.000:d=2.000" in result
|
||||
|
||||
def test_volume_clamped_high(self):
|
||||
"""音量超过 1.0 被钳制."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.5,
|
||||
)
|
||||
assert "volume=1.000" not in result # 1.0不加
|
||||
# 钳制到1.0后和1.0一样,不加volume滤镜
|
||||
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_volume_clamped_low(self):
|
||||
"""音量为负被钳制到 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=-0.5,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出时长超过总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=20.0,
|
||||
)
|
||||
def test_fade_out_longer_than_target_skipped(self):
|
||||
result = build_bgm_filter_chain(100, 10, fade_out=20)
|
||||
# fade_out >= safe_target,不做淡出
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出时长等于总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=10.0,
|
||||
)
|
||||
assert "afade=t=out" not in result
|
||||
def test_loop_applied_when_needed(self):
|
||||
result = build_bgm_filter_chain(10, 60)
|
||||
assert "aloop=loop=" in result
|
||||
|
||||
def test_zero_target_duration_fallback(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=0,
|
||||
volume=0.5,
|
||||
)
|
||||
def test_no_loop_when_bgm_long(self):
|
||||
result = build_bgm_filter_chain(100, 60)
|
||||
assert "aloop=" not in result
|
||||
|
||||
def test_loop_disabled(self):
|
||||
result = build_bgm_filter_chain(10, 60, loop_enabled=False)
|
||||
assert "aloop=" not in result
|
||||
|
||||
def test_trim_to_target_duration(self):
|
||||
result = build_bgm_filter_chain(100, 60)
|
||||
assert "atrim=0:60.000" in result
|
||||
|
||||
def test_zero_target_uses_fallback(self):
|
||||
result = build_bgm_filter_chain(100, 0)
|
||||
# 兜底5秒
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_negative_target_duration_fallback(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=-5,
|
||||
volume=0.5,
|
||||
)
|
||||
def test_negative_target_uses_fallback(self):
|
||||
result = build_bgm_filter_chain(100, -5)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_full_chain_with_all_effects(self):
|
||||
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
|
||||
def test_all_features_combined(self):
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.4,
|
||||
bgm_duration=15,
|
||||
target_duration=60,
|
||||
volume=0.3,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=True,
|
||||
)
|
||||
parts = result.split(",")
|
||||
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
|
||||
assert len(parts) >= 6
|
||||
assert "aloop" in parts[0]
|
||||
assert "volume" in parts[1]
|
||||
assert "afade=t=in" in parts[2]
|
||||
assert "afade=t=out" in parts[3]
|
||||
assert "atrim" in parts[4]
|
||||
assert "asetpts" in parts[5]
|
||||
assert "aloop=loop=" in result
|
||||
assert "volume=0.300" in result
|
||||
assert "afade=t=in" in result
|
||||
assert "afade=t=out" in result
|
||||
assert "atrim=0:60.000" in result
|
||||
assert "asetpts=N/SR/TB" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_sidechain_ratio 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── calculate_sidechain_ratio ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateSidechainRatio:
|
||||
"""Sidechain 压缩比计算测试."""
|
||||
def test_zero_ratio_minimum(self):
|
||||
assert calculate_sidechain_ratio(0) == 2.0
|
||||
|
||||
def test_default_ratio_0_3(self):
|
||||
"""默认 0.3."""
|
||||
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
|
||||
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_5(self):
|
||||
"""比例 0.5."""
|
||||
# 1 / (1 - 0.5) = 2.0
|
||||
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_8(self):
|
||||
"""比例 0.8."""
|
||||
# 1 / (1 - 0.8) = 5.0
|
||||
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_9(self):
|
||||
"""比例 0.9."""
|
||||
# 1 / (1 - 0.9) = 10.0
|
||||
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
|
||||
|
||||
def test_ratio_0(self):
|
||||
"""比例 0,返回下限 2.0."""
|
||||
assert calculate_sidechain_ratio(0.0) == 2.0
|
||||
|
||||
def test_ratio_negative(self):
|
||||
"""比例为负,返回下限 2.0."""
|
||||
def test_negative_clamped(self):
|
||||
assert calculate_sidechain_ratio(-0.5) == 2.0
|
||||
|
||||
def test_ratio_1_0(self):
|
||||
"""比例 1.0,返回上限 10.0."""
|
||||
def test_one_ratio_maximum(self):
|
||||
assert calculate_sidechain_ratio(1.0) == 10.0
|
||||
|
||||
def test_ratio_greater_than_1(self):
|
||||
"""比例超过 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(2.0) == 10.0
|
||||
def test_above_one_clamped(self):
|
||||
assert calculate_sidechain_ratio(1.5) == 10.0
|
||||
|
||||
def test_mid_value(self):
|
||||
# ratio = 1/(1-0.5) = 2.0
|
||||
result = calculate_sidechain_ratio(0.5)
|
||||
assert abs(result - 2.0) < 0.01
|
||||
|
||||
def test_high_value(self):
|
||||
# 1/(1-0.9) = 10 → 钳制到10
|
||||
assert calculate_sidechain_ratio(0.9) == 10.0
|
||||
|
||||
def test_03_default(self):
|
||||
# 1/(1-0.3) = 1.428... → 钳制到2.0
|
||||
result = calculate_sidechain_ratio(0.3)
|
||||
assert result >= 2.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_simple_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_simple_mix_filter ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSimpleMixFilter:
|
||||
"""普通混音滤镜构建测试."""
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix."""
|
||||
def test_contains_inputs_and_output(self):
|
||||
result = build_simple_mix_filter()
|
||||
assert "[0:a][1:a]" in result
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_contains_volume_compensation(self):
|
||||
"""包含 volume=2 补偿."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
assert "[final]" in result
|
||||
assert "volume=2" in result
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签为 [final]."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_duration_first(self):
|
||||
"""duration=first,以主音频时长为准."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_sidechain_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_sidechain_mix_filter ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSidechainMixFilter:
|
||||
"""Sidechain 混音滤镜构建测试."""
|
||||
|
||||
def test_contains_sidechaincompress(self):
|
||||
"""包含 sidechaincompress."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "sidechaincompress=" in result
|
||||
assert "[1:a][0:a]sidechaincompress" in result
|
||||
|
||||
def test_threshold_param(self):
|
||||
"""threshold 参数正确."""
|
||||
def test_threshold_in_db(self):
|
||||
result = build_sidechain_mix_filter(threshold=-30.0)
|
||||
assert "threshold=-30.0dB" in result
|
||||
|
||||
def test_attack_param(self):
|
||||
"""attack 参数正确."""
|
||||
result = build_sidechain_mix_filter(attack=0.05)
|
||||
assert "attack=0.050" in result
|
||||
|
||||
def test_release_param(self):
|
||||
"""release 参数正确."""
|
||||
result = build_sidechain_mix_filter(release=0.8)
|
||||
assert "release=0.800" in result
|
||||
|
||||
def test_knee_param(self):
|
||||
"""knee=6 参数."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "knee=6" in result
|
||||
def test_attack_and_release(self):
|
||||
result = build_sidechain_mix_filter(attack=0.01, release=0.3)
|
||||
assert "attack=0.010" in result
|
||||
assert "release=0.300" in result
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix 混音."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
assert "duration=first" in result
|
||||
|
||||
def test_volume_compensation(self):
|
||||
"""volume=1.5 轻微补偿."""
|
||||
def test_contains_volume_compensation(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "volume=1.5" in result
|
||||
|
||||
def test_bgmc_comp_label(self):
|
||||
"""包含 [bgm_comp] 中间标签."""
|
||||
def test_output_label(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_bgm_comp_label(self):
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[bgm_comp]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# normalize_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── normalize_bgm_config ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeBGMConfig:
|
||||
"""配置规范化测试."""
|
||||
|
||||
def test_empty_dict_defaults(self):
|
||||
"""空字典返回默认值."""
|
||||
class TestNormalizeBgmConfig:
|
||||
def test_default_values(self):
|
||||
result = normalize_bgm_config({})
|
||||
assert result["volume"] == 0.3
|
||||
assert result["fade_in"] == 0.0
|
||||
@@ -426,233 +301,184 @@ class TestNormalizeBGMConfig:
|
||||
assert result["loop_enabled"] is True
|
||||
assert result["sidechain_enabled"] is False
|
||||
assert result["sidechain_ratio"] == 0.3
|
||||
assert result["sidechain_attack"] == 0.02
|
||||
assert result["sidechain_release"] == 0.5
|
||||
assert result["sidechain_threshold"] == -25.0
|
||||
|
||||
def test_volume_clamped(self):
|
||||
"""音量钳制."""
|
||||
result = normalize_bgm_config({"volume": 1.5})
|
||||
result = normalize_bgm_config({"volume": 2.0})
|
||||
assert result["volume"] == 1.0
|
||||
result2 = normalize_bgm_config({"volume": -0.5})
|
||||
assert result2["volume"] == 0.0
|
||||
result = normalize_bgm_config({"volume": -1.0})
|
||||
assert result["volume"] == 0.0
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""淡入为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_in": -1})
|
||||
def test_fade_in_clamped_to_zero(self):
|
||||
result = normalize_bgm_config({"fade_in": -5})
|
||||
assert result["fade_in"] == 0.0
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""淡出为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_out": -1})
|
||||
def test_fade_out_clamped_to_zero(self):
|
||||
result = normalize_bgm_config({"fade_out": -5})
|
||||
assert result["fade_out"] == 0.0
|
||||
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
"""sidechain_ratio 钳制."""
|
||||
result = normalize_bgm_config({"sidechain_ratio": 1.5})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
|
||||
assert result2["sidechain_ratio"] == 0.0
|
||||
def test_loop_enabled_bool_conversion(self):
|
||||
assert normalize_bgm_config({"loop_enabled": True})["loop_enabled"] is True
|
||||
assert normalize_bgm_config({"loop_enabled": False})["loop_enabled"] is False
|
||||
assert normalize_bgm_config({"loop_enabled": 1})["loop_enabled"] is True
|
||||
assert normalize_bgm_config({"loop_enabled": 0})["loop_enabled"] is False
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
"""attack 最小值 0.001."""
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
result = normalize_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result = normalize_bgm_config({"sidechain_ratio": -1.0})
|
||||
assert result["sidechain_ratio"] == 0.0
|
||||
|
||||
def test_sidechain_attack_minimum(self):
|
||||
result = normalize_bgm_config({"sidechain_attack": 0})
|
||||
assert result["sidechain_attack"] == 0.001
|
||||
|
||||
def test_sidechain_release_min(self):
|
||||
"""release 最小值 0.01."""
|
||||
def test_sidechain_release_minimum(self):
|
||||
result = normalize_bgm_config({"sidechain_release": 0})
|
||||
assert result["sidechain_release"] == 0.01
|
||||
|
||||
def test_sidechain_threshold_pass_through(self):
|
||||
result = normalize_bgm_config({"sidechain_threshold": -40.0})
|
||||
assert result["sidechain_threshold"] == -40.0
|
||||
|
||||
def test_string_values_converted(self):
|
||||
"""字符串数值被转换."""
|
||||
result = normalize_bgm_config(
|
||||
{
|
||||
"volume": "0.5",
|
||||
"fade_in": "2.0",
|
||||
"fade_in": "1.0",
|
||||
"sidechain_ratio": "0.7",
|
||||
}
|
||||
)
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_in"] == 2.0
|
||||
|
||||
def test_loop_enabled_truthy(self):
|
||||
"""loop_enabled 真值转换."""
|
||||
result = normalize_bgm_config({"loop_enabled": 1})
|
||||
assert result["loop_enabled"] is True
|
||||
result2 = normalize_bgm_config({"loop_enabled": 0})
|
||||
assert result2["loop_enabled"] is False
|
||||
|
||||
def test_preserves_unknown_keys(self):
|
||||
"""未知 key 不保留."""
|
||||
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
|
||||
assert "unknown_key" not in result
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_in"] == 1.0
|
||||
assert result["sidechain_ratio"] == 0.7
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# validate_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_bgm_config ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateBGMConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
class TestValidateBgmConfig:
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 0.5,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
"sidechain_ratio": 0.3,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
valid, errors = validate_bgm_config({"volume": 0.3})
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
def test_volume_not_number(self):
|
||||
"""volume 不是数字."""
|
||||
ok, errors = validate_bgm_config({"volume": "high"})
|
||||
assert ok is False
|
||||
def test_invalid_volume_type(self):
|
||||
valid, errors = validate_bgm_config({"volume": "abc"})
|
||||
assert valid is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_volume_out_of_range(self):
|
||||
"""volume 超出范围."""
|
||||
ok, errors = validate_bgm_config({"volume": 1.5})
|
||||
assert ok is False
|
||||
valid, errors = validate_bgm_config({"volume": -0.1})
|
||||
assert valid is False
|
||||
assert any("volume" in e for e in errors)
|
||||
valid, errors = validate_bgm_config({"volume": 1.1})
|
||||
assert valid is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""fade_in 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_in": -1})
|
||||
assert ok is False
|
||||
def test_volume_at_boundaries(self):
|
||||
assert validate_bgm_config({"volume": 0})[0] is True
|
||||
assert validate_bgm_config({"volume": 1})[0] is True
|
||||
|
||||
def test_invalid_fade_in_type(self):
|
||||
valid, errors = validate_bgm_config({"fade_in": "abc"})
|
||||
assert valid is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""fade_out 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_out": -1})
|
||||
assert ok is False
|
||||
def test_negative_fade_in(self):
|
||||
valid, errors = validate_bgm_config({"fade_in": -1})
|
||||
assert valid is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_invalid_fade_out_type(self):
|
||||
valid, errors = validate_bgm_config({"fade_out": "abc"})
|
||||
assert valid is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
valid, errors = validate_bgm_config({"fade_out": -1})
|
||||
assert valid is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_invalid_sidechain_ratio_type(self):
|
||||
valid, errors = validate_bgm_config({"sidechain_ratio": "abc"})
|
||||
assert valid is False
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
|
||||
def test_sidechain_ratio_out_of_range(self):
|
||||
"""sidechain_ratio 超出范围."""
|
||||
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert ok is False
|
||||
valid, errors = validate_bgm_config({"sidechain_ratio": -0.1})
|
||||
assert valid is False
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
valid, errors = validate_bgm_config({"sidechain_ratio": 1.1})
|
||||
assert valid is False
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误同时报告."""
|
||||
ok, errors = validate_bgm_config(
|
||||
valid, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 2.0,
|
||||
"volume": "bad",
|
||||
"fade_in": -1,
|
||||
"sidechain_ratio": -0.5,
|
||||
"sidechain_ratio": 2.0,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert valid is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
def test_empty_config_valid(self):
|
||||
"""空配置(全用默认值)视为合法."""
|
||||
ok, errors = validate_bgm_config({})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_fade_out_start 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── calculate_fade_out_start ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
|
||||
assert calculate_fade_out_start(60, 2) == 58.0
|
||||
|
||||
def test_zero_fade_out(self):
|
||||
"""淡出时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(100, 0) is None
|
||||
assert calculate_fade_out_start(60, 0) is None
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""淡出时长为负,返回 None."""
|
||||
assert calculate_fade_out_start(100, -1) is None
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""总时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(0, 3) is None
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出等于总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 10) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# estimate_bgm_processing_duration 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateBGMProcessingDuration:
|
||||
"""BGM 处理时长估算测试."""
|
||||
|
||||
def test_normal_case_with_loop(self):
|
||||
"""正常循环情况,输出目标时长."""
|
||||
assert estimate_bgm_processing_duration(10, 100, True) == 100
|
||||
|
||||
def test_bgm_longer_no_loop(self):
|
||||
"""BGM 够长,不循环,截断到目标时长."""
|
||||
assert estimate_bgm_processing_duration(200, 100, False) == 100
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
|
||||
assert estimate_bgm_processing_duration(10, 100, False) == 100
|
||||
assert calculate_fade_out_start(60, -1) is None
|
||||
|
||||
def test_zero_target(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
|
||||
assert calculate_fade_out_start(0, 2) is None
|
||||
|
||||
def test_negative_target(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
|
||||
assert calculate_fade_out_start(-5, 2) is None
|
||||
|
||||
def test_fade_longer_than_target(self):
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_equal_to_target(self):
|
||||
assert calculate_fade_out_start(10, 10) is None
|
||||
|
||||
def test_float_values(self):
|
||||
assert calculate_fade_out_start(60.5, 2.5) == 58.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# BGMPureConfig 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── estimate_bgm_processing_duration ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMPureConfig:
|
||||
"""BGMPureConfig 数据类测试."""
|
||||
class TestEstimateBgmProcessingDuration:
|
||||
def test_bgm_longer_no_loop(self):
|
||||
assert estimate_bgm_processing_duration(100, 60, loop_enabled=False) == 60.0
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = BGMPureConfig()
|
||||
assert config.volume == 0.3
|
||||
assert config.fade_in == 0.0
|
||||
assert config.fade_out == 0.0
|
||||
assert config.loop_enabled is True
|
||||
assert config.sidechain_enabled is False
|
||||
assert config.sidechain_ratio == 0.3
|
||||
assert config.sidechain_attack == 0.02
|
||||
assert config.sidechain_release == 0.5
|
||||
assert config.sidechain_threshold == -25.0
|
||||
def test_bgm_longer_with_loop(self):
|
||||
# 够长但允许循环,仍然截断到target
|
||||
assert estimate_bgm_processing_duration(100, 60, loop_enabled=True) == 60.0
|
||||
|
||||
def test_custom_values(self):
|
||||
"""自定义值."""
|
||||
config = BGMPureConfig(
|
||||
volume=0.7,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=False,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
sidechain_attack=0.05,
|
||||
sidechain_release=0.8,
|
||||
sidechain_threshold=-30.0,
|
||||
)
|
||||
assert config.volume == 0.7
|
||||
assert config.loop_enabled is False
|
||||
assert config.sidechain_enabled is True
|
||||
assert config.sidechain_threshold == -30.0
|
||||
def test_bgm_shorter_with_loop(self):
|
||||
assert estimate_bgm_processing_duration(10, 60, loop_enabled=True) == 60.0
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
# 需要循环但不允许 → 截断到target
|
||||
assert estimate_bgm_processing_duration(10, 60, loop_enabled=False) == 60.0
|
||||
|
||||
def test_zero_target_fallback(self):
|
||||
assert estimate_bgm_processing_duration(100, 0) == 5.0
|
||||
|
||||
def test_negative_target_fallback(self):
|
||||
assert estimate_bgm_processing_duration(100, -5) == 5.0
|
||||
|
||||
def test_equal_duration(self):
|
||||
assert estimate_bgm_processing_duration(60, 60) == 60.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
"""concat_engine_pure 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
from apps.worker.video_processing.concat_engine_pure import (
|
||||
build_concat_filter,
|
||||
build_fps_filter,
|
||||
build_scale_pad_filter,
|
||||
build_setpts_filter,
|
||||
build_single_segment_filter_chain,
|
||||
calculate_scaled_size,
|
||||
can_use_stream_copy,
|
||||
@@ -20,200 +20,203 @@ from video_processing.concat_engine_pure import (
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── parse_fps ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
assert parse_fps(29.97) == pytest.approx(29.97)
|
||||
|
||||
def test_string_integer(self):
|
||||
"""字符串整数."""
|
||||
assert parse_fps("30") == 30.0
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 30.0
|
||||
|
||||
def test_fraction_24000_1001(self):
|
||||
"""23.976 帧率."""
|
||||
result = parse_fps("24000/1001")
|
||||
assert result == pytest.approx(23.976, rel=0.01)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入返回默认值."""
|
||||
def test_none_returns_default(self):
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
def test_integer_value(self):
|
||||
assert parse_fps(30) == 30.0
|
||||
assert parse_fps(24) == 24.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
def test_float_value(self):
|
||||
assert parse_fps(29.97) == 29.97
|
||||
|
||||
def test_string_integer(self):
|
||||
assert parse_fps("30") == 30.0
|
||||
assert parse_fps(" 60 ") == 60.0 # 带空格
|
||||
|
||||
def test_string_fraction(self):
|
||||
assert parse_fps("30/1") == 30.0
|
||||
assert abs(parse_fps("24000/1001") - 23.976) < 0.01
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_fps("") == 30.0
|
||||
assert parse_fps(" ") == 30.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
assert parse_fps("abc") == 30.0
|
||||
assert parse_fps("30fps") == 30.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
assert parse_fps(-30) == -30.0
|
||||
|
||||
def test_zero_fps(self):
|
||||
assert parse_fps(0) == 0.0
|
||||
|
||||
|
||||
# ── format_fps_filter ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFormatFpsFilter:
|
||||
"""format_fps_filter 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert format_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
def test_near_integer_fps(self):
|
||||
# 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入)
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30
|
||||
|
||||
def test_non_integer_fps(self):
|
||||
result = format_fps_filter(23.976)
|
||||
assert result.startswith("fps=")
|
||||
assert "23.976" in result
|
||||
|
||||
def test_float_precision(self):
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
# 三位小数
|
||||
parts = result.split("=")[1]
|
||||
assert len(parts.split(".")[1]) == 3
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
def test_one_fps(self):
|
||||
assert format_fps_filter(1.0) == "fps=1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── resolve_output_params ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
def test_config_specified(self):
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
def test_no_specified_use_defaults(self):
|
||||
"""全部未指定,用默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080
|
||||
assert h == 1920
|
||||
assert fps == 30.0
|
||||
|
||||
def test_use_first_video_info(self):
|
||||
"""用第一段视频信息."""
|
||||
def test_fallback_to_first_video_info(self):
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert w == 1280
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
def test_fallback_to_defaults(self):
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080 # default_width
|
||||
assert h == 1920 # default_height
|
||||
assert fps == 30.0
|
||||
|
||||
def test_partial_config(self):
|
||||
# 宽度配置了,高度和帧率用探测的
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(1920, 0, 0, info)
|
||||
assert w == 1920 # 指定的
|
||||
assert h == 720 # 探测的
|
||||
assert w == 1920
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_zero_size_clamped(self):
|
||||
"""零尺寸被钳制."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
w, h, fps = resolve_output_params(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
default_width=640,
|
||||
default_height=480,
|
||||
default_fps=25.0,
|
||||
)
|
||||
assert w == 640
|
||||
assert h == 480
|
||||
assert fps == 25.0
|
||||
|
||||
def test_minimum_size(self):
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_fps_fraction_in_info(self):
|
||||
info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}
|
||||
_, _, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert abs(fps - 23.976) < 0.01
|
||||
|
||||
|
||||
# ── calculate_scaled_size ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateScaledSize:
|
||||
"""calculate_scaled_size 测试."""
|
||||
|
||||
def test_same_ratio(self):
|
||||
"""比例相同."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_wider_source(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
def test_wider_source_pad_top_bottom(self):
|
||||
# 源是16:9,目标是9:16竖屏 → 上下填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
def test_taller_source_pad_left_right(self):
|
||||
# 源是9:16竖屏,目标是16:9横屏 → 左右填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
def test_zero_source(self):
|
||||
"""零尺寸源."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
|
||||
assert sw == 100
|
||||
assert sh == 100
|
||||
|
||||
def test_scale_down(self):
|
||||
"""缩小."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
|
||||
assert sw == 640
|
||||
assert sh == 360
|
||||
def test_zero_source_size(self):
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
def test_negative_source_size(self):
|
||||
sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_target_same_ratio_different_size(self):
|
||||
# 比例相同,尺寸不同 → 直接缩放到目标大小
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── can_use_stream_copy ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
def test_force_reencode_false(self):
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 stream copy."""
|
||||
def test_empty_segments(self):
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment_matching_params(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_multiple_segments_same_params(self):
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重编码."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_different_codec(self):
|
||||
"""编码不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
@@ -221,7 +224,6 @@ class TestCanUseStreamCopy:
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_resolution(self):
|
||||
"""分辨率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
|
||||
@@ -229,306 +231,349 @@ class TestCanUseStreamCopy:
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_fps(self):
|
||||
"""帧率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_target_differs(self):
|
||||
"""目标参数与源不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
def test_target_differs_from_source(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
# 目标分辨率不同
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
# 目标帧率不同
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False
|
||||
|
||||
def test_fps_fraction_match(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── generate_concat_file_list ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
result = generate_concat_file_list(["/tmp/video.mp4"])
|
||||
assert result == "file '/tmp/video.mp4'\n"
|
||||
|
||||
def test_multiple_files(self):
|
||||
"""多个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "file '/a.mp4'"
|
||||
assert lines[1] == "file '/b.mp4'"
|
||||
assert lines[2] == "file '/c.mp4'"
|
||||
assert result.endswith("\n")
|
||||
|
||||
def test_escapes_single_quotes(self):
|
||||
result = generate_concat_file_list(["/path/with'quote.mp4"])
|
||||
# 单引号转义: '\''
|
||||
assert "'\\''" in result
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
result = generate_concat_file_list([])
|
||||
assert result == "\n"
|
||||
|
||||
def test_path_with_single_quote(self):
|
||||
"""路径包含单引号(转义)."""
|
||||
result = generate_concat_file_list(["/path/to/file's.mp4"])
|
||||
# 单引号应该被转义
|
||||
assert "'\\''" in result or file
|
||||
assert "file '" in result
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""路径包含空格."""
|
||||
result = generate_concat_file_list(["/path/to/my video.mp4"])
|
||||
assert "my video" in result
|
||||
result = generate_concat_file_list(["/path/to/video file.mp4"])
|
||||
assert "file '/path/to/video file.mp4'" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_scale_pad_filter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""scale+pad 滤镜测试."""
|
||||
|
||||
def test_contains_scale(self):
|
||||
"""包含 scale."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=" in result
|
||||
|
||||
def test_contains_pad(self):
|
||||
"""包含 pad."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "pad=" in result
|
||||
assert "1920:1080" in result
|
||||
|
||||
def test_force_original_aspect_ratio(self):
|
||||
"""保持宽高比."""
|
||||
def test_basic_filter(self):
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=1920:1080" in result
|
||||
assert "force_original_aspect_ratio=decrease" in result
|
||||
assert "pad=1920:1080" in result
|
||||
assert "black" in result
|
||||
assert "(ow-iw)/2" in result
|
||||
assert "(oh-ih)/2" in result
|
||||
|
||||
def test_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" in result
|
||||
def test_different_resolution(self):
|
||||
result = build_scale_pad_filter(1080, 1920)
|
||||
assert "scale=1080:1920" in result
|
||||
assert "pad=1080:1920" in result
|
||||
|
||||
def test_ignores_source_size(self):
|
||||
# src_w/src_h 目前不影响输出,都是用表达式
|
||||
result1 = build_scale_pad_filter(1920, 1080)
|
||||
result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720)
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
# ── build_fps_filter ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildFpsFilter:
|
||||
"""fps 滤镜测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert build_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = build_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
# ── build_setpts_filter ─────────────────────────────────────────────────────
|
||||
|
||||
def test_two_inputs_with_audio(self):
|
||||
"""两路输入,有音频."""
|
||||
result = build_concat_filter(2, has_audio=True)
|
||||
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
|
||||
|
||||
class TestBuildSetptsFilter:
|
||||
def test_returns_correct_string(self):
|
||||
assert build_setpts_filter() == "setpts=PTS-STARTPTS"
|
||||
|
||||
|
||||
# ── build_concat_filter ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
def test_zero_inputs(self):
|
||||
assert build_concat_filter(0) == ""
|
||||
|
||||
def test_single_input_with_audio(self):
|
||||
result = build_concat_filter(1)
|
||||
assert "[0:v][0:a]" in result
|
||||
assert "concat=n=1:v=1:a=1" in result
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
|
||||
def test_single_input_no_audio(self):
|
||||
result = build_concat_filter(1, has_audio=False)
|
||||
assert "[0:v]" in result
|
||||
assert "concat=n=1:v=1:a=0" in result
|
||||
assert "[concat_v]" in result
|
||||
assert "[concat_a]" not in result
|
||||
|
||||
def test_single_input(self):
|
||||
"""单路输入."""
|
||||
result = build_concat_filter(1, has_audio=True)
|
||||
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
|
||||
def test_multiple_inputs_with_audio(self):
|
||||
result = build_concat_filter(3)
|
||||
assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result
|
||||
assert "concat=n=3:v=1:a=1" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
def test_multiple_inputs_no_audio(self):
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]" in result
|
||||
assert "concat=n=3:v=1:a=0" in result
|
||||
|
||||
def test_negative_inputs(self):
|
||||
assert build_concat_filter(-1) == ""
|
||||
|
||||
|
||||
# ── build_single_segment_filter_chain ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
# 视频链
|
||||
assert "[0:v]" in result
|
||||
assert "[v0]" in result
|
||||
assert "scale=1920:1080" in result
|
||||
assert "fps=30" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
# 音频链
|
||||
assert "[0:a]" in result
|
||||
assert "[a0]" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
# 用分号分隔
|
||||
assert ";" in result
|
||||
|
||||
def test_video_only(self):
|
||||
"""无音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
|
||||
assert "scale=" in result
|
||||
assert "setpts=" in result
|
||||
assert "asetpts" not in result
|
||||
assert "[v1]" in result
|
||||
def test_without_audio(self):
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False)
|
||||
assert "[2:v]" in result
|
||||
assert "[v2]" in result
|
||||
assert "[2:a]" not in result
|
||||
assert ";" not in result # 没有音频就没有分号
|
||||
|
||||
def test_segment_index_in_labels(self):
|
||||
"""段索引在标签中."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
|
||||
assert "[5:v]" in result
|
||||
assert "[v5]" in result
|
||||
def test_segment_index_propagated(self):
|
||||
for idx in [0, 5, 10]:
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, idx)
|
||||
assert f"[{idx}:v]" in result
|
||||
assert f"[v{idx}]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_concat_config ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateConcatConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": "/b.mp4"},
|
||||
],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
def test_no_segments(self):
|
||||
valid, errors = validate_concat_config({})
|
||||
assert valid is False
|
||||
assert any("至少需要一个" in e for e in errors)
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
valid, errors = validate_concat_config({"segments": []})
|
||||
assert valid is False
|
||||
assert len(errors) >= 1
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
config = {"segments": [{"video_path": ""}]}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_multiple_missing_paths(self):
|
||||
config = {
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"video_path": ""},
|
||||
]
|
||||
}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
path_errors = [e for e in errors if "video_path" in e]
|
||||
assert len(path_errors) == 2
|
||||
|
||||
def test_negative_output_width(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_negative_output_height(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_negative_output_fps(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
def test_zero_output_params_valid(self):
|
||||
# 0值表示未指定,是合法的
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 0,
|
||||
"output_height": 0,
|
||||
"output_fps": 0,
|
||||
}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_video_path ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
valid, err = validate_video_path("", "/work")
|
||||
assert valid is False
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_path_traversal(self):
|
||||
"""路径遍历."""
|
||||
ok, msg = validate_video_path("../etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "回溯" in msg or ".." in msg
|
||||
def test_relative_path_valid(self):
|
||||
valid, err = validate_video_path("video.mp4", "/work")
|
||||
assert valid is True
|
||||
assert err == ""
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
def test_relative_path_with_subdir(self):
|
||||
valid, err = validate_video_path("sub/video.mp4", "/work")
|
||||
assert valid is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert ok is True
|
||||
def test_path_traversal_rejected(self):
|
||||
valid, err = validate_video_path("../secret.mp4", "/work")
|
||||
assert valid is False
|
||||
assert ".." in err
|
||||
|
||||
def test_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
def test_nested_path_traversal_rejected(self):
|
||||
valid, err = validate_video_path("sub/../../secret.mp4", "/work")
|
||||
assert valid is False
|
||||
|
||||
def test_absolute_path_inside_workdir(self):
|
||||
valid, err = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert valid is True
|
||||
|
||||
def test_absolute_path_outside_workdir(self):
|
||||
valid, err = validate_video_path("/etc/passwd", "/work")
|
||||
assert valid is False
|
||||
assert "工作目录内" in err
|
||||
|
||||
def test_path_object_input(self):
|
||||
valid, err = validate_video_path(Path("video.mp4"), Path("/work"))
|
||||
assert valid is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── estimate_total_duration ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
"""总时长估算测试."""
|
||||
def test_single_segment(self):
|
||||
assert estimate_total_duration([{"duration": 10.5}]) == 10.5
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段视频."""
|
||||
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": 20.5},
|
||||
{"duration": 5.5},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 36.0
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
def test_missing_duration_field(self):
|
||||
segs = [{"path": "a.mp4"}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == 10.0
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
def test_invalid_duration_skipped(self):
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": "abc"},
|
||||
{"duration": 20},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 30.0
|
||||
|
||||
def test_string_duration(self):
|
||||
segs = [{"duration": "15.5"}]
|
||||
assert estimate_total_duration(segs) == 15.5
|
||||
|
||||
def test_negative_duration(self):
|
||||
segs = [{"duration": -5}]
|
||||
assert estimate_total_duration(segs) == -5.0
|
||||
|
||||
|
||||
# ── count_valid_segments ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCountValidSegments:
|
||||
"""有效段统计测试."""
|
||||
|
||||
def test_all_valid(self):
|
||||
"""全部有效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
|
||||
segs = [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": "/b.mp4"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_some_invalid(self):
|
||||
"""部分无效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
segs = [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"video_path": "/c.mp4"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_none_valid(self):
|
||||
segs = [
|
||||
{"video_path": ""},
|
||||
{"other_field": "x"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 0
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
"""InMemoryAssetRepository 单元测试."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.domain.entities import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo() -> InMemoryAssetRepository:
|
||||
return InMemoryAssetRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_asset() -> Asset:
|
||||
return Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="test.mp4",
|
||||
storage_key="storage/key1",
|
||||
mime_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="hash-abc",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset2() -> Asset:
|
||||
return Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="test2.jpg",
|
||||
storage_key="storage/key2",
|
||||
mime_type="image/jpeg",
|
||||
file_size=512,
|
||||
file_hash="hash-def",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_other_project() -> Asset:
|
||||
return Asset.create(
|
||||
project_id="proj-2",
|
||||
library_id="lib-2",
|
||||
name="other.mp3",
|
||||
storage_key="storage/key3",
|
||||
mime_type="audio/mpeg",
|
||||
file_size=256,
|
||||
file_hash="hash-ghi",
|
||||
)
|
||||
|
||||
|
||||
class TestCreateAndGet:
|
||||
def test_create_returns_asset(self, repo, sample_asset):
|
||||
result = repo.create(sample_asset)
|
||||
assert result.id == sample_asset.id
|
||||
assert result.name == "test.mp4"
|
||||
|
||||
def test_get_existing_asset(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
result = repo.get(sample_asset.id)
|
||||
assert result is not None
|
||||
assert result.id == sample_asset.id
|
||||
|
||||
def test_get_nonexistent_returns_none(self, repo):
|
||||
assert repo.get("nonexistent") is None
|
||||
|
||||
def test_find_by_id_same_as_get(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
assert repo.find_by_id(sample_asset.id).id == repo.get(sample_asset.id).id
|
||||
|
||||
|
||||
class TestListByProject:
|
||||
def test_list_by_project_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
repo.create(asset_other_project)
|
||||
|
||||
proj1 = repo.list_by_project("proj-1")
|
||||
assert len(proj1) == 2
|
||||
assert all(a.project_id == "proj-1" for a in proj1)
|
||||
|
||||
proj2 = repo.list_by_project("proj-2")
|
||||
assert len(proj2) == 1
|
||||
assert proj2[0].id == asset_other_project.id
|
||||
|
||||
def test_list_by_project_empty(self, repo):
|
||||
assert repo.list_by_project("nonexistent") == []
|
||||
|
||||
|
||||
class TestListByLibrary:
|
||||
def test_list_by_library_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
repo.create(asset_other_project)
|
||||
|
||||
lib1 = repo.list_by_library("lib-1")
|
||||
assert len(lib1) == 2
|
||||
|
||||
lib2 = repo.list_by_library("lib-2")
|
||||
assert len(lib2) == 1
|
||||
assert lib2[0].id == asset_other_project.id
|
||||
|
||||
def test_find_by_library_is_alias(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
assert repo.find_by_library("lib-1") == repo.list_by_library("lib-1")
|
||||
|
||||
def test_list_by_library_empty(self, repo):
|
||||
assert repo.list_by_library("nonexistent") == []
|
||||
|
||||
|
||||
class TestFindByLibraryAndFileType:
|
||||
def test_filter_by_video(self, repo, sample_asset, asset2, asset_other_project):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
repo.create(asset_other_project)
|
||||
|
||||
videos = repo.find_by_library_and_file_type("lib-1", "video")
|
||||
assert len(videos) == 1
|
||||
assert videos[0].mime_type.startswith("video/")
|
||||
|
||||
def test_filter_by_image(self, repo, sample_asset, asset2):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
images = repo.find_by_library_and_file_type("lib-1", "image")
|
||||
assert len(images) == 1
|
||||
assert images[0].mime_type.startswith("image/")
|
||||
|
||||
def test_filter_by_audio(self, repo, sample_asset, asset_other_project):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset_other_project)
|
||||
|
||||
audio = repo.find_by_library_and_file_type("lib-2", "audio")
|
||||
assert len(audio) == 1
|
||||
|
||||
def test_empty_result(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
assert repo.find_by_library_and_file_type("lib-1", "audio") == []
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_existing_asset(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
sample_asset.name = "updated.mp4"
|
||||
sample_asset.file_size = 2048
|
||||
|
||||
result = repo.update(sample_asset)
|
||||
assert result.name == "updated.mp4"
|
||||
assert result.file_size == 2048
|
||||
|
||||
fetched = repo.get(sample_asset.id)
|
||||
assert fetched.name == "updated.mp4"
|
||||
|
||||
def test_update_nonexistent_creates(self, repo, sample_asset):
|
||||
"""update 直接覆盖,不存在则相当于 create."""
|
||||
result = repo.update(sample_asset)
|
||||
assert result.id == sample_asset.id
|
||||
assert repo.get(sample_asset.id) is not None
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_existing(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
assert repo.delete(sample_asset.id) is True
|
||||
assert repo.get(sample_asset.id) is None
|
||||
|
||||
def test_delete_nonexistent(self, repo):
|
||||
assert repo.delete("nonexistent") is False
|
||||
|
||||
|
||||
class TestBatchDelete:
|
||||
def test_batch_delete_soft_delete(self, repo, sample_asset, asset2):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
count = repo.batch_delete([sample_asset.id, asset2.id])
|
||||
assert count == 2
|
||||
|
||||
a1 = repo.get(sample_asset.id)
|
||||
a2 = repo.get(asset2.id)
|
||||
assert a1.status == AssetStatus.DELETED
|
||||
assert a2.status == AssetStatus.DELETED
|
||||
assert a1.updated_at is not None
|
||||
assert a2.updated_at is not None
|
||||
|
||||
def test_batch_delete_skip_already_deleted(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
sample_asset.status = AssetStatus.DELETED
|
||||
repo.update(sample_asset)
|
||||
|
||||
count = repo.batch_delete([sample_asset.id])
|
||||
assert count == 0
|
||||
|
||||
def test_batch_delete_nonexistent(self, repo):
|
||||
count = repo.batch_delete(["nonexistent-1", "nonexistent-2"])
|
||||
assert count == 0
|
||||
|
||||
def test_batch_delete_partial(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
count = repo.batch_delete([sample_asset.id, "nonexistent"])
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestBatchUpdateMetadata:
|
||||
def test_batch_update_metadata_merge(self, repo, sample_asset, asset2):
|
||||
sample_asset.metadata = {"key1": "val1"}
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
count = repo.batch_update_metadata(
|
||||
[sample_asset.id, asset2.id],
|
||||
{"key2": "val2"},
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
a1 = repo.get(sample_asset.id)
|
||||
a2 = repo.get(asset2.id)
|
||||
assert a1.metadata == {"key1": "val1", "key2": "val2"}
|
||||
assert a2.metadata == {"key2": "val2"}
|
||||
|
||||
def test_batch_update_metadata_overwrite_existing_key(self, repo, sample_asset):
|
||||
sample_asset.metadata = {"key1": "old"}
|
||||
repo.create(sample_asset)
|
||||
|
||||
count = repo.batch_update_metadata([sample_asset.id], {"key1": "new"})
|
||||
assert count == 1
|
||||
assert repo.get(sample_asset.id).metadata["key1"] == "new"
|
||||
|
||||
def test_batch_update_metadata_nonexistent(self, repo):
|
||||
count = repo.batch_update_metadata(["nonexistent"], {"key": "val"})
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestBatchAddTags:
|
||||
def test_batch_add_tags_new_tags(self, repo, sample_asset, asset2):
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
count = repo.batch_add_tags([sample_asset.id, asset2.id], ["tag1", "tag2"])
|
||||
assert count == 2
|
||||
|
||||
a1 = repo.get(sample_asset.id)
|
||||
a2 = repo.get(asset2.id)
|
||||
assert set(a1.tag_ids) == {"tag1", "tag2"}
|
||||
assert set(a2.tag_ids) == {"tag1", "tag2"}
|
||||
|
||||
def test_batch_add_tags_dedup(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["tag1"]
|
||||
repo.create(sample_asset)
|
||||
|
||||
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
|
||||
assert count == 1 # tag1已存在,但tag2新增,所以有变化
|
||||
|
||||
tags = repo.get(sample_asset.id).tag_ids
|
||||
assert tags.count("tag1") == 1
|
||||
assert "tag2" in tags
|
||||
|
||||
def test_batch_add_tags_no_change_when_all_exist(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["tag1", "tag2"]
|
||||
repo.create(sample_asset)
|
||||
|
||||
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
|
||||
assert count == 0 # 没有变化
|
||||
|
||||
def test_batch_add_tags_nonexistent_assets(self, repo):
|
||||
count = repo.batch_add_tags(["nonexistent"], ["tag1"])
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestBatchReplaceTags:
|
||||
def test_batch_replace_tags_override(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["old1", "old2"]
|
||||
repo.create(sample_asset)
|
||||
|
||||
count = repo.batch_replace_tags([sample_asset.id], ["new1", "new2", "new3"])
|
||||
assert count == 1
|
||||
|
||||
tags = repo.get(sample_asset.id).tag_ids
|
||||
assert tags == ["new1", "new2", "new3"]
|
||||
|
||||
def test_batch_replace_tags_empty(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["tag1"]
|
||||
repo.create(sample_asset)
|
||||
|
||||
count = repo.batch_replace_tags([sample_asset.id], [])
|
||||
assert count == 1
|
||||
assert repo.get(sample_asset.id).tag_ids == []
|
||||
|
||||
def test_batch_replace_tags_nonexistent(self, repo):
|
||||
count = repo.batch_replace_tags(["nonexistent"], ["tag1"])
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestFindByProjectPagination:
|
||||
@pytest.fixture
|
||||
def five_assets(self, repo):
|
||||
assets = []
|
||||
for i in range(5):
|
||||
a = Asset.create(
|
||||
project_id="proj-paged",
|
||||
library_id="lib-paged",
|
||||
name=f"asset-{i}.mp4",
|
||||
storage_key=f"key-{i}",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
repo.create(a)
|
||||
assets.append(a)
|
||||
return assets
|
||||
|
||||
def test_find_by_project_default_pagination(self, repo, five_assets):
|
||||
result = repo.find_by_project("proj-paged")
|
||||
assert len(result) == 5
|
||||
|
||||
def test_find_by_project_skip(self, repo, five_assets):
|
||||
result = repo.find_by_project("proj-paged", skip=2)
|
||||
assert len(result) == 3
|
||||
|
||||
def test_find_by_project_limit(self, repo, five_assets):
|
||||
result = repo.find_by_project("proj-paged", limit=2)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_by_project_skip_and_limit(self, repo, five_assets):
|
||||
result = repo.find_by_project("proj-paged", skip=1, limit=2)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_by_project_skip_past_end(self, repo, five_assets):
|
||||
result = repo.find_by_project("proj-paged", skip=10)
|
||||
assert result == []
|
||||
|
||||
def test_find_by_project_empty(self, repo):
|
||||
assert repo.find_by_project("nonexistent") == []
|
||||
|
||||
|
||||
class TestFindByTagIds:
|
||||
def test_find_by_tag_ids_match_all(self, repo, sample_asset, asset2):
|
||||
sample_asset.tag_ids = ["tag1", "tag2", "tag3"]
|
||||
asset2.tag_ids = ["tag1", "tag2"]
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
result = repo.find_by_tag_ids(["tag1", "tag2"])
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_by_tag_ids_subset_match(self, repo, sample_asset, asset2):
|
||||
sample_asset.tag_ids = ["tag1", "tag2"]
|
||||
asset2.tag_ids = ["tag1"]
|
||||
repo.create(sample_asset)
|
||||
repo.create(asset2)
|
||||
|
||||
result = repo.find_by_tag_ids(["tag1", "tag2"])
|
||||
assert len(result) == 1
|
||||
assert result[0].id == sample_asset.id
|
||||
|
||||
def test_find_by_tag_ids_empty_tag_list(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["tag1"]
|
||||
repo.create(sample_asset)
|
||||
assert repo.find_by_tag_ids([]) == []
|
||||
|
||||
def test_find_by_tag_ids_no_match(self, repo, sample_asset):
|
||||
sample_asset.tag_ids = ["tag1"]
|
||||
repo.create(sample_asset)
|
||||
assert repo.find_by_tag_ids(["tag999"]) == []
|
||||
|
||||
def test_find_by_tag_ids_pagination(self, repo):
|
||||
for i in range(5):
|
||||
a = Asset.create(
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name=f"a{i}.mp4",
|
||||
storage_key=f"k{i}",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
a.tag_ids = ["shared-tag"]
|
||||
repo.create(a)
|
||||
|
||||
result = repo.find_by_tag_ids(["shared-tag"], skip=1, limit=2)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestFindByLibraryAndFileHash:
|
||||
def test_find_by_hash_match(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
result = repo.find_by_library_and_file_hash("lib-1", "hash-abc")
|
||||
assert result is not None
|
||||
assert result.id == sample_asset.id
|
||||
|
||||
def test_find_by_hash_wrong_library(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
result = repo.find_by_library_and_file_hash("lib-2", "hash-abc")
|
||||
assert result is None
|
||||
|
||||
def test_find_by_hash_wrong_hash(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
result = repo.find_by_library_and_file_hash("lib-1", "hash-wrong")
|
||||
assert result is None
|
||||
|
||||
def test_find_by_hash_empty_hash(self, repo, sample_asset):
|
||||
repo.create(sample_asset)
|
||||
result = repo.find_by_library_and_file_hash("lib-1", "")
|
||||
assert result is None
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
"""InMemoryUserRepository 单元测试."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.user_repository import InMemoryUserRepository
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo() -> InMemoryUserRepository:
|
||||
return InMemoryUserRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user() -> User:
|
||||
return User(
|
||||
id="user-1",
|
||||
email="Test@Example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
password_hash="hashed-pw",
|
||||
email_verification_token="verify-token-123",
|
||||
password_reset_token="reset-token-456",
|
||||
wechat_openid="wx-openid-abc",
|
||||
wechat_unionid="wx-unionid-def",
|
||||
phone="13800138000",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class TestSaveAndFindById:
|
||||
def test_save_and_find_by_id(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_id("user-1")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
assert found.email == "Test@Example.com"
|
||||
|
||||
def test_find_by_id_not_found(self, repo):
|
||||
assert repo.find_by_id("nonexistent") is None
|
||||
|
||||
def test_save_overwrite_existing(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
sample_user.display_name = "Updated Name"
|
||||
repo.save(sample_user)
|
||||
|
||||
found = repo.find_by_id("user-1")
|
||||
assert found.display_name == "Updated Name"
|
||||
|
||||
|
||||
class TestFindByEmail:
|
||||
def test_find_by_email_case_insensitive(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
# 用不同大小写查找
|
||||
found = repo.find_by_email("test@example.com")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_email_exact_case(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_email("Test@Example.com")
|
||||
assert found is not None
|
||||
|
||||
def test_find_by_email_not_found(self, repo):
|
||||
assert repo.find_by_email("notfound@example.com") is None
|
||||
|
||||
|
||||
class TestFindByUsername:
|
||||
def test_find_by_username_case_insensitive(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_username("TESTUSER")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_username_not_found(self, repo):
|
||||
assert repo.find_by_username("nobody") is None
|
||||
|
||||
def test_find_by_username_empty(self, repo, sample_user):
|
||||
sample_user.username = ""
|
||||
repo.save(sample_user)
|
||||
# 空 username 不应该建立索引,但查找空字符串应该返回None
|
||||
found = repo.find_by_username("")
|
||||
assert found is None
|
||||
|
||||
|
||||
class TestFindByVerificationToken:
|
||||
def test_find_by_verification_token(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_verification_token("verify-token-123")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_verification_token_not_found(self, repo):
|
||||
assert repo.find_by_verification_token("bad-token") is None
|
||||
|
||||
|
||||
class TestFindByPasswordResetToken:
|
||||
def test_find_by_password_reset_token(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_password_reset_token("reset-token-456")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_password_reset_token_not_found(self, repo):
|
||||
assert repo.find_by_password_reset_token("bad-token") is None
|
||||
|
||||
|
||||
class TestFindByWechat:
|
||||
def test_find_by_wechat_openid(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_wechat_openid("wx-openid-abc")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_wechat_openid_not_found(self, repo):
|
||||
assert repo.find_by_wechat_openid("bad-openid") is None
|
||||
|
||||
def test_find_by_wechat_unionid(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_wechat_unionid("wx-unionid-def")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_wechat_unionid_not_found(self, repo):
|
||||
assert repo.find_by_wechat_unionid("bad-unionid") is None
|
||||
|
||||
def test_find_by_wechat_unionid_empty(self, repo, sample_user):
|
||||
sample_user.wechat_unionid = None
|
||||
repo.save(sample_user)
|
||||
assert repo.find_by_wechat_unionid("") is None
|
||||
|
||||
|
||||
class TestFindByPhone:
|
||||
def test_find_by_phone(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_phone("13800138000")
|
||||
assert found is not None
|
||||
assert found.id == "user-1"
|
||||
|
||||
def test_find_by_phone_not_found(self, repo):
|
||||
assert repo.find_by_phone("13900139000") is None
|
||||
|
||||
def test_find_by_phone_empty(self, repo, sample_user):
|
||||
sample_user.phone = None
|
||||
repo.save(sample_user)
|
||||
assert repo.find_by_phone("") is None
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_existing_user(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
assert repo.delete("user-1") is True
|
||||
assert repo.find_by_id("user-1") is None
|
||||
|
||||
def test_delete_cleans_all_indexes(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
repo.delete("user-1")
|
||||
|
||||
assert repo.find_by_email("test@example.com") is None
|
||||
assert repo.find_by_username("testuser") is None
|
||||
assert repo.find_by_verification_token("verify-token-123") is None
|
||||
assert repo.find_by_password_reset_token("reset-token-456") is None
|
||||
|
||||
def test_delete_nonexistent_user(self, repo):
|
||||
assert repo.delete("nonexistent") is False
|
||||
|
||||
def test_delete_twice_returns_false(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
assert repo.delete("user-1") is True
|
||||
assert repo.delete("user-1") is False
|
||||
|
||||
|
||||
class TestIndexUpdates:
|
||||
def test_save_new_user_with_same_email_overwrites_index(self, repo, sample_user):
|
||||
"""不同用户同邮箱,后者覆盖索引."""
|
||||
repo.save(sample_user)
|
||||
user2 = User(
|
||||
id="user-2",
|
||||
email="test@example.com", # 同邮箱不同大小写
|
||||
display_name="User 2",
|
||||
username="user2",
|
||||
)
|
||||
repo.save(user2)
|
||||
|
||||
# 邮箱索引指向最后保存的用户
|
||||
found = repo.find_by_email("test@example.com")
|
||||
assert found.id == "user-2"
|
||||
# 原用户仍然可通过ID找到
|
||||
assert repo.find_by_id("user-1") is not None
|
||||
+393
-304
@@ -1,63 +1,68 @@
|
||||
"""JWT 服务单元测试 — wave130."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""JWT 服务与处理器单元测试."""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt as pyjwt
|
||||
import jwt
|
||||
import pytest
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
|
||||
from packages.application.auth.jwt_handler import (
|
||||
JWTHandler,
|
||||
configure_jwt_handler,
|
||||
get_jwt_handler,
|
||||
)
|
||||
from packages.application.auth.jwt_service import (
|
||||
JWTConfig,
|
||||
JWTService,
|
||||
TokenType,
|
||||
)
|
||||
|
||||
# ── 测试常量 ────────────────────────────────────────────────────────────────
|
||||
# ── 测试常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_SECRET = "test-secret-key-for-unit-testing-only-not-for-production"
|
||||
STRONG_SECRET = "x" * 32 # 满足长度要求的测试密钥
|
||||
|
||||
|
||||
TEST_SECRET = "test-secret-key-for-unit-testing-only-1234567890"
|
||||
TEST_ALGORITHM = "HS256"
|
||||
|
||||
|
||||
# ── JWTConfig 配置 ──────────────────────────────────────────────────────────
|
||||
# ── JWTConfig 测试 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJWTConfig:
|
||||
def test_normal_config(self):
|
||||
config = JWTConfig(secret_key=TEST_SECRET)
|
||||
assert config.SECRET_KEY == TEST_SECRET
|
||||
"""JWTConfig 配置类测试"""
|
||||
|
||||
def test_init_with_valid_secret(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET)
|
||||
assert config.SECRET_KEY == STRONG_SECRET
|
||||
assert config.ALGORITHM == "HS256"
|
||||
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15
|
||||
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7
|
||||
|
||||
def test_custom_config(self):
|
||||
def test_init_custom_values(self):
|
||||
config = JWTConfig(
|
||||
secret_key=TEST_SECRET,
|
||||
secret_key=STRONG_SECRET,
|
||||
algorithm="HS384",
|
||||
access_token_expire_minutes=60,
|
||||
refresh_token_expire_days=30,
|
||||
refresh_token_expire_days=14,
|
||||
)
|
||||
assert config.SECRET_KEY == STRONG_SECRET
|
||||
assert config.ALGORITHM == "HS384"
|
||||
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 60
|
||||
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 30
|
||||
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 14
|
||||
|
||||
def test_empty_secret_raises(self):
|
||||
with pytest.raises(ValueError, match="secret_key must be provided"):
|
||||
JWTConfig(secret_key="")
|
||||
|
||||
def test_whitespace_secret_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
def test_whitespace_only_secret_raises(self):
|
||||
with pytest.raises(ValueError, match="secret_key must be provided"):
|
||||
JWTConfig(secret_key=" ")
|
||||
|
||||
def test_none_secret_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
JWTConfig(secret_key=None) # type: ignore
|
||||
with pytest.raises(ValueError, match="secret_key must be provided"):
|
||||
JWTConfig(secret_key=None)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_secret",
|
||||
"insecure_secret",
|
||||
[
|
||||
"your-secret-key-change-in-production",
|
||||
"your-secret-key",
|
||||
@@ -68,323 +73,407 @@ class TestJWTConfig:
|
||||
"Your-Secret-Key",
|
||||
],
|
||||
)
|
||||
def test_insecure_defaults_rejected(self, bad_secret):
|
||||
def test_insecure_default_secret_raises(self, insecure_secret):
|
||||
with pytest.raises(ValueError, match="insecure"):
|
||||
JWTConfig(secret_key=bad_secret)
|
||||
JWTConfig(secret_key=insecure_secret)
|
||||
|
||||
def test_zero_expire_minutes_allowed(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0)
|
||||
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 0
|
||||
|
||||
def test_negative_expire_days_allowed(self):
|
||||
# 配置类不校验合理性,由业务层判断
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=-1)
|
||||
assert config.REFRESH_TOKEN_EXPIRE_DAYS == -1
|
||||
|
||||
|
||||
# ── JWTService 初始化 ──────────────────────────────────────────────────────
|
||||
# ── JWTService 初始化测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJWTServiceInit:
|
||||
def test_with_config_works(self):
|
||||
config = JWTConfig(secret_key=TEST_SECRET)
|
||||
"""JWTService 初始化测试"""
|
||||
|
||||
def test_init_with_config(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET)
|
||||
service = JWTService(config)
|
||||
assert service.config is config
|
||||
|
||||
def test_none_config_raises(self):
|
||||
with pytest.raises(ValueError, match="JWTService requires"):
|
||||
def test_init_none_config_raises(self):
|
||||
with pytest.raises(ValueError, match="JWTService requires a JWTConfig"):
|
||||
JWTService(None)
|
||||
|
||||
|
||||
# ── create_access_token ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateAccessToken:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
|
||||
def test_creates_valid_jwt(self):
|
||||
token = self.service.create_access_token(user_id="user123")
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
# JWT 格式:xxx.yyy.zzz
|
||||
assert token.count(".") == 2
|
||||
|
||||
def test_payload_contains_user_id(self):
|
||||
token = self.service.create_access_token(user_id="user_001")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "user_001"
|
||||
|
||||
def test_payload_contains_role(self):
|
||||
token = self.service.create_access_token(user_id="u1", role="admin")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_default_role_empty(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["role"] == ""
|
||||
|
||||
def test_token_type_is_access(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["type"] == TokenType.ACCESS
|
||||
|
||||
def test_has_iat_and_exp(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] > payload["iat"]
|
||||
|
||||
def test_expiration_correct(self):
|
||||
"""过期时间大约等于当前时间 + 配置的分钟数."""
|
||||
config = JWTConfig(secret_key=TEST_SECRET, access_token_expire_minutes=30)
|
||||
service = JWTService(config)
|
||||
before = datetime.now(timezone.utc)
|
||||
token = service.create_access_token(user_id="u1")
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
|
||||
min_expected = before + timedelta(minutes=30) - timedelta(seconds=1)
|
||||
max_expected = after + timedelta(minutes=30) + timedelta(seconds=1)
|
||||
assert min_expected <= exp <= max_expected
|
||||
|
||||
def test_additional_claims_included(self):
|
||||
extra = {"email": "test@example.com", "org_id": "org_001", "level": 5}
|
||||
token = self.service.create_access_token(user_id="u1", additional_claims=extra)
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["email"] == "test@example.com"
|
||||
assert payload["org_id"] == "org_001"
|
||||
assert payload["level"] == 5
|
||||
|
||||
def test_additional_claims_none(self):
|
||||
token = self.service.create_access_token(user_id="u1", additional_claims=None)
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert "email" not in payload
|
||||
|
||||
def test_signed_with_correct_key(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
# 用正确的密钥可以解码
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "u1"
|
||||
# 用错误的密钥无法解码
|
||||
with pytest.raises(InvalidTokenError):
|
||||
pyjwt.decode(token, "wrong-secret", algorithms=["HS256"])
|
||||
|
||||
|
||||
# ── create_refresh_token ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateRefreshToken:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
|
||||
def test_creates_valid_token(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="sess_001")
|
||||
assert isinstance(token, str)
|
||||
assert token.count(".") == 2
|
||||
|
||||
def test_payload_contains_session_id(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="sess_abc")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["session_id"] == "sess_abc"
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_token_type_is_refresh(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
assert payload["type"] == TokenType.REFRESH
|
||||
|
||||
def test_refresh_expiration_days(self):
|
||||
config = JWTConfig(secret_key=TEST_SECRET, refresh_token_expire_days=7)
|
||||
service = JWTService(config)
|
||||
before = datetime.now(timezone.utc)
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
|
||||
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
|
||||
min_exp = before + timedelta(days=7) - timedelta(seconds=1)
|
||||
max_exp = after + timedelta(days=7, seconds=1)
|
||||
assert min_exp <= exp <= max_exp
|
||||
|
||||
|
||||
# ── verify_token ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyToken:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
|
||||
def test_valid_token_returns_payload(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
payload = self.service.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_expired_token_raises(self):
|
||||
# 创建一个 1 秒过期的 token
|
||||
config = JWTConfig(secret_key=TEST_SECRET, access_token_expire_minutes=1)
|
||||
service = JWTService(config)
|
||||
token = service.create_access_token(user_id="u1")
|
||||
|
||||
# 等待过期(用 pyjwt 直接构造过期 token 更可靠)
|
||||
expired_payload = {
|
||||
"sub": "u1",
|
||||
"type": "access",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(seconds=10),
|
||||
}
|
||||
expired_token = pyjwt.encode(expired_payload, TEST_SECRET, algorithm="HS256")
|
||||
|
||||
with pytest.raises(ExpiredSignatureError, match="expired"):
|
||||
self.service.verify_token(expired_token)
|
||||
|
||||
def test_invalid_token_raises(self):
|
||||
with pytest.raises(InvalidTokenError, match="Invalid token"):
|
||||
self.service.verify_token("not-a-valid-jwt-token")
|
||||
|
||||
def test_wrong_signature_raises(self):
|
||||
token = pyjwt.encode({"sub": "u1"}, "different-secret", algorithm="HS256")
|
||||
with pytest.raises(InvalidTokenError):
|
||||
self.service.verify_token(token)
|
||||
|
||||
def test_tampered_payload_raises(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
# 尝试篡改:JWT 有签名保护,篡改会导致验证失败
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
# 把 payload 部分替换(不会成功,因为签名不对)
|
||||
import base64
|
||||
|
||||
fake_payload = base64.urlsafe_b64encode(b'{"sub":"admin","role":"admin"}').rstrip(b"=").decode()
|
||||
tampered = f"{parts[0]}.{fake_payload}.{parts[2]}"
|
||||
with pytest.raises(InvalidTokenError):
|
||||
self.service.verify_token(tampered)
|
||||
|
||||
|
||||
# ── verify_access_token ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyAccessToken:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
|
||||
def test_access_token_passes(self):
|
||||
token = self.service.create_access_token(user_id="u1", role="user")
|
||||
payload = self.service.verify_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_refresh_token_rejected(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
with pytest.raises(ValueError, match="Token type must be 'access'"):
|
||||
self.service.verify_access_token(token)
|
||||
|
||||
def test_expired_token_raises(self):
|
||||
expired_payload = {
|
||||
"sub": "u1",
|
||||
"type": "access",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(seconds=10),
|
||||
}
|
||||
token = pyjwt.encode(expired_payload, TEST_SECRET, algorithm="HS256")
|
||||
with pytest.raises(ExpiredSignatureError):
|
||||
self.service.verify_access_token(token)
|
||||
|
||||
|
||||
# ── verify_refresh_token ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyRefreshToken:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
|
||||
def test_refresh_token_passes(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="sess_001")
|
||||
payload = self.service.verify_refresh_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["session_id"] == "sess_001"
|
||||
|
||||
def test_access_token_rejected(self):
|
||||
token = self.service.create_access_token(user_id="u1")
|
||||
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
|
||||
self.service.verify_refresh_token(token)
|
||||
|
||||
def test_has_session_id(self):
|
||||
token = self.service.create_refresh_token(user_id="u1", session_id="custom_sess")
|
||||
payload = self.service.verify_refresh_token(token)
|
||||
assert payload["session_id"] == "custom_sess"
|
||||
|
||||
|
||||
# ── TokenType 常量 ──────────────────────────────────────────────────────────
|
||||
# ── TokenType 测试 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTokenType:
|
||||
"""TokenType 常量测试"""
|
||||
|
||||
def test_access_value(self):
|
||||
assert TokenType.ACCESS == "access"
|
||||
|
||||
def test_refresh_value(self):
|
||||
assert TokenType.REFRESH == "refresh"
|
||||
|
||||
def test_different_types(self):
|
||||
def test_access_and_refresh_different(self):
|
||||
assert TokenType.ACCESS != TokenType.REFRESH
|
||||
|
||||
|
||||
# ── 多算法支持 ──────────────────────────────────────────────────────────────
|
||||
# ── JWTService create_access_token 测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestDifferentAlgorithms:
|
||||
def test_hs384_works(self):
|
||||
config = JWTConfig(secret_key=TEST_SECRET * 2, algorithm="HS384")
|
||||
class TestCreateAccessToken:
|
||||
"""创建 access_token 测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return JWTService(JWTConfig(secret_key=STRONG_SECRET))
|
||||
|
||||
def test_creates_valid_jwt_string(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_token_contains_user_id_as_sub(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "user-123"
|
||||
|
||||
def test_token_type_is_access(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["type"] == TokenType.ACCESS
|
||||
|
||||
def test_default_role_is_empty_string(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["role"] == ""
|
||||
|
||||
def test_custom_role(self, service):
|
||||
token = service.create_access_token(user_id="user-123", role="admin")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_has_iat_and_exp(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] > payload["iat"]
|
||||
|
||||
def test_expire_matches_config(self, service):
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
iat = datetime.fromtimestamp(payload["iat"], tz=timezone.utc)
|
||||
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
delta = exp - iat
|
||||
assert delta.total_seconds() == 15 * 60 # 15分钟
|
||||
|
||||
def test_custom_expire_time(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=30)
|
||||
service = JWTService(config)
|
||||
token = service.create_access_token(user_id="user-123")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
delta = payload["exp"] - payload["iat"]
|
||||
assert delta == 30 * 60
|
||||
|
||||
def test_additional_claims(self, service):
|
||||
extra = {"custom_field": "value", "another": 42}
|
||||
token = service.create_access_token(user_id="user-123", additional_claims=extra)
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["custom_field"] == "value"
|
||||
assert payload["another"] == 42
|
||||
|
||||
def test_additional_claims_can_override_standard(self, service):
|
||||
# additional_claims 可以覆盖标准字段(由调用者负责)
|
||||
token = service.create_access_token(
|
||||
user_id="user-123",
|
||||
additional_claims={"sub": "overridden"},
|
||||
)
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "overridden"
|
||||
|
||||
def test_additional_claims_none_is_same_as_empty(self, service):
|
||||
token = service.create_access_token(user_id="user-123", additional_claims=None)
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "user-123"
|
||||
|
||||
def test_uses_correct_algorithm(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, algorithm="HS384")
|
||||
service = JWTService(config)
|
||||
token = service.create_access_token(user_id="u1")
|
||||
payload = service.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_hs512_works(self):
|
||||
config = JWTConfig(secret_key=TEST_SECRET * 3, algorithm="HS512")
|
||||
service = JWTService(config)
|
||||
token = service.create_access_token(user_id="u1")
|
||||
payload = service.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_algorithm_mismatch_fails(self):
|
||||
config_hs256 = JWTConfig(secret_key=TEST_SECRET, algorithm="HS256")
|
||||
config_hs384 = JWTConfig(secret_key=TEST_SECRET, algorithm="HS384")
|
||||
service_256 = JWTService(config_hs256)
|
||||
service_384 = JWTService(config_hs384)
|
||||
|
||||
token = service_256.create_access_token(user_id="u1")
|
||||
# 用 HS256 解码应该失败
|
||||
with pytest.raises(InvalidTokenError):
|
||||
service_384.verify_token(token)
|
||||
jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
# 用 HS384 解码应该成功
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS384"])
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
|
||||
# ── 边界:空用户ID等 ────────────────────────────────────────────────────────
|
||||
# ── JWTService create_refresh_token 测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def setup_method(self):
|
||||
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
|
||||
class TestCreateRefreshToken:
|
||||
"""创建 refresh_token 测试"""
|
||||
|
||||
def test_empty_user_id(self):
|
||||
token = self.service.create_access_token(user_id="")
|
||||
payload = self.service.verify_access_token(token)
|
||||
assert payload["sub"] == ""
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return JWTService(JWTConfig(secret_key=STRONG_SECRET))
|
||||
|
||||
def test_long_user_id(self):
|
||||
long_id = "x" * 1000
|
||||
token = self.service.create_access_token(user_id=long_id)
|
||||
payload = self.service.verify_access_token(token)
|
||||
assert payload["sub"] == long_id
|
||||
def test_creates_valid_string(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_special_chars_in_user_id(self):
|
||||
uid = "user@#$%^&*()_+-=[]{}|;:',.<>?/`~"
|
||||
token = self.service.create_access_token(user_id=uid)
|
||||
payload = self.service.verify_access_token(token)
|
||||
assert payload["sub"] == uid
|
||||
def test_contains_user_id_and_session_id(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="sess-abc")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["session_id"] == "sess-abc"
|
||||
|
||||
def test_unicode_user_id(self):
|
||||
uid = "用户_测试_123_🎉"
|
||||
token = self.service.create_access_token(user_id=uid)
|
||||
payload = self.service.verify_access_token(token)
|
||||
assert payload["sub"] == uid
|
||||
def test_token_type_is_refresh(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert payload["type"] == TokenType.REFRESH
|
||||
|
||||
def test_many_additional_claims(self):
|
||||
claims = {f"key_{i}": f"value_{i}" for i in range(50)}
|
||||
token = self.service.create_access_token(user_id="u1", additional_claims=claims)
|
||||
payload = self.service.verify_access_token(token)
|
||||
for i in range(50):
|
||||
assert payload[f"key_{i}"] == f"value_{i}"
|
||||
def test_has_iat_and_exp(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] > payload["iat"]
|
||||
|
||||
def test_expire_matches_config_days(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
delta = payload["exp"] - payload["iat"]
|
||||
assert delta == 7 * 24 * 60 * 60 # 7天
|
||||
|
||||
def test_custom_refresh_expire_days(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=30)
|
||||
service = JWTService(config)
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"])
|
||||
delta = payload["exp"] - payload["iat"]
|
||||
assert delta == 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
# ── JWTService verify_token 测试 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyToken:
|
||||
"""通用 Token 验证测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return JWTService(JWTConfig(secret_key=STRONG_SECRET))
|
||||
|
||||
def test_verify_valid_access_token(self, service):
|
||||
token = service.create_access_token(user_id="u1")
|
||||
payload = service.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["type"] == TokenType.ACCESS
|
||||
|
||||
def test_verify_valid_refresh_token(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = service.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["session_id"] == "s1"
|
||||
|
||||
def test_verify_expired_token_raises(self, service):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0)
|
||||
svc = JWTService(config)
|
||||
token = svc.create_access_token(user_id="u1")
|
||||
# 0 分钟过期,立即过期
|
||||
time.sleep(0.1) # 稍微等一下确保过期
|
||||
with pytest.raises(ExpiredSignatureError):
|
||||
svc.verify_token(token)
|
||||
|
||||
def test_verify_wrong_secret_raises(self, service):
|
||||
token = service.create_access_token(user_id="u1")
|
||||
other_service = JWTService(JWTConfig(secret_key="different-secret-1234567890"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
other_service.verify_token(token)
|
||||
|
||||
def test_verify_tampered_token_raises(self, service):
|
||||
token = service.create_access_token(user_id="u1")
|
||||
# 篡改 token 中间部分
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
tampered = parts[0] + "." + parts[1][:-1] + "A." + parts[2]
|
||||
with pytest.raises(InvalidTokenError):
|
||||
service.verify_token(tampered)
|
||||
|
||||
def test_verify_empty_string_raises(self, service):
|
||||
with pytest.raises(InvalidTokenError):
|
||||
service.verify_token("")
|
||||
|
||||
def test_verify_garbage_string_raises(self, service):
|
||||
with pytest.raises(InvalidTokenError):
|
||||
service.verify_token("not.a.valid.jwt.token")
|
||||
|
||||
def test_verify_returns_dict(self, service):
|
||||
token = service.create_access_token(user_id="u1", role="admin")
|
||||
payload = service.verify_token(token)
|
||||
assert isinstance(payload, dict)
|
||||
assert "sub" in payload
|
||||
assert "role" in payload
|
||||
|
||||
|
||||
# ── JWTService verify_access_token 测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyAccessToken:
|
||||
"""Access Token 专属验证测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return JWTService(JWTConfig(secret_key=STRONG_SECRET))
|
||||
|
||||
def test_valid_access_token_passes(self, service):
|
||||
token = service.create_access_token(user_id="u1", role="admin")
|
||||
payload = service.verify_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_refresh_token_fails_type_check(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
with pytest.raises(ValueError, match="Token type must be 'access'"):
|
||||
service.verify_access_token(token)
|
||||
|
||||
def test_token_without_type_field_raises(self, service):
|
||||
# 手动构造一个没有 type 字段的 token
|
||||
payload_data = {"sub": "u1", "iat": 1000, "exp": 9999999999}
|
||||
token = jwt.encode(payload_data, STRONG_SECRET, algorithm="HS256")
|
||||
with pytest.raises(ValueError, match="Token type must be 'access'"):
|
||||
service.verify_access_token(token)
|
||||
|
||||
def test_expired_access_token_raises_expired_error(self, service):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0)
|
||||
svc = JWTService(config)
|
||||
token = svc.create_access_token(user_id="u1")
|
||||
time.sleep(0.1)
|
||||
with pytest.raises(ExpiredSignatureError):
|
||||
svc.verify_access_token(token)
|
||||
|
||||
|
||||
# ── JWTService verify_refresh_token 测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyRefreshToken:
|
||||
"""Refresh Token 专属验证测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return JWTService(JWTConfig(secret_key=STRONG_SECRET))
|
||||
|
||||
def test_valid_refresh_token_passes(self, service):
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
payload = service.verify_refresh_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["session_id"] == "s1"
|
||||
|
||||
def test_access_token_fails_type_check(self, service):
|
||||
token = service.create_access_token(user_id="u1")
|
||||
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
|
||||
service.verify_refresh_token(token)
|
||||
|
||||
def test_token_without_type_field_raises(self, service):
|
||||
payload_data = {"sub": "u1", "session_id": "s1", "iat": 1000, "exp": 9999999999}
|
||||
token = jwt.encode(payload_data, STRONG_SECRET, algorithm="HS256")
|
||||
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
|
||||
service.verify_refresh_token(token)
|
||||
|
||||
def test_expired_refresh_token_raises(self):
|
||||
config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=0)
|
||||
service = JWTService(config)
|
||||
token = service.create_refresh_token(user_id="u1", session_id="s1")
|
||||
# 0天过期,应该立即使exp <= iat
|
||||
with pytest.raises(ExpiredSignatureError):
|
||||
service.verify_refresh_token(token)
|
||||
|
||||
|
||||
# ── JWTHandler 委托层测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJWTHandler:
|
||||
"""JWTHandler 委托层测试"""
|
||||
|
||||
def test_init_creates_handler(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET)
|
||||
assert handler is not None
|
||||
|
||||
def test_create_and_verify_access_token(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET)
|
||||
token = handler.create_access_token(user_id="u1", role="user")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["role"] == "user"
|
||||
|
||||
def test_verify_token_generic(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET)
|
||||
token = handler.create_access_token(user_id="u1")
|
||||
payload = handler.verify_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_custom_algorithm(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET, algorithm="HS384")
|
||||
token = handler.create_access_token(user_id="u1")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_custom_expire_minutes(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET, access_token_expire_minutes=45)
|
||||
token = handler.create_access_token(user_id="u1")
|
||||
payload = handler.verify_access_token(token)
|
||||
delta = payload["exp"] - payload["iat"]
|
||||
assert delta == 45 * 60
|
||||
|
||||
def test_additional_claims_passthrough(self):
|
||||
handler = JWTHandler(secret_key=STRONG_SECRET)
|
||||
extra = {"org_id": "org-1", "plan": "pro"}
|
||||
token = handler.create_access_token("u1", additional_claims={"org_id": "org-1"})
|
||||
payload = handler.verify_access_token(
|
||||
token := handler.create_access_token("u1", additional_claims={"org_id": "org-1"})
|
||||
)
|
||||
# 这里直接测试更简洁
|
||||
payload = handler.verify_access_token(handler.create_access_token("u1", additional_claims={"x": 1}))
|
||||
assert payload["x"] == 1
|
||||
|
||||
|
||||
# ── 全局 JWT handler 测试 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGlobalJWTHandler:
|
||||
"""全局 JWT Handler 配置与获取测试"""
|
||||
|
||||
def test_configure_creates_handler(self):
|
||||
handler = configure_jwt_handler(secret_key=STRONG_SECRET)
|
||||
assert isinstance(handler, JWTHandler)
|
||||
|
||||
def test_get_after_configure_works(self):
|
||||
configure_jwt_handler(secret_key=STRONG_SECRET)
|
||||
handler = get_jwt_handler()
|
||||
assert isinstance(handler, JWTHandler)
|
||||
token = handler.create_access_token(user_id="u1")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
|
||||
def test_get_before_configure_raises(self):
|
||||
# 重置全局状态(通过设置 None 模拟未配置)
|
||||
import packages.application.auth.jwt_handler as mod
|
||||
|
||||
mod._default_handler = None
|
||||
with pytest.raises(RuntimeError, match="JWT handler not configured"):
|
||||
get_jwt_handler()
|
||||
|
||||
def test_configure_returns_same_as_get(self):
|
||||
h1 = configure_jwt_handler(secret_key=STRONG_SECRET)
|
||||
h2 = get_jwt_handler()
|
||||
assert h1 is h2
|
||||
|
||||
def test_reconfigure_replaces_handler(self):
|
||||
h1 = configure_jwt_handler(secret_key=STRONG_SECRET)
|
||||
h2 = configure_jwt_handler(secret_key=STRONG_SECRET + "_new")
|
||||
assert h1 is not h2
|
||||
assert get_jwt_handler() is h2
|
||||
|
||||
+601
-412
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+79
-288
@@ -1,9 +1,6 @@
|
||||
"""通用分页器单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""pagination 单元测试."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.application.common.pagination import (
|
||||
PaginatedResponse,
|
||||
@@ -12,377 +9,171 @@ from packages.application.common.pagination import (
|
||||
paginate,
|
||||
)
|
||||
|
||||
# ── PaginationParams ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginationParams:
|
||||
"""PaginationParams 测试"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确"""
|
||||
params = PaginationParams()
|
||||
assert params.page == 1
|
||||
assert params.page_size == 20
|
||||
|
||||
def test_offset_first_page(self):
|
||||
"""第一页 offset 为 0"""
|
||||
def test_custom_values(self):
|
||||
params = PaginationParams(page=3, page_size=50)
|
||||
assert params.page == 3
|
||||
assert params.page_size == 50
|
||||
|
||||
def test_offset_calculation(self):
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
assert params.offset == 0
|
||||
|
||||
def test_offset_second_page(self):
|
||||
"""第二页 offset 计算正确"""
|
||||
params = PaginationParams(page=2, page_size=20)
|
||||
assert params.offset == 20
|
||||
params = PaginationParams(page=3, page_size=20)
|
||||
assert params.offset == 40
|
||||
|
||||
def test_offset_custom_page_size(self):
|
||||
"""自定义 page_size 的 offset"""
|
||||
params = PaginationParams(page=3, page_size=10)
|
||||
assert params.offset == 20
|
||||
params = PaginationParams(page=10, page_size=50)
|
||||
assert params.offset == 450
|
||||
|
||||
def test_limit_equals_page_size(self):
|
||||
"""limit 等于 page_size"""
|
||||
params = PaginationParams(page_size=50)
|
||||
assert params.limit == 50
|
||||
params = PaginationParams(page_size=30)
|
||||
assert params.limit == 30
|
||||
|
||||
def test_page_must_be_at_least_1(self):
|
||||
"""page 不能小于 1"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
PaginationParams(page=0)
|
||||
|
||||
def test_page_negative_raises(self):
|
||||
"""page 不能为负数"""
|
||||
with pytest.raises(ValidationError):
|
||||
PaginationParams(page=-1)
|
||||
|
||||
def test_page_size_must_be_at_least_1(self):
|
||||
"""page_size 不能小于 1"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
PaginationParams(page_size=0)
|
||||
|
||||
def test_page_size_max_100(self):
|
||||
"""page_size 最大 100"""
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ValueError):
|
||||
PaginationParams(page_size=101)
|
||||
|
||||
def test_page_size_100_is_valid(self):
|
||||
"""page_size=100 是合法的"""
|
||||
params = PaginationParams(page_size=100)
|
||||
assert params.page_size == 100
|
||||
|
||||
# ── PaginationMeta ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginationMeta:
|
||||
"""PaginationMeta 测试"""
|
||||
|
||||
def test_from_params_first_page(self):
|
||||
"""第一页元数据"""
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=25)
|
||||
|
||||
assert meta.page == 1
|
||||
assert meta.page_size == 10
|
||||
assert meta.total == 25
|
||||
assert meta.total_pages == 3
|
||||
assert meta.total_pages == 3 # ceil(25/10)
|
||||
assert meta.has_next is True
|
||||
assert meta.has_prev is False
|
||||
|
||||
def test_from_params_last_page(self):
|
||||
"""最后一页元数据"""
|
||||
params = PaginationParams(page=3, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=25)
|
||||
|
||||
assert meta.page == 3
|
||||
assert meta.total_pages == 3
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is True
|
||||
|
||||
def test_from_params_middle_page(self):
|
||||
"""中间页元数据"""
|
||||
params = PaginationParams(page=2, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=50)
|
||||
|
||||
assert meta.page == 2
|
||||
assert meta.total_pages == 5
|
||||
meta = PaginationMeta.from_params(params, total=25)
|
||||
assert meta.has_next is True
|
||||
assert meta.has_prev is True
|
||||
|
||||
def test_from_params_single_page(self):
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
meta = PaginationMeta.from_params(params, total=5)
|
||||
assert meta.total_pages == 1
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
def test_from_params_zero_total(self):
|
||||
"""总数为 0 时"""
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
meta = PaginationMeta.from_params(params, total=0)
|
||||
|
||||
assert meta.total == 0
|
||||
assert meta.total_pages == 0
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
def test_from_params_exact_multiple(self):
|
||||
"""总数刚好是 page_size 的整数倍"""
|
||||
def test_from_params_exact_page_size(self):
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=30)
|
||||
|
||||
assert meta.total_pages == 3
|
||||
|
||||
def test_from_params_single_page(self):
|
||||
"""单页即可放下所有数据"""
|
||||
params = PaginationParams(page=1, page_size=100)
|
||||
meta = PaginationMeta.from_params(params, total=50)
|
||||
|
||||
meta = PaginationMeta.from_params(params, total=10)
|
||||
assert meta.total_pages == 1
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
def test_from_params_one_extra(self):
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=11)
|
||||
assert meta.total_pages == 2
|
||||
|
||||
|
||||
# ── PaginatedResponse ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginatedResponse:
|
||||
"""PaginatedResponse 测试"""
|
||||
|
||||
def test_create_success(self):
|
||||
"""创建分页响应"""
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
data = [1, 2, 3]
|
||||
|
||||
response = PaginatedResponse.create(data, params, total=25)
|
||||
|
||||
assert response.data == [1, 2, 3]
|
||||
def test_create_response(self):
|
||||
params = PaginationParams(page=1, page_size=5)
|
||||
data = [1, 2, 3, 4, 5]
|
||||
response = PaginatedResponse.create(data, params, total=15)
|
||||
assert response.data == data
|
||||
assert response.pagination.page == 1
|
||||
assert response.pagination.total == 25
|
||||
assert response.pagination.total == 15
|
||||
assert response.pagination.total_pages == 3
|
||||
|
||||
def test_create_empty_data(self):
|
||||
"""空数据分页响应"""
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
response = PaginatedResponse.create([], params, total=0)
|
||||
|
||||
assert response.data == []
|
||||
assert response.pagination.total == 0
|
||||
assert response.pagination.total_pages == 0
|
||||
# ── paginate function ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginateFunction:
|
||||
"""paginate 函数测试(内存分页)"""
|
||||
|
||||
class TestPaginate:
|
||||
def test_first_page(self):
|
||||
"""第一页分页"""
|
||||
items = list(range(30))
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert result.data == list(range(10))
|
||||
assert result.pagination.total == 30
|
||||
assert result.pagination.total_pages == 3
|
||||
assert result.pagination.has_next is True
|
||||
assert result.pagination.has_prev is False
|
||||
|
||||
def test_second_page(self):
|
||||
"""第二页分页"""
|
||||
items = list(range(30))
|
||||
params = PaginationParams(page=2, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert result.data == list(range(10, 20))
|
||||
assert result.pagination.page == 2
|
||||
|
||||
def test_last_page(self):
|
||||
"""最后一页分页"""
|
||||
items = list(range(25))
|
||||
params = PaginationParams(page=3, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert result.data == list(range(20, 25))
|
||||
assert len(result.data) == 5
|
||||
assert result.pagination.has_next is False
|
||||
assert result.pagination.has_prev is True
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表分页"""
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
result = paginate([], params)
|
||||
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 0
|
||||
assert result.pagination.total_pages == 0
|
||||
|
||||
def test_page_beyond_total(self):
|
||||
"""页码超出总数"""
|
||||
def test_single_page(self):
|
||||
items = list(range(5))
|
||||
params = PaginationParams(page=10, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 5
|
||||
assert result.pagination.total_pages == 1
|
||||
|
||||
def test_custom_page_size(self):
|
||||
"""自定义每页数量"""
|
||||
items = list(range(100))
|
||||
params = PaginationParams(page=1, page_size=50)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert len(result.data) == 50
|
||||
assert result.pagination.total_pages == 2
|
||||
|
||||
def test_single_item(self):
|
||||
"""单条数据"""
|
||||
items = ["only_one"]
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert result.data == ["only_one"]
|
||||
assert result.pagination.total == 1
|
||||
assert result.pagination.total_pages == 1
|
||||
|
||||
def test_generic_type_preserved(self):
|
||||
"""泛型类型数据正确"""
|
||||
items = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
|
||||
result = paginate(items, params)
|
||||
|
||||
assert len(result.data) == 2
|
||||
assert result.data[0]["id"] == 1
|
||||
|
||||
|
||||
# ── PaginationParams 补充边界 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginationParamsEdgeCases:
|
||||
"""PaginationParams 补充边界场景."""
|
||||
|
||||
def test_page_size_1_minimum(self):
|
||||
"""page_size=1 是允许的最小值."""
|
||||
params = PaginationParams(page_size=1)
|
||||
assert params.page_size == 1
|
||||
assert params.limit == 1
|
||||
|
||||
def test_page_size_100_maximum(self):
|
||||
"""page_size=100 是允许的最大值."""
|
||||
params = PaginationParams(page_size=100)
|
||||
assert params.page_size == 100
|
||||
|
||||
def test_offset_page_1_size_100(self):
|
||||
"""第1页每页100条 offset=0."""
|
||||
params = PaginationParams(page=1, page_size=100)
|
||||
assert params.offset == 0
|
||||
|
||||
def test_offset_page_100_size_100(self):
|
||||
"""第100页每页100条 offset=9900."""
|
||||
params = PaginationParams(page=100, page_size=100)
|
||||
assert params.offset == 9900
|
||||
|
||||
def test_large_page_number_accepted(self):
|
||||
"""极大页码(超过实际页数)允许."""
|
||||
params = PaginationParams(page=999999, page_size=20)
|
||||
assert params.page == 999999
|
||||
assert params.offset == (999999 - 1) * 20
|
||||
|
||||
|
||||
# ── PaginationMeta 补充边界 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginationMetaEdgeCases:
|
||||
"""PaginationMeta 补充边界场景."""
|
||||
|
||||
def test_total_0_page_1(self):
|
||||
"""total=0, page=1 时 total_pages=0, 无上下页."""
|
||||
params = PaginationParams(page=1, page_size=20)
|
||||
meta = PaginationMeta.from_params(params, total=0)
|
||||
assert meta.total_pages == 0
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
def test_total_0_page_beyond(self):
|
||||
"""total=0, page>1 时 has_prev=True(因为page>1)."""
|
||||
params = PaginationParams(page=3, page_size=20)
|
||||
meta = PaginationMeta.from_params(params, total=0)
|
||||
assert meta.total_pages == 0
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is True
|
||||
|
||||
def test_exact_last_page(self):
|
||||
"""刚好是最后一页时 has_next=False."""
|
||||
params = PaginationParams(page=5, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=50)
|
||||
assert meta.total_pages == 5
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is True
|
||||
|
||||
def test_one_more_than_exact(self):
|
||||
"""比整数页多1条时总页数+1."""
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=51)
|
||||
assert meta.total_pages == 6
|
||||
|
||||
def test_page_exactly_total_pages(self):
|
||||
"""page == total_pages 时 has_next=False."""
|
||||
params = PaginationParams(page=3, page_size=10)
|
||||
meta = PaginationMeta.from_params(params, total=30)
|
||||
assert meta.has_next is False
|
||||
|
||||
def test_total_1_page_1_size_1(self):
|
||||
"""1条数据1页."""
|
||||
params = PaginationParams(page=1, page_size=1)
|
||||
meta = PaginationMeta.from_params(params, total=1)
|
||||
assert meta.total_pages == 1
|
||||
assert meta.has_next is False
|
||||
assert meta.has_prev is False
|
||||
|
||||
|
||||
# ── paginate 补充边界 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginateEdgeCases:
|
||||
"""paginate 补充边界场景."""
|
||||
|
||||
def test_single_item_list(self):
|
||||
"""单元素列表."""
|
||||
result = paginate([42], PaginationParams(page=1, page_size=10))
|
||||
assert result.data == [42]
|
||||
assert result.pagination.total == 1
|
||||
assert result.pagination.total_pages == 1
|
||||
|
||||
def test_page_exactly_last(self):
|
||||
"""刚好在最后一页."""
|
||||
items = list(range(25))
|
||||
result = paginate(items, PaginationParams(page=3, page_size=10))
|
||||
assert result.data == list(range(20, 25))
|
||||
assert result.pagination.has_next is False
|
||||
|
||||
def test_page_past_end_returns_empty(self):
|
||||
"""页码超过总数返回空."""
|
||||
items = list(range(5))
|
||||
result = paginate(items, PaginationParams(page=10, page_size=10))
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 5
|
||||
|
||||
def test_empty_list_page_1(self):
|
||||
"""空列表第1页."""
|
||||
result = paginate([], PaginationParams(page=1, page_size=10))
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 0
|
||||
assert result.pagination.total_pages == 0
|
||||
|
||||
def test_page_size_1_iterates_all(self):
|
||||
"""page_size=1 时每页1条."""
|
||||
items = ["a", "b", "c"]
|
||||
r1 = paginate(items, PaginationParams(page=1, page_size=1))
|
||||
r2 = paginate(items, PaginationParams(page=2, page_size=1))
|
||||
r3 = paginate(items, PaginationParams(page=3, page_size=1))
|
||||
assert r1.data == ["a"]
|
||||
assert r2.data == ["b"]
|
||||
assert r3.data == ["c"]
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
"""不修改输入列表."""
|
||||
items = [1, 2, 3, 4, 5]
|
||||
original = items[:]
|
||||
paginate(items, PaginationParams(page=1, page_size=2))
|
||||
assert items == original
|
||||
|
||||
def test_page_size_greater_than_total(self):
|
||||
"""每页条数大于总数."""
|
||||
items = list(range(5))
|
||||
result = paginate(items, PaginationParams(page=1, page_size=100))
|
||||
assert result.data == items
|
||||
assert result.pagination.total_pages == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
items = []
|
||||
params = PaginationParams(page=1, page_size=10)
|
||||
result = paginate(items, params)
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 0
|
||||
assert result.pagination.total_pages == 0
|
||||
|
||||
def test_page_beyond_end(self):
|
||||
items = list(range(5))
|
||||
params = PaginationParams(page=10, page_size=10)
|
||||
result = paginate(items, params)
|
||||
assert result.data == []
|
||||
assert result.pagination.total == 5
|
||||
|
||||
def test_page_size_larger_than_items(self):
|
||||
items = list(range(5))
|
||||
params = PaginationParams(page=1, page_size=100)
|
||||
result = paginate(items, params)
|
||||
assert result.data == items
|
||||
assert result.pagination.total_pages == 1
|
||||
|
||||
def test_middle_page(self):
|
||||
items = list(range(100))
|
||||
params = PaginationParams(page=5, page_size=10)
|
||||
result = paginate(items, params)
|
||||
assert result.data == list(range(40, 50))
|
||||
assert result.pagination.has_next is True
|
||||
assert result.pagination.has_prev is True
|
||||
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
"""密码哈希与验证模块单元测试."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.password_handler import (
|
||||
PasswordHandler,
|
||||
configure_password_handler,
|
||||
get_password_handler,
|
||||
)
|
||||
from packages.application.auth.password_hasher import (
|
||||
PasswordHasher,
|
||||
PasswordValidator,
|
||||
)
|
||||
|
||||
# ==================== PasswordValidator ====================
|
||||
|
||||
|
||||
class TestPasswordValidator:
|
||||
@pytest.fixture
|
||||
def default_validator(self):
|
||||
return PasswordValidator()
|
||||
|
||||
@pytest.fixture
|
||||
def strict_validator(self):
|
||||
return PasswordValidator(
|
||||
min_length=12,
|
||||
require_uppercase=True,
|
||||
require_lowercase=True,
|
||||
require_digit=True,
|
||||
require_special=True,
|
||||
)
|
||||
|
||||
class TestBasicValidation:
|
||||
def test_valid_password(self, default_validator):
|
||||
ok, msg = default_validator.validate("SecurePass123")
|
||||
assert ok is True
|
||||
assert msg is None
|
||||
|
||||
def test_empty_password(self, default_validator):
|
||||
ok, msg = default_validator.validate("")
|
||||
assert ok is False
|
||||
assert "empty" in msg.lower()
|
||||
|
||||
def test_none_password_treated_as_empty(self, default_validator):
|
||||
# None被not判定为falsy,返回空密码错误
|
||||
ok, msg = default_validator.validate(None)
|
||||
assert ok is False
|
||||
assert "empty" in msg.lower()
|
||||
|
||||
class TestMinLength:
|
||||
def test_too_short(self, default_validator):
|
||||
ok, msg = default_validator.validate("Ab1")
|
||||
assert ok is False
|
||||
assert "8" in msg
|
||||
|
||||
def test_exact_min_length(self, default_validator):
|
||||
# 刚好8个字符
|
||||
ok, _ = default_validator.validate("Abcdefg1")
|
||||
assert ok is True
|
||||
|
||||
def test_custom_min_length(self, strict_validator):
|
||||
ok, msg = strict_validator.validate("Short1!")
|
||||
assert ok is False
|
||||
assert "12" in msg
|
||||
|
||||
class TestUppercase:
|
||||
def test_no_uppercase(self, default_validator):
|
||||
ok, msg = default_validator.validate("password123")
|
||||
assert ok is False
|
||||
assert "uppercase" in msg.lower()
|
||||
|
||||
def test_with_uppercase(self, default_validator):
|
||||
ok, _ = default_validator.validate("Password123")
|
||||
assert ok is True
|
||||
|
||||
def test_disabled_requirement(self):
|
||||
v = PasswordValidator(require_uppercase=False)
|
||||
ok, _ = v.validate("password123")
|
||||
assert ok is True
|
||||
|
||||
class TestLowercase:
|
||||
def test_no_lowercase(self, default_validator):
|
||||
ok, msg = default_validator.validate("PASSWORD123")
|
||||
assert ok is False
|
||||
assert "lowercase" in msg.lower()
|
||||
|
||||
def test_with_lowercase(self, default_validator):
|
||||
ok, _ = default_validator.validate("Password123")
|
||||
assert ok is True
|
||||
|
||||
def test_disabled_requirement(self):
|
||||
v = PasswordValidator(require_lowercase=False)
|
||||
ok, _ = v.validate("PASSWORD123")
|
||||
assert ok is True
|
||||
|
||||
class TestDigit:
|
||||
def test_no_digit(self, default_validator):
|
||||
ok, msg = default_validator.validate("Passworddd")
|
||||
assert ok is False
|
||||
assert "digit" in msg.lower()
|
||||
|
||||
def test_with_digit(self, default_validator):
|
||||
ok, _ = default_validator.validate("Password1")
|
||||
assert ok is True
|
||||
|
||||
def test_disabled_requirement(self):
|
||||
v = PasswordValidator(require_digit=False)
|
||||
ok, _ = v.validate("Passworddd")
|
||||
assert ok is True
|
||||
|
||||
class TestSpecialChar:
|
||||
def test_no_special_when_not_required(self, default_validator):
|
||||
ok, _ = default_validator.validate("Password123")
|
||||
assert ok is True
|
||||
|
||||
def test_no_special_when_required(self, strict_validator):
|
||||
ok, msg = strict_validator.validate("Password1234")
|
||||
assert ok is False
|
||||
assert "special" in msg.lower()
|
||||
|
||||
def test_with_special(self, strict_validator):
|
||||
ok, _ = strict_validator.validate("Password123!")
|
||||
assert ok is True
|
||||
|
||||
def test_various_special_chars(self, strict_validator):
|
||||
for char in "!@#$%^&*()_+-=[]{}|;:,.<>?~":
|
||||
pw = f"LongPassword1{char}" # 13字符,含大小写数字特殊
|
||||
ok, msg = strict_validator.validate(pw)
|
||||
assert ok is True, f"special char {char} should be valid: {msg}"
|
||||
|
||||
class TestAllDisabled:
|
||||
def test_all_disabled_min_length_only(self):
|
||||
v = PasswordValidator(
|
||||
min_length=1,
|
||||
require_uppercase=False,
|
||||
require_lowercase=False,
|
||||
require_digit=False,
|
||||
require_special=False,
|
||||
)
|
||||
ok, _ = v.validate("a")
|
||||
assert ok is True
|
||||
|
||||
def test_all_disabled_empty_still_fails(self):
|
||||
v = PasswordValidator(
|
||||
min_length=1,
|
||||
require_uppercase=False,
|
||||
require_lowercase=False,
|
||||
require_digit=False,
|
||||
require_special=False,
|
||||
)
|
||||
ok, _ = v.validate("")
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ==================== PasswordHasher ====================
|
||||
|
||||
|
||||
class TestPasswordHasher:
|
||||
@pytest.fixture
|
||||
def hasher(self):
|
||||
return PasswordHasher(rounds=4) # 用最低rounds加速测试
|
||||
|
||||
class TestHashPassword:
|
||||
def test_hash_returns_string(self, hasher):
|
||||
result = hasher.hash_password("testpassword")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_hash_starts_with_bcrypt_prefix(self, hasher):
|
||||
result = hasher.hash_password("testpassword")
|
||||
assert result.startswith("$2")
|
||||
|
||||
def test_hash_contains_rounds(self, hasher):
|
||||
result = hasher.hash_password("testpassword")
|
||||
parts = result.split("$")
|
||||
assert parts[2] == "04" # bcrypt rounds格式是两位数
|
||||
|
||||
def test_hash_different_salts(self, hasher):
|
||||
# 同一密码两次哈希结果不同(因为salt随机)
|
||||
h1 = hasher.hash_password("samepassword")
|
||||
h2 = hasher.hash_password("samepassword")
|
||||
assert h1 != h2
|
||||
|
||||
def test_hash_empty_password_raises(self, hasher):
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
hasher.hash_password("")
|
||||
|
||||
def test_hash_unicode_password(self, hasher):
|
||||
result = hasher.hash_password("密码Pass123!")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_hash_long_password(self, hasher):
|
||||
long_pw = "a" * 72 # bcrypt最大72字节
|
||||
result = hasher.hash_password(long_pw)
|
||||
assert isinstance(result, str)
|
||||
|
||||
class TestVerifyPassword:
|
||||
def test_verify_correct_password(self, hasher):
|
||||
hashed = hasher.hash_password("CorrectPass123")
|
||||
assert hasher.verify_password("CorrectPass123", hashed) is True
|
||||
|
||||
def test_verify_wrong_password(self, hasher):
|
||||
hashed = hasher.hash_password("CorrectPass123")
|
||||
assert hasher.verify_password("WrongPass123", hashed) is False
|
||||
|
||||
def test_verify_empty_password(self, hasher):
|
||||
hashed = hasher.hash_password("testpass")
|
||||
assert hasher.verify_password("", hashed) is False
|
||||
|
||||
def test_verify_empty_hash(self, hasher):
|
||||
assert hasher.verify_password("testpass", "") is False
|
||||
|
||||
def test_verify_invalid_hash_format(self, hasher):
|
||||
assert hasher.verify_password("testpass", "invalid-hash-format") is False
|
||||
|
||||
def test_verify_none_hash(self, hasher):
|
||||
assert hasher.verify_password("testpass", None) is False
|
||||
|
||||
def test_verify_unicode_password(self, hasher):
|
||||
pw = "密码Pass123!"
|
||||
hashed = hasher.hash_password(pw)
|
||||
assert hasher.verify_password(pw, hashed) is True
|
||||
|
||||
class TestNeedsRehash:
|
||||
def test_same_rounds_no_rehash(self, hasher):
|
||||
hashed = hasher.hash_password("testpass")
|
||||
assert hasher.needs_rehash(hashed) is False
|
||||
|
||||
def test_lower_rounds_needs_rehash(self):
|
||||
hasher_low = PasswordHasher(rounds=4)
|
||||
hashed = hasher_low.hash_password("testpass")
|
||||
|
||||
hasher_high = PasswordHasher(rounds=5)
|
||||
assert hasher_high.needs_rehash(hashed) is True
|
||||
|
||||
def test_higher_rounds_needs_rehash(self):
|
||||
hasher_high = PasswordHasher(rounds=5)
|
||||
hashed = hasher_high.hash_password("testpass")
|
||||
|
||||
hasher_low = PasswordHasher(rounds=4)
|
||||
assert hasher_low.needs_rehash(hashed) is True
|
||||
|
||||
def test_invalid_hash_no_rehash(self, hasher):
|
||||
assert hasher.needs_rehash("invalid-format") is False
|
||||
|
||||
def test_empty_hash_no_rehash(self, hasher):
|
||||
assert hasher.needs_rehash("") is False
|
||||
|
||||
class TestInit:
|
||||
def test_rounds_below_min_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
PasswordHasher(rounds=3)
|
||||
|
||||
def test_rounds_above_max_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
PasswordHasher(rounds=32)
|
||||
|
||||
def test_min_rounds_ok(self):
|
||||
h = PasswordHasher(rounds=4)
|
||||
assert h.rounds == 4
|
||||
|
||||
def test_max_rounds_ok(self):
|
||||
h = PasswordHasher(rounds=31)
|
||||
assert h.rounds == 31
|
||||
|
||||
|
||||
# ==================== PasswordHandler ====================
|
||||
|
||||
|
||||
class TestPasswordHandler:
|
||||
@pytest.fixture
|
||||
def handler(self):
|
||||
return PasswordHandler(rounds=4)
|
||||
|
||||
def test_hash_and_verify_roundtrip(self, handler):
|
||||
hashed = handler.hash_password("MySecurePass123")
|
||||
assert handler.verify_password("MySecurePass123", hashed) is True
|
||||
assert handler.verify_password("WrongPass", hashed) is False
|
||||
|
||||
def test_needs_rehash(self, handler):
|
||||
# 用当前rounds哈希,不需要rehash
|
||||
hashed = handler.hash_password("testpass")
|
||||
assert handler.needs_rehash(hashed) is False
|
||||
|
||||
def test_validate_strength(self, handler):
|
||||
# 强密码通过
|
||||
ok, msg = handler.validate_strength("StrongPass123")
|
||||
assert ok is True
|
||||
assert msg is None
|
||||
|
||||
# 弱密码不通过
|
||||
ok, msg = handler.validate_strength("weak")
|
||||
assert ok is False
|
||||
assert msg is not None
|
||||
|
||||
def test_hash_empty_raises(self, handler):
|
||||
with pytest.raises(ValueError):
|
||||
handler.hash_password("")
|
||||
|
||||
|
||||
class TestGlobalHandler:
|
||||
def test_get_password_handler_returns_instance(self):
|
||||
# 重置全局实例
|
||||
import packages.application.auth.password_handler as ph
|
||||
|
||||
ph._default_handler = None
|
||||
|
||||
handler = get_password_handler()
|
||||
assert isinstance(handler, PasswordHandler)
|
||||
|
||||
def test_configure_password_handler(self):
|
||||
handler = configure_password_handler(rounds=4)
|
||||
assert isinstance(handler, PasswordHandler)
|
||||
|
||||
# get应该返回同一个配置好的实例
|
||||
same_handler = get_password_handler()
|
||||
assert same_handler is handler
|
||||
@@ -1,6 +1,4 @@
|
||||
"""密码重置 UseCase 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""密码重置 Use Case 单元测试."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -15,285 +13,477 @@ from packages.application.auth.password_reset_use_case import (
|
||||
)
|
||||
from packages.domain.entities import User
|
||||
|
||||
# ── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(
|
||||
user_id="user-1",
|
||||
email="user@example.com",
|
||||
username="testuser",
|
||||
display_name="Test User",
|
||||
password_hash="hashed_password_123",
|
||||
):
|
||||
"""创建一个测试用户."""
|
||||
return User(
|
||||
id=user_id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_repo():
|
||||
return MagicMock()
|
||||
"""mock 用户仓储."""
|
||||
repo = MagicMock()
|
||||
repo.find_by_email.return_value = None
|
||||
repo.find_by_password_reset_token.return_value = None
|
||||
repo.save.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_email_service():
|
||||
"""mock 邮件服务."""
|
||||
svc = MagicMock()
|
||||
svc.send_password_reset_email.return_value = (True, None)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user():
|
||||
user = User(
|
||||
id="user_001",
|
||||
email="user@example.com",
|
||||
display_name="测试用户",
|
||||
username="testuser",
|
||||
password_hash="old_hash",
|
||||
)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires_at = None
|
||||
return user
|
||||
# ── RequestPasswordResetUseCase 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRequestPasswordResetRequest:
|
||||
"""RequestPasswordResetRequest 测试"""
|
||||
class TestRequestPasswordReset:
|
||||
"""请求密码重置用例测试"""
|
||||
|
||||
def test_email_lowercased_and_stripped(self):
|
||||
"""邮箱转小写并去空格"""
|
||||
req = RequestPasswordResetRequest(" User@Example.COM ")
|
||||
assert req.email == "user@example.com"
|
||||
def test_request_success_sends_email(self, mock_user_repo, mock_email_service):
|
||||
"""成功请求时发送重置邮件."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
def test_empty_email(self):
|
||||
"""空邮箱"""
|
||||
req = RequestPasswordResetRequest("")
|
||||
assert req.email == ""
|
||||
|
||||
|
||||
class TestRequestPasswordResetUseCase:
|
||||
"""RequestPasswordResetUseCase 测试"""
|
||||
|
||||
def test_request_success(self, mock_user_repo, mock_email_service, sample_user):
|
||||
"""请求重置成功"""
|
||||
mock_user_repo.find_by_email.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("user@example.com")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert sample_user.password_reset_token is not None
|
||||
assert len(sample_user.password_reset_token) > 0
|
||||
assert sample_user.password_reset_expires_at is not None
|
||||
mock_user_repo.save.assert_called_once()
|
||||
mock_email_service.send_password_reset_email.assert_called_once()
|
||||
|
||||
def test_request_user_not_found_returns_success(self, mock_user_repo, mock_email_service):
|
||||
"""用户不存在也返回成功(安全考虑,不暴露用户存在性)"""
|
||||
mock_user_repo.find_by_email.return_value = None
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("nonexistent@example.com")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
mock_user_repo.save.assert_not_called()
|
||||
mock_email_service.send_password_reset_email.assert_not_called()
|
||||
|
||||
def test_request_empty_email_returns_error(self, mock_user_repo, mock_email_service):
|
||||
"""空邮箱返回错误"""
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "Email is required" in error
|
||||
|
||||
def test_reset_token_expiry_set(self, mock_user_repo, mock_email_service, sample_user):
|
||||
"""重置令牌过期时间正确设置"""
|
||||
mock_user_repo.find_by_email.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
token_expire_hours=2,
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("user@example.com")
|
||||
use_case.execute(request)
|
||||
|
||||
assert sample_user.password_reset_expires_at is not None
|
||||
# 过期时间应该在约2小时后
|
||||
expected = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
diff = abs((sample_user.password_reset_expires_at - expected).total_seconds())
|
||||
assert diff < 10 # 允许10秒误差
|
||||
|
||||
def test_email_contains_reset_url(self, mock_user_repo, mock_email_service, sample_user):
|
||||
"""重置邮件包含正确的重置链接"""
|
||||
mock_user_repo.find_by_email.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("user@example.com")
|
||||
use_case.execute(request)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
call_args = mock_email_service.send_password_reset_email.call_args
|
||||
reset_url = call_args[1]["reset_url"] if "reset_url" in call_args[1] else call_args[0][2]
|
||||
assert "https://app.example.com/reset-password?token=" in reset_url
|
||||
assert ok is True
|
||||
assert error is None
|
||||
# 用户被更新了 reset_token
|
||||
mock_user_repo.save.assert_called_once()
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
assert saved_user.password_reset_token is not None
|
||||
assert len(saved_user.password_reset_token) > 0
|
||||
assert saved_user.password_reset_expires_at is not None
|
||||
# 邮件发送了
|
||||
mock_email_service.send_password_reset_email.assert_called_once()
|
||||
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
|
||||
assert call_kwargs["to_email"] == "user@example.com"
|
||||
assert "reset-password?token=" in call_kwargs["reset_url"]
|
||||
assert "https://app.example.com" in call_kwargs["reset_url"]
|
||||
|
||||
def test_email_failure_does_not_affect_result(self, mock_user_repo, mock_email_service, sample_user):
|
||||
"""邮件发送失败不影响返回结果(安全考虑)"""
|
||||
mock_user_repo.find_by_email.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
def test_request_nonexistent_user_returns_success(self, mock_user_repo, mock_email_service):
|
||||
"""用户不存在时也返回成功(不暴露用户存在性)."""
|
||||
mock_user_repo.find_by_email.return_value = None
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="nonexistent@example.com")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
assert error is None
|
||||
# 不保存任何东西
|
||||
mock_user_repo.save.assert_not_called()
|
||||
# 不发邮件
|
||||
mock_email_service.send_password_reset_email.assert_not_called()
|
||||
|
||||
def test_request_empty_email(self, mock_user_repo, mock_email_service):
|
||||
"""空邮箱返回错误."""
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "Email is required" in error
|
||||
|
||||
def test_request_email_normalized(self, mock_user_repo, mock_email_service):
|
||||
"""邮箱会被规范化(小写+去空格)."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
|
||||
ok, _ = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
# find_by_email 收到的是小写的
|
||||
mock_user_repo.find_by_email.assert_called_with("user@example.com")
|
||||
|
||||
def test_request_token_expiry_custom_hours(self, mock_user_repo, mock_email_service):
|
||||
"""自定义令牌过期时间."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
token_expire_hours=6,
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
before = datetime.now(timezone.utc)
|
||||
ok, _ = uc.execute(req)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
assert ok is True
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
expires_at = saved_user.password_reset_expires_at
|
||||
# 过期时间应该在 ~6 小时后
|
||||
expected_min = before + timedelta(hours=6)
|
||||
expected_max = after + timedelta(hours=6)
|
||||
assert expected_min <= expires_at <= expected_max
|
||||
|
||||
def test_request_email_failure_returns_success(self, mock_user_repo, mock_email_service):
|
||||
"""邮件发送失败不影响返回结果(安全考虑)."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
mock_email_service.send_password_reset_email.return_value = (False, "SMTP error")
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("user@example.com")
|
||||
success, error = use_case.execute(request)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert ok is True
|
||||
assert error is None
|
||||
# token 仍然保存了
|
||||
mock_user_repo.save.assert_called_once()
|
||||
|
||||
def test_request_email_exception_does_not_propagate(self, mock_user_repo, mock_email_service):
|
||||
"""邮件服务异常不向外传播."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
mock_email_service.send_password_reset_email.side_effect = Exception("SMTP down")
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
assert error is None
|
||||
|
||||
def test_different_tokens_each_time(self, mock_user_repo, mock_email_service, sample_user):
|
||||
"""每次请求生成不同的 token"""
|
||||
mock_user_repo.find_by_email.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
def test_request_username_uses_display_name_fallback(self, mock_user_repo, mock_email_service):
|
||||
"""用户名为空时用 display_name."""
|
||||
user = _make_user(username="", display_name="Display Name")
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
mock_user_repo,
|
||||
base_url="https://example.com",
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
request = RequestPasswordResetRequest("user@example.com")
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
uc.execute(req)
|
||||
|
||||
use_case.execute(request)
|
||||
token1 = sample_user.password_reset_token
|
||||
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
|
||||
assert call_kwargs["username"] == "Display Name"
|
||||
|
||||
use_case.execute(request)
|
||||
token2 = sample_user.password_reset_token
|
||||
def test_request_uses_username_when_available(self, mock_user_repo, mock_email_service):
|
||||
"""有用户名时用用户名."""
|
||||
user = _make_user(username="myusername", display_name="Display Name")
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
assert token1 != token2
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
uc.execute(req)
|
||||
|
||||
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
|
||||
assert call_kwargs["username"] == "myusername"
|
||||
|
||||
def test_request_generates_unique_tokens(self, mock_user_repo, mock_email_service):
|
||||
"""每次请求生成不同的令牌."""
|
||||
user = _make_user()
|
||||
mock_user_repo.find_by_email.return_value = user
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
|
||||
tokens = []
|
||||
for _ in range(3):
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
uc.execute(req)
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
tokens.append(saved_user.password_reset_token)
|
||||
|
||||
assert len(set(tokens)) == 3 # 三个不同的令牌
|
||||
|
||||
def test_request_general_exception_returns_error(self, mock_user_repo, mock_email_service):
|
||||
"""其他异常返回错误信息."""
|
||||
mock_user_repo.find_by_email.side_effect = Exception("DB connection error")
|
||||
|
||||
uc = RequestPasswordResetUseCase(
|
||||
user_repository=mock_user_repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=mock_email_service,
|
||||
)
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "failed" in error.lower()
|
||||
|
||||
|
||||
# ── ResetPasswordUseCase 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResetPassword:
|
||||
"""重置密码用例测试"""
|
||||
|
||||
def test_reset_success(self, mock_user_repo):
|
||||
"""成功重置密码."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "valid-token-123"
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
# mock password_hasher 和 password_validator
|
||||
with (
|
||||
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
|
||||
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
|
||||
):
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
mock_hasher.hash_password.return_value = "new_hashed_password"
|
||||
|
||||
req = ResetPasswordRequest(token="valid-token-123", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
assert error is None
|
||||
# 密码被更新
|
||||
mock_user_repo.save.assert_called_once()
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
assert saved_user.password_hash == "new_hashed_password"
|
||||
# 令牌被清除
|
||||
assert saved_user.password_reset_token is None
|
||||
assert saved_user.password_reset_expires_at is None
|
||||
|
||||
def test_reset_empty_token(self, mock_user_repo):
|
||||
"""空令牌返回错误."""
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
req = ResetPasswordRequest(token="", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "token is required" in error.lower()
|
||||
|
||||
def test_reset_empty_password(self, mock_user_repo):
|
||||
"""空密码返回错误."""
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
req = ResetPasswordRequest(token="valid-token", new_password="")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "password is required" in error.lower()
|
||||
|
||||
def test_reset_invalid_token(self, mock_user_repo):
|
||||
"""无效令牌返回错误."""
|
||||
mock_user_repo.find_by_password_reset_token.return_value = None
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
req = ResetPasswordRequest(token="invalid-token", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "Invalid or expired" in error
|
||||
|
||||
def test_reset_expired_token(self, mock_user_repo):
|
||||
"""过期令牌返回错误."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "expired-token"
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
req = ResetPasswordRequest(token="expired-token", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "expired" in error.lower()
|
||||
|
||||
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo):
|
||||
"""不带时区的过期时间被当作 UTC 处理."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "token-123"
|
||||
# 用 naive datetime(无时区),应该被当作 UTC
|
||||
user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1) # type: ignore
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
req = ResetPasswordRequest(token="token-123", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "expired" in error.lower()
|
||||
|
||||
def test_reset_weak_password_fails(self, mock_user_repo):
|
||||
"""弱密码被拒绝."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "valid-token"
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
|
||||
mock_validator.validate.return_value = (False, "Password too short")
|
||||
req = ResetPasswordRequest(token="valid-token", new_password="123")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "too short" in error.lower()
|
||||
# 密码没被更新
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_no_expires_at_still_works(self, mock_user_repo):
|
||||
"""没有过期时间时视为不过期."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "valid-token"
|
||||
user.password_reset_expires_at = None
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with (
|
||||
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
|
||||
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
|
||||
):
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
mock_hasher.hash_password.return_value = "newhash"
|
||||
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
assert error is None
|
||||
|
||||
def test_reset_exception_returns_error(self, mock_user_repo):
|
||||
"""异常情况返回错误信息."""
|
||||
mock_user_repo.find_by_password_reset_token.side_effect = Exception("DB error")
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
req = ResetPasswordRequest(token="token", new_password="NewPass123!")
|
||||
ok, error = uc.execute(req)
|
||||
|
||||
assert ok is False
|
||||
assert "failed" in error.lower()
|
||||
|
||||
def test_reset_clears_token_on_success(self, mock_user_repo):
|
||||
"""成功重置后令牌被清除,防止重复使用."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "valid-token"
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with (
|
||||
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
|
||||
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
|
||||
):
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
mock_hasher.hash_password.return_value = "newhash"
|
||||
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
|
||||
ok, _ = uc.execute(req)
|
||||
|
||||
assert ok is True
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
assert saved_user.password_reset_token is None
|
||||
assert saved_user.password_reset_expires_at is None
|
||||
|
||||
def test_reset_hashes_new_password(self, mock_user_repo):
|
||||
"""密码被哈希后保存."""
|
||||
user = _make_user()
|
||||
user.password_reset_token = "valid-token"
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = user
|
||||
|
||||
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
|
||||
|
||||
with (
|
||||
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
|
||||
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
|
||||
):
|
||||
mock_validator.validate.return_value = (True, None)
|
||||
mock_hasher.hash_password.return_value = "hashed_abcdef"
|
||||
req = ResetPasswordRequest(token="valid-token", new_password="MyNewPass123!")
|
||||
uc.execute(req)
|
||||
|
||||
mock_hasher.hash_password.assert_called_once_with("MyNewPass123!")
|
||||
saved_user = mock_user_repo.save.call_args[0][0]
|
||||
assert saved_user.password_hash == "hashed_abcdef"
|
||||
|
||||
|
||||
# ── RequestPasswordResetRequest 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRequestPasswordResetRequest:
|
||||
"""请求数据类测试"""
|
||||
|
||||
def test_email_stripped_and_lowercased(self):
|
||||
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
|
||||
assert req.email == "user@example.com"
|
||||
|
||||
def test_email_already_lowercase(self):
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
assert req.email == "user@example.com"
|
||||
|
||||
|
||||
# ── ResetPasswordRequest 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResetPasswordRequest:
|
||||
"""ResetPasswordRequest 测试"""
|
||||
"""重置密码请求数据类测试"""
|
||||
|
||||
def test_stores_token_and_password(self):
|
||||
"""正确存储 token 和新密码"""
|
||||
req = ResetPasswordRequest(token="abc123", new_password="NewPass1!")
|
||||
assert req.token == "abc123"
|
||||
assert req.new_password == "NewPass1!"
|
||||
|
||||
|
||||
class TestResetPasswordUseCase:
|
||||
"""ResetPasswordUseCase 测试"""
|
||||
|
||||
def test_reset_success(self, mock_user_repo, sample_user):
|
||||
"""重置密码成功"""
|
||||
sample_user.password_reset_token = "valid_token"
|
||||
sample_user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="valid_token", new_password="NewSecurePass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert sample_user.password_reset_token is None
|
||||
assert sample_user.password_reset_expires_at is None
|
||||
assert sample_user.password_hash != "old_hash"
|
||||
mock_user_repo.save.assert_called_once()
|
||||
|
||||
def test_reset_empty_token(self, mock_user_repo):
|
||||
"""空 token 返回错误"""
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="", new_password="NewPass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "Reset token is required" in error
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_empty_password(self, mock_user_repo):
|
||||
"""空密码返回错误"""
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="sometoken", new_password="")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "New password is required" in error
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_weak_password(self, mock_user_repo):
|
||||
"""弱密码返回错误"""
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="sometoken", new_password="weak")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert error is not None
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_invalid_token(self, mock_user_repo):
|
||||
"""无效 token 返回错误"""
|
||||
mock_user_repo.find_by_password_reset_token.return_value = None
|
||||
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="invalid_token", new_password="NewPass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "Invalid or expired" in error
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_expired_token(self, mock_user_repo, sample_user):
|
||||
"""过期 token 返回错误"""
|
||||
sample_user.password_reset_token = "expired_token"
|
||||
sample_user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
||||
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="expired_token", new_password="NewPass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "expired" in error.lower()
|
||||
mock_user_repo.save.assert_not_called()
|
||||
|
||||
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo, sample_user):
|
||||
"""无时区的过期时间按 UTC 处理"""
|
||||
sample_user.password_reset_token = "naive_token"
|
||||
# 用无时区的时间,设置为过去
|
||||
sample_user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
||||
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="naive_token", new_password="NewPass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "expired" in error.lower()
|
||||
|
||||
def test_reset_no_expiry_set(self, mock_user_repo, sample_user):
|
||||
"""没有设置过期时间的 token 可以使用"""
|
||||
sample_user.password_reset_token = "no_expiry_token"
|
||||
sample_user.password_reset_expires_at = None
|
||||
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
||||
|
||||
use_case = ResetPasswordUseCase(mock_user_repo)
|
||||
request = ResetPasswordRequest(token="no_expiry_token", new_password="NewPass1!")
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is True
|
||||
req = ResetPasswordRequest(token="token123", new_password="password123")
|
||||
assert req.token == "token123"
|
||||
assert req.new_password == "password123"
|
||||
|
||||
+224
-209
@@ -1,18 +1,14 @@
|
||||
"""路径安全校验工具单元测试 — 路径遍历防护."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""path_security 单元测试."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
import pytest
|
||||
|
||||
from video_processing.path_security import ( # noqa: E402
|
||||
from apps.worker.video_processing.path_security import (
|
||||
LOCAL_SCHEMA_PREFIX,
|
||||
MAX_PATH_LENGTH,
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
is_path_safe,
|
||||
safe_resolve_path,
|
||||
@@ -21,223 +17,242 @@ from video_processing.path_security import ( # noqa: E402
|
||||
)
|
||||
|
||||
|
||||
class TestSafeResolvePath(unittest.TestCase):
|
||||
"""安全路径解析测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
# ── 正常路径 ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_simple_relative_path(self):
|
||||
"""简单相对路径应该正常解析."""
|
||||
result = safe_resolve_path("test.mp4", self.tmpdir)
|
||||
self.assertEqual(result.name, "test.mp4")
|
||||
self.assertTrue(str(result).startswith(self.tmpdir))
|
||||
|
||||
def test_subdirectory_path(self):
|
||||
"""子目录路径应该正常解析."""
|
||||
result = safe_resolve_path("sub/dir/file.mp4", self.tmpdir)
|
||||
self.assertTrue(str(result).startswith(self.tmpdir))
|
||||
self.assertIn("sub/dir/file.mp4", str(result).replace("\\", "/"))
|
||||
|
||||
def test_dot_slash_path(self):
|
||||
"""./ 开头的路径应该正常解析."""
|
||||
result = safe_resolve_path("./test.mp4", self.tmpdir)
|
||||
self.assertEqual(result.name, "test.mp4")
|
||||
|
||||
# ── 路径遍历防护 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_parent_traversal_rejected(self):
|
||||
"""../ 路径遍历应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("../etc/passwd", self.tmpdir)
|
||||
|
||||
def test_multiple_parent_traversal_rejected(self):
|
||||
"""多级 ../ 遍历应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("../../etc/passwd", self.tmpdir)
|
||||
|
||||
def test_mixed_traversal_rejected(self):
|
||||
"""混合路径遍历应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("./sub/../../etc/shadow", self.tmpdir)
|
||||
|
||||
def test_absolute_path_rejected(self):
|
||||
"""绝对路径(超出基目录)应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("/etc/passwd", self.tmpdir)
|
||||
|
||||
# ── 空字节注入 ───────────────────────────────────────────────────────
|
||||
|
||||
def test_null_byte_rejected(self):
|
||||
"""空字节注入应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("test\x00.mp4", self.tmpdir)
|
||||
|
||||
# ── 空路径 ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_empty_path_rejected(self):
|
||||
"""空路径应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("", self.tmpdir)
|
||||
|
||||
def test_none_path_rejected(self):
|
||||
"""None 路径应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path(None, self.tmpdir) # type: ignore
|
||||
|
||||
def test_whitespace_path_rejected(self):
|
||||
"""空白路径应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path(" ", self.tmpdir)
|
||||
|
||||
# ── 路径长度 ────────────────────────────────────────────────────────
|
||||
|
||||
def test_too_long_path_rejected(self):
|
||||
"""超长路径应该被拒绝."""
|
||||
long_path = "a" * 5000 + ".mp4"
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path(long_path, self.tmpdir)
|
||||
|
||||
# ── 系统路径防护 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_proc_path_rejected_when_absolute(self):
|
||||
"""/proc/ 路径在绝对路径模式下应该被拒绝(因为超出基目录)."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path("/proc/self/environ", self.tmpdir)
|
||||
|
||||
# ── 扩展名校验 ───────────────────────────────────────────────────────
|
||||
|
||||
def test_extension_whitelist_pass(self):
|
||||
"""白名单内的扩展名应该通过."""
|
||||
result = safe_resolve_path(
|
||||
"test.mp4",
|
||||
self.tmpdir,
|
||||
allowed_extensions={".mp4", ".mov"},
|
||||
)
|
||||
self.assertEqual(result.suffix.lower(), ".mp4")
|
||||
|
||||
def test_extension_whitelist_reject(self):
|
||||
"""白名单外的扩展名应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
safe_resolve_path(
|
||||
"test.exe",
|
||||
self.tmpdir,
|
||||
allowed_extensions={".mp4", ".mov"},
|
||||
)
|
||||
@pytest.fixture
|
||||
def base_dir():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# 创建一个子文件用于测试
|
||||
with open(os.path.join(tmpdir, "test.mp4"), "w") as f:
|
||||
f.write("test")
|
||||
subdir = os.path.join(tmpdir, "subdir")
|
||||
os.makedirs(subdir)
|
||||
with open(os.path.join(subdir, "audio.mp3"), "w") as f:
|
||||
f.write("test")
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestLocalSchemaPath(unittest.TestCase):
|
||||
"""local:// schema 路径测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def test_valid_local_schema(self):
|
||||
"""有效的 local:// 相对路径应该通过."""
|
||||
# 创建测试文件
|
||||
test_file = Path(self.tmpdir) / "test.mp4"
|
||||
test_file.touch()
|
||||
|
||||
result = validate_local_schema_path("local://test.mp4", self.tmpdir)
|
||||
self.assertTrue(result.exists())
|
||||
|
||||
def test_local_schema_absolute_rejected(self):
|
||||
"""local:// + 绝对路径应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
validate_local_schema_path("local:///etc/passwd", self.tmpdir)
|
||||
|
||||
def test_local_schema_traversal_rejected(self):
|
||||
"""local:// + 路径遍历应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
validate_local_schema_path("local://../etc/passwd", self.tmpdir)
|
||||
|
||||
def test_non_local_schema_rejected(self):
|
||||
"""非 local:// 开头的路径应该被拒绝."""
|
||||
with self.assertRaises(PathSecurityError):
|
||||
validate_local_schema_path("http://example.com/test", self.tmpdir)
|
||||
# ── safe_resolve_path ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitizeFilename(unittest.TestCase):
|
||||
"""文件名清理测试."""
|
||||
class TestSafeResolvePath:
|
||||
def test_none_path_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="不能为空"):
|
||||
safe_resolve_path(None, base_dir)
|
||||
|
||||
def test_empty_string_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="不能为空"):
|
||||
safe_resolve_path("", base_dir)
|
||||
|
||||
def test_whitespace_path_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="不能为空"):
|
||||
safe_resolve_path(" ", base_dir)
|
||||
|
||||
def test_too_long_path_raises(self, base_dir):
|
||||
long_path = "a" * (MAX_PATH_LENGTH + 1)
|
||||
with pytest.raises(PathSecurityError, match="路径过长"):
|
||||
safe_resolve_path(long_path, base_dir)
|
||||
|
||||
def test_null_byte_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="空字节"):
|
||||
safe_resolve_path("file\x00.mp4", base_dir)
|
||||
|
||||
def test_relative_path_within_base(self, base_dir):
|
||||
result = safe_resolve_path("test.mp4", base_dir)
|
||||
assert result.name == "test.mp4"
|
||||
assert str(result).startswith(str(os.path.realpath(base_dir)))
|
||||
|
||||
def test_subdirectory_path(self, base_dir):
|
||||
result = safe_resolve_path("subdir/audio.mp3", base_dir)
|
||||
assert result.name == "audio.mp3"
|
||||
assert "subdir" in str(result)
|
||||
|
||||
def test_parent_traversal_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="路径遍历"):
|
||||
safe_resolve_path("../etc/passwd", base_dir)
|
||||
|
||||
def test_nested_parent_traversal_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="路径遍历"):
|
||||
safe_resolve_path("subdir/../../etc/passwd", base_dir)
|
||||
|
||||
def test_absolute_path_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="绝对路径"):
|
||||
safe_resolve_path("/etc/passwd", base_dir)
|
||||
|
||||
def test_absolute_path_with_allow_outside(self, base_dir):
|
||||
# allow_outside=True 时允许绝对路径(但会被危险路径模式检查)
|
||||
with pytest.raises(PathSecurityError, match="系统路径"):
|
||||
safe_resolve_path("/etc/passwd", base_dir, allow_outside=True)
|
||||
|
||||
def test_local_schema_relative(self, base_dir):
|
||||
result = safe_resolve_path("local://test.mp4", base_dir)
|
||||
assert result.name == "test.mp4"
|
||||
assert str(result).startswith(str(os.path.realpath(base_dir)))
|
||||
|
||||
def test_local_schema_absolute_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="绝对路径"):
|
||||
safe_resolve_path("local:///etc/passwd", base_dir)
|
||||
|
||||
def test_local_schema_traversal_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="路径遍历"):
|
||||
safe_resolve_path("local://../secret", base_dir)
|
||||
|
||||
def test_invalid_base_dir_raises(self):
|
||||
with pytest.raises(PathSecurityError, match="基路径"):
|
||||
safe_resolve_path("file.txt", "/nonexistent/dir")
|
||||
|
||||
def test_allowed_extensions_valid(self, base_dir):
|
||||
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp4"})
|
||||
assert result.suffix.lower() == ".mp4"
|
||||
|
||||
def test_allowed_extensions_invalid_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="文件类型"):
|
||||
safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp3"})
|
||||
|
||||
def test_no_extension_restriction(self, base_dir):
|
||||
# allowed_extensions=None 时不检查
|
||||
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions=None)
|
||||
assert result is not None
|
||||
|
||||
def test_path_object_input(self, base_dir):
|
||||
from pathlib import Path
|
||||
|
||||
result = safe_resolve_path(Path("test.mp4"), base_dir)
|
||||
assert result.name == "test.mp4"
|
||||
|
||||
def test_path_object_base_dir(self, base_dir):
|
||||
from pathlib import Path
|
||||
|
||||
result = safe_resolve_path("test.mp4", Path(base_dir))
|
||||
assert result.name == "test.mp4"
|
||||
|
||||
|
||||
# ── is_path_safe ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsPathSafe:
|
||||
def test_safe_path_returns_true(self, base_dir):
|
||||
assert is_path_safe("test.mp4", base_dir) is True
|
||||
|
||||
def test_unsafe_path_returns_false(self, base_dir):
|
||||
assert is_path_safe("../etc/passwd", base_dir) is False
|
||||
|
||||
def test_none_returns_false(self, base_dir):
|
||||
assert is_path_safe(None, base_dir) is False
|
||||
|
||||
|
||||
# ── validate_local_schema_path ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateLocalSchemaPath:
|
||||
def test_valid_local_path(self, base_dir):
|
||||
result = validate_local_schema_path("local://test.mp4", base_dir)
|
||||
assert result.name == "test.mp4"
|
||||
|
||||
def test_missing_prefix_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError, match="开头"):
|
||||
validate_local_schema_path("test.mp4", base_dir)
|
||||
|
||||
def test_traversal_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError):
|
||||
validate_local_schema_path("local://../secret", base_dir)
|
||||
|
||||
def test_absolute_path_raises(self, base_dir):
|
||||
with pytest.raises(PathSecurityError):
|
||||
validate_local_schema_path("local:///etc/passwd", base_dir)
|
||||
|
||||
|
||||
# ── sanitize_filename ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
def test_normal_filename(self):
|
||||
"""正常文件名应该保持不变."""
|
||||
self.assertEqual(sanitize_filename("video.mp4"), "video.mp4")
|
||||
assert sanitize_filename("hello.mp4") == "hello.mp4"
|
||||
|
||||
def test_path_separators_removed(self):
|
||||
"""路径分隔符应该被替换."""
|
||||
self.assertNotIn("/", sanitize_filename("../path/to/file.mp4"))
|
||||
self.assertNotIn("\\", sanitize_filename("..\\path\\file.mp4"))
|
||||
def test_empty_returns_unnamed(self):
|
||||
assert sanitize_filename("") == "unnamed"
|
||||
|
||||
def test_leading_dots_removed(self):
|
||||
"""开头的点应该被移除."""
|
||||
result = sanitize_filename(".hidden")
|
||||
self.assertFalse(result.startswith("."))
|
||||
self.assertEqual(result, "hidden")
|
||||
def test_none_default(self):
|
||||
# 空字符串会返回unnamed
|
||||
assert sanitize_filename("") == "unnamed"
|
||||
|
||||
def test_multiple_leading_dots_removed(self):
|
||||
"""多个开头的点应该全部被移除."""
|
||||
result = sanitize_filename("...hidden")
|
||||
self.assertFalse(result.startswith("."))
|
||||
def test_removes_path_separators(self):
|
||||
assert "/" not in sanitize_filename("path/to/file.mp4")
|
||||
assert "\\" not in sanitize_filename("path\\to\\file.mp4")
|
||||
|
||||
def test_empty_filename_default(self):
|
||||
"""空文件名应该返回 unnamed."""
|
||||
self.assertEqual(sanitize_filename(""), "unnamed")
|
||||
def test_removes_control_characters(self):
|
||||
result = sanitize_filename("file\x01\x02name.mp4")
|
||||
assert "\x01" not in result
|
||||
assert "\x02" not in result
|
||||
|
||||
def test_special_chars_removed(self):
|
||||
"""特殊字符应该被替换."""
|
||||
result = sanitize_filename('file<name>:"test|?*.mp4')
|
||||
self.assertNotIn("<", result)
|
||||
self.assertNotIn(">", result)
|
||||
self.assertNotIn(":", result)
|
||||
self.assertNotIn('"', result)
|
||||
self.assertNotIn("|", result)
|
||||
self.assertNotIn("?", result)
|
||||
self.assertNotIn("*", result)
|
||||
def test_removes_dangerous_chars(self):
|
||||
result = sanitize_filename("file<name>.mp4")
|
||||
assert "<" not in result
|
||||
assert ">" not in result
|
||||
|
||||
def test_chinese_filename_preserved(self):
|
||||
"""中文文件名应该保留."""
|
||||
result = sanitize_filename("视频素材.mp4")
|
||||
self.assertIn("视频素材", result)
|
||||
def test_removes_leading_dots(self):
|
||||
assert not sanitize_filename(".hidden").startswith(".")
|
||||
assert not sanitize_filename("..hidden").startswith(".")
|
||||
|
||||
def test_chinese_characters_preserved(self):
|
||||
result = sanitize_filename("视频文件.mp4")
|
||||
assert "视频文件" in result
|
||||
|
||||
def test_long_filename_truncated(self):
|
||||
"""超长文件名应该被截断."""
|
||||
long_name = "a" * 300 + ".mp4"
|
||||
result = sanitize_filename(long_name)
|
||||
self.assertLessEqual(len(result), 255)
|
||||
self.assertTrue(result.endswith(".mp4"))
|
||||
assert len(result) <= 255
|
||||
assert result.endswith(".mp4")
|
||||
|
||||
def test_spaces_preserved(self):
|
||||
result = sanitize_filename("my file.mp4")
|
||||
assert "my file.mp4" == result
|
||||
|
||||
def test_underscores_hyphens_preserved(self):
|
||||
result = sanitize_filename("my_file-name.mp4")
|
||||
assert result == "my_file-name.mp4"
|
||||
|
||||
def test_all_dots_returns_unnamed(self):
|
||||
assert sanitize_filename("...") == "unnamed"
|
||||
|
||||
|
||||
class TestAllowedDirs(unittest.TestCase):
|
||||
"""允许目录配置测试."""
|
||||
|
||||
def test_get_allowed_dirs_returns_list(self):
|
||||
"""get_allowed_local_dirs 应该返回列表."""
|
||||
dirs = get_allowed_local_dirs()
|
||||
self.assertIsInstance(dirs, list)
|
||||
|
||||
def test_is_in_allowed_dirs_tmp(self):
|
||||
"""/tmp 应该在默认允许目录内."""
|
||||
self.assertTrue(is_in_allowed_dirs("/tmp/test.mp4"))
|
||||
|
||||
def test_is_path_safe_convenience(self):
|
||||
"""is_path_safe 便捷函数应该正常工作."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
self.assertTrue(is_path_safe("test.mp4", tmpdir))
|
||||
self.assertFalse(is_path_safe("../etc/passwd", tmpdir))
|
||||
# ── is_in_allowed_dirs ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
class TestIsInAllowedDirs:
|
||||
def test_path_in_allowed_dir(self, base_dir):
|
||||
filepath = os.path.join(base_dir, "test.mp4")
|
||||
from pathlib import Path
|
||||
|
||||
assert is_in_allowed_dirs(filepath, [Path(base_dir)]) is True
|
||||
|
||||
def test_path_not_in_allowed_dir(self, base_dir):
|
||||
from pathlib import Path
|
||||
|
||||
assert is_in_allowed_dirs("/etc/passwd", [Path(base_dir)]) is False
|
||||
|
||||
def test_subdirectory_in_allowed(self, base_dir):
|
||||
from pathlib import Path
|
||||
|
||||
sub = os.path.join(base_dir, "subdir", "audio.mp3")
|
||||
assert is_in_allowed_dirs(sub, [Path(base_dir)]) is True
|
||||
|
||||
def test_none_allowed_dirs_uses_default(self):
|
||||
# None 使用默认配置(包含 /tmp)
|
||||
result = is_in_allowed_dirs("/tmp/test.mp4")
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_allowed_dirs_list_is_empty(self):
|
||||
from pathlib import Path
|
||||
|
||||
assert is_in_allowed_dirs("/tmp/test", []) is False
|
||||
|
||||
|
||||
# ── PathSecurityError class ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPathSecurityError:
|
||||
def test_is_value_error(self):
|
||||
assert issubclass(PathSecurityError, ValueError)
|
||||
|
||||
def test_message_preserved(self):
|
||||
err = PathSecurityError("test message")
|
||||
assert str(err) == "test message"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user