Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f0a5d63c7 | |||
| 39187a0660 | |||
| f0bbedab23 | |||
| 750444c8bb | |||
| 581a146d2f | |||
| eab45e0819 | |||
| e243d70082 | |||
| d4c3743e45 |
@@ -1805,7 +1805,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
Regular → Executable
+5
-81
@@ -1,11 +1,10 @@
|
||||
import React, { useState, useRef } from "react"
|
||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import React, { useState } from "react"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../types"
|
||||
import { GENDER_OPTIONS } from "../constants"
|
||||
import { genderClass, formatFileSize } from "../utils/format"
|
||||
import TagSelector from "./TagSelector"
|
||||
import FileUploadField from "./material-form/FileUploadField"
|
||||
import GenderSelector from "./material-form/GenderSelector"
|
||||
|
||||
export interface MaterialFormProps {
|
||||
initial?: VoiceMaterial
|
||||
@@ -33,7 +32,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
|
||||
const [file, setFile] = useState<File | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) return
|
||||
@@ -53,65 +51,10 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
|
||||
return (
|
||||
<div className="vmat-form">
|
||||
{/* 音频文件上传(编辑模式不显示) */}
|
||||
{!initial && (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音频文件 *</label>
|
||||
<div
|
||||
className="vmat-upload-zone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f?.type.startsWith("audio/")) setFile(f)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="vmat-upload-selected">
|
||||
<SoundOutlined className="vmat-upload-icon" />
|
||||
<span className="vmat-upload-filename">{file.name}</span>
|
||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-upload-clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFile(undefined)
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vmat-upload-placeholder">
|
||||
<UploadOutlined className="vmat-upload-icon" />
|
||||
<p>点击或拖拽音频文件到此处</p>
|
||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 上传进度条 */}
|
||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||
<div className="vmat-upload-progress">
|
||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} />
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">名称 *</label>
|
||||
<Input
|
||||
@@ -122,7 +65,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音色描述</label>
|
||||
<Input.TextArea
|
||||
@@ -134,25 +76,8 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">性别</label>
|
||||
<div className="vmat-gender-group">
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||
onClick={() => setGender(opt.value)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GenderSelector value={gender} onChange={setGender} />
|
||||
|
||||
{/* 风格标签 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">风格标签</label>
|
||||
<TagSelector
|
||||
@@ -164,7 +89,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-form-actions">
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
|
||||
取消
|
||||
|
||||
Regular → Executable
+16
-72
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from "react"
|
||||
import React from "react"
|
||||
import { CheckOutlined } from "@ant-design/icons"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
import { useTagInput } from "./tag-selector/useTagInput"
|
||||
|
||||
export interface TagSelectorProps {
|
||||
/** 已选标签 ID 列表 */
|
||||
@@ -24,77 +25,22 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
onCreateTag,
|
||||
placeholder = "输入标签后回车添加",
|
||||
}) => {
|
||||
const [inputVal, setInputVal] = useState("")
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** 按名称查找已有标签(大小写不敏感) */
|
||||
const findTagByName = useCallback(
|
||||
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
|
||||
[tags],
|
||||
)
|
||||
|
||||
/** 去重添加标签(按 ID) */
|
||||
const addTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
if (value.includes(tagId)) return
|
||||
onChange([...value, tagId])
|
||||
setInputVal("")
|
||||
setShowSuggestions(false)
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
|
||||
const addTagByName = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const existing = findTagByName(trimmed)
|
||||
if (existing) {
|
||||
addTagId(existing.id)
|
||||
} else {
|
||||
try {
|
||||
const created = await onCreateTag(trimmed)
|
||||
addTagId(created.id)
|
||||
} catch {
|
||||
/* 创建失败静默忽略 */
|
||||
}
|
||||
}
|
||||
},
|
||||
[findTagByName, addTagId, onCreateTag],
|
||||
)
|
||||
|
||||
const removeTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
onChange(value.filter((t) => t !== tagId))
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
/** 输入补全建议(排除已选) */
|
||||
const suggestions = useMemo(() => {
|
||||
if (!inputVal.trim()) return []
|
||||
const lower = inputVal.toLowerCase()
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
|
||||
}, [inputVal, tags, value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (suggestions.length > 0) {
|
||||
addTagId(suggestions[0].id)
|
||||
} else {
|
||||
addTagByName(inputVal)
|
||||
}
|
||||
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
|
||||
removeTagId(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
const {
|
||||
inputVal,
|
||||
setInputVal,
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
inputRef,
|
||||
suggestions,
|
||||
addTagId,
|
||||
removeTagId,
|
||||
handleKeyDown,
|
||||
focus,
|
||||
} = useTagInput({ value, onChange, tags, onCreateTag })
|
||||
|
||||
return (
|
||||
<div className="vmat-tag-selector-wrapper">
|
||||
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
|
||||
<div className="vmat-tag-selector" onClick={focus}>
|
||||
{value.map((tagId) => (
|
||||
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
@@ -115,7 +61,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自动补全下拉 */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="vmat-tag-suggestions">
|
||||
{suggestions.slice(0, 6).map((tag) => (
|
||||
@@ -134,7 +79,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已有标签快捷选择 */}
|
||||
{tags.length > 0 && (
|
||||
<div className="vmat-tag-selector-presets">
|
||||
{tags.map((tag) => {
|
||||
|
||||
Regular → Executable
+9
-57
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react"
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceMaterial } from "../types"
|
||||
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
|
||||
import {
|
||||
genderClass,
|
||||
genderIcon,
|
||||
@@ -18,6 +15,8 @@ import {
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
} from "../utils/format"
|
||||
import { useRowProgress } from "./voice-material-row/useRowProgress"
|
||||
import TagDisplay from "./voice-material-row/TagDisplay"
|
||||
|
||||
export interface VoiceRowProps {
|
||||
material: VoiceMaterial
|
||||
@@ -48,26 +47,10 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * material.duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
const { progressRef, handleMouseDown } = useRowProgress({
|
||||
duration: material.duration,
|
||||
onSeek,
|
||||
})
|
||||
|
||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||
|
||||
@@ -75,7 +58,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
||||
<div
|
||||
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||
>
|
||||
{/* 批量选择 checkbox */}
|
||||
{(batchMode || isSelected) && (
|
||||
<div
|
||||
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
@@ -88,7 +70,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-row-play"
|
||||
@@ -101,60 +82,31 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 名称 + 描述 */}
|
||||
<div className="vmat-row-info">
|
||||
<h4 className="vmat-row-name">{material.name}</h4>
|
||||
{material.description && <p className="vmat-row-desc">{material.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="vmat-row-tags">
|
||||
{material.tagIds.length === 0 ? (
|
||||
<span className="vmat-tag-empty" onClick={() => onEdit()}>
|
||||
添加标签
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{material.tagIds.length > MAX_ROW_TAGS && (
|
||||
<Tooltip
|
||||
title={material.tagIds
|
||||
.slice(MAX_ROW_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<TagDisplay tagIds={material.tagIds} tagMap={tagMap} onAddTag={() => onEdit()} />
|
||||
</div>
|
||||
|
||||
{/* 进度条(可拖拽) */}
|
||||
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleMouseDown}>
|
||||
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<span className="vmat-row-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||
</span>
|
||||
|
||||
{/* 文件大小 */}
|
||||
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="vmat-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import React, { useRef } from "react"
|
||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import { formatFileSize } from "../../utils/format"
|
||||
|
||||
interface FileUploadFieldProps {
|
||||
file: File | undefined
|
||||
onChange: (file: File | undefined) => void
|
||||
uploadProgress?: number | null
|
||||
}
|
||||
|
||||
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f?.type.startsWith("audio/")) onChange(f)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音频文件 *</label>
|
||||
<div
|
||||
className="vmat-upload-zone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onChange(f)
|
||||
}}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="vmat-upload-selected">
|
||||
<SoundOutlined className="vmat-upload-icon" />
|
||||
<span className="vmat-upload-filename">{file.name}</span>
|
||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-upload-clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(undefined)
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vmat-upload-placeholder">
|
||||
<UploadOutlined className="vmat-upload-icon" />
|
||||
<p>点击或拖拽音频文件到此处</p>
|
||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||
<div className="vmat-upload-progress">
|
||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploadField
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import type { VoiceGender } from "../../types"
|
||||
import { GENDER_OPTIONS } from "../../constants"
|
||||
import { genderClass } from "../../utils/format"
|
||||
|
||||
interface GenderSelectorProps {
|
||||
value: VoiceGender
|
||||
onChange: (value: VoiceGender) => void
|
||||
}
|
||||
|
||||
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">性别</label>
|
||||
<div className="vmat-gender-group">
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenderSelector
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useRef, useCallback, useMemo } from "react"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface UseTagInputOptions {
|
||||
value: string[]
|
||||
onChange: (tagIds: string[]) => void
|
||||
tags: TagItem[]
|
||||
onCreateTag: (name: string) => Promise<TagItem>
|
||||
}
|
||||
|
||||
export function useTagInput({ value, onChange, tags, onCreateTag }: UseTagInputOptions) {
|
||||
const [inputVal, setInputVal] = useState("")
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const findTagByName = useCallback(
|
||||
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
|
||||
[tags],
|
||||
)
|
||||
|
||||
const addTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
if (value.includes(tagId)) return
|
||||
onChange([...value, tagId])
|
||||
setInputVal("")
|
||||
setShowSuggestions(false)
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
const addTagByName = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const existing = findTagByName(trimmed)
|
||||
if (existing) {
|
||||
addTagId(existing.id)
|
||||
} else {
|
||||
try {
|
||||
const created = await onCreateTag(trimmed)
|
||||
addTagId(created.id)
|
||||
} catch {
|
||||
/* 创建失败静默忽略 */
|
||||
}
|
||||
}
|
||||
},
|
||||
[findTagByName, addTagId, onCreateTag],
|
||||
)
|
||||
|
||||
const removeTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
onChange(value.filter((t) => t !== tagId))
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!inputVal.trim()) return []
|
||||
const lower = inputVal.toLowerCase()
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
|
||||
}, [inputVal, tags, value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (suggestions.length > 0) {
|
||||
addTagId(suggestions[0].id)
|
||||
} else {
|
||||
addTagByName(inputVal)
|
||||
}
|
||||
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
|
||||
removeTagId(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
const focus = () => inputRef.current?.focus()
|
||||
|
||||
return {
|
||||
inputVal,
|
||||
setInputVal,
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
inputRef,
|
||||
suggestions,
|
||||
addTagId,
|
||||
removeTagId,
|
||||
handleKeyDown,
|
||||
focus,
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../../constants"
|
||||
|
||||
interface TagDisplayProps {
|
||||
tagIds: string[]
|
||||
tagMap: Map<string, TagItem>
|
||||
onAddTag?: () => void
|
||||
}
|
||||
|
||||
const TagDisplay: React.FC<TagDisplayProps> = ({ tagIds, tagMap, onAddTag }) => {
|
||||
if (tagIds.length === 0) {
|
||||
return (
|
||||
<span className="vmat-tag-empty" onClick={onAddTag}>
|
||||
添加标签
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const visible = tagIds.slice(0, MAX_ROW_TAGS)
|
||||
const overflow = tagIds.slice(MAX_ROW_TAGS)
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<Tooltip title={overflow.map((id) => tagMap.get(id)?.name ?? id).join("、")}>
|
||||
<Tag className="vmat-tag-overflow">+{overflow.length}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagDisplay
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
|
||||
interface UseRowProgressOptions {
|
||||
duration: number
|
||||
onSeek: (time: number) => void
|
||||
}
|
||||
|
||||
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
|
||||
doSeek(e.nativeEvent)
|
||||
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
},
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
return { progressRef, handleMouseDown }
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { type VoiceMaterial } from "../../../types"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { type AssetLibraryItem } from "@/api/assets"
|
||||
import { useVoiceUpload } from "./actions/useVoiceUpload"
|
||||
import { useVoiceEdit } from "./actions/useVoiceEdit"
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
||||
import CardHeader from "./clone-voice-card/CardHeader"
|
||||
import CardFooter from "./clone-voice-card/CardFooter"
|
||||
|
||||
export interface CloneVoiceCardProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
@@ -28,26 +21,18 @@ export interface CloneVoiceCardProps {
|
||||
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onPlay,
|
||||
onPause,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onShowDetail,
|
||||
...footerProps
|
||||
}) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
||||
const isFailed = voice.status === "failed"
|
||||
const isProcessing = voice.status === "processing"
|
||||
const genderText =
|
||||
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
|
||||
onClick={isFailed ? undefined : onShowDetail}
|
||||
>
|
||||
{/* 右上角操作按钮 */}
|
||||
<div className="xx-clone-card-actions">
|
||||
<Tooltip title="删除">
|
||||
<button
|
||||
@@ -77,38 +62,8 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="xx-clone-card-header">
|
||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-header-info">
|
||||
<h4 className="xx-clone-name" title={voice.name}>
|
||||
{voice.name}
|
||||
</h4>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader voice={voice} />
|
||||
|
||||
{/* 描述 */}
|
||||
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="xx-clone-meta">
|
||||
{(voice.gender || voice.language) && (
|
||||
<span className="xx-clone-meta-item">
|
||||
<UserOutlined />
|
||||
{genderText}
|
||||
{voice.language ? ` · ${voice.language}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{isFailed && voice.errorMessage && (
|
||||
<div className="xx-clone-error">
|
||||
<CloseCircleOutlined />
|
||||
@@ -116,63 +71,7 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<div className="xx-clone-footer">
|
||||
{voice.status === "ready" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-use-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUse()
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<ReloadOutlined spin />
|
||||
克隆处理中,请稍候...
|
||||
</div>
|
||||
)}
|
||||
{isFailed && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-retry-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry()
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
重试克隆
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CardFooter voice={voice} isPlaying={isPlaying} onRetry={onRetry} {...footerProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
|
||||
interface CardFooterProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onUse: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const CardFooter: React.FC<CardFooterProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onPlay,
|
||||
onPause,
|
||||
onUse,
|
||||
onRetry,
|
||||
}) => {
|
||||
const isFailed = voice.status === "failed"
|
||||
const isProcessing = voice.status === "processing"
|
||||
|
||||
return (
|
||||
<div className="xx-clone-footer">
|
||||
{voice.status === "ready" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-use-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUse()
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<ReloadOutlined spin />
|
||||
克隆处理中,请稍候...
|
||||
</div>
|
||||
)}
|
||||
{isFailed && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-retry-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry()
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
重试克隆
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardFooter
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
import { SoundOutlined, UserOutlined } from "@ant-design/icons"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
||||
|
||||
interface CardHeaderProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
}
|
||||
|
||||
const genderTextOf = (gender: string) =>
|
||||
gender === "male" ? "男声" : gender === "female" ? "女声" : gender
|
||||
|
||||
const CardHeader: React.FC<CardHeaderProps> = ({ voice }) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
||||
const isProcessing = voice.status === "processing"
|
||||
const genderText = genderTextOf(voice.gender ?? "")
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xx-clone-card-header">
|
||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-header-info">
|
||||
<h4 className="xx-clone-name" title={voice.name}>
|
||||
{voice.name}
|
||||
</h4>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
|
||||
|
||||
<div className="xx-clone-meta">
|
||||
{(voice.gender || voice.language) && (
|
||||
<span className="xx-clone-meta-item">
|
||||
<UserOutlined />
|
||||
{genderText}
|
||||
{voice.language ? ` · ${voice.language}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardHeader
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -261,7 +260,6 @@ def db_to_linear(db: float) -> float:
|
||||
Returns:
|
||||
线性音量值
|
||||
"""
|
||||
import math
|
||||
|
||||
return 10 ** (db / 20.0)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
@@ -349,7 +348,7 @@ def build_pip_filters(
|
||||
input_args: list[str] = []
|
||||
current_label = base_label
|
||||
|
||||
for i, (layer, path) in enumerate(zip(layers, source_paths)):
|
||||
for i, (layer, path) in enumerate(zip(layers, source_paths, strict=False)):
|
||||
# 计算实际大小
|
||||
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
|
||||
|
||||
@@ -483,4 +482,4 @@ def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
|
||||
|
||||
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
|
||||
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
|
||||
return sorted(layers, key=lambda l: l.z_index)
|
||||
return sorted(layers, key=lambda layer: layer.z_index)
|
||||
|
||||
@@ -13,12 +13,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
_split_atempo_stages,
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
|
||||
|
||||
settings = get_settings()
|
||||
ensure_database_exists(settings.database_url)
|
||||
_db_url = settings.effective_database_url
|
||||
ensure_database_exists(_db_url)
|
||||
engine, SessionLocal = build_session_factory(
|
||||
settings.database_url,
|
||||
_db_url,
|
||||
pool_size=settings.database_pool_size,
|
||||
max_overflow=settings.database_max_overflow,
|
||||
pool_timeout=settings.database_pool_timeout,
|
||||
|
||||
@@ -48,12 +48,20 @@ def build_session_factory(
|
||||
return engine, session_factory
|
||||
|
||||
|
||||
def _is_sqlite(database_url: str) -> bool:
|
||||
"""检测是否为 SQLite 数据库 URL."""
|
||||
return database_url.startswith("sqlite")
|
||||
|
||||
|
||||
def _build_admin_url(database_url: str) -> URL:
|
||||
url = make_url(database_url)
|
||||
return url.set(database="postgres")
|
||||
|
||||
|
||||
def ensure_database_exists(database_url: str) -> None:
|
||||
"""确保数据库存在(仅 PostgreSQL 需要,SQLite 自动创建)."""
|
||||
if _is_sqlite(database_url):
|
||||
return
|
||||
target_url = make_url(database_url)
|
||||
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
@@ -70,6 +78,14 @@ def ensure_database_exists(database_url: str) -> None:
|
||||
|
||||
|
||||
def initialize_database(engine) -> None:
|
||||
"""初始化数据库 schema。
|
||||
|
||||
PostgreSQL 使用 advisory lock 防止并发初始化冲突;
|
||||
SQLite 直接 create_all(单文件,无并发风险)。
|
||||
"""
|
||||
if _is_sqlite(str(engine.url)):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return
|
||||
with engine.connect() as connection:
|
||||
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
|
||||
try:
|
||||
|
||||
@@ -34,6 +34,9 @@ class SharedSettings(BaseSettings):
|
||||
database_pool_timeout: int = 30
|
||||
database_pool_recycle: int = 3600
|
||||
|
||||
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL)
|
||||
use_in_memory_db: bool = False
|
||||
|
||||
# ── Redis ────────────────────────────────────────────────────────────
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
@@ -66,6 +69,16 @@ class SharedSettings(BaseSettings):
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
"""返回实际使用的数据库 URL。
|
||||
|
||||
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
|
||||
"""
|
||||
if self.use_in_memory_db:
|
||||
return "sqlite:///./test.db"
|
||||
return self.database_url
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -22,22 +22,11 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from packages.domain.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
)
|
||||
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
|
||||
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
|
||||
from packages.domain.url_security import (
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
MAGIC_NUMBERS,
|
||||
MAX_URL_LENGTH,
|
||||
)
|
||||
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
|
||||
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
|
||||
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
|
||||
from packages.domain.url_security import is_ip_address as _is_ip_address_base
|
||||
from packages.domain.url_security import is_trusted_domain as _is_trusted_domain_base
|
||||
from packages.domain.url_security import validate_magic_number as _validate_magic_number_base
|
||||
from packages.domain.url_security import validate_url_basic as _validate_url_basic_base
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[pytest]
|
||||
pythonpath = . apps/api apps/worker packages
|
||||
testpaths = tests
|
||||
# importlib 模式避免同名测试文件的模块名冲突
|
||||
addopts = --import-mode=importlib
|
||||
|
||||
# ===== 覆盖率配置 =====
|
||||
# 覆盖率统计范围(供 --cov 使用时的默认源)
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
"""plan_generator_utils 单元测试 - wave166
|
||||
|
||||
覆盖:
|
||||
- distribute_assets 素材分配(4种模式 + 边界)
|
||||
- map_clip_types_for_mode clip类型映射(4种模式)
|
||||
- generate_default_clips 默认片段生成(4种模式 + 边界)
|
||||
- create_clips_from_configs 从模板配置创建
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.plan_generator_utils import (
|
||||
DEFAULT_CLIP_DURATION,
|
||||
create_clips_from_configs,
|
||||
distribute_assets,
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_main_clip(plan_id: str, order: int = 0) -> EditPlanClip:
|
||||
return EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_clips(plan_id: str, count: int, clip_type: str = "main") -> list[EditPlanClip]:
|
||||
return [
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
duration=5.0,
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - ONE_TAKE
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeOneTake:
|
||||
def test_equal_count(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_more_clips_than_assets(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "" # 没分配到
|
||||
|
||||
def test_more_assets_than_clips(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
def test_empty_assets(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
|
||||
for c in clips:
|
||||
assert c.asset_id == ""
|
||||
|
||||
def test_empty_clips(self):
|
||||
# 不报错即可
|
||||
distribute_assets([], ["a1", "a2"], EditingMode.ONE_TAKE.value)
|
||||
|
||||
def test_only_main_clips_get_assigned(self):
|
||||
# intro/outro 不应该被分配
|
||||
clips = []
|
||||
clips.append(EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0))
|
||||
clips.append(_make_main_clip("p1", order=1))
|
||||
clips.append(EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0))
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "" # intro 无
|
||||
assert clips[1].asset_id == "a1" # main 有
|
||||
assert clips[2].asset_id == "" # outro 无
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - PIP
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributePip:
|
||||
def test_first_asset_to_main(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
# 第一个main是背景,其余改为overlay
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1" # main → 背景
|
||||
assert clips[1].asset_id == "a2" # overlay
|
||||
assert clips[2].asset_id == "a3" # overlay
|
||||
|
||||
def test_single_asset(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
def test_only_main_clip_with_no_overlays(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1", "a2", "a3"] # 多余素材
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - VOICE_OVER
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeVoiceOver:
|
||||
def test_assets_to_main_clips(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_more_clips_than_assets(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - VOICE_PIP
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeVoicePip:
|
||||
def test_three_assets_three_roles(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_single_asset(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
def test_two_assets(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - 边界情况
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeEdgeCases:
|
||||
def test_unknown_mode_falls_back_to_one_take(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, "unknown_mode")
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
def test_none_clips_no_crash(self):
|
||||
# 空列表
|
||||
distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)
|
||||
|
||||
def test_none_assets_no_crash(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
|
||||
for c in clips:
|
||||
assert c.asset_id == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# map_clip_types_for_mode
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestMapClipTypesForMode:
|
||||
def test_one_take_unchanged(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
original_types = [c.clip_type for c in clips]
|
||||
map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
|
||||
assert [c.clip_type for c in clips] == original_types
|
||||
|
||||
def test_voice_over_unchanged(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_pip_first_stays_main_rest_overlay(self):
|
||||
clips = _make_clips("p1", 4)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
assert clips[3].clip_type == "overlay"
|
||||
|
||||
def test_pip_single_clip_stays_main(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_voice_pip_mapping(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
assert clips[4].clip_type == "b_roll"
|
||||
|
||||
def test_voice_pip_two_clips(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
|
||||
def test_voice_pip_single_clip(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
def test_non_main_clips_unchanged(self):
|
||||
clips = [
|
||||
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
|
||||
_make_main_clip("p1", order=1),
|
||||
EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0),
|
||||
]
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "intro"
|
||||
assert clips[1].clip_type == "background" # main 被改了
|
||||
assert clips[2].clip_type == "outro"
|
||||
|
||||
def test_empty_clips_no_error(self):
|
||||
map_clip_types_for_mode([], EditingMode.PIP.value)
|
||||
|
||||
def test_no_main_clips_no_error(self):
|
||||
clips = [
|
||||
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
|
||||
]
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == "intro"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate_default_clips
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsOneTake:
|
||||
def test_basic(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
assert c.plan_id == "p1"
|
||||
|
||||
def test_order_sequential(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 5)
|
||||
for i, c in enumerate(clips):
|
||||
assert c.order == i
|
||||
|
||||
def test_zero_assets_at_least_one(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 0)
|
||||
assert len(clips) == 1
|
||||
|
||||
def test_negative_assets_at_least_one(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, -5)
|
||||
assert len(clips) == 1
|
||||
|
||||
def test_default_duration(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 1)
|
||||
assert clips[0].duration == DEFAULT_CLIP_DURATION
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsPip:
|
||||
def test_one_asset(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 1)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_three_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 3)
|
||||
assert len(clips) == 3
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
|
||||
def test_zero_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 0)
|
||||
assert len(clips) >= 1
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsVoiceOver:
|
||||
def test_basic(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_has_b_roll_config(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 2)
|
||||
# VOICE_OVER 标记 role=b_roll
|
||||
assert clips[0].config.get("role") == "b_roll"
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsVoicePip:
|
||||
def test_one_asset(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 1)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
def test_two_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 2)
|
||||
assert len(clips) == 2
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
|
||||
def test_five_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 5)
|
||||
assert len(clips) == 5
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
assert clips[4].clip_type == "b_roll"
|
||||
|
||||
def test_zero_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 0)
|
||||
assert len(clips) >= 1
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsUnknownMode:
|
||||
def test_falls_back_to_one_take(self):
|
||||
clips = generate_default_clips("p1", "unknown_mode", 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
|
||||
# ============================================================
|
||||
# create_clips_from_configs
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_template_config(
|
||||
cfg_id: str,
|
||||
order: int,
|
||||
clip_type: ClipType = ClipType.MAIN,
|
||||
min_dur: float = 0,
|
||||
max_dur: float = 0,
|
||||
) -> TemplateClipConfig:
|
||||
return TemplateClipConfig(
|
||||
id=cfg_id,
|
||||
template_id="t1",
|
||||
clip_type=clip_type,
|
||||
order=order,
|
||||
min_duration=min_dur,
|
||||
max_duration=max_dur,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
class TestCreateClipsFromConfigs:
|
||||
def test_empty_configs(self):
|
||||
result = create_clips_from_configs("p1", [])
|
||||
assert result == []
|
||||
|
||||
def test_single_config(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=7.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert len(result) == 1
|
||||
assert result[0].plan_id == "p1"
|
||||
assert result[0].template_clip_config_id == "c1"
|
||||
# 平均时长 = (3+7)/2 = 5.0
|
||||
assert result[0].duration == pytest.approx(5.0)
|
||||
|
||||
def test_duration_min_only(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=4.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == 4.0
|
||||
|
||||
def test_duration_max_only(self):
|
||||
configs = [_make_template_config("c1", 0, max_dur=6.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == 6.0
|
||||
|
||||
def test_duration_default_when_no_bounds(self):
|
||||
configs = [_make_template_config("c1", 0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == DEFAULT_CLIP_DURATION
|
||||
|
||||
def test_sorted_by_order(self):
|
||||
configs = [
|
||||
_make_template_config("c_third", 2),
|
||||
_make_template_config("c_first", 0),
|
||||
_make_template_config("c_second", 1),
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert len(result) == 3
|
||||
assert result[0].template_clip_config_id == "c_first"
|
||||
assert result[1].template_clip_config_id == "c_second"
|
||||
assert result[2].template_clip_config_id == "c_third"
|
||||
assert result[0].order == 0
|
||||
assert result[1].order == 1
|
||||
assert result[2].order == 2
|
||||
|
||||
def test_clip_type_preserved(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c_intro",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.INTRO,
|
||||
order=0,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
),
|
||||
TemplateClipConfig(
|
||||
id="c_main",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
),
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].clip_type == ClipType.INTRO.value
|
||||
assert result[1].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_playback_speed_from_config(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={"playback_speed": 1.5},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(1.5)
|
||||
|
||||
def test_speed_ratio_fallback(self):
|
||||
# 兼容 speed_ratio 字段名
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={"speed_ratio": 0.8},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(0.8)
|
||||
|
||||
def test_default_playback_speed(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=5.0, max_dur=5.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(1.0)
|
||||
|
||||
def test_transition_effect_preserved(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="fade",
|
||||
config={},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].transition_effect == "fade"
|
||||
|
||||
def test_returns_edit_plan_clip_objects(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=5.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert isinstance(result[0], EditPlanClip)
|
||||
@@ -5,6 +5,8 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -131,7 +133,7 @@ class TestClipFilterChain:
|
||||
def test_frozen(self):
|
||||
"""frozen dataclass 不可修改."""
|
||||
chain = _make_chain()
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
chain.duration = 10.0 # type: ignore
|
||||
|
||||
|
||||
|
||||
@@ -934,7 +934,7 @@ class TestSortLayersByZIndex:
|
||||
_make_layer(z_index=3, source="/tmp/c.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [1, 3, 5]
|
||||
assert [layer.z_index for layer in sorted_layers] == [1, 3, 5]
|
||||
|
||||
def test_same_z_index_stable(self):
|
||||
"""相同 z_index 保持相对顺序."""
|
||||
@@ -963,4 +963,4 @@ class TestSortLayersByZIndex:
|
||||
_make_layer(z_index=3, source="/tmp/3.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [-5, 0, 3]
|
||||
assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3]
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
@@ -57,7 +60,7 @@ class TestTransitionPreset:
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen dataclass 不可修改."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
with pytest.raises(Exception): # FrozenInstanceError
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
p.name = "新名字"
|
||||
|
||||
def test_not_hashable_due_to_list(self):
|
||||
|
||||
Reference in New Issue
Block a user