Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0717aea36e | |||
| db83222dd7 | |||
| d3373144aa | |||
| 9aab0f5dca | |||
| 542ea59869 |
Executable → Regular
+58
-14
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
ASR_LANGUAGE_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
|
||||
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
|
||||
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
|
||||
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
@@ -38,6 +40,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
@@ -52,11 +55,26 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
@@ -70,6 +88,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
@@ -82,6 +101,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
@@ -93,6 +113,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
@@ -104,24 +125,46 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<SubtitlePositionSelector
|
||||
position={config.position}
|
||||
onPositionChange={(position) => update({ position })}
|
||||
/>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<SubtitleEffectButtons
|
||||
stroke={config.stroke}
|
||||
shadow={config.shadow}
|
||||
onStrokeChange={(stroke) => update({ stroke })}
|
||||
onShadowChange={(shadow) => update({ shadow })}
|
||||
/>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
@@ -136,6 +179,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleEffectButtonsProps {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
onStrokeChange: (enabled: boolean) => void
|
||||
onShadowChange: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
|
||||
stroke,
|
||||
shadow,
|
||||
onStrokeChange,
|
||||
onShadowChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${stroke ? " active" : ""}`}
|
||||
onClick={() => onStrokeChange(!stroke)}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${shadow ? " active" : ""}`}
|
||||
onClick={() => onShadowChange(!shadow)}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleModeSwitchProps {
|
||||
mode: "manual" | "asr"
|
||||
onModeChange: (mode: "manual" | "asr") => void
|
||||
}
|
||||
|
||||
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("asr")}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
|
||||
interface SubtitlePositionSelectorProps {
|
||||
position: SubtitleStyleConfig["position"]
|
||||
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
|
||||
}
|
||||
|
||||
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
|
||||
position,
|
||||
onPositionChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
|
||||
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,4 +1,4 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { useRef, useCallback } from "react"
|
||||
|
||||
interface UseRowProgressOptions {
|
||||
duration: number
|
||||
@@ -7,22 +7,6 @@ interface UseRowProgressOptions {
|
||||
|
||||
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const listenersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({
|
||||
move: null,
|
||||
up: null,
|
||||
})
|
||||
|
||||
const cleanupListeners = useCallback(() => {
|
||||
const { move, up } = listenersRef.current
|
||||
if (move) {
|
||||
document.removeEventListener("mousemove", move)
|
||||
listenersRef.current.move = null
|
||||
}
|
||||
if (up) {
|
||||
document.removeEventListener("mouseup", up)
|
||||
listenersRef.current.up = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -32,7 +16,6 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
@@ -41,26 +24,15 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
cleanupListeners()
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
// 先清理旧的,再添加新的
|
||||
cleanupListeners()
|
||||
listenersRef.current.move = handleMove
|
||||
listenersRef.current.up = handleUp
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
},
|
||||
[duration, onSeek, cleanupListeners],
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupListeners()
|
||||
}
|
||||
}, [cleanupListeners])
|
||||
|
||||
return { progressRef, handleMouseDown }
|
||||
}
|
||||
|
||||
@@ -65,9 +65,6 @@ import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
|
||||
import "@/pages/editing-planner/components/tts/VoiceSelector"
|
||||
import "@/pages/editing-planner/components/tts/TtsSlider"
|
||||
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -20,8 +20,6 @@ import "@/pages/voice-materials/components/voice-material-card/CardActions"
|
||||
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
|
||||
import "@/pages/voice-materials/components/voice-material-card/types"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
import "@/pages/voice-materials/components/voice-material-row/useRowProgress"
|
||||
import "@/pages/voice-materials/components/voice-material-row/TagDisplay"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
import "@/pages/voice-materials/components/BatchBar"
|
||||
|
||||
@@ -569,9 +569,7 @@ class CosyVoiceService:
|
||||
清洗后的 prefix
|
||||
"""
|
||||
# 只保留字母和数字
|
||||
import re
|
||||
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
|
||||
cleaned = "".join(c for c in name if c.isalnum())
|
||||
# 最多10字符
|
||||
cleaned = cleaned[:10]
|
||||
# 如果清洗后为空,用默认值
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
"""模板片段转换器单测.
|
||||
|
||||
纯函数模块,覆盖:枚举安全解析、config过滤、
|
||||
clip→template转换、snapshot双向转换、名称校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_clip_converter import (
|
||||
clip_config_to_snapshot,
|
||||
clip_configs_to_snapshots,
|
||||
clip_to_template_clip_config,
|
||||
clips_to_template_clip_configs,
|
||||
filter_clip_config,
|
||||
filter_plan_config_to_template,
|
||||
safe_parse_clip_type,
|
||||
safe_parse_transition_effect,
|
||||
snapshot_to_template_clip_config,
|
||||
snapshots_to_template_clip_configs,
|
||||
validate_template_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSafeParseTransitionEffect:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_transition_effect(TransitionEffect.FADE)
|
||||
assert result == TransitionEffect.FADE
|
||||
assert isinstance(result, TransitionEffect)
|
||||
|
||||
def test_valid_string(self):
|
||||
result = safe_parse_transition_effect("fade")
|
||||
assert result == TransitionEffect.FADE
|
||||
|
||||
def test_cut_string(self):
|
||||
result = safe_parse_transition_effect("cut")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("invalid_effect")
|
||||
assert result == TransitionEffect.CUT # 默认
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE)
|
||||
assert result == TransitionEffect.DISSOLVE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_transition_effect(None)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_int_value_returns_default(self):
|
||||
result = safe_parse_transition_effect(123)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestSafeParseClipType:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_clip_type(ClipType.SUBTITLE)
|
||||
assert result == ClipType.SUBTITLE
|
||||
assert isinstance(result, ClipType)
|
||||
|
||||
def test_valid_string_main(self):
|
||||
result = safe_parse_clip_type("main")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_valid_string_text(self):
|
||||
result = safe_parse_clip_type("subtitle")
|
||||
assert result == ClipType.SUBTITLE
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_clip_type("unknown_type")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_clip_type("bad", default=ClipType.TITLE)
|
||||
assert result == ClipType.TITLE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_clip_type(None)
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_dict_returns_default(self):
|
||||
result = safe_parse_clip_type({"key": "val"})
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
|
||||
class TestFilterClipConfig:
|
||||
def test_none_config(self):
|
||||
result = filter_clip_config(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_clip_config({})
|
||||
assert result == {}
|
||||
|
||||
def test_basic_config_passthrough(self):
|
||||
cfg = {"font_size": 24, "color": "red"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert result == {"font_size": 24, "color": "red"}
|
||||
|
||||
def test_filters_asset_info(self):
|
||||
cfg = {"font_size": 24, "asset_info": {"id": "123"}}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "asset_info" not in result
|
||||
assert result["font_size"] == 24
|
||||
|
||||
def test_filters_source_asset_id(self):
|
||||
cfg = {"source_asset_id": "asset_1", "text_key": "hi"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "source_asset_id" not in result
|
||||
assert result["text_key"] == "hi"
|
||||
|
||||
def test_playback_speed_added_when_not_one(self):
|
||||
result = filter_clip_config({}, playback_speed=1.5)
|
||||
assert result["playback_speed"] == 1.5
|
||||
|
||||
def test_playback_speed_one_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=1.0)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_none_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=None)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_config_takes_priority(self):
|
||||
"""clip_config中的playback_speed会覆盖参数传入的(因为update在后面)."""
|
||||
cfg = {"playback_speed": 0.5, "other": "val"}
|
||||
result = filter_clip_config(cfg, playback_speed=2.0)
|
||||
assert result["playback_speed"] == 0.5 # config里的覆盖参数的
|
||||
assert result["other"] == "val"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3}
|
||||
skip = frozenset({"drop_me", "also_drop"})
|
||||
result = filter_clip_config(cfg, skip_keys=skip)
|
||||
assert result == {"keep_me": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, "asset_info": "x"}
|
||||
original = dict(cfg)
|
||||
filter_clip_config(cfg)
|
||||
assert cfg == original # 原dict不变
|
||||
|
||||
|
||||
class TestFilterPlanConfigToTemplate:
|
||||
def test_none_config(self):
|
||||
result = filter_plan_config_to_template(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_plan_config_to_template({})
|
||||
assert result == {}
|
||||
|
||||
def test_keeps_template_fields(self):
|
||||
cfg = {"title": "My Template", "aspect_ratio": "9:16"}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert result == cfg
|
||||
|
||||
def test_filters_runtime_fields(self):
|
||||
cfg = {
|
||||
"title": "T",
|
||||
"is_template_draft": True,
|
||||
"asset_ids": ["a1"],
|
||||
"source_edit_plan_id": "ep1",
|
||||
"generation_task_id": "gt1",
|
||||
}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert "is_template_draft" not in result
|
||||
assert "asset_ids" not in result
|
||||
assert "source_edit_plan_id" not in result
|
||||
assert "generation_task_id" not in result
|
||||
assert result["title"] == "T"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep": 1, "skip_a": 2, "skip_b": 3}
|
||||
skip = frozenset({"skip_a", "skip_b"})
|
||||
result = filter_plan_config_to_template(cfg, skip_keys=skip)
|
||||
assert result == {"keep": 1}
|
||||
|
||||
|
||||
class TestClipToTemplateClipConfig:
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
duration: float = 5.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
playback_speed: float | None = None
|
||||
config: dict | None = None
|
||||
|
||||
def test_basic_conversion(self):
|
||||
clip = self.FakeClip(
|
||||
clip_type="subtitle",
|
||||
order=2,
|
||||
duration=3.5,
|
||||
text_content="Hello",
|
||||
transition_effect="fade",
|
||||
)
|
||||
result = clip_to_template_clip_config("tpl_1", clip)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_1"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 2
|
||||
assert result.min_duration == 3.5
|
||||
assert result.max_duration == 3.5
|
||||
assert result.text_template == "Hello"
|
||||
assert result.transition_effect == TransitionEffect.FADE
|
||||
|
||||
def test_duration_fixed_min_max_equal(self):
|
||||
"""转换后 min_duration == max_duration == clip.duration."""
|
||||
clip = self.FakeClip(duration=7.2)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 7.2
|
||||
assert result.max_duration == 7.2
|
||||
|
||||
def test_zero_duration(self):
|
||||
clip = self.FakeClip(duration=0.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_none_duration_defaults_to_zero(self):
|
||||
clip = self.FakeClip()
|
||||
clip.duration = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_empty_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip(text_content="")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_none_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip()
|
||||
clip.text_content = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_playback_speed_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.config["playback_speed"] == 1.5
|
||||
assert result.config["font"] == "bold"
|
||||
|
||||
def test_playback_speed_one_not_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "playback_speed" not in result.config
|
||||
|
||||
def test_config_asset_info_filtered(self):
|
||||
clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "asset_info" not in result.config
|
||||
assert result.config["text_key"] == "hi"
|
||||
|
||||
def test_invalid_clip_type_falls_back(self):
|
||||
clip = self.FakeClip(clip_type="invalid_type")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_missing_attributes(self):
|
||||
"""对象没有某些属性时使用默认值."""
|
||||
|
||||
class MinimalClip:
|
||||
pass
|
||||
|
||||
result = clip_to_template_clip_config("t1", MinimalClip())
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestClipsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = clips_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_clips(self):
|
||||
clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A")
|
||||
clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="")
|
||||
result = clips_to_template_clip_configs("t1", [clip_a, clip_b])
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].order == 0
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
assert result[1].order == 1
|
||||
assert all(isinstance(r, TemplateClipConfig) for r in result)
|
||||
|
||||
|
||||
class TestClipConfigToSnapshot:
|
||||
def test_basic_snapshot(self):
|
||||
cfg = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=2,
|
||||
min_duration=3.0,
|
||||
max_duration=5.0,
|
||||
text_template="Hello",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"font_size": 20},
|
||||
)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "subtitle"
|
||||
assert snap["order"] == 2
|
||||
assert snap["min_duration"] == 3.0
|
||||
assert snap["max_duration"] == 5.0
|
||||
assert snap["text_template"] == "Hello"
|
||||
assert snap["transition_effect"] == "fade"
|
||||
assert snap["config"] == {"font_size": 20}
|
||||
|
||||
def test_enum_values_are_strings(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "main"
|
||||
assert isinstance(snap["clip_type"], str)
|
||||
assert snap["transition_effect"] == "cut"
|
||||
assert isinstance(snap["transition_effect"], str)
|
||||
|
||||
def test_config_is_copy_not_reference(self):
|
||||
config = {"key": "val"}
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
snap["config"]["key"] = "changed"
|
||||
assert config["key"] == "val" # 原config不变
|
||||
|
||||
def test_empty_config(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={})
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["config"] == {}
|
||||
|
||||
def test_none_text_becomes_empty(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
cfg.text_template = None # type: ignore
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["text_template"] == ""
|
||||
|
||||
|
||||
class TestClipConfigsToSnapshots:
|
||||
def test_empty_list(self):
|
||||
assert clip_configs_to_snapshots([]) == []
|
||||
|
||||
def test_multiple_configs(self):
|
||||
cfg1 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=0,
|
||||
min_duration=2.0,
|
||||
max_duration=2.0,
|
||||
)
|
||||
cfg2 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.TITLE,
|
||||
order=1,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
)
|
||||
snaps = clip_configs_to_snapshots([cfg1, cfg2])
|
||||
assert len(snaps) == 2
|
||||
assert snaps[0]["clip_type"] == "subtitle"
|
||||
assert snaps[1]["clip_type"] == "title"
|
||||
|
||||
|
||||
class TestSnapshotToTemplateClipConfig:
|
||||
def test_basic_conversion(self):
|
||||
snap = {
|
||||
"clip_type": "subtitle",
|
||||
"order": 3,
|
||||
"min_duration": 2.5,
|
||||
"max_duration": 4.5,
|
||||
"text_template": "World",
|
||||
"transition_effect": "dissolve",
|
||||
"config": {"color": "blue"},
|
||||
}
|
||||
result = snapshot_to_template_clip_config("tpl_2", snap)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_2"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 3
|
||||
assert result.min_duration == 2.5
|
||||
assert result.max_duration == 4.5
|
||||
assert result.text_template == "World"
|
||||
assert result.transition_effect == TransitionEffect.DISSOLVE
|
||||
assert result.config == {"color": "blue"}
|
||||
|
||||
def test_empty_snapshot_uses_defaults(self):
|
||||
result = snapshot_to_template_clip_config("t1", {})
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
assert result.config == {}
|
||||
|
||||
def test_invalid_clip_type_defaults(self):
|
||||
snap = {"clip_type": "unknown"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_invalid_transition_defaults(self):
|
||||
snap = {"transition_effect": "bad_effect"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
def test_none_config_becomes_empty(self):
|
||||
snap = {"config": None}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.config == {}
|
||||
|
||||
|
||||
class TestSnapshotsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = snapshots_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_snapshots(self):
|
||||
snaps = [
|
||||
{"clip_type": "subtitle", "order": 0, "text_template": "A"},
|
||||
{"clip_type": "title", "order": 1},
|
||||
]
|
||||
result = snapshots_to_template_clip_configs("t1", snaps)
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].text_template == "A"
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""clip → config → snapshot → config 双向转换一致性."""
|
||||
|
||||
def test_snapshot_config_round_trip(self):
|
||||
original = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=5,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
text_template="Round trip",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"key": "value"},
|
||||
)
|
||||
snap = clip_config_to_snapshot(original)
|
||||
restored = snapshot_to_template_clip_config("t1", snap)
|
||||
assert restored.clip_type == original.clip_type
|
||||
assert restored.order == original.order
|
||||
assert restored.min_duration == original.min_duration
|
||||
assert restored.max_duration == original.max_duration
|
||||
assert restored.text_template == original.text_template
|
||||
assert restored.transition_effect == original.transition_effect
|
||||
assert restored.config == original.config
|
||||
|
||||
|
||||
class TestValidateTemplateName:
|
||||
def test_valid_name(self):
|
||||
assert validate_template_name("我的模板") == "我的模板"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert validate_template_name(" Hello ") == "Hello"
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
try:
|
||||
validate_template_name("")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
try:
|
||||
validate_template_name(" ")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_none_raises(self):
|
||||
try:
|
||||
validate_template_name(None)
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
@@ -1,280 +0,0 @@
|
||||
"""VerificationCode 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
"""create() 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.id is not None
|
||||
assert len(vc.id) == 32
|
||||
assert vc.recipient == "test@example.com"
|
||||
assert vc.code_type == "email_login"
|
||||
assert len(vc.code) == 6
|
||||
assert vc.code.isdigit()
|
||||
assert vc.used_at is None
|
||||
assert vc.attempts == 0
|
||||
assert vc.created_at is not None
|
||||
assert vc.expires_at > vc.created_at
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
vc = VerificationCode.create(" test@example.com ", "email_login")
|
||||
assert vc.recipient == "test@example.com"
|
||||
|
||||
def test_create_custom_code(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
|
||||
assert vc.code == "123456"
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=60)
|
||||
|
||||
def test_create_default_ttl_300(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=300)
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
vc1 = VerificationCode.create("a@b.com", "email_login")
|
||||
vc2 = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc1.id != vc2.id
|
||||
|
||||
def test_create_unique_codes(self):
|
||||
codes = set()
|
||||
for _ in range(20):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
codes.add(vc.code)
|
||||
# 20个随机6位码几乎肯定不都一样
|
||||
assert len(codes) > 1
|
||||
|
||||
def test_create_phone_recipient(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_login")
|
||||
assert vc.recipient == "13800138000"
|
||||
assert vc.code_type == "phone_login"
|
||||
|
||||
def test_create_all_code_types(self):
|
||||
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
|
||||
vc = VerificationCode.create("test@example.com", ct)
|
||||
assert vc.code_type == ct
|
||||
|
||||
|
||||
class TestVerificationCodeIsExpired:
|
||||
"""is_expired 属性测试."""
|
||||
|
||||
def test_not_expired_future(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_expired_past(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_expired_boundary_exact(self):
|
||||
# 用mock固定时间,expires_at等于当前时间不算过期
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=fixed_now,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
|
||||
class TestVerificationCodeIsUsed:
|
||||
"""is_used 属性测试."""
|
||||
|
||||
def test_not_used_default(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.is_used is False
|
||||
|
||||
def test_is_used_after_mark(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
assert vc.is_used is True
|
||||
|
||||
|
||||
class TestVerificationCodeIsValid:
|
||||
"""is_valid 属性测试."""
|
||||
|
||||
def test_valid_fresh(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_valid is True
|
||||
|
||||
def test_invalid_expired(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_used(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_expired_and_used(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
|
||||
class TestVerificationCodeMarkUsed:
|
||||
"""mark_used 方法测试."""
|
||||
|
||||
def test_mark_used_sets_timestamp(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.used_at is None
|
||||
before = datetime.now(timezone.utc)
|
||||
vc.mark_used()
|
||||
after = datetime.now(timezone.utc)
|
||||
assert vc.used_at is not None
|
||||
assert before <= vc.used_at <= after
|
||||
|
||||
def test_mark_used_twice_overwrites(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
first = vc.used_at
|
||||
# 时间足够短,一般不会不同,但确保可以重复调用
|
||||
vc.mark_used()
|
||||
assert vc.used_at is not None
|
||||
|
||||
|
||||
class TestVerificationCodeIncrementAttempts:
|
||||
"""increment_attempts 方法测试."""
|
||||
|
||||
def test_default_zero(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_once(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_increment_multiple(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
for _i in range(5):
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 5
|
||||
|
||||
|
||||
class TestVerificationCodeBasics:
|
||||
"""基础构造和 slots 测试."""
|
||||
|
||||
def test_direct_construction(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc = VerificationCode(
|
||||
id="abc123",
|
||||
recipient="test@test.com",
|
||||
code="000000",
|
||||
code_type="email_bind",
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
used_at=None,
|
||||
attempts=0,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc.id == "abc123"
|
||||
assert vc.recipient == "test@test.com"
|
||||
assert vc.code == "000000"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
vc.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_id(self):
|
||||
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc1 == vc2
|
||||
|
||||
def test_equality_different_id(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="id1",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="id2",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
assert vc1 != vc2
|
||||
Reference in New Issue
Block a user