Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b42ff0e96 | |||
| 38617515ee | |||
| a1ba05d869 | |||
| 824222de87 |
@@ -26,7 +26,7 @@ concurrency:
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
env:
|
||||
CI_PG_HOST: host.docker.internal
|
||||
CI_LOCAL_PG_PORT: "5432"
|
||||
CI_PG_PORT: "5432"
|
||||
CI_PG_USER: postgres
|
||||
CI_PG_PASSWORD: postgres
|
||||
CI_PG_DB: xiaoxia_saas
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
|
||||
@@ -115,11 +115,11 @@ jobs:
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci; then
|
||||
if ! npm ci --include=dev; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci
|
||||
npm ci --include=dev
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Step 1 模板选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_GRADIENTS } from "../constants"
|
||||
import { useStep1Template } from "../hooks/useStep1Template"
|
||||
|
||||
interface Step1TemplateSelectProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
|
||||
const { templates, selectedTemplate, handleSelect, handleKeySelect } = useStep1Template(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎨 选择模板</h3>
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无可用模板</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「模板编辑器」中创建模板
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-choice-list">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
||||
onClick={() => handleSelect(tpl.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTemplate === tpl.id}
|
||||
onKeyDown={(e) => handleKeySelect(e, tpl.id)}
|
||||
>
|
||||
<span className="xx-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{tpl.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1TemplateSelect
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Step 2 素材选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
import SmartMatchInput from "./material/SmartMatchInput"
|
||||
import SmartMatchResults from "./material/SmartMatchResults"
|
||||
|
||||
interface Step2MaterialSelectProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
const m = useStep2Materials(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
|
||||
<MaterialModeTabs mode={m.materialMode} onModeChange={m.onMaterialModeChange} />
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{m.materialMode === "manual" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">已选 {m.selectedMaterials.length} 个素材</span>
|
||||
</div>
|
||||
|
||||
<ManualMaterialList
|
||||
materials={m.materials}
|
||||
materialsLoading={m.materialsLoading}
|
||||
selectedMaterials={m.selectedMaterials}
|
||||
onToggle={m.handleToggleMaterial}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{m.materialMode === "auto" && (
|
||||
<div className="xx-smart-match-section">
|
||||
<SmartMatchInput
|
||||
inputValue={m.smartMatchInput}
|
||||
onInputChange={m.setSmartMatchInput}
|
||||
matching={m.smartMatching}
|
||||
onMatch={m.handleSmartMatch}
|
||||
hasMatched={m.hasMatched}
|
||||
onRefresh={m.handleRefreshMatch}
|
||||
materialsCount={m.materials.items.length}
|
||||
loading={m.materialsLoading}
|
||||
/>
|
||||
|
||||
<SmartMatchResults
|
||||
results={m.smartMatchedResults}
|
||||
selectedIds={m.smartSelectedIds}
|
||||
matching={m.smartMatching}
|
||||
hasMatched={m.hasMatched}
|
||||
onToggle={m.handleToggleSmartSelect}
|
||||
onSelectAll={m.handleSelectAllMatched}
|
||||
onClear={m.handleClearSmartSelect}
|
||||
formatDuration={m.formatDuration}
|
||||
selectedTotalDuration={m.smartSelectedTotalDuration}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step2MaterialSelect
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useStep3Preview } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = (props) => {
|
||||
const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">模板草稿预览</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${t.titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={t.toggleAiAutoSelect}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!t.titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={t.titleSettings.title || undefined}
|
||||
onChange={(val) => t.updateTitle(val || "")}
|
||||
options={t.userTitles.map((ut) => ({
|
||||
label: ut.content,
|
||||
value: ut.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
t.userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={t.titleSettings.title}
|
||||
onChange={(e) => t.updateTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4TitleSettings
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
|
||||
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
|
||||
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
|
||||
import CustomVoicePanel from "./voice/CustomVoicePanel"
|
||||
import SaveVoiceModal from "./voice/SaveVoiceModal"
|
||||
import CloneVoiceSection from "./voice/CloneVoiceSection"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const v = useStep5Voice(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
<VoiceRecommendSection
|
||||
presetVoices={v.presetVoices}
|
||||
voiceRecommendLoading={v.voiceRecommendLoading}
|
||||
voiceRecommendations={v.voiceRecommendations}
|
||||
hasVoiceRecommend={v.hasVoiceRecommend}
|
||||
onRecommend={v.handleVoiceRecommend}
|
||||
onSelectVoice={v.handleSelectRecommendedVoice}
|
||||
selectedVoiceId={v.selectedVoice}
|
||||
voiceMode={v.voiceMode}
|
||||
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
|
||||
/>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{v.presetVoices.slice(0, 3).map((pv) => (
|
||||
<VoiceChoiceCard
|
||||
key={pv.voice_id}
|
||||
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
|
||||
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
|
||||
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
|
||||
title={pv.name}
|
||||
description={pv.description}
|
||||
/>
|
||||
))}
|
||||
{v.presetVoicesLoading && (
|
||||
<VoiceChoiceCard
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
avatar="⏳"
|
||||
title="加载中…"
|
||||
loading
|
||||
/>
|
||||
)}
|
||||
<VoiceChoiceCard
|
||||
selected={v.voiceMode === "clone"}
|
||||
onClick={v.handleSelectCloneVoice}
|
||||
avatar="🎤"
|
||||
title="克隆我的声音"
|
||||
description="上传语音样本克隆"
|
||||
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{v.voiceMode === "preset" && (
|
||||
<PresetVoiceDetail
|
||||
presetVoices={v.presetVoices}
|
||||
selectedVoice={v.selectedVoice}
|
||||
onSelect={v.handleSelectPresetVoice}
|
||||
playingVoice={v.playingVoice}
|
||||
onTogglePlay={v.toggleVoicePlay}
|
||||
presetVoicesLoading={v.presetVoicesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "custom" && (
|
||||
<>
|
||||
<CustomVoicePanel
|
||||
customVoiceText={v.customVoiceText}
|
||||
onTextChange={v.setCustomVoiceText}
|
||||
synthesizePending={v.synthesizeMutation.isPending}
|
||||
onSynthesize={v.handleSynthesizeVoice}
|
||||
ttsError={v.ttsError}
|
||||
customAudioUrl={v.customAudioUrl}
|
||||
completedTtsJobId={v.completedTtsJobId}
|
||||
onOpenSaveModal={v.handleOpenSaveModal}
|
||||
/>
|
||||
<SaveVoiceModal
|
||||
open={v.saveModalOpen}
|
||||
onClose={() => v.setSaveModalOpen(false)}
|
||||
saveName={v.saveName}
|
||||
onNameChange={v.setSaveName}
|
||||
saveTagIds={v.saveTagIds}
|
||||
onTagIdsChange={v.setSaveTagIds}
|
||||
saveNewTag={v.saveNewTag}
|
||||
onNewTagChange={v.setSaveNewTag}
|
||||
onAddTag={v.handleAddTagInModal}
|
||||
allTags={v.allTags}
|
||||
savePending={v.saveToLibraryMutation.isPending}
|
||||
onConfirm={v.handleConfirmSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "clone" && (
|
||||
<CloneVoiceSection
|
||||
clonedVoices={v.clonedVoices}
|
||||
hasProcessing={v.hasProcessing}
|
||||
selectedClonedVoice={v.selectedClonedVoice}
|
||||
onSelect={v.handleSelectClonedVoice}
|
||||
onOpenCloneModal={v.handleOpenCloneModal}
|
||||
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
|
||||
formatDuration={v.formatDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5VoiceSelect
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img src={coverSettings.upload_url} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step6CoverSettings
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
const {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
} = useStep7Generate(props)
|
||||
|
||||
const { onRetry, onDismissError } = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step7ConfirmGenerate
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 手动选择素材列表
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ManualMaterialListProps {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
materialsLoading: boolean
|
||||
selectedMaterials: string[]
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ManualMaterialList
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 素材模式切换 Tab
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface MaterialModeTabsProps {
|
||||
mode: "manual" | "auto"
|
||||
onModeChange: (mode: "manual" | "auto") => void
|
||||
}
|
||||
|
||||
const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="xx-material-mode-tabs">
|
||||
<button
|
||||
className={`xx-material-mode-tab ${mode === "manual" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
type="button"
|
||||
>
|
||||
手动选择素材
|
||||
</button>
|
||||
<button
|
||||
className={`xx-material-mode-tab ${mode === "auto" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialModeTabs
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 单个智能匹配卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchCardProps {
|
||||
result: SmartMatchResultItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
result,
|
||||
selected,
|
||||
onClick,
|
||||
formatDuration,
|
||||
}) => {
|
||||
const { asset, matchScore, matchReason } = result
|
||||
return (
|
||||
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-smart-match-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="xx-smart-match-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-smart-match-score">{matchScore}%</div>
|
||||
{selected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
</div>
|
||||
)}
|
||||
{asset.duration && (
|
||||
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 信息区 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="xx-smart-match-reason">🎯 {matchReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchCard
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 智能匹配输入区
|
||||
* textarea + 提示 + 按钮组
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SmartMatchInputProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
matching: boolean
|
||||
onMatch: () => void
|
||||
hasMatched: boolean
|
||||
onRefresh: () => void
|
||||
materialsCount: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
matching,
|
||||
onMatch,
|
||||
hasMatched,
|
||||
onRefresh,
|
||||
materialsCount,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-smart-match-input-area">
|
||||
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
|
||||
<textarea
|
||||
className="xx-smart-match-input"
|
||||
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
onMatch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="xx-smart-match-input-footer">
|
||||
<span className="xx-smart-match-tip">
|
||||
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材可供匹配`}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{hasMatched && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onRefresh}
|
||||
disabled={matching || loading}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onMatch}
|
||||
disabled={matching || loading || !inputValue.trim()}
|
||||
>
|
||||
{matching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
匹配中…
|
||||
</>
|
||||
) : (
|
||||
"✨ 智能匹配"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchInput
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* 智能匹配结果区(含加载/空状态/已选汇总)
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import SmartMatchCard from "./SmartMatchCard"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchResultsProps {
|
||||
results: SmartMatchResultItem[]
|
||||
selectedIds: string[]
|
||||
matching: boolean
|
||||
hasMatched: boolean
|
||||
onToggle: (assetId: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
selectedTotalDuration: number
|
||||
}
|
||||
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
results,
|
||||
selectedIds,
|
||||
matching,
|
||||
hasMatched,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
formatDuration,
|
||||
selectedTotalDuration,
|
||||
}) => {
|
||||
// 匹配中状态
|
||||
if (matching) {
|
||||
return (
|
||||
<div className="xx-smart-match-loading">
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
|
||||
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
正在根据描述从视频库中匹配最合适的素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未匹配状态提示
|
||||
if (!hasMatched) {
|
||||
return (
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无结果
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">推荐素材 ({results.length}个)</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
|
||||
全选
|
||||
</button>
|
||||
<span style={{ color: "var(--border-color)" }}>|</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{results.map((result) => {
|
||||
const isSelected = selectedIds.includes(result.asset.id)
|
||||
return (
|
||||
<SmartMatchCard
|
||||
key={result.asset.id}
|
||||
result={result}
|
||||
selected={isSelected}
|
||||
onClick={() => onToggle(result.asset.id)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选素材汇总 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="xx-smart-match-summary">
|
||||
<div className="xx-smart-match-summary-header">
|
||||
<span className="xx-pill xx-pill-ok">已选 {selectedIds.length} 个素材</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
|
||||
预计总时长约 {selectedTotalDuration.toFixed(0)} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchResults
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 单个 AI 标题卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
|
||||
interface AiTitleCardProps {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AiTitleCard: React.FC<AiTitleCardProps> = ({
|
||||
title,
|
||||
highlight,
|
||||
style,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
|
||||
<div className="xx-ai-title-card-text">{title}</div>
|
||||
<div className="xx-ai-title-card-tag">{highlight}</div>
|
||||
{selected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleCard
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* AI 智能生成标题
|
||||
* 输入框 + 生成按钮 + 结果列表 + 加载状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import AiTitleCard from "./AiTitleCard"
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface AiTitleGeneratorProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
generating: boolean
|
||||
onGenerate: () => void
|
||||
results: AiTitleItem[]
|
||||
hasGenerated: boolean
|
||||
onSelect: (title: string) => void
|
||||
selectedTitle: string
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
generating,
|
||||
onGenerate,
|
||||
results,
|
||||
hasGenerated,
|
||||
onSelect,
|
||||
selectedTitle,
|
||||
onRefresh,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onGenerate()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || !inputValue.trim()}
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGenerated && !generating && results.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{results.map((item, idx) => (
|
||||
<AiTitleCard
|
||||
key={idx}
|
||||
title={item.title}
|
||||
highlight={item.highlight}
|
||||
style={item.style}
|
||||
selected={selectedTitle === item.title}
|
||||
onClick={() => onSelect(item.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{generating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleGenerator
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 标题预设样式网格
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitlePresetsGridProps {
|
||||
presets: TitlePresetItem[]
|
||||
activePreset: string | null
|
||||
onApply: (presetKey: string) => void
|
||||
}
|
||||
|
||||
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ presets, activePreset, onApply }) => {
|
||||
return (
|
||||
<div className="xx-title-presets-grid">
|
||||
{presets.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => onApply(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span className="xx-title-preset-preview-text" style={p.previewStyle}>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlePresetsGrid
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* 标题样式设置区
|
||||
* 位置/字体/字号/样式按钮/预设
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
|
||||
interface PositionOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitleStylePanelProps {
|
||||
settings: TitleSettings
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: TitlePresetItem[]
|
||||
POSITION_OPTIONS: PositionOption[]
|
||||
FONT_OPTIONS: string[]
|
||||
}
|
||||
|
||||
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
settings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.position}
|
||||
onChange={(e) => onUpdatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.font}
|
||||
onChange={(e) => onUpdateFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{settings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={settings.size}
|
||||
onChange={(e) => onUpdateSize(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<TitlePresetsGrid
|
||||
presets={titlePresets}
|
||||
activePreset={activePreset}
|
||||
onApply={onApplyPreset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
|
||||
onClick={onToggleBold}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
|
||||
onClick={onToggleItalic}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
|
||||
onClick={onToggleStroke}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
|
||||
onClick={onToggleShadow}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleStylePanel
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 克隆声音展开区域
|
||||
* 克隆按钮、轮询提示、已克隆列表、空状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface CloneVoiceSectionProps {
|
||||
clonedVoices: VoiceClone[]
|
||||
hasProcessing: boolean
|
||||
selectedClonedVoice: string
|
||||
onSelect: (voiceId: string) => void
|
||||
onOpenCloneModal: () => void
|
||||
CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }>
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
selectedClonedVoice,
|
||||
onSelect,
|
||||
onOpenCloneModal,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clone-section">
|
||||
<p className="xx-clone-section-title">克隆我的声音</p>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 36, fontSize: 13 }}
|
||||
onClick={onOpenCloneModal}
|
||||
>
|
||||
<ThunderboltOutlined /> 克隆新声音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 轮询提示 */}
|
||||
{hasProcessing && (
|
||||
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
|
||||
<span className="xx-clone-polling-dot" />
|
||||
正在同步克隆进度...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已克隆声音列表 */}
|
||||
{clonedVoices.length > 0 && (
|
||||
<div className="xx-clone-voices-list">
|
||||
{clonedVoices.map((cv) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
||||
const isReady = cv.status === "ready"
|
||||
const selected = selectedClonedVoice === cv.id
|
||||
return (
|
||||
<div
|
||||
key={cv.id}
|
||||
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${
|
||||
!isReady ? "disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isReady) onSelect(cv.id)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isReady ? 0 : -1}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`xx-clone-avatar ${cv.status}`}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-info">
|
||||
<div className="xx-clone-name">{cv.name}</div>
|
||||
<div className="xx-clone-status">
|
||||
<span className="xx-clone-status-dot" style={{ background: statusCfg.color }} />
|
||||
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
|
||||
{isReady && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clonedVoices.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
暂无克隆音色,点击「克隆新声音」开始
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
marginTop: 10,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
💡 提示:录音环境越安静,克隆效果越好。
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneVoiceSection
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 自定义录制面板
|
||||
* textarea + 合成按钮 + 结果区 + 存为素材按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { AudioOutlined, SaveOutlined } from "@ant-design/icons"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface CustomVoicePanelProps {
|
||||
customVoiceText: string
|
||||
onTextChange: (text: string) => void
|
||||
synthesizePending: boolean
|
||||
onSynthesize: () => void
|
||||
ttsError: string | null
|
||||
customAudioUrl: string | null
|
||||
completedTtsJobId: string | null
|
||||
onOpenSaveModal: () => void
|
||||
}
|
||||
|
||||
const CustomVoicePanel: React.FC<CustomVoicePanelProps> = ({
|
||||
customVoiceText,
|
||||
onTextChange,
|
||||
synthesizePending,
|
||||
onSynthesize,
|
||||
ttsError,
|
||||
customAudioUrl,
|
||||
completedTtsJobId,
|
||||
onOpenSaveModal,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="输入配音文案,点击合成按钮生成语音…"
|
||||
value={customVoiceText}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
maxLength={500}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
border: "1px solid var(--border-color, #e2e8f0)",
|
||||
borderRadius: "var(--radius-sm, 10px)",
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
disabled={!customVoiceText.trim() || synthesizePending}
|
||||
onClick={onSynthesize}
|
||||
>
|
||||
<AudioOutlined /> {synthesizePending ? "合成中…" : "合成语音"}
|
||||
</button>
|
||||
</div>
|
||||
{ttsError && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--error, #ef4444)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && completedTtsJobId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
||||
onClick={onOpenSaveModal}
|
||||
>
|
||||
<SaveOutlined /> 存为素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomVoicePanel
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 预设音色下拉选择 + 试听按钮
|
||||
* voiceMode === "preset" 时显示的详情区
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined } from "@ant-design/icons"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
interface PresetVoiceDetailProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
selectedVoice: string
|
||||
onSelect: (voiceId: string) => void
|
||||
playingVoice: string | null
|
||||
onTogglePlay: (voiceId: string, previewUrl: string | null) => void
|
||||
presetVoicesLoading: boolean
|
||||
}
|
||||
|
||||
const PresetVoiceDetail: React.FC<PresetVoiceDetailProps> = ({
|
||||
presetVoices,
|
||||
selectedVoice,
|
||||
onSelect,
|
||||
playingVoice,
|
||||
onTogglePlay,
|
||||
presetVoicesLoading,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="xx-form-field">
|
||||
<label>从配音库选择</label>
|
||||
<select value={selectedVoice} onChange={(e) => onSelect(e.target.value)}>
|
||||
<option value="">请选择配音…</option>
|
||||
{presetVoicesLoading ? (
|
||||
<option disabled>加载中…</option>
|
||||
) : (
|
||||
presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name} — {v.description}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 试听按钮 */}
|
||||
{presetVoices.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{presetVoices.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.voice_id}
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
|
||||
onClick={() => onTogglePlay(v.voice_id, v.preview_url)}
|
||||
>
|
||||
{playingVoice === v.voice_id ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 停止
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> {v.name}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PresetVoiceDetail
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 保存到配音库弹窗
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface SaveVoiceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
saveName: string
|
||||
onNameChange: (name: string) => void
|
||||
saveTagIds: string[]
|
||||
onTagIdsChange: (tags: string[] | ((prev: string[]) => string[])) => void
|
||||
saveNewTag: string
|
||||
onNewTagChange: (tag: string) => void
|
||||
onAddTag: (tagName: string) => void
|
||||
allTags: TagItem[]
|
||||
savePending: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const SaveVoiceModal: React.FC<SaveVoiceModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
saveName,
|
||||
onNameChange,
|
||||
saveTagIds,
|
||||
onTagIdsChange,
|
||||
saveNewTag,
|
||||
onNewTagChange,
|
||||
onAddTag,
|
||||
allTags,
|
||||
savePending,
|
||||
onConfirm,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="xx-save-modal-overlay" onClick={onClose}>
|
||||
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音库</span>
|
||||
<button className="xx-save-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-save-modal-body">
|
||||
<label className="xx-save-modal-label">素材名称</label>
|
||||
<input
|
||||
className="xx-save-modal-input"
|
||||
placeholder="留空则自动生成名称"
|
||||
value={saveName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="xx-save-modal-label">
|
||||
标签
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<div className="xx-save-modal-tags">
|
||||
{saveTagIds.map((id) => {
|
||||
const tag = allTags.find((t) => t.id === id)
|
||||
return tag ? (
|
||||
<span key={id} className="xx-save-modal-tag active">
|
||||
{tag.name}
|
||||
<CloseOutlined
|
||||
className="xx-save-modal-tag-remove"
|
||||
onClick={() => onTagIdsChange((prev: string[]) => prev.filter((x) => x !== id))}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
<input
|
||||
className="xx-save-modal-tag-input"
|
||||
placeholder="输入标签名回车添加"
|
||||
value={saveNewTag}
|
||||
onChange={(e) => onNewTagChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
onAddTag(saveNewTag)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allTags.length > 0 && (
|
||||
<div className="xx-save-modal-tag-presets">
|
||||
{allTags
|
||||
.filter((t) => !saveTagIds.includes(t.id))
|
||||
.slice(0, 12)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="xx-save-modal-tag-preset"
|
||||
onClick={() => onTagIdsChange((prev: string[]) => [...prev, t.id])}
|
||||
>
|
||||
{t.name}
|
||||
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-save-modal-footer">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-primary" disabled={savePending} onClick={onConfirm}>
|
||||
{savePending ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveVoiceModal
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 音色选择卡片
|
||||
* 用于顶部预设音色卡片和克隆入口卡片
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface VoiceChoiceCardProps {
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
avatar: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
avatarStyle?: React.CSSProperties
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const VoiceChoiceCard: React.FC<VoiceChoiceCardProps> = ({
|
||||
selected,
|
||||
onClick,
|
||||
avatar,
|
||||
title,
|
||||
description,
|
||||
avatarStyle,
|
||||
loading = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`xx-voice-choice-item ${selected ? "selected" : ""}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}}
|
||||
style={loading ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div className="xx-voice-choice-avatar" style={avatarStyle}>
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>{title}</h4>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceChoiceCard
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* AI 智能推荐配音区域
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
interface VoiceRecommendSectionProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
voiceRecommendLoading: boolean
|
||||
voiceRecommendations: string[]
|
||||
hasVoiceRecommend: boolean
|
||||
onRecommend: () => void
|
||||
onSelectVoice: (voiceId: string) => void
|
||||
selectedVoiceId: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
VOICE_GENDER_ICON: Record<string, string>
|
||||
}
|
||||
|
||||
const VoiceRecommendSection: React.FC<VoiceRecommendSectionProps> = ({
|
||||
presetVoices,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
onRecommend,
|
||||
onSelectVoice,
|
||||
selectedVoiceId,
|
||||
voiceMode,
|
||||
VOICE_GENDER_ICON,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-voice-recommend-section">
|
||||
<div className="xx-voice-recommend-header">
|
||||
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onRecommend}
|
||||
disabled={voiceRecommendLoading}
|
||||
>
|
||||
{voiceRecommendLoading ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
推荐中
|
||||
</>
|
||||
) : hasVoiceRecommend ? (
|
||||
"换一批"
|
||||
) : (
|
||||
"智能推荐"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceRecommendLoading && (
|
||||
<div className="xx-voice-recommend-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
根据视频内容为你匹配最合适的音色…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
|
||||
<div className="xx-voice-recommend-list">
|
||||
{voiceRecommendations.map((voiceId) => {
|
||||
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
|
||||
if (!v) return null
|
||||
const isSelected = voiceMode === "preset" && selectedVoiceId === v.voice_id
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => onSelectVoice(v.voice_id)}
|
||||
>
|
||||
<div className="xx-voice-recommend-avatar">
|
||||
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
|
||||
</div>
|
||||
<div className="xx-voice-recommend-info">
|
||||
<div className="xx-voice-recommend-name">{v.name}</div>
|
||||
<div className="xx-voice-recommend-desc">{v.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="xx-voice-recommend-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && !hasVoiceRecommend && (
|
||||
<div className="xx-voice-recommend-empty">
|
||||
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceRecommendSection
|
||||
@@ -1,370 +0,0 @@
|
||||
/**
|
||||
* 视频生成 Hook
|
||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||
*/
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import {
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseGenerateVideoProps {
|
||||
titleSettings: TitleSettings
|
||||
selectedTemplate: string
|
||||
selectedMaterials: string[]
|
||||
materialMode: "manual" | "auto"
|
||||
smartSelectedIds: string[]
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
}
|
||||
|
||||
export function useGenerateVideo({
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
}: UseGenerateVideoProps) {
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
|
||||
/* ── 生成阶段映射 ── */
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
})
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
|
||||
try {
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
> = {}
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
} else if (voiceMode === "clone") {
|
||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||
// 自定义配音模式需从 step5 组件传回
|
||||
}
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getGenerationStatus(selectedTemplate)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
setGeneratedVideos(videos)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const errorMsg = safeExtract(rawMsg)
|
||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
||||
setGenerateError(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
||||
setProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object
|
||||
error?: string | object
|
||||
detail?: string | object
|
||||
msg?: string | object
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return extractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const backendMsg =
|
||||
extractString(axiosErr.response?.data?.message) ||
|
||||
extractString(axiosErr.response?.data?.error) ||
|
||||
extractString(axiosErr.response?.data?.detail) ||
|
||||
extractString(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.message ||
|
||||
""
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const rawError = safeExtractErr(backendMsg)
|
||||
const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||
return "正在准备生成,请稍候再试"
|
||||
}
|
||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
return "网络连接超时,请检查网络后重试"
|
||||
}
|
||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||
}
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||
return "生成失败,请稍后重试或联系管理员"
|
||||
}
|
||||
const finalMsg = translateError(rawError)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
try {
|
||||
const url = video.download_url || video.file_url
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = video.name || "generated-video.mp4"
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err)
|
||||
message.error("下载失败,请重试")
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
const shareUrl = video.file_url || window.location.href
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
message.success("视频链接已复制到剪贴板")
|
||||
} catch {
|
||||
message.info(`视频链接: ${shareUrl}`)
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGenerateVideo
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Step 1 模板选择 Hook
|
||||
* 封装模板选择的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep1TemplateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
export function useStep1Template({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onSelectTemplate,
|
||||
}: UseStep1TemplateProps) {
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectTemplate(id)
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
const handleKeySelect = useCallback(
|
||||
(e: React.KeyboardEvent, id: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectTemplate(id)
|
||||
}
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
return {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
handleSelect,
|
||||
handleKeySelect,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep1Template
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Step 2 素材选择 Hook
|
||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { SMART_MATCH_REASONS } from "../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
/* ── 智能素材匹配状态 ── */
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
onSelectedMaterialsChange(
|
||||
selectedMaterials.includes(materialId)
|
||||
? selectedMaterials.filter((id) => id !== materialId)
|
||||
: [...selectedMaterials, materialId],
|
||||
)
|
||||
},
|
||||
[selectedMaterials, onSelectedMaterialsChange],
|
||||
)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
// 素材库
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep2Materials
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 封装预览信息的计算逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
}: UseStep3PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
/* ── 标题库 API ── */
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── AI 标题生成状态 ── */
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
/* ── 辅助函数 ── */
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
// 取前3个关键词组合
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||
|
||||
/* ── AI 标题生成 ── */
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
|
||||
// 模拟 AI 生成延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
// 每种风格随机选2个
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
const title = tpl.replace(/\{topic\}/g, topic)
|
||||
const highlights = {
|
||||
catchy: "吸睛标题",
|
||||
emotional: "情感共鸣",
|
||||
informative: "知识干货",
|
||||
}
|
||||
results.push({
|
||||
title,
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 打乱顺序
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
// 重新生成一批
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/* ── 标题设置更新 ── */
|
||||
const updateTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleAiAutoSelect = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateFont = useCallback(
|
||||
(font: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, font })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateSize = useCallback(
|
||||
(size: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, size })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateColor = useCallback(
|
||||
(color: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, color })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleBold = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleItalic = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleStroke = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleShadow = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
// 标题设置操作
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Title
|
||||
@@ -1,397 +0,0 @@
|
||||
/**
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
|
||||
interface UseStep5VoiceProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
export function useStep5Voice({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
titleText,
|
||||
}: UseStep5VoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 音频播放 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||
|
||||
const toggleVoicePlay = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoice === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoice(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请检查网络")
|
||||
})
|
||||
audio.onended = () => {
|
||||
setPlayingVoice(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoice(voiceId)
|
||||
},
|
||||
[playingVoice],
|
||||
)
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
||||
const title = titleText.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
// 默认推荐前3个
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
// 不足3个时补足
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleText])
|
||||
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/* ── TTS 自定义合成状态 ── */
|
||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||
|
||||
/* ── TTS mutation ── */
|
||||
const synthesizeMutation = useMutation({
|
||||
mutationFn: synthesizeSpeech,
|
||||
onSuccess: (data) => {
|
||||
setTtsJobId(data.job_id)
|
||||
message.info("语音合成已提交,等待处理…")
|
||||
},
|
||||
onError: () => {
|
||||
setTtsError("语音合成请求失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
/** 轮询 TTS 任务状态 */
|
||||
useEffect(() => {
|
||||
if (!ttsJobId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTTSJobStatus(ttsJobId)
|
||||
if (cancelled) return
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url)
|
||||
setCompletedTtsJobId(ttsJobId)
|
||||
setTtsJobId(null)
|
||||
setTtsError(null)
|
||||
message.success("语音合成完成!")
|
||||
return
|
||||
}
|
||||
if (status.status === "failed" || status.status === "cancelled") {
|
||||
setTtsError(status.error_message || "语音合成失败")
|
||||
setTtsJobId(null)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTtsError("查询合成状态失败")
|
||||
setTtsJobId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [ttsJobId])
|
||||
|
||||
/** 触发自定义文本 TTS 合成 */
|
||||
const handleSynthesizeVoice = useCallback(() => {
|
||||
if (!customVoiceText.trim()) {
|
||||
message.warning("请先输入配音文案")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setCustomAudioUrl(null)
|
||||
synthesizeMutation.mutate({
|
||||
text: customVoiceText.trim(),
|
||||
voice_id: selectedVoice || undefined,
|
||||
language: "zh-CN",
|
||||
})
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||
|
||||
/* ── 存为素材弹窗状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||
const [saveNewTag, setSaveNewTag] = useState("")
|
||||
|
||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 存为素材 mutation ── */
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
})
|
||||
setSaveModalOpen(false)
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setCompletedTtsJobId(null)
|
||||
setCustomAudioUrl(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开存为素材弹窗 */
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setSaveModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 确认保存 */
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
})
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||
|
||||
/** 在弹窗中新增标签(先创建再选中) */
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim()
|
||||
if (!trimmed) return
|
||||
/* 已在选中列表则跳过 */
|
||||
const existing = allTags.find((t) => t.name === trimmed)
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed)
|
||||
setSaveTagIds((prev) => [...prev, created.id])
|
||||
setSaveNewTag("")
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`)
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
/** 保存成功后跳转到视频库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
onCloneModalOpenChange(false)
|
||||
message.success("音色克隆成功!")
|
||||
},
|
||||
[addClone, onCloneModalOpenChange],
|
||||
)
|
||||
|
||||
/* ── 预设音色选择操作 ── */
|
||||
const handleSelectPresetVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectCloneVoice = useCallback(() => {
|
||||
onVoiceModeChange("clone")
|
||||
}, [onVoiceModeChange])
|
||||
|
||||
const handleSelectClonedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onSelectedClonedVoiceChange(voiceId)
|
||||
},
|
||||
[onSelectedClonedVoiceChange],
|
||||
)
|
||||
|
||||
const handleOpenCloneModal = useCallback(() => {
|
||||
onCloneModalOpenChange(true)
|
||||
}, [onCloneModalOpenChange])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
// 模式 & 选择
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
// AI 推荐
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
handleSelectRecommendedVoice,
|
||||
// 音频播放
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
// 预设音色操作
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
// 自定义 TTS
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
ttsJobId,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
// 存为素材
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
// 克隆
|
||||
cloneModalOpen,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess,
|
||||
handleSelectClonedVoice,
|
||||
// utils
|
||||
VOICE_GENDER_ICON,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep5Voice
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
}: UseStep6CoverProps) {
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}, [])
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
onCoverSettingsChange({ ...coverSettings, enabled })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: CoverConfig["mode"]) => {
|
||||
onCoverSettingsChange({ ...coverSettings, mode })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setFrameTime = useCallback(
|
||||
(frameTime: number) => {
|
||||
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep6Cover
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Step 7 确认生成 Hook
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep7Generate({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
}: UseStep7GenerateProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialSummary = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
if (voiceMode === "clone") {
|
||||
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
||||
return cv ? cv.name : "未选择"
|
||||
}
|
||||
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
||||
return pv ? pv.name : "未选择"
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
|
||||
}, [coverSettings])
|
||||
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleDecrement = () => {
|
||||
onGenerateCountChange(Math.max(1, generateCount - 1))
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
onGenerateCountChange(Math.min(10, generateCount + 1))
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep7Generate
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 时长格式化工具
|
||||
* 秒数转分秒格式,如 65 -> "1:05"
|
||||
*/
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* GenerateHeader 组件单元测试
|
||||
* 同时 import GeneratePage 主组件,确保 vitest related 模式
|
||||
* 能匹配到 generate 目录下所有文件的改动
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import GenerateHeader from "@/pages/generate/components/GenerateHeader"
|
||||
// 引入主组件以建立依赖链,让 vitest related 覆盖整个 generate 目录
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GenerateHeader", () => {
|
||||
it("should render title and description", () => {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* GeneratePage 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/voice/VoiceRecommendSection"
|
||||
import "@/pages/generate/components/voice/VoiceChoiceCard"
|
||||
import "@/pages/generate/components/voice/PresetVoiceDetail"
|
||||
import "@/pages/generate/components/voice/CustomVoicePanel"
|
||||
import "@/pages/generate/components/voice/SaveVoiceModal"
|
||||
import "@/pages/generate/components/voice/CloneVoiceSection"
|
||||
import "@/pages/generate/components/material/MaterialModeTabs"
|
||||
import "@/pages/generate/components/material/ManualMaterialList"
|
||||
import "@/pages/generate/components/material/SmartMatchInput"
|
||||
import "@/pages/generate/components/material/SmartMatchResults"
|
||||
import "@/pages/generate/components/material/SmartMatchCard"
|
||||
import "@/pages/generate/components/title/AiTitleGenerator"
|
||||
import "@/pages/generate/components/title/AiTitleCard"
|
||||
import "@/pages/generate/components/title/TitleStylePanel"
|
||||
import "@/pages/generate/components/title/TitlePresetsGrid"
|
||||
import "@/pages/generate/utils/formatDuration"
|
||||
|
||||
describe("GeneratePage module smoke test", () => {
|
||||
it("should load all generate modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
# 端口分配清单
|
||||
|
||||
> 本文档梳理 xiaoxia-saas 项目中所有服务、容器及 CI 环境使用的端口,
|
||||
> 作为运维、排障和新功能开发时的统一参考。
|
||||
>
|
||||
> 最后更新:2026-07-24
|
||||
|
||||
---
|
||||
|
||||
## 一、应用服务端口
|
||||
|
||||
| 服务 | 容器内端口 | 环境变量名 | Staging 宿主机 | Production 宿主机 | 说明 |
|
||||
| -------- | ---------- | ---------------- | -------------- | ----------------- | ----------------------------------- |
|
||||
| API | 8000 | `API_PORT` | 8000 | 8001 | FastAPI 服务,Nginx 反代后端 |
|
||||
| Web | 80 | `WEB_PORT` | 3001 | 3002 | Nginx + 前端静态文件 |
|
||||
| Worker | — | — | — | — | Celery 任务队列,不暴露端口 |
|
||||
|
||||
### 补充说明
|
||||
- API 容器内部固定监听 8000(`API_HOST=0.0.0.0`,`API_PORT=8000`)
|
||||
- Web 容器内部 Nginx 固定监听 80
|
||||
- 所有端口均绑定 `127.0.0.1`,不直接暴露公网,由前置 Nginx/CDN 转发
|
||||
|
||||
---
|
||||
|
||||
## 二、基础设施端口
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
|
||||
| ------------ | ---------- | ---------- | --------------------- | -------- |
|
||||
| Production | 5432 | 5433 | `POSTGRES_PORT` | 5433 |
|
||||
| Staging | 5432 | 5434 | `POSTGRES_PORT` | 5434 |
|
||||
| 开发本地 | 5432 | 5432 | `DATABASE_URL` 中端口 | 5432 |
|
||||
| CI 共享 PG | 5432 | 5433 | `CI_SHARED_PG_PORT` | 5433 |
|
||||
| CI 本地 PG | 5432 | 5432 | `CI_LOCAL_PG_PORT` | 5432 |
|
||||
|
||||
### Redis
|
||||
|
||||
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
|
||||
| ------------ | ---------- | ---------- | ------------------- | -------- |
|
||||
| Production | 6379 | 6380 | `REDIS_URL` 中端口 | — |
|
||||
| Staging | 6379 | 6381 | `REDIS_URL` 中端口 | — |
|
||||
| 开发本地 | 6379 | 6379 | `REDIS_URL` | 6379 |
|
||||
| CI 动态创建 | 6379 | 随机 | 运行时 `REDIS_PORT` | — |
|
||||
|
||||
> CI Integration Tests 中 Redis 容器使用 `-P` 随机映射端口,
|
||||
> 通过 `docker port` 命令获取实际端口后写入 `REDIS_URL`。
|
||||
|
||||
### 容器镜像 Registry
|
||||
|
||||
| 服务 | 端口 | 地址 | 说明 |
|
||||
| ----------------- | ----- | ---------------------- | ------------------------------ |
|
||||
| Gitea Registry | 5000 | 172.30.18.198:5000 | CI 构建服务器内网 Registry |
|
||||
| ACR(生产镜像源) | 443 | crpi-xxx.aliyuncs.com | 阿里云容器镜像服务(HTTPS) |
|
||||
|
||||
---
|
||||
|
||||
## 三、CI / DevOps 端口
|
||||
|
||||
| 服务/用途 | 端口 | 环境变量名 | 默认值 | 说明 |
|
||||
| ------------------- | ----- | --------------------- | ------ | ------------------------------------- |
|
||||
| CI ChatOps Webhook | 8090 | `CHATOPS_WEBHOOK_PORT`| 8090 | Gitea webhook 接收服务(`scripts/ci/chatops/`) |
|
||||
| Staging SSH 部署 | 22222 | `STAGING_SSH_PORT` | 22222 | Staging 服务器 SSH 端口(secrets 配置) |
|
||||
| Preview SSH 部署 | 22222 | `PREVIEW_SSH_PORT` | 22222 | Preview 服务器 SSH 端口(secrets 配置) |
|
||||
| Preview 前端访问 | 80 | — | 80 | Nginx 子域名路由,`*.preview.xiaoxiajianji.com` |
|
||||
|
||||
---
|
||||
|
||||
## 四、开发环境默认端口(.env.example)
|
||||
|
||||
| 用途 | 端口 | 环境变量名 / 出处 |
|
||||
| ------------ | ----- | ------------------------------------------ |
|
||||
| API 服务 | 8000 | `API_PORT` |
|
||||
| 数据库 | 5432 | `DATABASE_URL`(`postgresql+psycopg://...:5432/...`) |
|
||||
| Redis | 6379 | `REDIS_URL` / `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` |
|
||||
| SMTP | 587 | `SMTP_PORT` |
|
||||
| 前端开发服务 | 3000 | `APP_BASE_URL`(默认 localhost:3000) |
|
||||
| Vite Dev | 5173 | `CORS_ORIGINS_RAW` 中包含 |
|
||||
|
||||
---
|
||||
|
||||
## 五、CI Workflow 中的端口变量
|
||||
|
||||
### ci-pipeline.yml 顶层 env
|
||||
|
||||
| 变量名 | 默认值 | 用途 |
|
||||
| ------------------- | ------ | ------------------------ |
|
||||
| `CI_PG_PORT` | 5432 | CI PG 容器端口(本地) |
|
||||
| `CI_SHARED_PG_PORT` | 5433 | CI 共享常驻 PG 端口 |
|
||||
|
||||
### scripts/ci/ci_env.sh(统一常量)
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
| ------------------- | ----------- | ----------------------------- |
|
||||
| `CI_SHARED_PG_PORT` | 5433 | 共享常驻 PG 实例端口 |
|
||||
| `CI_LOCAL_PG_PORT` | 5432 | 本地 PG 容器默认端口 |
|
||||
| `CI_DEFAULT_DB` | xiaoxia_saas | 默认数据库名 |
|
||||
|
||||
---
|
||||
|
||||
## 六、命名规范
|
||||
|
||||
### 推荐命名格式
|
||||
|
||||
统一使用 `{服务/用途}_PORT` 格式:
|
||||
|
||||
```bash
|
||||
API_PORT # 应用服务
|
||||
WEB_PORT # 应用服务
|
||||
POSTGRES_PORT # 基础设施
|
||||
REDIS_PORT # 基础设施
|
||||
SMTP_PORT # 外部服务
|
||||
CI_SHARED_PG_PORT # CI 特定
|
||||
CI_LOCAL_PG_PORT # CI 特定
|
||||
CHATOPS_WEBHOOK_PORT # DevOps 服务
|
||||
```
|
||||
|
||||
### 历史命名不一致(待统一)
|
||||
|
||||
- `WEBHOOK_PORT`(chatops config.py 内部变量)→ 应与外部 env 名 `CHATOPS_WEBHOOK_PORT` 对齐
|
||||
- `STAGING_SSH_PORT` / `PREVIEW_SSH_PORT` → 符合规范,保留
|
||||
- `CI_PG_PORT`(workflow 中)→ 建议统一为 `CI_LOCAL_PG_PORT` 与 `ci_env.sh` 对齐
|
||||
|
||||
---
|
||||
|
||||
## 七、相关配置文件路径
|
||||
|
||||
| 文件路径 | 端口相关内容 |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| `infra/docker/compose.yml` | API / Web / Worker 端口映射 |
|
||||
| `infra/docker/infra.yml` | Staging PG / Redis 端口 |
|
||||
| `infra/docker/infra-production.yml` | Production PG / Redis 端口 |
|
||||
| `.env.example` | 开发环境全部端口变量 |
|
||||
| `.gitea/workflows/ci-pipeline.yml` | CI PG 端口配置 |
|
||||
| `scripts/ci/ci_env.sh` | CI 端口统一常量 |
|
||||
| `scripts/ci/chatops/config.py` | ChatOps Webhook 端口 |
|
||||
| `scripts/ci/run_integration_tests.sh` | Redis 动态端口 + PG 端口 |
|
||||
| `scripts/ci/run_validate.sh` | PG 端口 |
|
||||
| `scripts/ci/validate_migration.sh` | PG 端口 |
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Storage 端口接口 — 统一存储服务的抽象定义。
|
||||
|
||||
所有存储实现(OSS、本地、S3等)都必须实现这个端口。
|
||||
API 和 Worker 都通过这个端口与存储交互,消除两套独立实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class StoragePort(ABC):
|
||||
"""统一存储服务端口。
|
||||
|
||||
定义所有存储后端必须实现的核心能力。
|
||||
具体实现见 packages.shared.storage.SharedStorageService。
|
||||
"""
|
||||
|
||||
# ── 基础上传 / 下载 ────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def upload_file(
|
||||
self,
|
||||
file_or_path: Union[str, Path, object],
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""上传文件到存储,返回公开 URL。
|
||||
|
||||
Args:
|
||||
file_or_path: 本地文件路径(str/Path)或类文件对象
|
||||
storage_key: 目标存储键
|
||||
content_type: MIME 类型
|
||||
|
||||
Returns:
|
||||
公开访问 URL
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def download_file(self, storage_key_or_url: str, local_path: Union[str, Path]) -> bool:
|
||||
"""从存储下载文件到本地。
|
||||
|
||||
自动识别输入:完整URL走HTTP下载(支持预签名),存储键走SDK下载。
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL
|
||||
local_path: 本地保存路径
|
||||
|
||||
Returns:
|
||||
True 成功,False 失败
|
||||
"""
|
||||
...
|
||||
|
||||
# ── URL 生成 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""获取公开 URL。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""获取预签名下载 URL(私有 bucket 用)。
|
||||
|
||||
未配置OSS时降级为公开URL。
|
||||
"""
|
||||
...
|
||||
|
||||
# ── 文件操作 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
"""删除文件(不抛异常)。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""检查文件是否存在。"""
|
||||
...
|
||||
|
||||
# ── 浏览器直传 ────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
storage_key: str,
|
||||
content_type: str,
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 POST 表单(用于前端直传OSS)。"""
|
||||
...
|
||||
|
||||
# ── Asset 解析(Worker 用)────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def resolve_asset_path(self, asset_id: str, work_dir: Union[str, Path]) -> Optional[Path]:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略:本地路径 → 缓存命中 → OSS下载 → None
|
||||
缓存:SHA256(asset_id)[:16] 为文件名,避免重复下载
|
||||
"""
|
||||
...
|
||||
|
||||
# ── 工具方法 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键,URL decode 处理。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def diagnose(self) -> None:
|
||||
"""输出存储配置诊断日志。"""
|
||||
...
|
||||
+61
-321
@@ -1,13 +1,4 @@
|
||||
"""统一存储服务 — API 和 Worker 共用的唯一存储入口。
|
||||
|
||||
实现 StoragePort 端口接口,整合原来分散在各处的存储能力:
|
||||
- API端 SharedStorageService 的全部能力(上传/下载/签名URL/直传POST)
|
||||
- Worker端 oss_helpers 的高级能力(分片上传/超时保护/HTTP下载/Asset路径解析)
|
||||
|
||||
所有服务都通过这个统一入口与存储交互,消除重复实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
"""Shared OSS storage service for API and Worker."""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
@@ -16,70 +7,53 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError: # pragma: no cover
|
||||
oss2 = None
|
||||
|
||||
from packages.config import get_shared_settings
|
||||
from packages.ports.storage_port import StoragePort
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── OSS 高级配置(从 oss_helpers 合并)─────────────────────────────────
|
||||
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒)
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒)
|
||||
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB
|
||||
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
|
||||
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
|
||||
OSS_HTTP_DOWNLOAD_TIMEOUT = 300 # HTTP下载超时(秒)
|
||||
|
||||
|
||||
class SharedStorageService(StoragePort):
|
||||
"""统一存储服务 — 实现 StoragePort,API 和 Worker 共用。
|
||||
|
||||
整合了原 SharedStorageService + oss_helpers 的全部能力。
|
||||
"""
|
||||
class SharedStorageService:
|
||||
"""Shared OSS storage service."""
|
||||
|
||||
def __init__(self):
|
||||
settings = get_shared_settings()
|
||||
self.bucket_name = settings.oss_bucket_name
|
||||
self.endpoint = settings.oss_endpoint
|
||||
self.public_url = f"https://{settings.oss_bucket_name}.{settings.oss_endpoint}"
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
self.access_key_id = settings.oss_access_key_id
|
||||
self.access_key_secret = settings.oss_access_key_secret
|
||||
|
||||
has_key_id = bool(self.access_key_id)
|
||||
has_key_secret = bool(self.access_key_secret)
|
||||
has_key_id = bool(settings.oss_access_key_id)
|
||||
has_key_secret = bool(settings.oss_access_key_secret)
|
||||
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
try:
|
||||
# endpoint 不带 scheme 时补 https:// 前缀
|
||||
bucket_endpoint = self.endpoint
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.oss_endpoint
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(self.access_key_id, self.access_key_secret)
|
||||
auth = oss2.Auth(
|
||||
settings.oss_access_key_id,
|
||||
settings.oss_access_key_secret,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
bucket_endpoint,
|
||||
self.bucket_name,
|
||||
connect_timeout=OSS_CONNECT_TIMEOUT,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
logger.info(
|
||||
"OSS initialized: endpoint=%s bucket=%s",
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
settings.oss_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to initialize OSS bucket client: %s", error)
|
||||
@@ -93,10 +67,12 @@ class SharedStorageService(StoragePort):
|
||||
missing.append("OSS_ACCESS_KEY_SECRET")
|
||||
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
||||
|
||||
# ── 诊断 ───────────────────────────────────────────────────────────
|
||||
self.access_key_id = settings.oss_access_key_id
|
||||
self.access_key_secret = settings.oss_access_key_secret
|
||||
self.endpoint = settings.oss_endpoint
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""输出存储配置诊断日志。"""
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
@@ -110,234 +86,89 @@ class SharedStorageService(StoragePort):
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
# ── 工具方法 ───────────────────────────────────────────────────────
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
return path.startswith(f"{self.local_url_prefix}/")
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键,并做 URL 解码。
|
||||
|
||||
防止 URL 编码的字符(空格=%20、中文=%XX)导致签名不匹配。
|
||||
"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
return unquote(parsed.path.lstrip("/"))
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键(公开方法)。"""
|
||||
return self._normalize_storage_key(storage_key_or_url)
|
||||
|
||||
# ── 上传 ───────────────────────────────────────────────────────────
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file_or_path: Union[str, Path, object],
|
||||
file_or_path,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""上传文件到存储,返回公开 URL(简单上传,API端原有行为)。
|
||||
|
||||
- 路径字符串 → bucket.put_object_from_file
|
||||
- 类文件对象 → bucket.put_object
|
||||
- bucket未配置 → 抛 RuntimeError
|
||||
"""
|
||||
"""Upload file to OSS."""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
if isinstance(file_or_path, (str, Path)):
|
||||
self.bucket.put_object_from_file(storage_key, str(file_or_path), headers={"Content-Type": content_type})
|
||||
if isinstance(file_or_path, str):
|
||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
else:
|
||||
file_or_path.seek(0) # type: ignore[attr-defined]
|
||||
file_or_path.seek(0)
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}") from e
|
||||
|
||||
def upload_file_smart(
|
||||
self,
|
||||
local_path: Union[str, Path],
|
||||
storage_key: str,
|
||||
) -> Optional[str]:
|
||||
"""智能上传:大文件自动分片+超时保护(从 oss_helpers 合并)。
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""Get public URL for a file."""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
- 大文件(>100MB)走分片上传,3 线程并发
|
||||
- 总超时 300s,防止网络异常时挂死
|
||||
- 成功返回 URL,失败返回 None(不抛异常)
|
||||
|
||||
Worker端 oss_helpers.upload_to_oss 的统一入口。
|
||||
"""
|
||||
local_path = Path(local_path)
|
||||
if not local_path.exists():
|
||||
logger.error("上传文件不存在: %s", local_path)
|
||||
return None
|
||||
if self.bucket is None:
|
||||
logger.error("OSS未配置,无法上传: %s", storage_key[:80])
|
||||
return None
|
||||
|
||||
result: dict = {"url": None, "error": None, "file_size": 0}
|
||||
done = threading.Event()
|
||||
|
||||
def _do_upload():
|
||||
try:
|
||||
try:
|
||||
file_size = local_path.stat().st_size
|
||||
result["file_size"] = file_size
|
||||
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
|
||||
except OSError:
|
||||
use_multipart = False
|
||||
file_size = 0
|
||||
|
||||
if use_multipart:
|
||||
logger.info(
|
||||
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
|
||||
storage_key[:80],
|
||||
file_size / 1024 / 1024,
|
||||
OSS_PART_SIZE // 1024 // 1024,
|
||||
OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
oss2.resumable_upload(
|
||||
self.bucket,
|
||||
storage_key,
|
||||
str(local_path),
|
||||
multipart_threshold=OSS_MULTIPART_THRESHOLD,
|
||||
part_size=OSS_PART_SIZE,
|
||||
num_threads=OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
else:
|
||||
self.bucket.put_object_from_file(storage_key, str(local_path))
|
||||
|
||||
result["url"] = f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
result["error"] = e
|
||||
logger.exception("上传 OSS 失败: %s", storage_key)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
upload_thread = threading.Thread(target=_do_upload, daemon=True)
|
||||
upload_thread.start()
|
||||
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
|
||||
|
||||
if not finished:
|
||||
logger.error(
|
||||
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT,
|
||||
storage_key[:80],
|
||||
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
|
||||
)
|
||||
return None
|
||||
|
||||
if result["error"]:
|
||||
return None
|
||||
|
||||
return result["url"]
|
||||
|
||||
# ── 下载 ───────────────────────────────────────────────────────────
|
||||
|
||||
def download_file(self, storage_key: str, local_path: Union[str, Path]) -> None:
|
||||
"""从 OSS 下载文件(简单下载,API端原有行为)。
|
||||
|
||||
bucket未配置 → 抛 RuntimeError
|
||||
"""
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
"""Download file from OSS to local path."""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
local_path = Path(local_path)
|
||||
os.makedirs(local_path.parent, exist_ok=True)
|
||||
try:
|
||||
self.bucket.get_object_to_file(self._normalize_storage_key(storage_key), str(local_path))
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}") from e
|
||||
|
||||
def download_asset(self, asset_storage_key: str, local_path: Union[str, Path]) -> bool:
|
||||
"""下载素材(从 oss_helpers 合并)。
|
||||
|
||||
自动识别输入类型:
|
||||
- 完整 URL → 走 HTTP 下载(支持预签名URL)
|
||||
- 存储键 → 走 oss2 SDK 下载
|
||||
|
||||
成功返回 True,失败返回 False(不抛异常)。
|
||||
"""
|
||||
local_path = Path(local_path)
|
||||
os.makedirs(local_path.parent, exist_ok=True)
|
||||
|
||||
# 完整URL走HTTP下载(兼容预签名URL)
|
||||
if asset_storage_key.startswith(("http://", "https://")):
|
||||
return self._download_via_http(asset_storage_key, local_path)
|
||||
|
||||
# OSS存储键走SDK
|
||||
if self.bucket is None:
|
||||
logger.error("OSS not configured, cannot download: %s", asset_storage_key[:80])
|
||||
return False
|
||||
try:
|
||||
self.bucket.get_object_to_file(self._normalize_storage_key(asset_storage_key), str(local_path))
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("下载素材失败: %s", asset_storage_key)
|
||||
return False
|
||||
|
||||
def _download_via_http(self, url: str, local_path: Path) -> bool:
|
||||
"""通过 HTTP 下载文件(支持预签名 URL)。
|
||||
|
||||
流式下载避免大文件内存溢出。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=OSS_HTTP_DOWNLOAD_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("HTTP下载素材失败: %s", url[:100])
|
||||
return False
|
||||
|
||||
# ── URL 生成 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""获取公开 URL。"""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""获取预签名下载 URL。
|
||||
|
||||
bucket未配置时降级为公开URL;本地产物URL直接返回。
|
||||
"""
|
||||
"""Get signed download URL."""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. key=%s",
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self.normalize_storage_key(storage_key_or_url))
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self.normalize_storage_key(storage_key_or_url)
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. key=%s url_prefix=%s",
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. key=%s",
|
||||
"get_download_url: sign_url failed, falling back to raw URL. storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
# ── 浏览器直传 POST ────────────────────────────────────────────────
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""Extract storage key from URL.
|
||||
|
||||
从完整 URL 提取 OSS 存储键,并做 URL 解码 — 否则 URL 编码的字符
|
||||
(如空格=%20、中文=%XX)会导致 sign_url 计算的签名与 OSS 服务端
|
||||
不匹配(SignatureDoesNotMatch)。原始 key 传入时直接返回。
|
||||
"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
return unquote(parsed.path.lstrip("/"))
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
@@ -346,10 +177,10 @@ class SharedStorageService(StoragePort):
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 POST 表单。"""
|
||||
"""Create browser direct upload POST form."""
|
||||
if not self.access_key_id or not self.access_key_secret:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
normalized_key = self.normalize_storage_key(storage_key)
|
||||
normalized_key = self._normalize_storage_key(storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise ValueError("direct upload key must be under uploads/")
|
||||
|
||||
@@ -362,20 +193,12 @@ class SharedStorageService(StoragePort):
|
||||
{"bucket": self.bucket_name},
|
||||
{"key": normalized_key},
|
||||
["content-length-range", 1, max_size_bytes],
|
||||
[
|
||||
"starts-with",
|
||||
"$Content-Type",
|
||||
content_type.split("/", 1)[0] + "/" if "/" in content_type else "",
|
||||
],
|
||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
||||
],
|
||||
}
|
||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
self.access_key_secret.encode("utf-8"),
|
||||
encoded_policy.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
||||
).decode("ascii")
|
||||
|
||||
return {
|
||||
@@ -393,10 +216,8 @@ class SharedStorageService(StoragePort):
|
||||
},
|
||||
}
|
||||
|
||||
# ── 文件操作 ───────────────────────────────────────────────────────
|
||||
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
"""删除文件(不抛异常)。"""
|
||||
def delete_file(self, storage_key: str):
|
||||
"""Delete file from OSS."""
|
||||
if self.bucket is None:
|
||||
return
|
||||
try:
|
||||
@@ -405,98 +226,17 @@ class SharedStorageService(StoragePort):
|
||||
logger.warning("Failed to delete file from OSS", extra={"storage_key": storage_key, "error": str(error)})
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""检查文件是否存在。"""
|
||||
"""Check if file exists."""
|
||||
if self.bucket is None:
|
||||
return False
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
# ── Asset 路径解析(Worker 用)────────────────────────────────────
|
||||
|
||||
def resolve_asset_path(self, asset_id: str, work_dir: Union[str, Path]) -> Optional[Path]:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 本地绝对路径(在允许目录内)→ 直接返回
|
||||
2. work_dir 缓存命中 → 返回缓存路径
|
||||
3. 从OSS下载到缓存 → 返回下载路径
|
||||
4. 全部失败 → None
|
||||
|
||||
从 oss_helpers.resolve_asset_path 合并而来。
|
||||
"""
|
||||
# 延迟导入,避免循环依赖
|
||||
from video_processing.path_security import ( # type: ignore[import-not-found]
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
if not asset_id or not isinstance(asset_id, str):
|
||||
return None
|
||||
|
||||
work_dir = Path(work_dir)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
|
||||
# 空字节检测
|
||||
if "\x00" in asset_id:
|
||||
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
|
||||
return None
|
||||
|
||||
# 1. 本地绝对路径 — 必须在允许的目录内
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
try:
|
||||
resolved = Path(asset_id).resolve()
|
||||
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
|
||||
return resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
|
||||
asset_id[:80],
|
||||
get_allowed_local_dirs(),
|
||||
)
|
||||
return None
|
||||
except (OSError, PathSecurityError):
|
||||
return None
|
||||
|
||||
# 2. 缓存命中(SHA256 hash 防路径遍历)
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载(先标准化 key,防路径遍历注入)
|
||||
safe_key = self.normalize_storage_key(asset_id)
|
||||
if ".." in safe_key or safe_key.startswith("/"):
|
||||
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
|
||||
return None
|
||||
|
||||
if self.download_asset(safe_key, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
def resolve_asset_ids_to_paths(
|
||||
self,
|
||||
asset_ids: list[str],
|
||||
work_dir: Union[str, Path],
|
||||
) -> dict[str, Path]:
|
||||
"""批量解析 asset_id → 本地路径。"""
|
||||
result: dict[str, Path] = {}
|
||||
for aid in asset_ids:
|
||||
local_path = self.resolve_asset_path(aid, work_dir)
|
||||
if local_path:
|
||||
result[aid] = local_path
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例管理 ────────────────────────────────────────────────────────────
|
||||
|
||||
_storage_service: Optional[SharedStorageService] = None
|
||||
|
||||
|
||||
def get_shared_storage_service() -> SharedStorageService:
|
||||
"""获取统一存储服务单例。"""
|
||||
"""Get shared storage service instance (global singleton)."""
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = SharedStorageService()
|
||||
@@ -504,7 +244,7 @@ def get_shared_storage_service() -> SharedStorageService:
|
||||
return _storage_service
|
||||
|
||||
|
||||
# 向后兼容别名
|
||||
# Backward compatibility alias
|
||||
def get_storage_service() -> SharedStorageService:
|
||||
"""向后兼容:返回统一存储服务。"""
|
||||
"""Backward compatibility: returns shared storage service."""
|
||||
return get_shared_storage_service()
|
||||
|
||||
@@ -34,7 +34,7 @@ FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
NOTIFY_BRANCHES = [b.strip() for b in os.environ.get("CHATOPS_NOTIFY_BRANCHES", "main,develop").split(",") if b.strip()]
|
||||
|
||||
# ── Webhook 服务配置 ──────────────────────────────────
|
||||
CHATOPS_WEBHOOK_PORT = int(os.environ.get("CHATOPS_WEBHOOK_PORT", "8090"))
|
||||
WEBHOOK_PORT = int(os.environ.get("CHATOPS_WEBHOOK_PORT", "8090"))
|
||||
WEBHOOK_SECRET = os.environ.get("CHATOPS_WEBHOOK_SECRET", "")
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────
|
||||
|
||||
@@ -417,7 +417,7 @@ def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="CI ChatOps Webhook 服务")
|
||||
parser.add_argument("--port", type=int, default=config.CHATOPS_WEBHOOK_PORT, help="监听端口")
|
||||
parser.add_argument("--port", type=int, default=config.WEBHOOK_PORT, help="监听端口")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="监听地址")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""classification 分类领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.classification import (
|
||||
AssetClassification,
|
||||
AssetLibraryKind,
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
ClassificationStatus,
|
||||
IngestJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetLibraryKind:
|
||||
def test_values(self):
|
||||
assert AssetLibraryKind.VIDEO.value == "video"
|
||||
assert AssetLibraryKind.VOICE.value == "voice"
|
||||
assert AssetLibraryKind.IMAGE.value == "image"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(AssetLibraryKind.VIDEO, str)
|
||||
|
||||
|
||||
class TestIngestJobStatus:
|
||||
def test_values(self):
|
||||
assert IngestJobStatus.PENDING.value == "pending"
|
||||
assert IngestJobStatus.PROCESSING.value == "processing"
|
||||
assert IngestJobStatus.COMPLETED.value == "completed"
|
||||
assert IngestJobStatus.FAILED.value == "failed"
|
||||
|
||||
|
||||
class TestClassificationJobStatusMissing:
|
||||
"""ClassificationJobStatus._missing_ 兼容性测试."""
|
||||
|
||||
def test_normal_values(self):
|
||||
assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING
|
||||
assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING
|
||||
assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED
|
||||
|
||||
@pytest.mark.parametrize("value", ["done", "success", "finished", "complete"])
|
||||
def test_completed_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.COMPLETED
|
||||
|
||||
@pytest.mark.parametrize("value", ["fail", "error", "err"])
|
||||
def test_failed_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.FAILED
|
||||
|
||||
@pytest.mark.parametrize("value", ["process", "processing", "running", "run"])
|
||||
def test_processing_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.PROCESSING
|
||||
|
||||
@pytest.mark.parametrize("value", ["unknown", "foobar", ""])
|
||||
def test_unknown_fallback_to_pending(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.PENDING
|
||||
|
||||
def test_none_fallback_to_pending(self):
|
||||
assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING # type: ignore[arg-type]
|
||||
|
||||
def test_case_insensitive_with_strip(self):
|
||||
assert ClassificationJobStatus(" DONE ") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("ERROR") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_backward_compat_alias(self):
|
||||
"""ClassificationStatus 是 ClassificationJobStatus 的别名."""
|
||||
assert ClassificationStatus is ClassificationJobStatus
|
||||
assert ClassificationStatus("done") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
|
||||
class TestAssetClassification:
|
||||
def test_values(self):
|
||||
assert AssetClassification.SCENIC.value == "scenic"
|
||||
assert AssetClassification.PRODUCT.value == "product"
|
||||
assert AssetClassification.PERSON.value == "person"
|
||||
assert AssetClassification.ANIMAL.value == "animal"
|
||||
assert AssetClassification.FOOD.value == "food"
|
||||
assert AssetClassification.TECH.value == "tech"
|
||||
assert AssetClassification.SPORT.value == "sport"
|
||||
assert AssetClassification.MUSIC.value == "music"
|
||||
assert AssetClassification.OTHER.value == "other"
|
||||
|
||||
|
||||
class TestClassificationJobCreate:
|
||||
def test_create_normal(self):
|
||||
job = ClassificationJob.create(project_id="proj1", asset_id="asset1")
|
||||
assert job.id
|
||||
assert job.project_id == "proj1"
|
||||
assert job.asset_id == "asset1"
|
||||
assert job.status == ClassificationJobStatus.PENDING
|
||||
assert job.classification == ""
|
||||
assert job.confidence == 0.0
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
job = ClassificationJob.create(project_id=" proj1 ", asset_id=" asset1 ")
|
||||
assert job.project_id == "proj1"
|
||||
assert job.asset_id == "asset1"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id="", asset_id="a1")
|
||||
|
||||
def test_create_empty_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
ClassificationJob.create(project_id="p1", asset_id="")
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id=" ", asset_id="a1")
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
job1 = ClassificationJob.create(project_id="p1", asset_id="a1")
|
||||
job2 = ClassificationJob.create(project_id="p1", asset_id="a2")
|
||||
assert job1.id != job2.id
|
||||
@@ -1,271 +0,0 @@
|
||||
"""
|
||||
视频拼接引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 ConcatSegment.from_dict / ConcatConfig.from_config_dict / has_effect / total_segments 等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine import ConcatConfig, ConcatSegment
|
||||
|
||||
|
||||
class TestConcatSegmentFromDict:
|
||||
"""ConcatSegment.from_dict 构造逻辑."""
|
||||
|
||||
def test_basic(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "/tmp/a.mp4"})
|
||||
assert seg.video_path == "/tmp/a.mp4"
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.duration == 0.0
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_full_fields(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/b.mp4",
|
||||
"start_time": 5.5,
|
||||
"duration": 10.0,
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert seg.video_path == "/tmp/b.mp4"
|
||||
assert seg.start_time == 5.5
|
||||
assert seg.duration == 10.0
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_negative_start_time_clamped(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": -1.0,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"duration": -5.0,
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_invalid_start_time_type_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_invalid_duration_type_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"duration": "abc",
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_start_time_none_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": None,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_empty_video_path_stored(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": ""})
|
||||
assert seg.video_path == ""
|
||||
|
||||
|
||||
class TestConcatConfigFromConfigDict:
|
||||
"""ConcatConfig.from_config_dict 构造逻辑."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict(None)
|
||||
assert cfg.segments == []
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
assert cfg.force_reencode is False
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_non_dict_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict("not a dict")
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_single_segment(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/a.mp4", "duration": 5.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "/tmp/a.mp4"
|
||||
assert cfg.segments[0].duration == 5.0
|
||||
|
||||
def test_multiple_segments(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/a.mp4"},
|
||||
{"video_path": "/tmp/b.mp4", "start_time": 2.0},
|
||||
{"video_path": "/tmp/c.mp4", "duration": 3.0, "has_audio": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 3
|
||||
assert cfg.segments[0].video_path == "/tmp/a.mp4"
|
||||
assert cfg.segments[1].start_time == 2.0
|
||||
assert cfg.segments[2].has_audio is False
|
||||
|
||||
def test_invalid_segments_filtered(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/valid.mp4"},
|
||||
{"video_path": ""}, # 空路径被过滤
|
||||
{"not_video_path": "xxx"}, # 没有video_path被过滤
|
||||
"not_a_dict", # 不是dict被过滤
|
||||
None, # None被过滤
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "/tmp/valid.mp4"
|
||||
|
||||
def test_segments_not_a_list(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": "not_a_list",
|
||||
}
|
||||
)
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_output_params(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30.0,
|
||||
"force_reencode": True,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 1920
|
||||
assert cfg.output_height == 1080
|
||||
assert cfg.output_fps == 30.0
|
||||
assert cfg.force_reencode is True
|
||||
|
||||
def test_negative_output_params_clamped(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
"output_fps": -1.0,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
|
||||
def test_invalid_output_params_fall_back(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": "abc",
|
||||
"output_height": None,
|
||||
"output_fps": "xyz",
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
|
||||
def test_transition_config(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
}
|
||||
)
|
||||
assert cfg.transition == "crossfade"
|
||||
assert cfg.transition_duration == 1.0
|
||||
|
||||
def test_transition_duration_minimum(self):
|
||||
"""transition_duration 不能小于 0.1."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"transition_duration": 0.01,
|
||||
}
|
||||
)
|
||||
assert cfg.transition_duration >= 0.1
|
||||
|
||||
def test_default_values(self):
|
||||
cfg = ConcatConfig.from_config_dict({"segments": []})
|
||||
assert cfg.transition == "none"
|
||||
assert cfg.transition_duration == 0.3
|
||||
assert cfg.force_reencode is False
|
||||
|
||||
|
||||
class TestConcatConfigProperties:
|
||||
"""has_effect / total_segments 属性."""
|
||||
|
||||
def test_has_effect_two_or_more_valid(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
ConcatSegment(video_path="/tmp/b.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is True
|
||||
|
||||
def test_no_effect_one_segment(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_no_effect_zero_segments(self):
|
||||
cfg = ConcatConfig(segments=[])
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_no_effect_empty_paths(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path=""),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_total_segments(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path="/tmp/b.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.total_segments == 2
|
||||
|
||||
def test_total_segments_empty(self):
|
||||
cfg = ConcatConfig(segments=[])
|
||||
assert cfg.total_segments == 0
|
||||
+127
-130
@@ -1,181 +1,178 @@
|
||||
"""config/base.py 单测 — SharedSettings + 单例缓存管理."""
|
||||
from __future__ import annotations
|
||||
"""Config Base 单元测试"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.config.base import (
|
||||
SharedSettings,
|
||||
_settings_cache,
|
||||
get_cached_settings,
|
||||
get_shared_settings,
|
||||
reload_settings_cache,
|
||||
_get_env_file,
|
||||
)
|
||||
|
||||
|
||||
# ── SharedSettings 基本配置 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSharedSettingsDefaults:
|
||||
"""SharedSettings 默认值验证."""
|
||||
"""SharedSettings 默认值测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(self, monkeypatch):
|
||||
"""清除所有可能影响的环境变量,确保测的是代码默认值"""
|
||||
env_vars = [
|
||||
"ENVIRONMENT",
|
||||
"DEBUG",
|
||||
"AUTO_CREATE_SCHEMA",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_POOL_SIZE",
|
||||
"DATABASE_MAX_OVERFLOW",
|
||||
"DATABASE_POOL_TIMEOUT",
|
||||
"DATABASE_POOL_RECYCLE",
|
||||
"REDIS_URL",
|
||||
"CELERY_BROKER_URL",
|
||||
"CELERY_RESULT_BACKEND",
|
||||
"OSS_ENDPOINT",
|
||||
"OSS_ACCESS_KEY_ID",
|
||||
"OSS_ACCESS_KEY_SECRET",
|
||||
"OSS_BUCKET_NAME",
|
||||
"OSS_DIRECT_UPLOAD_MAX_MB",
|
||||
"OSS_DIRECT_UPLOAD_EXPIRE_SECONDS",
|
||||
"COSYVOICE_API_KEY",
|
||||
"COSYVOICE_BASE_URL",
|
||||
"COSYVOICE_MODEL",
|
||||
"COSYVOICE_VOICE",
|
||||
"COSYVOICE_SAMPLE_RATE",
|
||||
"COSYVOICE_FORMAT",
|
||||
"COSYVOICE_CLONE_MODEL",
|
||||
"DOUBAO_API_KEY",
|
||||
"DOUBAO_MODEL",
|
||||
"DOUBAO_BASE_URL",
|
||||
"DOUBAO_TIMEOUT",
|
||||
"DOUBAO_MAX_RETRIES",
|
||||
]
|
||||
for var in env_vars:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
reload_settings_cache()
|
||||
yield
|
||||
reload_settings_cache()
|
||||
|
||||
def _make_settings(self):
|
||||
"""构造不读 env 文件的纯净 settings"""
|
||||
return SharedSettings(_env_file="/dev/null")
|
||||
|
||||
def test_default_environment(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.environment == "development"
|
||||
"""默认环境为 development"""
|
||||
s = self._make_settings()
|
||||
assert s.environment == "development"
|
||||
|
||||
def test_default_debug(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.debug is True
|
||||
"""默认开启 debug"""
|
||||
s = self._make_settings()
|
||||
assert s.debug is True
|
||||
|
||||
def test_default_database_url(self):
|
||||
settings = SharedSettings()
|
||||
assert "postgresql" in settings.database_url
|
||||
def test_default_database_config(self):
|
||||
"""数据库默认配置"""
|
||||
s = self._make_settings()
|
||||
assert "postgresql" in s.database_url
|
||||
assert s.database_pool_size == 20
|
||||
assert s.database_max_overflow == 10
|
||||
assert s.database_pool_timeout == 30
|
||||
assert s.database_pool_recycle == 3600
|
||||
|
||||
def test_default_database_pool_size(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.database_pool_size == 20
|
||||
assert settings.database_max_overflow == 10
|
||||
|
||||
def test_default_redis_url(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.redis_url.startswith("redis://")
|
||||
def test_default_redis_config(self):
|
||||
"""Redis 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.redis_url.startswith("redis://")
|
||||
|
||||
def test_default_celery_config(self):
|
||||
settings = SharedSettings()
|
||||
assert "redis://" in settings.celery_broker_url
|
||||
assert "redis://" in settings.celery_result_backend
|
||||
"""Celery 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.celery_broker_url.startswith("redis://")
|
||||
assert s.celery_result_backend.startswith("redis://")
|
||||
|
||||
def test_default_oss_config(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.oss_bucket_name == "xiaoxia-autocut"
|
||||
assert settings.oss_direct_upload_max_mb == 2000
|
||||
assert settings.oss_direct_upload_expire_seconds == 900
|
||||
"""OSS 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.oss_endpoint.endswith("aliyuncs.com")
|
||||
assert s.oss_bucket_name == "xiaoxia-autocut"
|
||||
assert s.oss_direct_upload_max_mb == 2000
|
||||
assert s.oss_direct_upload_expire_seconds == 900
|
||||
|
||||
def test_default_cosyvoice_config(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.cosyvoice_model == "cosyvoice-v3-flash"
|
||||
assert settings.cosyvoice_sample_rate == 22050
|
||||
assert settings.cosyvoice_format == "mp3"
|
||||
"""CosyVoice 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.cosyvoice_model == "cosyvoice-v3-flash"
|
||||
assert s.cosyvoice_sample_rate == 22050
|
||||
assert s.cosyvoice_format == "mp3"
|
||||
assert s.cosyvoice_clone_model == "voice-enrollment"
|
||||
|
||||
def test_default_doubao_config(self):
|
||||
settings = SharedSettings()
|
||||
assert settings.doubao_timeout == 30
|
||||
assert settings.doubao_max_retries == 2
|
||||
"""豆包默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert "volces.com" in s.doubao_base_url
|
||||
|
||||
def test_env_override(self):
|
||||
"""环境变量可以覆盖默认值."""
|
||||
with patch.dict(os.environ, {"DEBUG": "false", "ENVIRONMENT": "production"}):
|
||||
settings = SharedSettings()
|
||||
assert settings.debug is False
|
||||
assert settings.environment == "production"
|
||||
def test_default_empty_api_keys(self):
|
||||
"""API Key 默认空字符串"""
|
||||
s = self._make_settings()
|
||||
assert s.oss_access_key_id == ""
|
||||
assert s.oss_access_key_secret == ""
|
||||
assert s.cosyvoice_api_key == ""
|
||||
assert s.doubao_api_key == ""
|
||||
|
||||
def test_extra_env_ignored(self):
|
||||
"""model_config extra=ignore,未定义字段忽略."""
|
||||
with patch.dict(os.environ, {"RANDOM_UNKNOWN_VAR": "value"}):
|
||||
# 不抛异常就是通过
|
||||
settings = SharedSettings()
|
||||
assert not hasattr(settings, "random_unknown_var")
|
||||
def test_auto_create_schema_default(self):
|
||||
"""auto_create_schema 默认 False"""
|
||||
s = self._make_settings()
|
||||
assert s.auto_create_schema is False
|
||||
|
||||
|
||||
# ── 单例缓存机制 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSettingsCache:
|
||||
"""get_cached_settings / reload_settings_cache 单例机制."""
|
||||
class TestSettingsSingleton:
|
||||
"""单例管理测试"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试前清空缓存."""
|
||||
"""每个测试前清空缓存"""
|
||||
reload_settings_cache()
|
||||
|
||||
def teardown_method(self):
|
||||
"""每个测试后清空缓存"""
|
||||
reload_settings_cache()
|
||||
|
||||
def test_first_call_creates_instance(self):
|
||||
"""第一次调用创建实例并缓存."""
|
||||
settings = get_cached_settings(SharedSettings)
|
||||
assert isinstance(settings, SharedSettings)
|
||||
assert "SharedSettings" in _settings_cache
|
||||
|
||||
def test_second_call_returns_same_instance(self):
|
||||
"""第二次调用返回同一实例(单例)."""
|
||||
def test_get_cached_settings_same_instance(self):
|
||||
"""同一类两次调用返回同一实例"""
|
||||
s1 = get_cached_settings(SharedSettings)
|
||||
s2 = get_cached_settings(SharedSettings)
|
||||
assert s1 is s2
|
||||
|
||||
def test_reload_clears_cache(self):
|
||||
"""reload 后再次调用会创建新实例."""
|
||||
def test_get_shared_settings_returns_shared_settings(self):
|
||||
"""get_shared_settings 返回 SharedSettings 实例"""
|
||||
s = get_shared_settings()
|
||||
assert isinstance(s, SharedSettings)
|
||||
|
||||
def test_get_shared_settings_singleton(self):
|
||||
"""get_shared_settings 是单例"""
|
||||
s1 = get_shared_settings()
|
||||
s2 = get_shared_settings()
|
||||
assert s1 is s2
|
||||
|
||||
def test_reload_settings_cache_clears(self):
|
||||
"""reload 后获取新实例"""
|
||||
s1 = get_cached_settings(SharedSettings)
|
||||
reload_settings_cache()
|
||||
s2 = get_cached_settings(SharedSettings)
|
||||
assert s1 is not s2
|
||||
|
||||
def test_custom_cache_key(self):
|
||||
"""支持自定义缓存 key."""
|
||||
s1 = get_cached_settings(SharedSettings, cache_key="custom_key")
|
||||
assert "custom_key" in _settings_cache
|
||||
assert "custom_key" not in ["SharedSettings"] or "SharedSettings" in _settings_cache
|
||||
|
||||
# 不同 key 是不同实例
|
||||
s2 = get_cached_settings(SharedSettings, cache_key="another")
|
||||
"""自定义 cache_key 分开缓存"""
|
||||
s1 = get_cached_settings(SharedSettings, cache_key="key_a")
|
||||
s2 = get_cached_settings(SharedSettings, cache_key="key_b")
|
||||
assert s1 is not s2
|
||||
# 但值相同
|
||||
assert s1.database_url == s2.database_url
|
||||
|
||||
def test_get_shared_settings_returns_singleton(self):
|
||||
"""get_shared_settings 是 SharedSettings 的便捷入口."""
|
||||
s1 = get_shared_settings()
|
||||
s2 = get_shared_settings()
|
||||
assert s1 is s2
|
||||
assert isinstance(s1, SharedSettings)
|
||||
def test_different_classes_separate_cache(self):
|
||||
"""不同类使用不同缓存"""
|
||||
from packages.config.api_settings import APISettings
|
||||
|
||||
|
||||
# ── _get_env_file ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetEnvFile:
|
||||
"""_get_env_file 环境文件选择逻辑."""
|
||||
|
||||
def test_default_development_uses_dot_env(self):
|
||||
"""默认 development 环境用 .env."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# 没有 APP_ENV 时默认 development
|
||||
result = _get_env_file()
|
||||
assert result == ".env"
|
||||
|
||||
def test_explicit_development_uses_dot_env(self):
|
||||
"""显式指定 development 也用 .env."""
|
||||
with patch.dict(os.environ, {"APP_ENV": "development"}):
|
||||
result = _get_env_file()
|
||||
assert result == ".env"
|
||||
|
||||
def test_production_env_file(self, tmp_path):
|
||||
"""非 development 环境用 .env.{env},文件存在时返回它."""
|
||||
env_file = tmp_path / ".env.production"
|
||||
env_file.write_text("DEBUG=false")
|
||||
|
||||
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
||||
# 用 tmp_path 作为工作目录
|
||||
import os as _os
|
||||
original_cwd = _os.getcwd()
|
||||
_os.chdir(tmp_path)
|
||||
try:
|
||||
result = _get_env_file()
|
||||
assert result == ".env.production"
|
||||
finally:
|
||||
_os.chdir(original_cwd)
|
||||
|
||||
def test_env_file_not_found_falls_back_to_dot_env(self, tmp_path):
|
||||
"""环境文件不存在时回退到 .env."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("DEBUG=true")
|
||||
|
||||
with patch.dict(os.environ, {"APP_ENV": "staging"}):
|
||||
import os as _os
|
||||
original_cwd = _os.getcwd()
|
||||
_os.chdir(tmp_path)
|
||||
try:
|
||||
result = _get_env_file()
|
||||
assert result == ".env"
|
||||
finally:
|
||||
_os.chdir(original_cwd)
|
||||
shared = get_shared_settings()
|
||||
api = get_cached_settings(APISettings)
|
||||
assert shared is not api
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
"""config_schemas 领域层单元测试 - 配置 schema / 枚举 / 标准化函数"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.domain.config_schemas import (
|
||||
DEFAULT_EDIT_PLAN_CONFIG,
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG,
|
||||
BGMConfig,
|
||||
BGMSource,
|
||||
CoverConfig,
|
||||
CoverType,
|
||||
EditPlanConfigSchema,
|
||||
EditTemplateConfigSchema,
|
||||
ExportConfig,
|
||||
FilterConfig,
|
||||
ShadowConfig,
|
||||
StrokeConfig,
|
||||
SubtitleConfig,
|
||||
TextAnimation,
|
||||
TextPosition,
|
||||
TitleConfig,
|
||||
normalize_plan_config,
|
||||
normalize_template_config,
|
||||
)
|
||||
|
||||
|
||||
class TestEnums:
|
||||
"""枚举类型测试"""
|
||||
|
||||
def test_cover_type_values(self):
|
||||
assert CoverType.AI_FRAME == "ai_frame"
|
||||
assert CoverType.MANUAL == "manual"
|
||||
assert CoverType.UPLOAD == "upload"
|
||||
assert CoverType.AI_REGENERATE == "ai_regenerate"
|
||||
|
||||
def test_text_position_values(self):
|
||||
assert TextPosition.TOP == "top"
|
||||
assert TextPosition.CENTER == "center"
|
||||
assert TextPosition.BOTTOM == "bottom"
|
||||
|
||||
def test_text_animation_values(self):
|
||||
assert TextAnimation.NONE == "none"
|
||||
assert TextAnimation.FADE_IN == "fade_in"
|
||||
assert TextAnimation.SLIDE_UP == "slide_up"
|
||||
assert TextAnimation.SLIDE_DOWN == "slide_down"
|
||||
assert TextAnimation.SCALE == "scale"
|
||||
|
||||
def test_bgm_source_values(self):
|
||||
assert BGMSource.LIBRARY == "library"
|
||||
assert BGMSource.UPLOAD == "upload"
|
||||
assert BGMSource.AI_RECOMMEND == "ai_recommend"
|
||||
|
||||
def test_enums_are_str_enum(self):
|
||||
"""枚举都是 str 类型"""
|
||||
assert isinstance(CoverType.AI_FRAME, str)
|
||||
assert isinstance(TextPosition.TOP, str)
|
||||
assert isinstance(TextAnimation.FADE_IN, str)
|
||||
assert isinstance(BGMSource.LIBRARY, str)
|
||||
|
||||
|
||||
class TestStrokeConfig:
|
||||
"""StrokeConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = StrokeConfig()
|
||||
assert config.enabled is False
|
||||
assert config.color == "#000000"
|
||||
assert config.width == 1
|
||||
|
||||
def test_width_min(self):
|
||||
config = StrokeConfig(width=1)
|
||||
assert config.width == 1
|
||||
|
||||
def test_width_max(self):
|
||||
config = StrokeConfig(width=10)
|
||||
assert config.width == 10
|
||||
|
||||
def test_width_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=0)
|
||||
|
||||
def test_width_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
StrokeConfig(width=11)
|
||||
|
||||
|
||||
class TestShadowConfig:
|
||||
"""ShadowConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = ShadowConfig()
|
||||
assert config.enabled is False
|
||||
assert config.blur == 4
|
||||
assert config.offset_x == 2
|
||||
assert config.offset_y == 2
|
||||
|
||||
def test_blur_min(self):
|
||||
config = ShadowConfig(blur=0)
|
||||
assert config.blur == 0
|
||||
|
||||
def test_blur_max(self):
|
||||
config = ShadowConfig(blur=20)
|
||||
assert config.blur == 20
|
||||
|
||||
def test_blur_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ShadowConfig(blur=-1)
|
||||
|
||||
def test_blur_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ShadowConfig(blur=21)
|
||||
|
||||
|
||||
class TestCoverConfig:
|
||||
"""CoverConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = CoverConfig()
|
||||
assert config.type == CoverType.AI_FRAME
|
||||
assert config.image_url == ""
|
||||
assert config.frame_time is None
|
||||
|
||||
def test_frame_time_ge_zero(self):
|
||||
config = CoverConfig(frame_time=0.0)
|
||||
assert config.frame_time == 0.0
|
||||
|
||||
def test_frame_time_negative_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CoverConfig(frame_time=-1.0)
|
||||
|
||||
def test_custom_values(self):
|
||||
config = CoverConfig(
|
||||
type=CoverType.MANUAL,
|
||||
image_url="http://example.com/cover.jpg",
|
||||
frame_time=5.5,
|
||||
)
|
||||
assert config.type == CoverType.MANUAL
|
||||
assert config.image_url == "http://example.com/cover.jpg"
|
||||
assert config.frame_time == 5.5
|
||||
|
||||
|
||||
class TestTitleConfig:
|
||||
"""TitleConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = TitleConfig()
|
||||
assert config.enabled is True
|
||||
assert config.ai_auto is True
|
||||
assert config.text == ""
|
||||
assert config.position == TextPosition.TOP
|
||||
assert config.font == "思源黑体"
|
||||
assert config.color == "#ffffff"
|
||||
assert config.size == 48
|
||||
assert config.bold is True
|
||||
assert config.italic is False
|
||||
assert isinstance(config.stroke, StrokeConfig)
|
||||
assert isinstance(config.shadow, ShadowConfig)
|
||||
|
||||
def test_size_min(self):
|
||||
config = TitleConfig(size=12)
|
||||
assert config.size == 12
|
||||
|
||||
def test_size_max(self):
|
||||
config = TitleConfig(size=120)
|
||||
assert config.size == 120
|
||||
|
||||
def test_size_below_min_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=11)
|
||||
|
||||
def test_size_above_max_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
TitleConfig(size=121)
|
||||
|
||||
|
||||
class TestSubtitleConfig:
|
||||
"""SubtitleConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = SubtitleConfig()
|
||||
assert config.enabled is True
|
||||
assert config.position == TextPosition.BOTTOM
|
||||
assert config.font == "思源黑体"
|
||||
assert config.color == "#ffffff"
|
||||
assert config.size == 24
|
||||
assert config.animation == TextAnimation.FADE_IN
|
||||
assert config.auto_generated is False
|
||||
assert config.language == ""
|
||||
assert config.max_chars_per_line == 20
|
||||
assert config.min_chars_per_segment == 8
|
||||
|
||||
def test_size_min(self):
|
||||
config = SubtitleConfig(size=12)
|
||||
assert config.size == 12
|
||||
|
||||
def test_size_max(self):
|
||||
config = SubtitleConfig(size=60)
|
||||
assert config.size == 60
|
||||
|
||||
def test_size_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(size=61)
|
||||
|
||||
def test_max_chars_per_line_range(self):
|
||||
config = SubtitleConfig(max_chars_per_line=40)
|
||||
assert config.max_chars_per_line == 40
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(max_chars_per_line=7)
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(max_chars_per_line=41)
|
||||
|
||||
def test_min_chars_per_segment_range(self):
|
||||
config = SubtitleConfig(min_chars_per_segment=20)
|
||||
assert config.min_chars_per_segment == 20
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(min_chars_per_segment=1)
|
||||
with pytest.raises(ValidationError):
|
||||
SubtitleConfig(min_chars_per_segment=21)
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGMConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = BGMConfig()
|
||||
assert config.enabled is False
|
||||
assert config.source == BGMSource.LIBRARY
|
||||
assert config.asset_id == ""
|
||||
assert config.preset_id == ""
|
||||
assert config.audio_url == ""
|
||||
assert config.volume == 0.3
|
||||
assert config.fade_in == 0.0
|
||||
assert config.fade_out == 0.0
|
||||
assert config.loop_enabled is True
|
||||
assert config.sidechain_enabled is False
|
||||
assert config.sidechain_ratio == 0.3
|
||||
assert config.sidechain_attack == 0.02
|
||||
assert config.sidechain_release == 0.5
|
||||
assert config.sidechain_threshold == -25.0
|
||||
|
||||
def test_volume_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(volume=1.1)
|
||||
|
||||
def test_fade_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(fade_in=31.0)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(fade_out=31.0)
|
||||
|
||||
def test_sidechain_ratio_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_ratio=1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_ratio=-0.1)
|
||||
|
||||
def test_sidechain_attack_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_attack=0.0001)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_attack=1.1)
|
||||
|
||||
def test_sidechain_release_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_release=0.001)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_release=5.1)
|
||||
|
||||
def test_sidechain_threshold_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=-61.0)
|
||||
with pytest.raises(ValidationError):
|
||||
BGMConfig(sidechain_threshold=1.0)
|
||||
|
||||
|
||||
class TestExportConfig:
|
||||
"""ExportConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = ExportConfig()
|
||||
assert config.resolution == "1080x1920"
|
||||
assert config.fps == 30
|
||||
assert config.video_bitrate == 8000
|
||||
assert config.audio_bitrate == 128
|
||||
assert config.format == "mp4"
|
||||
assert config.quality_preset == "balanced"
|
||||
assert config.watermark_enabled is False
|
||||
assert config.watermark_text == ""
|
||||
|
||||
def test_fps_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=14)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(fps=61)
|
||||
|
||||
def test_video_bitrate_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=999)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(video_bitrate=20001)
|
||||
|
||||
def test_audio_bitrate_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(audio_bitrate=63)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportConfig(audio_bitrate=321)
|
||||
|
||||
|
||||
class TestFilterConfig:
|
||||
"""FilterConfig 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = FilterConfig()
|
||||
assert config.enabled is False
|
||||
assert config.preset_id == "filter_none"
|
||||
assert config.intensity == 100
|
||||
assert config.brightness == 0.0
|
||||
assert config.contrast == 1.0
|
||||
assert config.saturation == 1.0
|
||||
assert config.warmth == 0.0
|
||||
|
||||
def test_intensity_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=-1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(intensity=101)
|
||||
|
||||
def test_brightness_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=-1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(brightness=1.1)
|
||||
|
||||
def test_contrast_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(contrast=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(contrast=2.1)
|
||||
|
||||
def test_saturation_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(saturation=-0.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(saturation=3.1)
|
||||
|
||||
def test_warmth_range(self):
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(warmth=-1.1)
|
||||
with pytest.raises(ValidationError):
|
||||
FilterConfig(warmth=1.1)
|
||||
|
||||
|
||||
class TestEditPlanConfigSchema:
|
||||
"""EditPlanConfigSchema 完整配置测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = EditPlanConfigSchema()
|
||||
assert isinstance(config.cover, CoverConfig)
|
||||
assert isinstance(config.title, TitleConfig)
|
||||
assert isinstance(config.subtitle, SubtitleConfig)
|
||||
assert isinstance(config.bgm, BGMConfig)
|
||||
assert isinstance(config.export, ExportConfig)
|
||||
assert isinstance(config.filter, FilterConfig)
|
||||
assert config.editing_mode == "one_take"
|
||||
|
||||
def test_partial_update(self):
|
||||
"""部分字段更新,其他保持默认"""
|
||||
config = EditPlanConfigSchema(
|
||||
title={"text": "自定义标题", "size": 36},
|
||||
bgm={"enabled": True, "volume": 0.5},
|
||||
)
|
||||
assert config.title.text == "自定义标题"
|
||||
assert config.title.size == 36
|
||||
assert config.title.font == "思源黑体" # 其他字段默认
|
||||
assert config.bgm.enabled is True
|
||||
assert config.bgm.volume == 0.5
|
||||
assert config.cover.type == CoverType.AI_FRAME # 未设置的保持默认
|
||||
|
||||
|
||||
class TestEditTemplateConfigSchema:
|
||||
"""EditTemplateConfigSchema 测试"""
|
||||
|
||||
def test_defaults(self):
|
||||
config = EditTemplateConfigSchema()
|
||||
assert isinstance(config.cover, CoverConfig)
|
||||
assert config.editing_mode == "one_take"
|
||||
assert config.transition_enabled is True
|
||||
|
||||
def test_custom_transition_enabled(self):
|
||||
config = EditTemplateConfigSchema(transition_enabled=False)
|
||||
assert config.transition_enabled is False
|
||||
|
||||
|
||||
class TestDefaultConfigs:
|
||||
"""默认配置常量测试"""
|
||||
|
||||
def test_plan_config_structure(self):
|
||||
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
|
||||
|
||||
def test_template_config_has_transition_enabled(self):
|
||||
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
|
||||
|
||||
def test_template_inherits_from_plan(self):
|
||||
"""模板配置继承计划配置的所有字段"""
|
||||
for key in DEFAULT_EDIT_PLAN_CONFIG:
|
||||
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
|
||||
|
||||
class TestNormalizePlanConfig:
|
||||
"""normalize_plan_config 工具函数测试"""
|
||||
|
||||
def test_none_returns_default_copy(self):
|
||||
result = normalize_plan_config(None)
|
||||
assert result == DEFAULT_EDIT_PLAN_CONFIG
|
||||
# 确保是深拷贝
|
||||
result["title"]["text"] = "modified"
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG["title"]["text"] == ""
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
result = normalize_plan_config({})
|
||||
assert result == DEFAULT_EDIT_PLAN_CONFIG
|
||||
|
||||
def test_updates_cover_section(self):
|
||||
result = normalize_plan_config({"cover": {"type": "manual", "frame_time": 5.0}})
|
||||
assert result["cover"]["type"] == "manual"
|
||||
assert result["cover"]["frame_time"] == 5.0
|
||||
assert result["cover"]["image_url"] == "" # 默认保留
|
||||
|
||||
def test_updates_title_section(self):
|
||||
result = normalize_plan_config({"title": {"text": "hello", "size": 32}})
|
||||
assert result["title"]["text"] == "hello"
|
||||
assert result["title"]["size"] == 32
|
||||
assert result["title"]["font"] == "思源黑体"
|
||||
|
||||
def test_updates_bgm_section(self):
|
||||
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.8}})
|
||||
assert result["bgm"]["enabled"] is True
|
||||
assert result["bgm"]["volume"] == 0.8
|
||||
|
||||
def test_updates_editing_mode(self):
|
||||
result = normalize_plan_config({"editing_mode": "smart"})
|
||||
assert result["editing_mode"] == "smart"
|
||||
|
||||
def test_preserves_extra_fields(self):
|
||||
"""非标准字段被保留"""
|
||||
result = normalize_plan_config({"custom_field": "value", "generation_task_id": "task-1"})
|
||||
assert result["custom_field"] == "value"
|
||||
assert result["generation_task_id"] == "task-1"
|
||||
|
||||
def test_ignores_non_dict_section(self):
|
||||
"""section 不是 dict 时忽略"""
|
||||
result = normalize_plan_config({"cover": "not_a_dict"})
|
||||
assert result["cover"] == DEFAULT_EDIT_PLAN_CONFIG["cover"]
|
||||
|
||||
def test_ignores_non_str_editing_mode(self):
|
||||
result = normalize_plan_config({"editing_mode": 123})
|
||||
assert result["editing_mode"] == "one_take"
|
||||
|
||||
def test_deep_copy_independence(self):
|
||||
"""修改结果不影响默认值"""
|
||||
result = normalize_plan_config({})
|
||||
result["title"]["size"] = 999
|
||||
assert DEFAULT_EDIT_PLAN_CONFIG["title"]["size"] == 48
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
"""normalize_template_config 工具函数测试"""
|
||||
|
||||
def test_none_returns_default_copy(self):
|
||||
result = normalize_template_config(None)
|
||||
assert result == DEFAULT_EDIT_TEMPLATE_CONFIG
|
||||
|
||||
def test_updates_transition_enabled(self):
|
||||
result = normalize_template_config({"transition_enabled": False})
|
||||
assert result["transition_enabled"] is False
|
||||
|
||||
def test_ignores_non_bool_transition_enabled(self):
|
||||
result = normalize_template_config({"transition_enabled": "yes"})
|
||||
assert result["transition_enabled"] is True
|
||||
|
||||
def test_updates_sections(self):
|
||||
result = normalize_template_config(
|
||||
{
|
||||
"title": {"text": "模板标题"},
|
||||
"bgm": {"enabled": True},
|
||||
}
|
||||
)
|
||||
assert result["title"]["text"] == "模板标题"
|
||||
assert result["bgm"]["enabled"] is True
|
||||
|
||||
def test_preserves_extra_fields(self):
|
||||
result = normalize_template_config({"custom": "value"})
|
||||
assert result["custom"] == "value"
|
||||
|
||||
def test_deep_copy_independence(self):
|
||||
result = normalize_template_config({})
|
||||
result["title"]["font"] = "CustomFont"
|
||||
assert DEFAULT_EDIT_TEMPLATE_CONFIG["title"]["font"] == "思源黑体"
|
||||
@@ -1,595 +0,0 @@
|
||||
"""domain 层剩余小模块单测 - bgm_utils/exceptions/editing_mode/recipe/template/template_version/template_clip_config/preset_bgm/preset_voices/title_library/voice_library"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.exceptions import (
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
QuotaExceededError,
|
||||
ValidationError,
|
||||
)
|
||||
from packages.domain.preset_bgm import (
|
||||
PRESET_BGM_LIBRARY,
|
||||
PresetBGM,
|
||||
get_preset_bgm,
|
||||
list_preset_bgm_by_style,
|
||||
search_preset_bgm,
|
||||
)
|
||||
from packages.domain.preset_voices import (
|
||||
PRESET_VOICES,
|
||||
PresetVoice,
|
||||
get_preset_voice_by_id,
|
||||
get_preset_voices,
|
||||
is_preset_voice,
|
||||
)
|
||||
from packages.domain.recipe import Recipe, RecipeItem
|
||||
from packages.domain.template import Template, TemplateCategory, TemplateSegment
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
from packages.domain.title_library import TitleLibraryItem
|
||||
from packages.domain.voice_library import VoiceLibraryItem
|
||||
|
||||
# ── EditingMode ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditingMode:
|
||||
"""EditingMode 枚举测试"""
|
||||
|
||||
def test_all_modes_exist(self):
|
||||
assert EditingMode.ONE_TAKE == "one_take"
|
||||
assert EditingMode.PIP == "pip"
|
||||
assert EditingMode.VOICE_OVER == "voice_over"
|
||||
assert EditingMode.VOICE_PIP == "voice_pip"
|
||||
|
||||
def test_str_enum(self):
|
||||
assert isinstance(EditingMode.ONE_TAKE, str)
|
||||
|
||||
def test_four_modes(self):
|
||||
assert len(EditingMode) == 4
|
||||
|
||||
|
||||
# ── Exceptions ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDomainExceptions:
|
||||
"""异常类测试"""
|
||||
|
||||
def test_domain_error_base(self):
|
||||
err = DomainError("test")
|
||||
assert isinstance(err, Exception)
|
||||
assert str(err) == "test"
|
||||
|
||||
def test_not_found_error(self):
|
||||
err = NotFoundError("not found")
|
||||
assert isinstance(err, DomainError)
|
||||
assert str(err) == "not found"
|
||||
|
||||
def test_validation_error(self):
|
||||
err = ValidationError("invalid")
|
||||
assert isinstance(err, DomainError)
|
||||
assert str(err) == "invalid"
|
||||
|
||||
def test_quota_exceeded_error(self):
|
||||
err = QuotaExceededError(dimension="storage", limit=100, used=150)
|
||||
assert isinstance(err, DomainError)
|
||||
assert err.dimension == "storage"
|
||||
assert err.limit == 100
|
||||
assert err.used == 150
|
||||
assert "storage" in str(err)
|
||||
assert "150/100" in str(err)
|
||||
|
||||
def test_not_found_is_domain_error(self):
|
||||
assert issubclass(NotFoundError, DomainError)
|
||||
|
||||
def test_validation_is_domain_error(self):
|
||||
assert issubclass(ValidationError, DomainError)
|
||||
|
||||
def test_quota_exceeded_is_domain_error(self):
|
||||
assert issubclass(QuotaExceededError, DomainError)
|
||||
|
||||
|
||||
# ── BGM Utils ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeBgmConfig:
|
||||
"""merge_bgm_config 函数测试"""
|
||||
|
||||
def test_user_empty_returns_template_copy(self):
|
||||
template = {"enabled": True, "volume": 0.5, "source": "library"}
|
||||
result = merge_bgm_config(template, {})
|
||||
assert result == template
|
||||
# 确保是拷贝不是引用
|
||||
result["volume"] = 0.9
|
||||
assert template["volume"] == 0.5
|
||||
|
||||
def test_template_empty_returns_user_copy(self):
|
||||
user = {"enabled": False, "volume": 0.3}
|
||||
result = merge_bgm_config({}, user)
|
||||
assert result == user
|
||||
|
||||
def test_user_none_returns_template(self):
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(template, None) # type: ignore
|
||||
assert result == template
|
||||
|
||||
def test_template_none_returns_user(self):
|
||||
user = {"enabled": True, "volume": 0.5}
|
||||
result = merge_bgm_config(None, user) # type: ignore
|
||||
assert result == user
|
||||
|
||||
def test_user_overrides_template(self):
|
||||
template = {"volume": 0.3, "source": "library", "asset_id": "tpl-1"}
|
||||
user = {"volume": 0.8, "asset_id": "user-1"}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["volume"] == 0.8
|
||||
assert result["asset_id"] == "user-1"
|
||||
assert result["source"] == "library" # 模板保留
|
||||
|
||||
def test_enabled_special_handling_user_not_set(self):
|
||||
"""用户没传 enabled 时保留模板的 enabled"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"volume": 0.8}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True # 保留模板值
|
||||
|
||||
def test_enabled_user_explicit_false(self):
|
||||
"""用户显式传了 enabled=False 则覆盖"""
|
||||
template = {"enabled": True, "volume": 0.5}
|
||||
user = {"enabled": False}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is False
|
||||
|
||||
def test_enabled_user_explicit_true(self):
|
||||
"""用户显式传了 enabled=True 则覆盖"""
|
||||
template = {"enabled": False, "volume": 0.5}
|
||||
user = {"enabled": True}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_full_merge(self):
|
||||
"""完整合并场景"""
|
||||
template = {
|
||||
"enabled": True,
|
||||
"source": "library",
|
||||
"volume": 0.3,
|
||||
"fade_in": 0.0,
|
||||
"fade_out": 0.0,
|
||||
"loop_enabled": True,
|
||||
}
|
||||
user = {
|
||||
"volume": 0.7,
|
||||
"asset_id": "my-bgm",
|
||||
"fade_in": 1.0,
|
||||
}
|
||||
result = merge_bgm_config(template, user)
|
||||
assert result["enabled"] is True # 保留模板
|
||||
assert result["volume"] == 0.7 # 用户覆盖
|
||||
assert result["source"] == "library" # 模板保留
|
||||
assert result["asset_id"] == "my-bgm" # 用户新增
|
||||
assert result["fade_in"] == 1.0 # 用户覆盖
|
||||
assert result["fade_out"] == 0.0 # 模板保留
|
||||
assert result["loop_enabled"] is True # 模板保留
|
||||
|
||||
|
||||
# ── Recipe ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecipe:
|
||||
"""Recipe / RecipeItem 测试"""
|
||||
|
||||
def test_recipe_item_create(self):
|
||||
item = RecipeItem(id="item-1", recipe_id="r1", item_type="asset", item_id="a1")
|
||||
assert item.id == "item-1"
|
||||
assert item.recipe_id == "r1"
|
||||
assert item.item_type == "asset"
|
||||
assert item.item_id == "a1"
|
||||
assert item.position == 0
|
||||
assert item.metadata_ == {}
|
||||
|
||||
def test_recipe_create(self):
|
||||
recipe = Recipe(id="r1", user_id="u1", name="我的配方")
|
||||
assert recipe.id == "r1"
|
||||
assert recipe.user_id == "u1"
|
||||
assert recipe.name == "我的配方"
|
||||
assert recipe.description == ""
|
||||
assert recipe.items == []
|
||||
assert recipe.is_active is True
|
||||
assert recipe.generation_params == {}
|
||||
assert recipe.created_at is not None
|
||||
|
||||
def test_recipe_with_items(self):
|
||||
items = [
|
||||
RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0),
|
||||
RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1),
|
||||
]
|
||||
recipe = Recipe(id="r1", user_id="u1", name="test", items=items)
|
||||
assert len(recipe.items) == 2
|
||||
assert recipe.items[0].item_type == "asset"
|
||||
assert recipe.items[1].position == 1
|
||||
|
||||
def test_recipe_items_independent_list(self):
|
||||
"""不同 recipe 的 items 是独立列表"""
|
||||
r1 = Recipe(id="r1", user_id="u1", name="r1")
|
||||
r2 = Recipe(id="r2", user_id="u1", name="r2")
|
||||
assert r1.items is not r2.items
|
||||
|
||||
|
||||
# ── Template ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTemplate:
|
||||
"""Template 相关实体测试"""
|
||||
|
||||
def test_template_segment(self):
|
||||
seg = TemplateSegment(
|
||||
id="seg-1",
|
||||
template_id="t1",
|
||||
segment_order=0,
|
||||
duration_min=3.0,
|
||||
duration_max=5.0,
|
||||
)
|
||||
assert seg.id == "seg-1"
|
||||
assert seg.template_id == "t1"
|
||||
assert seg.segment_order == 0
|
||||
assert seg.duration_min == 3.0
|
||||
assert seg.duration_max == 5.0
|
||||
assert seg.material_type is None
|
||||
assert seg.created_at is not None
|
||||
|
||||
def test_template_create(self):
|
||||
tpl = Template(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="口播模板",
|
||||
mode="voice_over",
|
||||
)
|
||||
assert tpl.id == "t1"
|
||||
assert tpl.user_id == "u1"
|
||||
assert tpl.name == "口播模板"
|
||||
assert tpl.mode == "voice_over"
|
||||
assert tpl.category == ""
|
||||
assert tpl.tags == []
|
||||
assert tpl.segments == []
|
||||
assert tpl.is_active is True
|
||||
assert tpl.estimated_duration == 0.0
|
||||
|
||||
def test_template_with_segments(self):
|
||||
segs = [
|
||||
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4),
|
||||
TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=5, duration_max=8),
|
||||
]
|
||||
tpl = Template(id="t1", user_id="u1", name="test", mode="one_take", segments=segs)
|
||||
assert len(tpl.segments) == 2
|
||||
assert tpl.segments[0].segment_order == 0
|
||||
|
||||
def test_template_category(self):
|
||||
cat = TemplateCategory(id="cat-1", user_id="u1", name="热门")
|
||||
assert cat.id == "cat-1"
|
||||
assert cat.user_id == "u1"
|
||||
assert cat.name == "热门"
|
||||
assert cat.created_at is not None
|
||||
|
||||
|
||||
# ── TemplateVersion ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateVersion:
|
||||
"""EditTemplateVersion 测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
assert v.id
|
||||
assert len(v.id) == 32
|
||||
assert v.template_id == "t1"
|
||||
assert v.version == 1
|
||||
assert v.name == ""
|
||||
assert v.editing_mode == "one_take"
|
||||
assert v.config == {}
|
||||
assert v.clip_configs == []
|
||||
assert v.change_note == ""
|
||||
assert v.published_by == ""
|
||||
|
||||
def test_create_with_details(self):
|
||||
config = {"title": {"size": 36}}
|
||||
clips = [{"clip_type": "intro"}, {"clip_type": "outro"}]
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="t1",
|
||||
version=2,
|
||||
name="V2 优化版",
|
||||
editing_mode="voice_over",
|
||||
config=config,
|
||||
clip_configs=clips,
|
||||
change_note="优化了节奏",
|
||||
published_by="user-1",
|
||||
)
|
||||
assert v.version == 2
|
||||
assert v.name == "V2 优化版"
|
||||
assert v.editing_mode == "voice_over"
|
||||
assert v.config == config
|
||||
assert v.clip_configs == clips
|
||||
assert v.change_note == "优化了节奏"
|
||||
assert v.published_by == "user-1"
|
||||
|
||||
def test_create_none_config_defaults_empty(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, config=None)
|
||||
assert v.config == {}
|
||||
|
||||
def test_create_none_clip_configs_defaults_empty(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=None)
|
||||
assert v.clip_configs == []
|
||||
|
||||
|
||||
# ── TemplateClipConfig ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTemplateClipConfig:
|
||||
"""TemplateClipConfig + 枚举测试"""
|
||||
|
||||
def test_clip_type_values(self):
|
||||
assert ClipType.INTRO == "intro"
|
||||
assert ClipType.MAIN == "main"
|
||||
assert ClipType.TRANSITION == "transition"
|
||||
assert ClipType.OUTRO == "outro"
|
||||
assert ClipType.TITLE == "title"
|
||||
assert ClipType.SUBTITLE == "subtitle"
|
||||
|
||||
def test_clip_type_count(self):
|
||||
assert len(ClipType) == 6
|
||||
|
||||
def test_transition_effect_values(self):
|
||||
assert TransitionEffect.CUT == "cut"
|
||||
assert TransitionEffect.FADE == "fade"
|
||||
assert TransitionEffect.SLIDE_LEFT == "slide_left"
|
||||
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
|
||||
assert TransitionEffect.DISSOLVE == "dissolve"
|
||||
assert TransitionEffect.WIPE == "wipe"
|
||||
|
||||
def test_transition_effect_count(self):
|
||||
assert len(TransitionEffect) == 6
|
||||
|
||||
def test_template_clip_config(self):
|
||||
clip = TemplateClipConfig(
|
||||
id="clip-1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=2.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
assert clip.id == "clip-1"
|
||||
assert clip.template_id == "t1"
|
||||
assert clip.clip_type == ClipType.MAIN
|
||||
assert clip.order == 1
|
||||
assert clip.min_duration == 2.0
|
||||
assert clip.max_duration == 5.0
|
||||
|
||||
def test_str_enum(self):
|
||||
assert isinstance(ClipType.INTRO, str)
|
||||
assert isinstance(TransitionEffect.FADE, str)
|
||||
|
||||
|
||||
# ── PresetBGM ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetBGM:
|
||||
"""PresetBGM + 查询函数测试"""
|
||||
|
||||
def test_preset_bgm_create(self):
|
||||
bgm = PresetBGM(
|
||||
id="bgm_test_001",
|
||||
name="测试音乐",
|
||||
style="upbeat",
|
||||
duration=120.0,
|
||||
)
|
||||
assert bgm.id == "bgm_test_001"
|
||||
assert bgm.name == "测试音乐"
|
||||
assert bgm.style == "upbeat"
|
||||
assert bgm.duration == 120.0
|
||||
assert bgm.artist == ""
|
||||
assert bgm.tags == []
|
||||
assert bgm.audio_url == ""
|
||||
|
||||
def test_preset_bgm_frozen(self):
|
||||
bgm = PresetBGM(id="t1", name="t", style="x", duration=10.0)
|
||||
with pytest.raises(Exception):
|
||||
bgm.name = "改名"
|
||||
|
||||
def test_library_not_empty(self):
|
||||
assert len(PRESET_BGM_LIBRARY) > 0
|
||||
|
||||
def test_all_presets_have_required_fields(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert bgm.id
|
||||
assert bgm.name
|
||||
assert bgm.style
|
||||
assert bgm.duration > 0
|
||||
|
||||
def test_get_preset_bgm_existing(self):
|
||||
first = PRESET_BGM_LIBRARY[0]
|
||||
result = get_preset_bgm(first.id)
|
||||
assert result is not None
|
||||
assert result.id == first.id
|
||||
|
||||
def test_get_preset_bgm_nonexistent(self):
|
||||
assert get_preset_bgm("nonexistent_bgm") is None
|
||||
|
||||
def test_list_preset_bgm_by_style(self):
|
||||
upbeat = list_preset_bgm_by_style("upbeat")
|
||||
assert len(upbeat) > 0
|
||||
assert all(b.style == "upbeat" for b in upbeat)
|
||||
|
||||
def test_list_preset_bgm_by_style_empty(self):
|
||||
result = list_preset_bgm_by_style("nonexistent_style")
|
||||
assert result == []
|
||||
|
||||
def test_search_preset_bgm_by_name(self):
|
||||
result = search_preset_bgm("阳光")
|
||||
assert len(result) >= 1
|
||||
assert any("阳光" in b.name for b in result)
|
||||
|
||||
def test_search_preset_bgm_by_tag(self):
|
||||
result = search_preset_bgm("轻快")
|
||||
assert len(result) >= 1
|
||||
assert any(any("轻快" in t for t in b.tags) for b in result)
|
||||
|
||||
def test_search_preset_bgm_empty_result(self):
|
||||
result = search_preset_bgm("xyz_not_exist_keyword")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── PresetVoices ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetVoices:
|
||||
"""PresetVoice + 查询函数测试"""
|
||||
|
||||
def test_preset_voice_create(self):
|
||||
v = PresetVoice(
|
||||
voice_id="test_voice",
|
||||
name="测试音色",
|
||||
description="测试用",
|
||||
gender="female",
|
||||
)
|
||||
assert v.voice_id == "test_voice"
|
||||
assert v.name == "测试音色"
|
||||
assert v.gender == "female"
|
||||
assert v.language == "zh-CN"
|
||||
assert v.preview_url == ""
|
||||
assert v.tags is None
|
||||
|
||||
def test_preset_voice_to_dict(self):
|
||||
v = PresetVoice(
|
||||
voice_id="v1",
|
||||
name="音色1",
|
||||
description="desc",
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
tags=["温柔", "男声"],
|
||||
)
|
||||
d = v.to_dict()
|
||||
assert d["voice_id"] == "v1"
|
||||
assert d["name"] == "音色1"
|
||||
assert d["description"] == "desc"
|
||||
assert d["gender"] == "male"
|
||||
assert d["language"] == "zh-CN"
|
||||
assert d["tags"] == ["温柔", "男声"]
|
||||
|
||||
def test_preset_voice_to_dict_no_tags(self):
|
||||
v = PresetVoice(voice_id="v1", name="t", description="d", gender="female")
|
||||
d = v.to_dict()
|
||||
assert d["tags"] == []
|
||||
|
||||
def test_preset_voice_frozen(self):
|
||||
v = PresetVoice(voice_id="v1", name="t", description="d", gender="female")
|
||||
with pytest.raises(Exception):
|
||||
v.name = "改名"
|
||||
|
||||
def test_preset_voices_list_not_empty(self):
|
||||
assert len(PRESET_VOICES) > 0
|
||||
|
||||
def test_get_preset_voices(self):
|
||||
voices = get_preset_voices()
|
||||
assert len(voices) == len(PRESET_VOICES)
|
||||
assert all(isinstance(v, PresetVoice) for v in voices)
|
||||
|
||||
def test_get_preset_voice_by_id_existing(self):
|
||||
first = PRESET_VOICES[0]
|
||||
result = get_preset_voice_by_id(first.voice_id)
|
||||
assert result is not None
|
||||
assert result.voice_id == first.voice_id
|
||||
|
||||
def test_get_preset_voice_by_id_nonexistent(self):
|
||||
assert get_preset_voice_by_id("nonexistent_voice") is None
|
||||
|
||||
def test_is_preset_voice_true(self):
|
||||
first = PRESET_VOICES[0]
|
||||
assert is_preset_voice(first.voice_id) is True
|
||||
|
||||
def test_is_preset_voice_false(self):
|
||||
assert is_preset_voice("fake_voice_id") is False
|
||||
|
||||
|
||||
# ── TitleLibrary ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTitleLibraryItem:
|
||||
"""TitleLibraryItem 测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文案")
|
||||
assert item.id == "t1"
|
||||
assert item.user_id == "u1"
|
||||
assert item.name == "标题1"
|
||||
assert item.text == "这是标题文案"
|
||||
assert item.category == "default"
|
||||
assert item.tags == []
|
||||
assert item.usage_count == 0
|
||||
assert item.is_active is True
|
||||
assert item.created_at is not None
|
||||
|
||||
def test_create_with_details(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="爆款标题",
|
||||
text="三个方法教你...",
|
||||
category="爆款",
|
||||
description="高点击率",
|
||||
tags=["热门", "干货"],
|
||||
usage_count=100,
|
||||
)
|
||||
assert item.category == "爆款"
|
||||
assert item.description == "高点击率"
|
||||
assert item.tags == ["热门", "干货"]
|
||||
assert item.usage_count == 100
|
||||
|
||||
|
||||
# ── VoiceLibrary ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoiceLibraryItem:
|
||||
"""VoiceLibraryItem 测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音")
|
||||
assert item.id == "v1"
|
||||
assert item.user_id == "u1"
|
||||
assert item.name == "我的配音"
|
||||
assert item.text == ""
|
||||
assert item.voice_provider == ""
|
||||
assert item.duration == 0
|
||||
assert item.status == "completed"
|
||||
assert item.project_id is None
|
||||
assert item.tags == []
|
||||
assert item.created_at is not None
|
||||
|
||||
def test_create_with_details(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="产品介绍",
|
||||
text="大家好,今天给大家介绍...",
|
||||
voice_provider="cosyvoice",
|
||||
voice_id="voice_001",
|
||||
voice_name="温柔女声",
|
||||
audio_url="http://cdn/audio.mp3",
|
||||
duration=30.5,
|
||||
file_size=102400,
|
||||
status="processing",
|
||||
project_id="proj-1",
|
||||
tags=["产品", "介绍"],
|
||||
)
|
||||
assert item.voice_provider == "cosyvoice"
|
||||
assert item.voice_id == "voice_001"
|
||||
assert item.audio_url == "http://cdn/audio.mp3"
|
||||
assert item.duration == 30.5
|
||||
assert item.file_size == 102400
|
||||
assert item.status == "processing"
|
||||
assert item.project_id == "proj-1"
|
||||
assert item.tags == ["产品", "介绍"]
|
||||
@@ -1,305 +0,0 @@
|
||||
"""Duplication 查重记录领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
|
||||
class TestDuplicateSegmentCreate:
|
||||
def test_create_normal(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=20.0,
|
||||
matched_video_id="vid123",
|
||||
matched_video_name="测试视频",
|
||||
matched_start=5.0,
|
||||
matched_end=15.0,
|
||||
similarity=85.5,
|
||||
)
|
||||
assert seg.id
|
||||
assert seg.source_start == 10.0
|
||||
assert seg.source_end == 20.0
|
||||
assert seg.matched_video_id == "vid123"
|
||||
assert seg.matched_video_name == "测试视频"
|
||||
assert seg.matched_start == 5.0
|
||||
assert seg.matched_end == 15.0
|
||||
assert seg.similarity == 85.5
|
||||
|
||||
def test_create_negative_source_start_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=-1.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_zero_duration_source_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_reversed_source_range_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=20.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_negative_matched_start_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=-1.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_zero_duration_matched_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=5.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=-1.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_over_100_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=101.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_boundary_values(self):
|
||||
# 0 和 100 都是合法的
|
||||
seg0 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=0.0,
|
||||
)
|
||||
assert seg0.similarity == 0.0
|
||||
|
||||
seg100 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=100.0,
|
||||
)
|
||||
assert seg100.similarity == 100.0
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
seg1 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
seg2 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
assert seg1.id != seg2.id
|
||||
|
||||
|
||||
class TestDuplicationRecordCreate:
|
||||
def test_create_normal(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id="user1",
|
||||
filename="test.mp4",
|
||||
file_size=1024000,
|
||||
storage_key="videos/test.mp4",
|
||||
duration_seconds=30.5,
|
||||
)
|
||||
assert record.id
|
||||
assert record.user_id == "user1"
|
||||
assert record.filename == "test.mp4"
|
||||
assert record.file_size == 1024000
|
||||
assert record.storage_key == "videos/test.mp4"
|
||||
assert record.duration_seconds == 30.5
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.segments == []
|
||||
assert record.error_message == ""
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id=" user1 ",
|
||||
filename=" test.mp4 ",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
assert record.user_id == "user1"
|
||||
assert record.filename == "test.mp4"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="",
|
||||
filename="test.mp4",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_empty_filename_raises(self):
|
||||
with pytest.raises(ValueError, match="filename cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_zero_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=0,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_negative_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=-100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicationRecordStatus:
|
||||
def test_mark_processing(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record.mark_processing()
|
||||
assert record.status == "processing"
|
||||
|
||||
def test_mark_completed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=90.0,
|
||||
)
|
||||
record.mark_completed(duplicate_rate=25.5, duplicate_count=3, segments=[seg])
|
||||
assert record.status == "completed"
|
||||
assert record.duplicate_rate == 25.5
|
||||
assert record.duplicate_count == 3
|
||||
assert len(record.segments) == 1
|
||||
|
||||
def test_mark_completed_invalid_rate_raises(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=-1, duplicate_count=0, segments=[])
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=101, duplicate_count=0, segments=[])
|
||||
|
||||
def test_mark_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record.mark_failed("网络超时")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == "网络超时"
|
||||
|
||||
def test_can_retry_only_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
assert record.can_retry() is False # pending
|
||||
|
||||
record.mark_processing()
|
||||
assert record.can_retry() is False # processing
|
||||
|
||||
record.mark_failed("error")
|
||||
assert record.can_retry() is True # failed
|
||||
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
record2 = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record2.mark_completed(10.0, 1, [seg])
|
||||
assert record2.can_retry() is False # completed
|
||||
|
||||
def test_reset_for_retry(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
record.mark_completed(50.0, 2, [seg])
|
||||
record.video_fingerprint = {"hash": "abc"}
|
||||
|
||||
record.reset_for_retry()
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.error_message == ""
|
||||
assert record.segments == []
|
||||
assert record.video_fingerprint is None
|
||||
@@ -1,78 +0,0 @@
|
||||
"""EditPlan 剪辑计划领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
class TestEditPlanStatus:
|
||||
def test_values(self):
|
||||
assert EditPlanStatus.DRAFT.value == "draft"
|
||||
assert EditPlanStatus.EDITING.value == "editing"
|
||||
assert EditPlanStatus.RENDERING.value == "rendering"
|
||||
assert EditPlanStatus.COMPLETED.value == "completed"
|
||||
assert EditPlanStatus.FAILED.value == "failed"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(EditPlanStatus.DRAFT, str)
|
||||
|
||||
|
||||
class TestEditPlanCreate:
|
||||
def test_create_normal(self):
|
||||
plan = EditPlan.create(template_id="tmpl1", name="我的剪辑计划")
|
||||
assert plan.id
|
||||
assert plan.template_id == "tmpl1"
|
||||
assert plan.name == "我的剪辑计划"
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
assert plan.total_duration == 0.0
|
||||
assert plan.config == {}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
plan = EditPlan.create(
|
||||
template_id=" tmpl1 ",
|
||||
name=" 我的计划 ",
|
||||
source_edit_plan_id=" src1 ",
|
||||
project_id=" proj1 ",
|
||||
created_by_user_id=" user1 ",
|
||||
)
|
||||
assert plan.template_id == "tmpl1"
|
||||
assert plan.name == "我的计划"
|
||||
assert plan.source_edit_plan_id == "src1"
|
||||
assert plan.project_id == "proj1"
|
||||
assert plan.created_by_user_id == "user1"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="计划名称不能为空"):
|
||||
EditPlan.create(template_id="tmpl1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="计划名称不能为空"):
|
||||
EditPlan.create(template_id="tmpl1", name=" ")
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id="", name="计划")
|
||||
|
||||
def test_create_whitespace_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id=" ", name="计划")
|
||||
|
||||
def test_create_with_config(self):
|
||||
config = {"resolution": "1080p", "fps": 30}
|
||||
plan = EditPlan.create(
|
||||
template_id="tmpl1",
|
||||
name="计划",
|
||||
config=config,
|
||||
total_duration=30.5,
|
||||
)
|
||||
assert plan.config == config
|
||||
assert plan.total_duration == 30.5
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
plan = EditPlan.create(template_id="tmpl1", name="计划", config=None)
|
||||
assert plan.config == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
p1 = EditPlan.create(template_id="t1", name="p1")
|
||||
p2 = EditPlan.create(template_id="t1", name="p2")
|
||||
assert p1.id != p2.id
|
||||
@@ -1,177 +0,0 @@
|
||||
"""EditPlanClip 剪辑计划片段领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
|
||||
class TestEditPlanClipStatus:
|
||||
def test_values(self):
|
||||
assert EditPlanClipStatus.PENDING.value == "pending"
|
||||
assert EditPlanClipStatus.READY.value == "ready"
|
||||
assert EditPlanClipStatus.RENDERED.value == "rendered"
|
||||
assert EditPlanClipStatus.FAILED.value == "failed"
|
||||
|
||||
|
||||
class TestEditPlanClipCreate:
|
||||
def test_create_normal(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id="plan1",
|
||||
clip_type="video",
|
||||
order=1,
|
||||
start_time=0.0,
|
||||
duration=5.0,
|
||||
)
|
||||
assert clip.id
|
||||
assert clip.plan_id == "plan1"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.order == 1
|
||||
assert clip.status == EditPlanClipStatus.PENDING
|
||||
assert clip.start_time == 0.0
|
||||
assert clip.duration == 5.0
|
||||
assert clip.playback_speed == 1.0
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_empty_plan_id_raises(self):
|
||||
with pytest.raises(ValueError, match="plan_id 不能为空"):
|
||||
EditPlanClip.create(plan_id="", clip_type="video", order=1)
|
||||
|
||||
def test_create_empty_clip_type_raises(self):
|
||||
with pytest.raises(ValueError, match="clip_type 不能为空"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="", order=1)
|
||||
|
||||
def test_create_negative_start_time_raises(self):
|
||||
with pytest.raises(ValueError, match="start_time 不能为负数"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=-1.0)
|
||||
|
||||
def test_create_negative_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="duration 不能为负数"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="v", order=1, duration=-1.0)
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=" plan1 ",
|
||||
clip_type=" video ",
|
||||
order=1,
|
||||
template_clip_config_id=" cfg1 ",
|
||||
asset_id=" a1 ",
|
||||
text_content=" 你好 ",
|
||||
transition_effect=" fade ",
|
||||
)
|
||||
assert clip.plan_id == "plan1"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.template_clip_config_id == "cfg1"
|
||||
assert clip.asset_id == "a1"
|
||||
assert clip.text_content == "你好"
|
||||
assert clip.transition_effect == "fade"
|
||||
|
||||
def test_create_empty_transition_effect_defaults_to_cut(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, transition_effect="")
|
||||
assert clip.transition_effect == "cut"
|
||||
|
||||
def test_create_playback_speed_zero_defaults_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_playback_speed_negative_defaults_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=-1.0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_playback_speed_below_min_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=0.1)
|
||||
assert clip.playback_speed == 0.25
|
||||
|
||||
def test_create_playback_speed_above_max_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=5.0)
|
||||
assert clip.playback_speed == 4.0
|
||||
|
||||
def test_create_playback_speed_within_range(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=1.5)
|
||||
assert clip.playback_speed == 1.5
|
||||
|
||||
def test_create_transition_duration_negative_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, transition_duration=-0.5)
|
||||
assert clip.transition_duration == 0.0
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, config=None)
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
c1 = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
c2 = EditPlanClip.create(plan_id="p1", clip_type="v", order=2)
|
||||
assert c1.id != c2.id
|
||||
|
||||
|
||||
class TestEditPlanClipStateTransitions:
|
||||
def test_pending_to_ready(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
assert clip.status == EditPlanClipStatus.READY
|
||||
|
||||
def test_ready_to_rendered(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
clip.mark_rendered()
|
||||
assert clip.status == EditPlanClipStatus.RENDERED
|
||||
|
||||
def test_ready_to_failed(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
clip.mark_failed()
|
||||
assert clip.status == EditPlanClipStatus.FAILED
|
||||
|
||||
def test_ready_mark_ready_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
with pytest.raises(ValueError, match="只有 pending 状态的片段可以标记就绪"):
|
||||
clip.mark_ready()
|
||||
|
||||
def test_pending_mark_rendered_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="只有 ready 状态的片段可以标记已渲染"):
|
||||
clip.mark_rendered()
|
||||
|
||||
def test_pending_mark_failed_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="只有 ready 状态的片段可以标记失败"):
|
||||
clip.mark_failed()
|
||||
|
||||
|
||||
class TestEditPlanClipProperties:
|
||||
def test_end_time(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=10.0, duration=5.0)
|
||||
assert clip.end_time == 15.0
|
||||
|
||||
def test_end_time_zero_duration(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=5.0, duration=0.0)
|
||||
assert clip.end_time == 5.0
|
||||
|
||||
def test_has_asset_true(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, asset_id="a1")
|
||||
assert clip.has_asset is True
|
||||
|
||||
def test_has_asset_false(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
assert clip.has_asset is False
|
||||
|
||||
def test_has_asset_empty_string(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, asset_id="")
|
||||
assert clip.has_asset is False
|
||||
|
||||
|
||||
class TestEditPlanClipAssignAsset:
|
||||
def test_assign_asset(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.assign_asset("asset1")
|
||||
assert clip.asset_id == "asset1"
|
||||
|
||||
def test_assign_asset_strips(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.assign_asset(" asset1 ")
|
||||
assert clip.asset_id == "asset1"
|
||||
|
||||
def test_assign_asset_empty_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
clip.assign_asset("")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""EditTemplate 剪辑模板领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
|
||||
class TestEditTemplateStatus:
|
||||
def test_values(self):
|
||||
assert EditTemplateStatus.ACTIVE.value == "active"
|
||||
assert EditTemplateStatus.INACTIVE.value == "inactive"
|
||||
|
||||
|
||||
class TestEditTemplateCreate:
|
||||
def test_create_default(self):
|
||||
tpl = EditTemplate.create(name="测试模板")
|
||||
assert tpl.id
|
||||
assert tpl.name == "测试模板"
|
||||
assert tpl.editing_mode == EditingMode.ONE_TAKE.value
|
||||
assert tpl.status == EditTemplateStatus.ACTIVE
|
||||
assert tpl.version == 1
|
||||
assert tpl.config == {}
|
||||
assert tpl.description == ""
|
||||
assert tpl.sort_weight == 0
|
||||
|
||||
def test_create_strips_name(self):
|
||||
tpl = EditTemplate.create(name=" 我的模板 ")
|
||||
assert tpl.name == "我的模板"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
EditTemplate.create(name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
EditTemplate.create(name=" ")
|
||||
|
||||
def test_create_valid_editing_modes(self):
|
||||
for mode in EditingMode:
|
||||
tpl = EditTemplate.create(name=f"模板_{mode.value}", editing_mode=mode.value)
|
||||
assert tpl.editing_mode == mode.value
|
||||
|
||||
def test_create_invalid_editing_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="无效的 editing_mode"):
|
||||
EditTemplate.create(name="模板", editing_mode="invalid_mode")
|
||||
|
||||
def test_create_empty_editing_mode_defaults_to_one_take(self):
|
||||
tpl = EditTemplate.create(name="模板", editing_mode="")
|
||||
assert tpl.editing_mode == EditingMode.ONE_TAKE.value
|
||||
|
||||
def test_create_with_config(self):
|
||||
config = {"key": "value"}
|
||||
tpl = EditTemplate.create(name="模板", config=config)
|
||||
assert tpl.config == config
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
tpl = EditTemplate.create(name="模板", config=None)
|
||||
assert tpl.config == {}
|
||||
|
||||
def test_create_with_custom_status(self):
|
||||
tpl = EditTemplate.create(name="模板", status=EditTemplateStatus.INACTIVE)
|
||||
assert tpl.status == EditTemplateStatus.INACTIVE
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
t1 = EditTemplate.create(name="t1")
|
||||
t2 = EditTemplate.create(name="t2")
|
||||
assert t1.id != t2.id
|
||||
+572
-432
File diff suppressed because it is too large
Load Diff
@@ -1,230 +0,0 @@
|
||||
"""filter_presets 领域层单元测试 - 滤镜预设库"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.filter_presets import (
|
||||
FILTER_PRESET_LIBRARY,
|
||||
FilterPreset,
|
||||
build_ffmpeg_filter,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
|
||||
class TestFilterPreset:
|
||||
"""FilterPreset 数据类测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
preset = FilterPreset(id="test", name="测试", category="basic")
|
||||
assert preset.id == "test"
|
||||
assert preset.name == "测试"
|
||||
assert preset.category == "basic"
|
||||
assert preset.description == ""
|
||||
assert preset.tags == []
|
||||
assert preset.brightness == 0.0
|
||||
assert preset.contrast == 1.0
|
||||
assert preset.saturation == 1.0
|
||||
assert preset.gamma == 1.0
|
||||
assert preset.gamma_r == 1.0
|
||||
assert preset.gamma_g == 1.0
|
||||
assert preset.gamma_b == 1.0
|
||||
assert preset.hue == 0.0
|
||||
assert preset.lut_url == ""
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
preset = FilterPreset(
|
||||
id="custom",
|
||||
name="自定义",
|
||||
category="cinematic",
|
||||
description="电影感调色",
|
||||
tags=["电影", "调色"],
|
||||
brightness=0.1,
|
||||
contrast=1.2,
|
||||
saturation=0.9,
|
||||
gamma=1.1,
|
||||
gamma_r=1.05,
|
||||
gamma_g=1.0,
|
||||
gamma_b=0.95,
|
||||
hue=10.0,
|
||||
lut_url="http://example.com/lut.png",
|
||||
)
|
||||
assert preset.category == "cinematic"
|
||||
assert preset.description == "电影感调色"
|
||||
assert preset.tags == ["电影", "调色"]
|
||||
assert preset.brightness == 0.1
|
||||
assert preset.contrast == 1.2
|
||||
assert preset.lut_url == "http://example.com/lut.png"
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen dataclass 不可修改"""
|
||||
preset = FilterPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(Exception): # FrozenInstanceError
|
||||
preset.name = "改名"
|
||||
|
||||
def test_tags_default_empty_list(self):
|
||||
preset = FilterPreset(id="test", name="测试", category="basic")
|
||||
assert preset.tags == []
|
||||
# 每次创建独立的 list
|
||||
preset2 = FilterPreset(id="test2", name="测试2", category="basic")
|
||||
assert preset.tags is not preset2.tags
|
||||
|
||||
|
||||
class TestFilterPresetLibrary:
|
||||
"""FILTER_PRESET_LIBRARY 预设库测试"""
|
||||
|
||||
def test_library_not_empty(self):
|
||||
assert len(FILTER_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_presets_have_unique_ids(self):
|
||||
"""所有预设 ID 唯一"""
|
||||
ids = [p.id for p in FILTER_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_all_presets_have_name_and_category(self):
|
||||
for preset in FILTER_PRESET_LIBRARY:
|
||||
assert preset.id, f"{preset} has no id"
|
||||
assert preset.name, f"{preset.id} has no name"
|
||||
assert preset.category, f"{preset.id} has no category"
|
||||
|
||||
def test_filter_none_exists(self):
|
||||
"""原图预设存在"""
|
||||
none_preset = next((p for p in FILTER_PRESET_LIBRARY if p.id == "filter_none"), None)
|
||||
assert none_preset is not None
|
||||
assert none_preset.name == "原图"
|
||||
assert none_preset.category == "basic"
|
||||
|
||||
def test_known_categories_exist(self):
|
||||
"""已知分类都有预设"""
|
||||
categories = {p.category for p in FILTER_PRESET_LIBRARY}
|
||||
assert "basic" in categories
|
||||
|
||||
def test_basic_category_presets(self):
|
||||
"""基础分类至少有几个预设"""
|
||||
basic = [p for p in FILTER_PRESET_LIBRARY if p.category == "basic"]
|
||||
assert len(basic) >= 3
|
||||
|
||||
def test_preset_params_in_reasonable_range(self):
|
||||
"""预设参数在合理范围内"""
|
||||
for preset in FILTER_PRESET_LIBRARY:
|
||||
assert -1.0 <= preset.brightness <= 1.0, f"{preset.id} brightness out of range"
|
||||
assert 0.0 <= preset.contrast <= 2.0, f"{preset.id} contrast out of range"
|
||||
assert 0.0 <= preset.saturation <= 3.0, f"{preset.id} saturation out of range"
|
||||
|
||||
|
||||
class TestGetFilterPreset:
|
||||
"""get_filter_preset 函数测试"""
|
||||
|
||||
def test_get_existing_preset(self):
|
||||
preset = get_filter_preset("filter_none")
|
||||
assert preset is not None
|
||||
assert preset.id == "filter_none"
|
||||
|
||||
def test_get_nonexistent_preset(self):
|
||||
assert get_filter_preset("nonexistent_filter") is None
|
||||
|
||||
def test_get_returns_correct_type(self):
|
||||
preset = get_filter_preset("filter_none")
|
||||
assert isinstance(preset, FilterPreset)
|
||||
|
||||
|
||||
class TestListFilterPresets:
|
||||
"""list_filter_presets 函数测试"""
|
||||
|
||||
def test_list_all(self):
|
||||
"""不带参数返回所有预设"""
|
||||
all_presets = list_filter_presets()
|
||||
assert len(all_presets) == len(FILTER_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category(self):
|
||||
"""按分类筛选"""
|
||||
basic_presets = list_filter_presets(category="basic")
|
||||
assert len(basic_presets) > 0
|
||||
assert all(p.category == "basic" for p in basic_presets)
|
||||
|
||||
def test_filter_by_nonexistent_category(self):
|
||||
"""不存在的分类返回空列表"""
|
||||
result = list_filter_presets(category="nonexistent_category")
|
||||
assert result == []
|
||||
|
||||
def test_search_by_name(self):
|
||||
"""按名称搜索"""
|
||||
result = list_filter_presets(keyword="明")
|
||||
assert len(result) >= 1
|
||||
assert any("明" in p.name for p in result)
|
||||
|
||||
def test_search_by_tag(self):
|
||||
"""按标签搜索"""
|
||||
# 找到有标签的预设
|
||||
tagged = [p for p in FILTER_PRESET_LIBRARY if p.tags]
|
||||
if tagged:
|
||||
tag = tagged[0].tags[0]
|
||||
result = list_filter_presets(keyword=tag)
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_search_case_insensitive_in_name(self):
|
||||
"""搜索对中文名称有效"""
|
||||
result = list_filter_presets(keyword="原图")
|
||||
assert any(p.id == "filter_none" for p in result)
|
||||
|
||||
def test_search_empty_returns_all(self):
|
||||
"""空搜索返回所有"""
|
||||
result = list_filter_presets(keyword="")
|
||||
assert len(result) == len(FILTER_PRESET_LIBRARY)
|
||||
|
||||
def test_combined_category_and_search(self):
|
||||
"""同时按分类和搜索筛选"""
|
||||
result = list_filter_presets(category="basic", keyword="明")
|
||||
assert all(p.category == "basic" for p in result)
|
||||
if result:
|
||||
assert any("明" in p.name or any("明" in t for t in p.tags) for p in result)
|
||||
|
||||
def test_returns_list_of_filterpreset(self):
|
||||
result = list_filter_presets()
|
||||
assert all(isinstance(p, FilterPreset) for p in result)
|
||||
|
||||
|
||||
class TestBuildFfmpegFilter:
|
||||
"""build_ffmpeg_filter 函数测试"""
|
||||
|
||||
def test_filter_none_returns_empty_or_simple(self):
|
||||
"""原图滤镜应该返回空字符串或无操作滤镜"""
|
||||
result = build_ffmpeg_filter("filter_none", 100)
|
||||
# 应该是字符串,且不包含实质性调色参数
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_full_intensity(self):
|
||||
"""强度 100 时应用全量参数"""
|
||||
result = build_ffmpeg_filter("filter_brighten", 100)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_zero_intensity(self):
|
||||
"""强度 0 时应该是原图效果"""
|
||||
result = build_ffmpeg_filter("filter_brighten", 0)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_half_intensity(self):
|
||||
"""强度 50 时参数减半"""
|
||||
result50 = build_ffmpeg_filter("filter_brighten", 50)
|
||||
result100 = build_ffmpeg_filter("filter_brighten", 100)
|
||||
# 50% 和 100% 的结果应该不同
|
||||
assert result50 != result100
|
||||
|
||||
def test_nonexistent_preset(self):
|
||||
"""不存在的预设返回空字符串或默认值"""
|
||||
result = build_ffmpeg_filter("nonexistent", 100)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_intensity_clamped(self):
|
||||
"""强度超过 100 或低于 0 的处理"""
|
||||
result_high = build_ffmpeg_filter("filter_brighten", 150)
|
||||
result_low = build_ffmpeg_filter("filter_brighten", -10)
|
||||
assert isinstance(result_high, str)
|
||||
assert isinstance(result_low, str)
|
||||
|
||||
def test_contains_eq_filter(self):
|
||||
"""结果应该包含 eq 滤镜参数"""
|
||||
result = build_ffmpeg_filter("filter_brighten", 100)
|
||||
# FFmpeg eq 滤镜通常包含 brightness/contrast/saturation 等参数
|
||||
# 至少应该有滤镜相关的字符串
|
||||
assert len(result) > 0
|
||||
@@ -1,139 +0,0 @@
|
||||
"""GeneratedVideo 生成视频领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
|
||||
class TestGeneratedVideoCreate:
|
||||
def test_create_normal(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="我的视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024000,
|
||||
duration=30.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=30.0,
|
||||
)
|
||||
assert video.id
|
||||
assert video.project_id == "proj1"
|
||||
assert video.generation_task_id == "task1"
|
||||
assert video.name == "我的视频"
|
||||
assert video.file_url == "https://example.com/video.mp4"
|
||||
assert video.file_size == 1024000
|
||||
assert video.duration == 30.5
|
||||
assert video.width == 1920
|
||||
assert video.height == 1080
|
||||
assert video.fps == 30.0
|
||||
assert video.status == "completed"
|
||||
assert video.review_status == "pending_review"
|
||||
assert video.is_duplicate is False
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id=" proj1 ",
|
||||
generation_task_id=" task1 ",
|
||||
name=" 我的视频 ",
|
||||
file_url=" https://example.com/video.mp4 ",
|
||||
user_id=" user1 ",
|
||||
)
|
||||
assert video.project_id == "proj1"
|
||||
assert video.generation_task_id == "task1"
|
||||
assert video.name == "我的视频"
|
||||
assert video.file_url == "https://example.com/video.mp4"
|
||||
assert video.user_id == "user1"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id=" ",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_generation_task_id_raises(self):
|
||||
with pytest.raises(ValueError, match="generation_task_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="name cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_file_url_raises(self):
|
||||
with pytest.raises(ValueError, match="file_url cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="",
|
||||
)
|
||||
|
||||
def test_create_default_values(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
assert video.file_size == 0
|
||||
assert video.duration == 0.0
|
||||
assert video.width == 0
|
||||
assert video.height == 0
|
||||
assert video.fps == 0.0
|
||||
assert video.thumbnail_url is None
|
||||
assert video.user_id == ""
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_with_generation_params(self):
|
||||
params = {"mode": "pip", "resolution": "1080p"}
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
generation_params=params,
|
||||
)
|
||||
assert video.generation_params == params
|
||||
|
||||
def test_create_none_generation_params(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
generation_params=None,
|
||||
)
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
v1 = GeneratedVideo.create(
|
||||
project_id="proj1", generation_task_id="t1", name="v1", file_url="https://a.com/1.mp4"
|
||||
)
|
||||
v2 = GeneratedVideo.create(
|
||||
project_id="proj1", generation_task_id="t2", name="v2", file_url="https://a.com/2.mp4"
|
||||
)
|
||||
assert v1.id != v2.id
|
||||
@@ -1,300 +0,0 @@
|
||||
"""GenerationTask 生成任务领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.generation_task import (
|
||||
TERMINAL_STATUSES,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerationTaskCreate:
|
||||
def test_create_with_template_id(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="",
|
||||
asset_library_id="lib1",
|
||||
template_id="tmpl1",
|
||||
)
|
||||
assert task.id
|
||||
assert task.template_id == "tmpl1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.progress == 0.0
|
||||
assert task.retry_count == 0
|
||||
assert task.auto_retry_enabled is False
|
||||
|
||||
def test_create_with_project_id(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="lib1",
|
||||
)
|
||||
assert task.project_id == "proj1"
|
||||
|
||||
def test_create_both_empty_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 或 template_id 至少需要提供一个"):
|
||||
GenerationTask.create(
|
||||
project_id="",
|
||||
asset_library_id="lib1",
|
||||
template_id="",
|
||||
)
|
||||
|
||||
def test_create_asset_library_and_assets_both_empty_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个"):
|
||||
GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="",
|
||||
asset_ids=None,
|
||||
title_ids=None,
|
||||
voice_ids=None,
|
||||
)
|
||||
|
||||
def test_create_with_asset_ids(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="",
|
||||
asset_ids=["a1", "a2"],
|
||||
)
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
task = GenerationTask.create(
|
||||
project_id=" proj1 ",
|
||||
asset_library_id=" lib1 ",
|
||||
template_id=" tmpl1 ",
|
||||
video_title=" 测试视频 ",
|
||||
resolution=" 1080p ",
|
||||
created_by_user_id=" user1 ",
|
||||
source_edit_plan_id=" plan1 ",
|
||||
)
|
||||
assert task.project_id == "proj1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.template_id == "tmpl1"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080p"
|
||||
assert task.created_by_user_id == "user1"
|
||||
assert task.source_edit_plan_id == "plan1"
|
||||
|
||||
def test_create_default_values(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="lib1",
|
||||
)
|
||||
assert task.title_ids == []
|
||||
assert task.voice_ids == []
|
||||
assert task.result_count == 0
|
||||
assert task.error_message == ""
|
||||
assert task.error_info == {}
|
||||
assert task.bgm_config == {}
|
||||
assert task.logs == "[]"
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
t1 = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
t2 = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert t1.id != t2.id
|
||||
|
||||
|
||||
class TestGenerationTaskStatusQueries:
|
||||
def test_is_terminal_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
assert task.is_terminal is True
|
||||
assert task.is_completed is True
|
||||
assert task.is_failed is False
|
||||
assert task.is_running is False
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error")
|
||||
assert task.is_terminal is True
|
||||
assert task.is_completed is False
|
||||
assert task.is_failed is True
|
||||
|
||||
def test_is_terminal_cancelled(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
assert task.is_terminal is True
|
||||
|
||||
def test_is_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert task.is_running is False
|
||||
task.mark_processing()
|
||||
assert task.is_running is True
|
||||
|
||||
def test_terminal_statuses_set(self):
|
||||
assert GenerationTaskStatus.COMPLETED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.FAILED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.CANCELLED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.RUNNING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestGenerationTaskStateTransitions:
|
||||
def test_pending_to_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
assert task.started_at is not None
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_running_to_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed(result_count=3)
|
||||
assert task.status == GenerationTaskStatus.COMPLETED
|
||||
assert task.completed_at is not None
|
||||
assert task.progress == 100.0
|
||||
assert task.result_count == 3
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_running_to_failed_with_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("渲染失败", error_info={"stage": "render"})
|
||||
assert task.status == GenerationTaskStatus.FAILED
|
||||
assert task.completed_at is not None
|
||||
assert task.error_message == "渲染失败"
|
||||
assert task.error_info["stage"] == "render"
|
||||
|
||||
def test_running_to_failed_without_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("未知错误")
|
||||
assert task.error_info is not None
|
||||
assert task.error_info["message"] == "未知错误"
|
||||
assert "error_type" in task.error_info
|
||||
assert "failed_at" in task.error_info
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error")
|
||||
assert task.retry_count == 0
|
||||
|
||||
task.mark_pending_from_failed()
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.retry_count == 1
|
||||
assert task.error_message == ""
|
||||
assert task.error_info == {}
|
||||
assert task.started_at is None
|
||||
assert task.completed_at is None
|
||||
assert task.progress == 0.0
|
||||
assert task.result_count == 0
|
||||
|
||||
def test_mark_pending_from_failed_wrong_status_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
with pytest.raises(ValueError, match="只有 failed 状态的任务可以重置为 pending"):
|
||||
task.mark_pending_from_failed()
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
# pending 不能直接到 completed
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to(GenerationTaskStatus.COMPLETED)
|
||||
|
||||
def test_completed_cannot_transition(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.mark_failed("test")
|
||||
|
||||
def test_transition_to_with_string(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.transition_to("running")
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
task.transition_to("invalid_status")
|
||||
|
||||
|
||||
class TestGenerationTaskLogs:
|
||||
def test_append_log_single(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("初始化", "任务创建成功")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["stage"] == "初始化"
|
||||
assert logs[0]["message"] == "任务创建成功"
|
||||
assert logs[0]["level"] == "INFO"
|
||||
assert "ts" in logs[0]
|
||||
|
||||
def test_append_log_multiple(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
for i in range(5):
|
||||
task.append_log(f"stage{i}", f"msg{i}", level="INFO")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 5
|
||||
assert logs[0]["stage"] == "stage0"
|
||||
assert logs[4]["stage"] == "stage4"
|
||||
|
||||
def test_append_log_with_extra_fields(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("下载", "下载完成", asset_id="a1", duration=10.5)
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["asset_id"] == "a1"
|
||||
assert logs[0]["duration"] == 10.5
|
||||
|
||||
def test_append_log_error_level(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("渲染", "渲染失败", level="ERROR")
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["level"] == "ERROR"
|
||||
|
||||
def test_logs_max_limit(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
# _MAX_LOGS = 200
|
||||
for i in range(250):
|
||||
task.append_log("test", f"msg{i}")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 200
|
||||
# 保留最新的200条
|
||||
assert logs[0]["message"] == "msg50"
|
||||
assert logs[-1]["message"] == "msg249"
|
||||
|
||||
def test_get_logs_empty(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_get_logs_corrupted_json(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.logs = "not json"
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_mark_failed_without_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error msg")
|
||||
assert task.error_info is not None
|
||||
assert task.error_info["message"] == "error msg"
|
||||
assert "error_type" in task.error_info
|
||||
assert "failed_at" in task.error_info
|
||||
|
||||
|
||||
class TestGenerationTaskCreateWithStrategy:
|
||||
def test_create_with_strategy_and_voice(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="l1",
|
||||
strategy_id="s1",
|
||||
voice_library_id="v1",
|
||||
auto_retry_enabled=True,
|
||||
auto_retry_max=3,
|
||||
)
|
||||
assert task.strategy_id == "s1"
|
||||
assert task.voice_library_id == "v1"
|
||||
assert task.auto_retry_enabled is True
|
||||
assert task.auto_retry_max == 3
|
||||
|
||||
def test_create_with_bgm_config(self):
|
||||
bgm = {"volume": 0.5, "track": "bgm1"}
|
||||
task = GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="l1",
|
||||
bgm_config=bgm,
|
||||
)
|
||||
assert task.bgm_config == bgm
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
In-Memory 项目仓储 + 小仓储测试.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.project_repository import InMemoryProjectRepository
|
||||
from packages.adapters.in_memory.classification_job_repository import InMemoryClassificationJobRepository
|
||||
from packages.adapters.in_memory.ingest_job_repository import InMemoryIngestJobRepository
|
||||
from packages.domain import Project, ClassificationJob, IngestJob
|
||||
|
||||
|
||||
# ── Project Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def project_repo():
|
||||
return InMemoryProjectRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_project():
|
||||
counter = 0
|
||||
|
||||
def _make(owner_id: str = "user_1", name: str = "测试项目"):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
return Project(
|
||||
id=f"proj_{counter}",
|
||||
owner_user_id=owner_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
class TestInMemoryProjectRepository:
|
||||
"""项目仓储."""
|
||||
|
||||
def test_save_and_find_by_id(self, project_repo, make_project):
|
||||
proj = make_project()
|
||||
project_repo.save(proj)
|
||||
found = project_repo.find_by_id(proj.id)
|
||||
assert found is not None
|
||||
assert found.id == proj.id
|
||||
assert found.name == "测试项目"
|
||||
|
||||
def test_find_by_id_not_found(self, project_repo):
|
||||
assert project_repo.find_by_id("nonexistent") is None
|
||||
|
||||
def test_find_by_owner(self, project_repo, make_project):
|
||||
for i in range(3):
|
||||
project_repo.save(make_project(owner_id="user_1", name=f"p{i}"))
|
||||
project_repo.save(make_project(owner_id="user_2", name="other"))
|
||||
|
||||
result = project_repo.find_by_owner_user_id("user_1")
|
||||
assert len(result) == 3
|
||||
assert all(p.owner_user_id == "user_1" for p in result)
|
||||
|
||||
def test_count_by_owner(self, project_repo, make_project):
|
||||
for i in range(5):
|
||||
project_repo.save(make_project(owner_id="user_1", name=f"p{i}"))
|
||||
project_repo.save(make_project(owner_id="user_2", name="other"))
|
||||
assert project_repo.count_by_owner("user_1") == 5
|
||||
assert project_repo.count_by_owner("user_2") == 1
|
||||
assert project_repo.count_by_owner("user_3") == 0
|
||||
|
||||
def test_delete_existing(self, project_repo, make_project):
|
||||
proj = make_project()
|
||||
project_repo.save(proj)
|
||||
result = project_repo.delete(proj.id)
|
||||
assert result is True
|
||||
assert project_repo.find_by_id(proj.id) is None
|
||||
|
||||
def test_delete_nonexistent(self, project_repo):
|
||||
assert project_repo.delete("nonexistent") is False
|
||||
|
||||
def test_save_updates(self, project_repo, make_project):
|
||||
proj = make_project(name="v1")
|
||||
project_repo.save(proj)
|
||||
proj.name = "v2"
|
||||
project_repo.save(proj)
|
||||
found = project_repo.find_by_id(proj.id)
|
||||
assert found.name == "v2"
|
||||
|
||||
def test_find_accessible_projects(self, project_repo, make_project):
|
||||
p1 = make_project(owner_id="user_1", name="owned")
|
||||
project_repo.save(p1)
|
||||
# 自己拥有的项目可访问
|
||||
result = project_repo.find_accessible_projects("user_1")
|
||||
assert len(result) >= 1
|
||||
assert any(p.id == p1.id for p in result)
|
||||
|
||||
|
||||
# ── Classification Job Repository ──────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def classif_repo():
|
||||
return InMemoryClassificationJobRepository()
|
||||
|
||||
|
||||
class TestInMemoryClassificationJobRepository:
|
||||
"""分类任务仓储."""
|
||||
|
||||
def test_create_and_get(self, classif_repo):
|
||||
job = ClassificationJob(
|
||||
id="job_1",
|
||||
project_id="p1",
|
||||
asset_id="a1",
|
||||
status="pending",
|
||||
)
|
||||
classif_repo.create(job)
|
||||
found = classif_repo.get("job_1")
|
||||
assert found is not None
|
||||
assert found.id == "job_1"
|
||||
assert found.status == "pending"
|
||||
|
||||
def test_get_not_found(self, classif_repo):
|
||||
assert classif_repo.get("nonexistent") is None
|
||||
|
||||
def test_update(self, classif_repo):
|
||||
job = ClassificationJob(
|
||||
id="job_1",
|
||||
project_id="p1",
|
||||
asset_id="a1",
|
||||
status="pending",
|
||||
)
|
||||
classif_repo.create(job)
|
||||
job.status = "completed"
|
||||
classif_repo.update(job)
|
||||
found = classif_repo.get("job_1")
|
||||
assert found.status == "completed"
|
||||
|
||||
def test_update_nonexistent_creates(self, classif_repo):
|
||||
"""update 对不存在的也会写入(dict 赋值)"""
|
||||
job = ClassificationJob(
|
||||
id="new_job",
|
||||
project_id="p1",
|
||||
asset_id="a1",
|
||||
status="running",
|
||||
)
|
||||
classif_repo.update(job)
|
||||
found = classif_repo.get("new_job")
|
||||
assert found is not None
|
||||
|
||||
|
||||
# ── Ingest Job Repository ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def ingest_repo():
|
||||
return InMemoryIngestJobRepository()
|
||||
|
||||
|
||||
class TestInMemoryIngestJobRepository:
|
||||
"""导入任务仓储."""
|
||||
|
||||
def test_create_and_get(self, ingest_repo):
|
||||
job = IngestJob(
|
||||
id="ingest_1",
|
||||
project_id="p1",
|
||||
library_id="lib1",
|
||||
storage_key="key1",
|
||||
status="pending",
|
||||
)
|
||||
ingest_repo.create(job)
|
||||
found = ingest_repo.get("ingest_1")
|
||||
assert found is not None
|
||||
assert found.id == "ingest_1"
|
||||
assert found.status == "pending"
|
||||
|
||||
def test_get_not_found(self, ingest_repo):
|
||||
assert ingest_repo.get("nonexistent") is None
|
||||
|
||||
def test_update(self, ingest_repo):
|
||||
job = IngestJob(
|
||||
id="ingest_1",
|
||||
project_id="p1",
|
||||
library_id="lib1",
|
||||
storage_key="key1",
|
||||
status="pending",
|
||||
)
|
||||
ingest_repo.create(job)
|
||||
job.status = "completed"
|
||||
ingest_repo.update(job)
|
||||
found = ingest_repo.get("ingest_1")
|
||||
assert found.status == "completed"
|
||||
|
||||
def test_update_nonexistent_creates(self, ingest_repo):
|
||||
job = IngestJob(
|
||||
id="new_ingest",
|
||||
project_id="p1",
|
||||
library_id="lib1",
|
||||
storage_key="key1",
|
||||
status="running",
|
||||
)
|
||||
ingest_repo.update(job)
|
||||
found = ingest_repo.get("new_ingest")
|
||||
assert found is not None
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
In-Memory 标签仓储测试.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.tag_repository import InMemoryTagRepository
|
||||
from packages.domain import Tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemoryTagRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_tag():
|
||||
counter = 0
|
||||
|
||||
def _make(user_id: str = "user_1", name: str = "默认标签"):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
return Tag(
|
||||
id=f"tag_{counter}",
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryCreate:
|
||||
"""创建标签."""
|
||||
|
||||
def test_create_tag(self, repo, make_tag):
|
||||
tag = make_tag()
|
||||
result = repo.create(tag)
|
||||
assert result.id == tag.id
|
||||
assert result.name == tag.name
|
||||
|
||||
def test_create_same_id_overwrites(self, repo, make_tag):
|
||||
tag = make_tag(name="first")
|
||||
repo.create(tag)
|
||||
tag.name = "second"
|
||||
repo.create(tag)
|
||||
found = repo.get(tag.id)
|
||||
assert found.name == "second"
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryGet:
|
||||
"""获取标签."""
|
||||
|
||||
def test_get_existing(self, repo, make_tag):
|
||||
tag = make_tag()
|
||||
repo.create(tag)
|
||||
found = repo.get(tag.id)
|
||||
assert found is not None
|
||||
assert found.id == tag.id
|
||||
|
||||
def test_get_nonexistent(self, repo):
|
||||
assert repo.get("nonexistent") is None
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryFindByName:
|
||||
"""按名称查找."""
|
||||
|
||||
def test_find_by_name_found(self, repo, make_tag):
|
||||
tag = make_tag(name="风景")
|
||||
repo.create(tag)
|
||||
found = repo.find_by_name("user_1", "风景")
|
||||
assert found is not None
|
||||
assert found.id == tag.id
|
||||
|
||||
def test_find_by_name_not_found(self, repo, make_tag):
|
||||
tag = make_tag(name="风景")
|
||||
repo.create(tag)
|
||||
assert repo.find_by_name("user_1", "美食") is None
|
||||
assert repo.find_by_name("user_2", "风景") is None
|
||||
|
||||
def test_find_by_name_different_user(self, repo, make_tag):
|
||||
tag = make_tag(user_id="user_1", name="风景")
|
||||
repo.create(tag)
|
||||
assert repo.find_by_name("user_2", "风景") is None
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryListByUser:
|
||||
"""用户标签列表."""
|
||||
|
||||
def test_list_by_user_empty(self, repo):
|
||||
result = repo.list_by_user("user_1")
|
||||
assert result == []
|
||||
|
||||
def test_list_by_user_filters_correctly(self, repo, make_tag):
|
||||
for i in range(5):
|
||||
make_tag(user_id="user_1", name=f"标签{i}")
|
||||
repo.create(make_tag(user_id="user_1", name=f"标签{i}"))
|
||||
repo.create(make_tag(user_id="user_2", name="其他标签"))
|
||||
|
||||
result = repo.list_by_user("user_1")
|
||||
assert len(result) == 5
|
||||
assert all(t.user_id == "user_1" for t in result)
|
||||
|
||||
def test_list_sorted_by_created_desc(self, repo, make_tag):
|
||||
tags = []
|
||||
for i in range(3):
|
||||
tag = make_tag(name=f"t{i}")
|
||||
tag.created_at = datetime.fromtimestamp(1000 + i * 100)
|
||||
repo.create(tag)
|
||||
tags.append(tag)
|
||||
|
||||
result = repo.list_by_user("user_1")
|
||||
assert len(result) == 3
|
||||
# 最新的排在前面
|
||||
assert result[0].created_at > result[2].created_at
|
||||
|
||||
def test_list_pagination(self, repo, make_tag):
|
||||
for i in range(10):
|
||||
repo.create(make_tag(name=f"tag{i}"))
|
||||
|
||||
page1 = repo.list_by_user("user_1", skip=0, limit=3)
|
||||
page2 = repo.list_by_user("user_1", skip=3, limit=3)
|
||||
assert len(page1) == 3
|
||||
assert len(page2) == 3
|
||||
assert page1[0].id != page2[0].id
|
||||
|
||||
def test_list_limit_exceeds_total(self, repo, make_tag):
|
||||
for i in range(3):
|
||||
repo.create(make_tag(name=f"t{i}"))
|
||||
result = repo.list_by_user("user_1", skip=0, limit=100)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryCount:
|
||||
"""统计用户标签数."""
|
||||
|
||||
def test_count_zero(self, repo):
|
||||
assert repo.count_by_user("user_1") == 0
|
||||
|
||||
def test_count_correct(self, repo, make_tag):
|
||||
for i in range(7):
|
||||
repo.create(make_tag(user_id="user_1", name=f"t{i}"))
|
||||
repo.create(make_tag(user_id="user_2", name="other"))
|
||||
assert repo.count_by_user("user_1") == 7
|
||||
assert repo.count_by_user("user_2") == 1
|
||||
|
||||
|
||||
class TestInMemoryTagRepositoryDelete:
|
||||
"""删除标签."""
|
||||
|
||||
def test_delete_existing(self, repo, make_tag):
|
||||
tag = make_tag()
|
||||
repo.create(tag)
|
||||
result = repo.delete(tag.id)
|
||||
assert result is True
|
||||
assert repo.get(tag.id) is None
|
||||
|
||||
def test_delete_nonexistent(self, repo):
|
||||
result = repo.delete("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_delete_does_not_affect_others(self, repo, make_tag):
|
||||
t1 = make_tag(name="a")
|
||||
t2 = make_tag(name="b")
|
||||
repo.create(t1)
|
||||
repo.create(t2)
|
||||
repo.delete(t1.id)
|
||||
assert repo.get(t2.id) is not None
|
||||
assert repo.count_by_user("user_1") == 1
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
In-Memory 用户仓储测试.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.user_repository import InMemoryUserRepository
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemoryUserRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user():
|
||||
return User(
|
||||
id="user_1",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
password_hash="hashed",
|
||||
email_verified=True,
|
||||
email_verification_token="verify_token",
|
||||
password_reset_token="reset_token",
|
||||
wechat_openid="openid_123",
|
||||
wechat_unionid="unionid_123",
|
||||
phone="13800138000",
|
||||
)
|
||||
|
||||
|
||||
class TestInMemoryUserRepositorySave:
|
||||
"""保存用户."""
|
||||
|
||||
def test_save_new_user(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_id("user_1")
|
||||
assert found is not None
|
||||
assert found.email == "test@example.com"
|
||||
|
||||
def test_save_updates_existing(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
sample_user.display_name = "Updated"
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_id("user_1")
|
||||
assert found.display_name == "Updated"
|
||||
|
||||
def test_email_index_case_insensitive(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_email("TEST@EXAMPLE.COM")
|
||||
assert found is not None
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_username_index_case_insensitive(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_username("TESTUSER")
|
||||
assert found is not None
|
||||
assert found.id == "user_1"
|
||||
|
||||
|
||||
class TestInMemoryUserRepositoryFind:
|
||||
"""各种查找方式."""
|
||||
|
||||
def test_find_by_id_found(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_id("user_1")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_id_not_found(self, repo):
|
||||
assert repo.find_by_id("nonexistent") is None
|
||||
|
||||
def test_find_by_email_found(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_email("test@example.com")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_email_not_found(self, repo):
|
||||
assert repo.find_by_email("no@example.com") is None
|
||||
|
||||
def test_find_by_username_found(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_username("testuser")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_username_not_found(self, repo):
|
||||
assert repo.find_by_username("nobody") is None
|
||||
|
||||
def test_find_by_verification_token(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_verification_token("verify_token")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_verification_token_not_found(self, repo):
|
||||
assert repo.find_by_verification_token("bad_token") is None
|
||||
|
||||
def test_find_by_password_reset_token(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_password_reset_token("reset_token")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_password_reset_token_not_found(self, repo):
|
||||
assert repo.find_by_password_reset_token("bad_token") is None
|
||||
|
||||
def test_find_by_wechat_openid(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_wechat_openid("openid_123")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_wechat_openid_not_found(self, repo):
|
||||
assert repo.find_by_wechat_openid("bad_openid") is None
|
||||
|
||||
def test_find_by_wechat_unionid(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_wechat_unionid("unionid_123")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_wechat_unionid_empty_returns_none(self, repo):
|
||||
"""空 unionid 直接返回 None."""
|
||||
assert repo.find_by_wechat_unionid("") is None
|
||||
assert repo.find_by_wechat_unionid(None) is None # type: ignore
|
||||
|
||||
def test_find_by_wechat_unionid_not_found(self, repo):
|
||||
assert repo.find_by_wechat_unionid("bad_unionid") is None
|
||||
|
||||
def test_find_by_phone(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
found = repo.find_by_phone("13800138000")
|
||||
assert found.id == "user_1"
|
||||
|
||||
def test_find_by_phone_empty_returns_none(self, repo):
|
||||
assert repo.find_by_phone("") is None
|
||||
assert repo.find_by_phone(None) is None # type: ignore
|
||||
|
||||
def test_find_by_phone_not_found(self, repo):
|
||||
assert repo.find_by_phone("13900139000") is None
|
||||
|
||||
def test_user_without_username_not_in_username_index(self, repo):
|
||||
user = User(id="u2", email="no_user@example.com", display_name="No Username")
|
||||
repo.save(user)
|
||||
assert repo.find_by_username("") is None
|
||||
|
||||
|
||||
class TestInMemoryUserRepositoryDelete:
|
||||
"""删除用户."""
|
||||
|
||||
def test_delete_existing(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
result = repo.delete("user_1")
|
||||
assert result is True
|
||||
assert repo.find_by_id("user_1") is None
|
||||
assert repo.find_by_email("test@example.com") is None
|
||||
assert repo.find_by_username("testuser") is None
|
||||
assert repo.find_by_verification_token("verify_token") is None
|
||||
assert repo.find_by_password_reset_token("reset_token") is None
|
||||
|
||||
def test_delete_nonexistent(self, repo):
|
||||
result = repo.delete("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_delete_cleans_wechat_and_phone_indexes(self, repo, sample_user):
|
||||
repo.save(sample_user)
|
||||
repo.delete("user_1")
|
||||
assert repo.find_by_wechat_openid("openid_123") is None
|
||||
assert repo.find_by_wechat_unionid("unionid_123") is None
|
||||
assert repo.find_by_phone("13800138000") is None
|
||||
|
||||
|
||||
class TestInMemoryUserRepositoryMultipleUsers:
|
||||
"""多用户场景."""
|
||||
|
||||
def test_multiple_users(self, repo):
|
||||
for i in range(5):
|
||||
user = User(
|
||||
id=f"user_{i}",
|
||||
email=f"user{i}@example.com",
|
||||
display_name=f"User {i}",
|
||||
username=f"user{i}",
|
||||
)
|
||||
repo.save(user)
|
||||
|
||||
for i in range(5):
|
||||
assert repo.find_by_id(f"user_{i}") is not None
|
||||
assert repo.find_by_email(f"user{i}@example.com") is not None
|
||||
assert repo.find_by_username(f"user{i}") is not None
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
片头片尾引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 IntroOutroConfig.from_dict / validate / has_intro / has_outro 等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from video_processing.intro_outro_engine import IntroOutroConfig
|
||||
|
||||
|
||||
class TestIntroOutroConfigFromDict:
|
||||
"""from_dict 构造逻辑."""
|
||||
|
||||
def test_none_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict(None)
|
||||
assert cfg.enabled is False
|
||||
assert cfg.intro_type == "none"
|
||||
assert cfg.outro_type == "none"
|
||||
|
||||
def test_empty_dict_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict({})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_enabled_false_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict({"enabled": False})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_enabled_with_video_intro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/intro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intro_type == "video"
|
||||
assert cfg.intro_video_path == "/tmp/intro.mp4"
|
||||
assert cfg.intro_duration == 5.0
|
||||
|
||||
def test_enabled_with_text_intro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "Hello",
|
||||
"subtitle": "World",
|
||||
"background": "#ffffff",
|
||||
"title_color": "black",
|
||||
"title_size": 64,
|
||||
"duration": 2.5,
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intro_type == "text"
|
||||
assert cfg.intro_title == "Hello"
|
||||
assert cfg.intro_subtitle == "World"
|
||||
assert cfg.intro_background == "#ffffff"
|
||||
assert cfg.intro_title_color == "black"
|
||||
assert cfg.intro_title_size == 64
|
||||
assert cfg.intro_duration == 2.5
|
||||
|
||||
def test_enabled_with_video_outro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 4.0,
|
||||
},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.outro_type == "video"
|
||||
assert cfg.outro_video_path == "/tmp/outro.mp4"
|
||||
assert cfg.outro_duration == 4.0
|
||||
|
||||
def test_enabled_with_text_outro_default_values(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "text"},
|
||||
})
|
||||
assert cfg.outro_title == "感谢观看"
|
||||
assert cfg.outro_subtitle == "点赞关注不迷路"
|
||||
assert cfg.outro_title_size == 48
|
||||
assert cfg.outro_duration == 3.0
|
||||
|
||||
def test_video_key_fallback(self):
|
||||
"""video 字段作为 video_path 的 fallback."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video": "/tmp/fallback.mp4",
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.intro_video_path == "/tmp/fallback.mp4"
|
||||
|
||||
def test_transition_config(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "none"},
|
||||
"transition": "fade",
|
||||
"transition_duration": 1.0,
|
||||
})
|
||||
assert cfg.transition_effect == "fade"
|
||||
assert cfg.transition_duration == 1.0
|
||||
|
||||
def test_default_transition(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.transition_effect == "fade"
|
||||
assert cfg.transition_duration == 0.5
|
||||
|
||||
|
||||
class TestIntroOutroConfigProperties:
|
||||
"""has_intro / has_outro 属性."""
|
||||
|
||||
def test_has_intro_video_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/a.mp4",
|
||||
)
|
||||
assert cfg.has_intro is True
|
||||
|
||||
def test_has_intro_text_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
)
|
||||
assert cfg.has_intro is True
|
||||
|
||||
def test_no_intro_when_disabled(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=False,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/a.mp4",
|
||||
)
|
||||
assert cfg.has_intro is False
|
||||
|
||||
def test_no_intro_when_none_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
)
|
||||
assert cfg.has_intro is False
|
||||
|
||||
def test_has_outro_video_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="video",
|
||||
outro_video_path="/tmp/a.mp4",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
|
||||
def test_has_outro_text_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
|
||||
def test_has_outro_follow_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="follow",
|
||||
outro_title="Follow me",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
|
||||
def test_no_outro_when_disabled(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=False,
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
)
|
||||
assert cfg.has_outro is False
|
||||
|
||||
def test_no_outro_when_none_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="none",
|
||||
)
|
||||
assert cfg.has_outro is False
|
||||
|
||||
|
||||
class TestIntroOutroConfigValidate:
|
||||
"""validate 校验逻辑."""
|
||||
|
||||
def test_disabled_is_valid(self):
|
||||
cfg = IntroOutroConfig(enabled=False)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_video_intro_missing_path(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="",
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_text_intro_missing_title(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="",
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_video_outro_missing_path(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="video",
|
||||
outro_video_path="",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_text_outro_missing_title(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="text",
|
||||
outro_title="",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_intro_duration_zero(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
intro_duration=0,
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "片头时长" in msg
|
||||
|
||||
def test_intro_duration_negative(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
intro_duration=-1.0,
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "片头时长" in msg
|
||||
|
||||
def test_outro_duration_zero(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
outro_duration=0,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "片尾时长" in msg
|
||||
|
||||
def test_valid_full_config(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/intro.mp4",
|
||||
intro_duration=3.0,
|
||||
outro_type="text",
|
||||
outro_title="Thanks",
|
||||
outro_duration=2.0,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
Executable → Regular
+422
-388
@@ -1,4 +1,10 @@
|
||||
"""Job 领域层单元测试 - job.py"""
|
||||
"""
|
||||
Job 领域模型单元测试
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,496 +19,524 @@ from packages.domain.job import (
|
||||
class TestJobType:
|
||||
"""JobType 枚举测试"""
|
||||
|
||||
def test_all_types_have_values(self):
|
||||
"""所有枚举成员都有字符串值"""
|
||||
for jt in JobType:
|
||||
assert isinstance(jt.value, str)
|
||||
assert jt.value
|
||||
|
||||
def test_str_enum_behavior(self):
|
||||
"""是 str 枚举"""
|
||||
def test_job_type_values(self):
|
||||
"""测试所有 JobType 值"""
|
||||
assert JobType.VIDEO_COMPOSE == "video_compose"
|
||||
assert isinstance(JobType.VIDEO_COMPOSE, str)
|
||||
assert JobType.RENDER_EDIT_PLAN == "render_edit_plan"
|
||||
assert JobType.ASSET_INGEST == "asset_ingest"
|
||||
assert JobType.CLASSIFICATION == "classification"
|
||||
assert JobType.VOICE_EXTRACTION == "voice_extraction"
|
||||
assert JobType.GENERATION == "generation"
|
||||
|
||||
def test_known_types_exist(self):
|
||||
"""核心任务类型都存在"""
|
||||
assert JobType.VIDEO_COMPOSE
|
||||
assert JobType.RENDER_EDIT_PLAN
|
||||
assert JobType.ASSET_INGEST
|
||||
assert JobType.CLASSIFICATION
|
||||
assert JobType.GENERATION
|
||||
def test_job_type_is_string(self):
|
||||
"""测试 StrEnum 行为"""
|
||||
assert isinstance(JobType.VIDEO_COMPOSE, str)
|
||||
assert JobType.VIDEO_COMPOSE == "video_compose"
|
||||
|
||||
|
||||
class TestJobStatus:
|
||||
"""JobStatus 枚举测试"""
|
||||
|
||||
def test_all_statuses_have_values(self):
|
||||
for js in JobStatus:
|
||||
assert isinstance(js.value, str)
|
||||
assert js.value
|
||||
|
||||
def test_str_enum_behavior(self):
|
||||
def test_job_status_values(self):
|
||||
"""测试所有 JobStatus 值"""
|
||||
assert JobStatus.PENDING == "pending"
|
||||
assert isinstance(JobStatus.PENDING, str)
|
||||
assert JobStatus.RUNNING == "running"
|
||||
assert JobStatus.SUCCESS == "success"
|
||||
assert JobStatus.FAILED == "failed"
|
||||
assert JobStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
"""终态集合包含成功/失败/取消"""
|
||||
"""测试终态集合"""
|
||||
assert JobStatus.SUCCESS in TERMINAL_STATUSES
|
||||
assert JobStatus.FAILED in TERMINAL_STATUSES
|
||||
assert JobStatus.CANCELLED in TERMINAL_STATUSES
|
||||
|
||||
def test_pending_not_terminal(self):
|
||||
assert JobStatus.PENDING not in TERMINAL_STATUSES
|
||||
|
||||
def test_running_not_terminal(self):
|
||||
assert JobStatus.RUNNING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestJobCreate:
|
||||
"""Job.create 工厂方法测试"""
|
||||
"""Job 创建测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
"""基本创建"""
|
||||
def test_create_basic_job(self):
|
||||
"""测试创建基本任务"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
project_id="proj-123",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
)
|
||||
assert job.id
|
||||
assert len(job.id) == 32 # uuid4 hex
|
||||
assert job.project_id == "proj-1"
|
||||
|
||||
assert job.id is not None
|
||||
assert len(job.id) > 0
|
||||
assert job.project_id == "proj-123"
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.progress == 0.0
|
||||
assert job.payload == {}
|
||||
assert job.result == {}
|
||||
assert job.error_message == ""
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.created_at
|
||||
assert job.updated_at
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
"""测试创建带所有参数的任务"""
|
||||
job = Job.create(
|
||||
project_id="proj-456",
|
||||
job_type=JobType.GENERATION,
|
||||
payload={"key": "value"},
|
||||
source_id="src-789",
|
||||
created_by_user_id="user-001",
|
||||
max_retries=5,
|
||||
)
|
||||
|
||||
assert job.project_id == "proj-456"
|
||||
assert job.job_type == JobType.GENERATION
|
||||
assert job.payload == {"key": "value"}
|
||||
assert job.source_id == "src-789"
|
||||
assert job.created_by_user_id == "user-001"
|
||||
assert job.max_retries == 5
|
||||
|
||||
def test_create_with_string_job_type(self):
|
||||
"""用字符串创建任务类型"""
|
||||
"""测试用字符串创建任务"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
project_id="proj-123",
|
||||
job_type="video_compose",
|
||||
)
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
|
||||
def test_create_invalid_string_job_type_raises(self):
|
||||
"""无效的任务类型字符串抛 ValueError"""
|
||||
def test_create_with_invalid_job_type(self):
|
||||
"""测试无效任务类型"""
|
||||
with pytest.raises(ValueError, match="不支持的任务类型"):
|
||||
Job.create(project_id="proj-1", job_type="invalid_type")
|
||||
Job.create(
|
||||
project_id="proj-123",
|
||||
job_type="invalid_type",
|
||||
)
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
"""空 project_id 抛 ValueError"""
|
||||
def test_create_empty_project_id(self):
|
||||
"""测试空 project_id"""
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
|
||||
Job.create(
|
||||
project_id="",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
)
|
||||
|
||||
def test_create_with_payload(self):
|
||||
"""带 payload 创建"""
|
||||
payload = {"video_id": "v1", "quality": "1080p"}
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
payload=payload,
|
||||
)
|
||||
assert job.payload == payload
|
||||
def test_create_whitespace_project_id(self):
|
||||
"""测试空白 project_id 被 strip 后为空"""
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
Job.create(
|
||||
project_id=" ",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
)
|
||||
|
||||
def test_create_with_source_id(self):
|
||||
"""带 source_id 创建"""
|
||||
def test_create_strips_strings(self):
|
||||
"""测试字符串字段被 strip"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
project_id=" proj-123 ",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
source_id="plan-123",
|
||||
source_id=" src-456 ",
|
||||
created_by_user_id=" user-789 ",
|
||||
)
|
||||
assert job.source_id == "plan-123"
|
||||
assert job.project_id == "proj-123"
|
||||
assert job.source_id == "src-456"
|
||||
assert job.created_by_user_id == "user-789"
|
||||
|
||||
def test_create_with_created_by(self):
|
||||
"""带创建人"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
assert job.created_by_user_id == "user-1"
|
||||
|
||||
def test_create_with_custom_max_retries(self):
|
||||
"""自定义最大重试次数"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
max_retries=5,
|
||||
)
|
||||
assert job.max_retries == 5
|
||||
|
||||
def test_create_project_id_stripped(self):
|
||||
"""project_id 会被 strip"""
|
||||
job = Job.create(
|
||||
project_id=" proj-1 ",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
)
|
||||
assert job.project_id == "proj-1"
|
||||
|
||||
def test_create_source_id_stripped(self):
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
source_id=" src-1 ",
|
||||
)
|
||||
assert job.source_id == "src-1"
|
||||
|
||||
def test_create_created_by_stripped(self):
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
created_by_user_id=" user-1 ",
|
||||
)
|
||||
assert job.created_by_user_id == "user-1"
|
||||
|
||||
def test_create_none_payload_defaults_to_empty_dict(self):
|
||||
"""payload=None 时默认为空 dict"""
|
||||
job = Job.create(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
payload=None,
|
||||
)
|
||||
def test_create_default_payload(self):
|
||||
"""测试 None payload 默认化为空 dict"""
|
||||
job = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE, payload=None)
|
||||
assert job.payload == {}
|
||||
|
||||
def test_create_generates_unique_ids(self):
|
||||
"""测试每次创建生成不同的 ID"""
|
||||
job1 = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
job2 = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job1.id != job2.id
|
||||
|
||||
class TestJobIsTerminal:
|
||||
"""is_terminal 属性测试"""
|
||||
def test_create_sets_timestamps(self):
|
||||
"""测试创建时设置时间戳"""
|
||||
before = datetime.now(timezone.utc)
|
||||
time.sleep(0.01)
|
||||
job = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
time.sleep(0.01)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
def test_pending_not_terminal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.is_terminal is False
|
||||
|
||||
def test_running_not_terminal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.is_terminal is False
|
||||
|
||||
def test_success_is_terminal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.is_terminal is True
|
||||
|
||||
def test_failed_is_terminal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.is_terminal is True
|
||||
|
||||
def test_cancelled_is_terminal(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.is_terminal is True
|
||||
assert before < job.created_at < after
|
||||
assert before < job.updated_at < after
|
||||
|
||||
|
||||
class TestJobTransitions:
|
||||
"""状态转换测试"""
|
||||
class TestJobStateTransitions:
|
||||
"""Job 状态转换测试"""
|
||||
|
||||
def test_pending_to_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.started_at is not None
|
||||
@pytest.fixture
|
||||
def new_job(self):
|
||||
return Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
|
||||
def test_pending_to_success(self):
|
||||
"""pending 可以直接到 success(快速成功)"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.completed_at is not None
|
||||
# ===== Pending → Running =====
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
def test_pending_to_running(self, new_job):
|
||||
"""测试 pending → running"""
|
||||
assert new_job.status == JobStatus.PENDING
|
||||
|
||||
def test_running_to_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.completed_at is not None
|
||||
new_job.mark_running()
|
||||
|
||||
def test_running_to_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert job.completed_at is not None
|
||||
assert new_job.status == JobStatus.RUNNING
|
||||
assert new_job.started_at is not None
|
||||
assert new_job.completed_at is None
|
||||
assert not new_job.is_terminal
|
||||
|
||||
def test_running_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
def test_pending_to_running_with_stage(self, new_job):
|
||||
"""测试 pending → running 带阶段描述"""
|
||||
new_job.mark_running(stage="初始化")
|
||||
assert new_job.current_stage == "初始化"
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
"""失败后可以回到 pending(重试)"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
assert job.status == JobStatus.PENDING
|
||||
# ===== Pending → Success =====
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
"""非法状态转换抛 ValueError"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
# pending 不能直接到 failed
|
||||
def test_pending_to_success(self, new_job):
|
||||
"""测试 pending → success(直接成功)"""
|
||||
new_job.mark_success()
|
||||
|
||||
assert new_job.status == JobStatus.SUCCESS
|
||||
assert new_job.progress == 100.0
|
||||
assert new_job.current_stage == "完成"
|
||||
assert new_job.completed_at is not None
|
||||
assert new_job.is_terminal
|
||||
|
||||
def test_pending_to_success_with_result(self, new_job):
|
||||
"""测试 pending → success 带结果"""
|
||||
result = {"output_url": "http://example.com/video.mp4"}
|
||||
new_job.mark_success(result=result)
|
||||
|
||||
assert new_job.result == result
|
||||
|
||||
# ===== Pending → Cancelled =====
|
||||
|
||||
def test_pending_to_cancelled(self, new_job):
|
||||
"""测试 pending → cancelled"""
|
||||
new_job.mark_cancelled()
|
||||
|
||||
assert new_job.status == JobStatus.CANCELLED
|
||||
assert new_job.current_stage == "已取消"
|
||||
assert new_job.is_terminal
|
||||
|
||||
# ===== Running → Success =====
|
||||
|
||||
def test_running_to_success(self, new_job):
|
||||
"""测试 running → success"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_success()
|
||||
|
||||
assert new_job.status == JobStatus.SUCCESS
|
||||
assert new_job.completed_at is not None
|
||||
assert new_job.progress == 100.0
|
||||
assert new_job.is_terminal
|
||||
|
||||
def test_running_to_success_preserves_started_at(self, new_job):
|
||||
"""测试 running → success 保留 started_at"""
|
||||
new_job.mark_running()
|
||||
started_at = new_job.started_at
|
||||
new_job.mark_success()
|
||||
|
||||
assert new_job.started_at == started_at
|
||||
|
||||
# ===== Running → Failed =====
|
||||
|
||||
def test_running_to_failed(self, new_job):
|
||||
"""测试 running → failed"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("Something went wrong")
|
||||
|
||||
assert new_job.status == JobStatus.FAILED
|
||||
assert new_job.error_message == "Something went wrong"
|
||||
assert new_job.current_stage == "失败"
|
||||
assert new_job.completed_at is not None
|
||||
assert new_job.is_terminal
|
||||
|
||||
# ===== Running → Cancelled =====
|
||||
|
||||
def test_running_to_cancelled(self, new_job):
|
||||
"""测试 running → cancelled"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_cancelled()
|
||||
|
||||
assert new_job.status == JobStatus.CANCELLED
|
||||
assert new_job.is_terminal
|
||||
|
||||
# ===== Failed → Pending (Retry) =====
|
||||
|
||||
def test_failed_to_pending_retry(self, new_job):
|
||||
"""测试 failed → pending(重试)"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error")
|
||||
assert new_job.retry_count == 0
|
||||
|
||||
new_job.prepare_retry()
|
||||
|
||||
assert new_job.status == JobStatus.PENDING
|
||||
assert new_job.retry_count == 1
|
||||
assert new_job.progress == 0.0
|
||||
assert new_job.error_message == ""
|
||||
assert new_job.started_at is None
|
||||
assert new_job.completed_at is None
|
||||
assert new_job.celery_task_id == ""
|
||||
assert "第 1 次重试" in new_job.current_stage
|
||||
|
||||
def test_retry_up_to_max_retries(self, new_job):
|
||||
"""测试最多重试 max_retries 次"""
|
||||
new_job.max_retries = 2
|
||||
new_job.mark_running()
|
||||
|
||||
# 第一次失败重试
|
||||
new_job.mark_failed("error 1")
|
||||
assert new_job.is_retryable # 失败后可重试
|
||||
new_job.prepare_retry()
|
||||
assert new_job.retry_count == 1
|
||||
|
||||
# 第二次失败重试
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error 2")
|
||||
assert new_job.is_retryable # retry_count=1 < max_retries=2
|
||||
new_job.prepare_retry()
|
||||
assert new_job.retry_count == 2
|
||||
|
||||
# 第三次失败后不可重试(retry_count == max_retries)
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error 3")
|
||||
assert not new_job.is_retryable # 达到上限
|
||||
with pytest.raises(ValueError, match="任务不可重试"):
|
||||
new_job.prepare_retry()
|
||||
|
||||
def test_retry_not_from_failed(self, new_job):
|
||||
"""测试非 failed 状态不可重试"""
|
||||
with pytest.raises(ValueError, match="任务不可重试"):
|
||||
new_job.prepare_retry() # pending 状态
|
||||
|
||||
# ===== 非法状态转换 =====
|
||||
|
||||
def test_invalid_transition_success_to_running(self, new_job):
|
||||
"""测试 success → running 非法"""
|
||||
new_job.mark_success()
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
new_job.mark_running()
|
||||
|
||||
def test_success_to_pending_raises(self):
|
||||
"""成功后不能回到 pending"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
with pytest.raises(ValueError):
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
def test_invalid_transition_cancelled_to_running(self, new_job):
|
||||
"""测试 cancelled → running 非法"""
|
||||
new_job.mark_cancelled()
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
new_job.mark_running()
|
||||
|
||||
def test_transition_with_string_status(self):
|
||||
"""用字符串做状态转换"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to("running")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
def test_invalid_transition_pending_to_failed(self, new_job):
|
||||
"""测试 pending → failed 非法(必须经过 running)"""
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
new_job.mark_failed("test error")
|
||||
|
||||
def test_transition_invalid_string_raises(self):
|
||||
"""无效状态字符串抛 ValueError"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
def test_invalid_status_string(self, new_job):
|
||||
"""测试无效状态字符串"""
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
job.transition_to("invalid_status")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
"""状态转换更新 updated_at"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.updated_at >= old_updated
|
||||
|
||||
def test_started_at_only_set_once(self):
|
||||
"""started_at 只在第一次 RUNNING 时设置"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
first_started = job.started_at
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
# 回到 pending 再 running(模拟重试场景,但started_at是None时才设置)
|
||||
# 注意:正常重试是通过 prepare_retry 重置的
|
||||
assert first_started is not None
|
||||
new_job.transition_to("invalid_status")
|
||||
|
||||
|
||||
class TestJobMarkMethods:
|
||||
"""便捷标记方法测试"""
|
||||
class TestJobProperties:
|
||||
"""Job 属性测试"""
|
||||
|
||||
def test_mark_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running("合成中")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == "合成中"
|
||||
@pytest.fixture
|
||||
def new_job(self):
|
||||
return Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
|
||||
def test_mark_running_no_stage(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == ""
|
||||
def test_is_terminal_pending(self, new_job):
|
||||
"""测试 pending 不是终态"""
|
||||
assert not new_job.is_terminal
|
||||
|
||||
def test_mark_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success({"output_url": "http://..."})
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.progress == 100.0
|
||||
assert job.current_stage == "完成"
|
||||
assert job.result == {"output_url": "http://..."}
|
||||
def test_is_terminal_running(self, new_job):
|
||||
"""测试 running 不是终态"""
|
||||
new_job.mark_running()
|
||||
assert not new_job.is_terminal
|
||||
|
||||
def test_mark_success_no_result(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success()
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.result == {}
|
||||
def test_is_terminal_success(self, new_job):
|
||||
"""测试 success 是终态"""
|
||||
new_job.mark_success()
|
||||
assert new_job.is_terminal
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_failed("网络超时")
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert job.error_message == "网络超时"
|
||||
assert job.current_stage == "失败"
|
||||
def test_is_terminal_failed(self, new_job):
|
||||
"""测试 failed 是终态"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error")
|
||||
assert new_job.is_terminal
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_cancelled()
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
assert job.current_stage == "已取消"
|
||||
def test_is_terminal_cancelled(self, new_job):
|
||||
"""测试 cancelled 是终态"""
|
||||
new_job.mark_cancelled()
|
||||
assert new_job.is_terminal
|
||||
|
||||
def test_is_retryable_failed_under_limit(self, new_job):
|
||||
"""测试失败且未达上限时可重试"""
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error")
|
||||
assert new_job.is_retryable
|
||||
|
||||
def test_is_retryable_failed_at_limit(self, new_job):
|
||||
"""测试失败且达上限时不可重试"""
|
||||
new_job.max_retries = 0
|
||||
new_job.mark_running()
|
||||
new_job.mark_failed("error")
|
||||
assert not new_job.is_retryable
|
||||
|
||||
def test_is_retryable_not_failed(self, new_job):
|
||||
"""测试非失败状态不可重试"""
|
||||
assert not new_job.is_retryable # pending
|
||||
new_job.mark_running()
|
||||
assert not new_job.is_retryable # running
|
||||
new_job.mark_success()
|
||||
assert not new_job.is_retryable # success
|
||||
|
||||
|
||||
class TestJobProgress:
|
||||
"""进度更新测试"""
|
||||
"""Job 进度更新测试"""
|
||||
|
||||
def test_update_progress(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(50.0, "渲染中")
|
||||
assert job.progress == 50.0
|
||||
assert job.current_stage == "渲染中"
|
||||
@pytest.fixture
|
||||
def running_job(self):
|
||||
job = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
return job
|
||||
|
||||
def test_update_progress_zero(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(0.0)
|
||||
assert job.progress == 0.0
|
||||
def test_update_progress_normal(self, running_job):
|
||||
"""测试正常更新进度"""
|
||||
running_job.update_progress(50.0, stage="处理中")
|
||||
assert running_job.progress == 50.0
|
||||
assert running_job.current_stage == "处理中"
|
||||
|
||||
def test_update_progress_100(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(100.0)
|
||||
assert job.progress == 100.0
|
||||
def test_update_progress_zero(self, running_job):
|
||||
"""测试更新进度为 0"""
|
||||
running_job.update_progress(0.0)
|
||||
assert running_job.progress == 0.0
|
||||
|
||||
def test_update_progress_negative_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
def test_update_progress_hundred(self, running_job):
|
||||
"""测试更新进度为 100"""
|
||||
running_job.update_progress(100.0)
|
||||
assert running_job.progress == 100.0
|
||||
|
||||
def test_update_progress_negative(self, running_job):
|
||||
"""测试负进度报错"""
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(-1.0)
|
||||
running_job.update_progress(-1.0)
|
||||
|
||||
def test_update_progress_over_100_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
def test_update_progress_over_hundred(self, running_job):
|
||||
"""测试超过 100 的进度报错"""
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(101.0)
|
||||
running_job.update_progress(101.0)
|
||||
|
||||
def test_update_progress_without_stage(self):
|
||||
"""不传 stage 时不修改 current_stage"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.current_stage = "初始阶段"
|
||||
job.update_progress(30.0)
|
||||
assert job.progress == 30.0
|
||||
assert job.current_stage == "初始阶段"
|
||||
def test_update_progress_without_stage(self, running_job):
|
||||
"""测试更新进度但不改变阶段"""
|
||||
running_job.current_stage = "初始阶段"
|
||||
running_job.update_progress(30.0)
|
||||
assert running_job.progress == 30.0
|
||||
assert running_job.current_stage == "初始阶段" # 保留原值
|
||||
|
||||
def test_update_progress_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.update_progress(50.0)
|
||||
assert job.updated_at >= old_updated
|
||||
|
||||
|
||||
class TestJobRetry:
|
||||
"""重试逻辑测试"""
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
"""失败且未超过重试上限时可重试"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.mark_running()
|
||||
job.mark_failed("错误")
|
||||
assert job.is_retryable is True
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
"""达到重试上限时不可重试"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
|
||||
job.mark_running()
|
||||
job.mark_failed("错误")
|
||||
job.retry_count = 1
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_is_retryable_pending_false(self):
|
||||
"""pending 状态不可重试"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_is_retryable_success_false(self):
|
||||
"""成功状态不可重试"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_success()
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_prepare_retry(self):
|
||||
"""准备重试"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.mark_running()
|
||||
job.mark_failed("网络错误")
|
||||
job.celery_task_id = "task-123"
|
||||
|
||||
job.prepare_retry()
|
||||
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.progress == 0.0
|
||||
assert "第 1 次重试" in job.current_stage
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
assert job.celery_task_id == ""
|
||||
|
||||
def test_prepare_retry_not_retryable_raises(self):
|
||||
"""不可重试时抛 ValueError"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=0)
|
||||
job.mark_running()
|
||||
job.mark_failed("错误")
|
||||
with pytest.raises(ValueError, match="任务不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_prepare_retry_increments_correctly(self):
|
||||
"""多次重试计数正确"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.mark_running()
|
||||
job.mark_failed("错误1")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 1
|
||||
|
||||
job.mark_running()
|
||||
job.mark_failed("错误2")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 2
|
||||
def test_update_progress_updates_updated_at(self, running_job):
|
||||
"""测试更新进度会更新 updated_at"""
|
||||
old_updated = running_job.updated_at
|
||||
time.sleep(0.01)
|
||||
running_job.update_progress(50.0)
|
||||
assert running_job.updated_at > old_updated
|
||||
|
||||
|
||||
class TestJobToDict:
|
||||
"""to_dict 序列化测试"""
|
||||
"""Job 序列化测试"""
|
||||
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
def test_to_dict_pending_job(self):
|
||||
"""测试 pending 状态的 Job 序列化为字典"""
|
||||
job = Job.create(
|
||||
project_id="p1",
|
||||
project_id="proj-123",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
payload={"key": "value"},
|
||||
source_id="src-1",
|
||||
created_by_user_id="user-1",
|
||||
payload={"input": "data"},
|
||||
source_id="src-456",
|
||||
)
|
||||
d = job.to_dict()
|
||||
|
||||
assert d["id"] == job.id
|
||||
assert d["project_id"] == "p1"
|
||||
assert d["project_id"] == "proj-123"
|
||||
assert d["job_type"] == "video_compose"
|
||||
assert d["status"] == "pending"
|
||||
assert d["progress"] == 0.0
|
||||
assert d["payload"] == {"key": "value"}
|
||||
assert d["source_id"] == "src-1"
|
||||
assert d["created_by_user_id"] == "user-1"
|
||||
assert d["payload"] == {"input": "data"}
|
||||
assert d["result"] == {}
|
||||
assert d["error_message"] == ""
|
||||
assert d["retry_count"] == 0
|
||||
assert d["max_retries"] == 3
|
||||
assert d["source_id"] == "src-456"
|
||||
assert d["is_retryable"] is False
|
||||
|
||||
def test_to_dict_datetime_fields_are_strings(self):
|
||||
"""时间字段序列化为 ISO 字符串"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
d = job.to_dict()
|
||||
assert isinstance(d["created_at"], str)
|
||||
assert isinstance(d["updated_at"], str)
|
||||
|
||||
def test_to_dict_none_datetime_fields(self):
|
||||
"""未设置的时间字段为 None"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
d = job.to_dict()
|
||||
assert d["started_at"] is None
|
||||
assert d["completed_at"] is None
|
||||
assert d["created_at"] is not None
|
||||
assert d["updated_at"] is not None
|
||||
|
||||
def test_to_dict_after_success(self):
|
||||
"""成功后 to_dict 状态正确"""
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
def test_to_dict_completed_job(self):
|
||||
"""测试完成状态的 Job 序列化为字典"""
|
||||
job = Job.create(project_id="proj-123", job_type=JobType.GENERATION)
|
||||
job.mark_running()
|
||||
job.mark_success({"url": "http://..."})
|
||||
job.mark_success(result={"output": "result"})
|
||||
d = job.to_dict()
|
||||
|
||||
assert d["status"] == "success"
|
||||
assert d["progress"] == 100.0
|
||||
assert d["result"] == {"url": "http://..."}
|
||||
assert d["result"] == {"output": "result"}
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
|
||||
def test_to_dict_failed_job(self):
|
||||
"""测试失败状态的 Job 序列化为字典"""
|
||||
job = Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
job.mark_failed("timeout error")
|
||||
d = job.to_dict()
|
||||
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "timeout error"
|
||||
assert d["is_retryable"] is True
|
||||
|
||||
|
||||
class TestTransitionTimestamps:
|
||||
"""状态转换时间戳测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def new_job(self):
|
||||
return Job.create(project_id="proj-123", job_type=JobType.VIDEO_COMPOSE)
|
||||
|
||||
def test_mark_running_sets_started_at(self, new_job):
|
||||
"""测试 mark_running 设置 started_at"""
|
||||
assert new_job.started_at is None
|
||||
new_job.mark_running()
|
||||
assert new_job.started_at is not None
|
||||
assert isinstance(new_job.started_at, datetime)
|
||||
assert new_job.started_at.tzinfo is not None
|
||||
|
||||
def test_mark_running_twice_preserves_started_at(self, new_job):
|
||||
"""测试再次 mark_running 不覆盖 started_at"""
|
||||
# 先手动转换到 running
|
||||
new_job.transition_to(JobStatus.RUNNING)
|
||||
first_started = new_job.started_at
|
||||
|
||||
# 不能直接再调 mark_running(会报错),但可以验证 started_at 不被重复设置
|
||||
# transition_to 已经处理了 started_at is None 的逻辑
|
||||
assert first_started == new_job.started_at
|
||||
|
||||
def test_mark_success_sets_completed_at(self, new_job):
|
||||
"""测试 mark_success 设置 completed_at"""
|
||||
new_job.mark_running()
|
||||
assert new_job.completed_at is None
|
||||
new_job.mark_success()
|
||||
assert new_job.completed_at is not None
|
||||
|
||||
def test_mark_failed_sets_completed_at(self, new_job):
|
||||
"""测试 mark_failed 设置 completed_at"""
|
||||
new_job.mark_running()
|
||||
assert new_job.completed_at is None
|
||||
new_job.mark_failed("error")
|
||||
assert new_job.completed_at is not None
|
||||
|
||||
def test_transition_updates_updated_at(self, new_job):
|
||||
"""测试每次状态转换都更新 updated_at"""
|
||||
old_updated = new_job.updated_at
|
||||
time.sleep(0.01)
|
||||
new_job.mark_running()
|
||||
assert new_job.updated_at > old_updated
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
多轨道混音引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 AudioTrack.from_dict / MultiTrackMixConfig.from_config_dict / has_effect 等纯逻辑.
|
||||
引擎核心混音方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.multi_track_mixer import (
|
||||
DEFAULT_VOLUMES,
|
||||
MAX_AUDIO_TRACKS,
|
||||
TRACK_TYPE_AMBIENT,
|
||||
TRACK_TYPE_BGM,
|
||||
TRACK_TYPE_MAIN,
|
||||
TRACK_TYPE_SFX,
|
||||
TRACK_TYPE_VOICEOVER,
|
||||
AudioTrack,
|
||||
MultiTrackMixConfig,
|
||||
)
|
||||
|
||||
|
||||
class TestTrackConstants:
|
||||
"""轨道类型常量与默认值."""
|
||||
|
||||
def test_track_types_exist(self):
|
||||
assert TRACK_TYPE_MAIN == "main"
|
||||
assert TRACK_TYPE_BGM == "bgm"
|
||||
assert TRACK_TYPE_VOICEOVER == "voiceover"
|
||||
assert TRACK_TYPE_SFX == "sfx"
|
||||
assert TRACK_TYPE_AMBIENT == "ambient"
|
||||
|
||||
def test_max_tracks(self):
|
||||
assert MAX_AUDIO_TRACKS == 8
|
||||
|
||||
def test_default_volumes(self):
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
|
||||
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
|
||||
|
||||
|
||||
class TestAudioTrackFromDict:
|
||||
"""AudioTrack.from_dict 构造逻辑."""
|
||||
|
||||
def test_basic(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
}
|
||||
)
|
||||
assert track.track_id == "t1"
|
||||
assert track.track_type == "bgm"
|
||||
assert track.audio_path == "/tmp/bgm.mp3"
|
||||
assert track.volume == 0.3 # bgm 默认音量
|
||||
|
||||
def test_custom_volume(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "main",
|
||||
"audio_path": "/tmp/main.wav",
|
||||
"volume": 0.8,
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.8
|
||||
|
||||
def test_volume_clamped_to_zero(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "sfx",
|
||||
"audio_path": "/tmp/sfx.wav",
|
||||
"volume": -1.0,
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.0
|
||||
|
||||
def test_volume_clamped_to_max(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "sfx",
|
||||
"audio_path": "/tmp/sfx.wav",
|
||||
"volume": 3.0,
|
||||
}
|
||||
)
|
||||
assert track.volume == 2.0
|
||||
|
||||
def test_invalid_volume_falls_back_to_default(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"volume": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.3 # bgm 默认
|
||||
|
||||
def test_none_volume_falls_back(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "voiceover",
|
||||
"audio_path": "/tmp/vo.wav",
|
||||
"volume": None,
|
||||
}
|
||||
)
|
||||
assert track.volume == 1.0 # voiceover 默认
|
||||
|
||||
def test_unknown_track_type_default_volume(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "unknown_type",
|
||||
"audio_path": "/tmp/a.wav",
|
||||
}
|
||||
)
|
||||
assert track.volume == 1.0 # 未知类型默认 1.0
|
||||
|
||||
def test_fade_in_fade_out(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"fade_in": 1.5,
|
||||
"fade_out": 2.0,
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 1.5
|
||||
assert track.fade_out == 2.0
|
||||
|
||||
def test_negative_fade_clamped(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"fade_in": -0.5,
|
||||
"fade_out": -1.0,
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 0.0
|
||||
assert track.fade_out == 0.0
|
||||
|
||||
def test_invalid_fade_falls_back(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"fade_in": "abc",
|
||||
"fade_out": None,
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 0.0
|
||||
assert track.fade_out == 0.0
|
||||
|
||||
def test_start_time_and_duration(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "sfx",
|
||||
"audio_path": "/tmp/sfx.wav",
|
||||
"start_time": 5.0,
|
||||
"duration": 3.0,
|
||||
}
|
||||
)
|
||||
assert track.start_time == 5.0
|
||||
assert track.duration == 3.0
|
||||
|
||||
def test_negative_start_time_clamped(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"start_time": -10.0,
|
||||
"duration": -2.0,
|
||||
}
|
||||
)
|
||||
assert track.start_time == 0.0
|
||||
assert track.duration == 0.0
|
||||
|
||||
def test_invalid_time_values_fall_back(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"start_time": "invalid",
|
||||
"duration": "bad",
|
||||
}
|
||||
)
|
||||
assert track.start_time == 0.0
|
||||
assert track.duration == 0.0
|
||||
|
||||
def test_enabled_default_true(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
}
|
||||
)
|
||||
assert track.enabled is True
|
||||
|
||||
def test_enabled_can_be_false(self):
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
assert track.enabled is False
|
||||
|
||||
|
||||
class TestMultiTrackMixConfigFromDict:
|
||||
"""MultiTrackMixConfig.from_config_dict 构造逻辑."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(None)
|
||||
assert cfg.tracks == []
|
||||
assert cfg.master_volume == 1.0
|
||||
assert cfg.normalize is True
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict({})
|
||||
assert cfg.tracks == []
|
||||
|
||||
def test_non_dict_returns_default(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict([])
|
||||
assert cfg.tracks == []
|
||||
|
||||
def test_single_track(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{
|
||||
"track_id": "bgm1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/tmp/bgm.mp3",
|
||||
"volume": 0.5,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.tracks) == 1
|
||||
assert cfg.tracks[0].track_id == "bgm1"
|
||||
assert cfg.tracks[0].volume == 0.5
|
||||
|
||||
def test_multiple_tracks(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "m", "track_type": "main", "audio_path": "/tmp/m.wav"},
|
||||
{"track_id": "b", "track_type": "bgm", "audio_path": "/tmp/b.mp3"},
|
||||
{"track_id": "v", "track_type": "voiceover", "audio_path": "/tmp/v.wav"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.tracks) == 3
|
||||
assert cfg.tracks[0].track_type == "main"
|
||||
assert cfg.tracks[1].track_type == "bgm"
|
||||
assert cfg.tracks[2].track_type == "voiceover"
|
||||
|
||||
def test_disabled_tracks_filtered(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "a", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
|
||||
{"track_id": "b", "track_type": "sfx", "audio_path": "/tmp/b.wav", "enabled": False},
|
||||
{"track_id": "c", "track_type": "sfx", "audio_path": "/tmp/c.wav"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.tracks) == 2
|
||||
assert all(t.track_id != "b" for t in cfg.tracks)
|
||||
|
||||
def test_empty_audio_path_filtered(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "valid", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
|
||||
{"track_id": "empty", "track_type": "sfx", "audio_path": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.tracks) == 1
|
||||
assert cfg.tracks[0].track_id == "valid"
|
||||
|
||||
def test_invalid_tracks_skipped(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "ok", "track_type": "sfx", "audio_path": "/tmp/a.wav"},
|
||||
"not_a_dict",
|
||||
None,
|
||||
{"no_audio_path": "xxx"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.tracks) == 1
|
||||
|
||||
def test_tracks_not_a_list(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": "not_a_list",
|
||||
}
|
||||
)
|
||||
assert cfg.tracks == []
|
||||
|
||||
def test_master_volume(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [],
|
||||
"master_volume": 0.8,
|
||||
}
|
||||
)
|
||||
assert cfg.master_volume == 0.8
|
||||
|
||||
def test_master_volume_clamped(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [],
|
||||
"master_volume": 3.0,
|
||||
}
|
||||
)
|
||||
assert cfg.master_volume == 2.0
|
||||
|
||||
cfg2 = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [],
|
||||
"master_volume": -1.0,
|
||||
}
|
||||
)
|
||||
assert cfg2.master_volume == 0.0
|
||||
|
||||
def test_invalid_master_volume_falls_back(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [],
|
||||
"master_volume": "abc",
|
||||
}
|
||||
)
|
||||
assert cfg.master_volume == 1.0
|
||||
|
||||
def test_normalize_and_max_output(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [],
|
||||
"normalize": False,
|
||||
"max_output_volume": 2.0,
|
||||
}
|
||||
)
|
||||
assert cfg.normalize is False
|
||||
assert cfg.max_output_volume == 2.0
|
||||
|
||||
def test_default_values(self):
|
||||
cfg = MultiTrackMixConfig.from_config_dict({"tracks": []})
|
||||
assert cfg.master_volume == 1.0
|
||||
assert cfg.normalize is True
|
||||
assert cfg.max_output_volume == 1.5
|
||||
|
||||
|
||||
class TestMultiTrackMixConfigProperties:
|
||||
"""has_effect 属性."""
|
||||
|
||||
def test_has_effect_with_tracks(self):
|
||||
cfg = MultiTrackMixConfig(
|
||||
tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/tmp/a.mp3"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is True
|
||||
|
||||
def test_no_effect_empty(self):
|
||||
cfg = MultiTrackMixConfig(tracks=[])
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_no_effect_all_disabled(self):
|
||||
cfg = MultiTrackMixConfig(
|
||||
tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/tmp/a.mp3", enabled=False),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_no_effect_empty_paths(self):
|
||||
cfg = MultiTrackMixConfig(
|
||||
tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path=""),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
+359
-232
@@ -1,11 +1,12 @@
|
||||
"""Quota 领域层单元测试 - quota.py"""
|
||||
"""
|
||||
Quota 配额系统单元测试
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.quota import (
|
||||
QUOTA_TIERS,
|
||||
QuotaChecker,
|
||||
QuotaCheckResult,
|
||||
QuotaDimension,
|
||||
@@ -19,103 +20,134 @@ from packages.domain.quota import (
|
||||
|
||||
|
||||
class TestQuotaDimension:
|
||||
"""QuotaDimension 枚举测试"""
|
||||
"""配额维度枚举测试"""
|
||||
|
||||
def test_all_dimensions_have_values(self):
|
||||
"""所有枚举成员都有字符串值"""
|
||||
for dim in QuotaDimension:
|
||||
assert isinstance(dim.value, str)
|
||||
assert dim.value
|
||||
|
||||
def test_dimension_count(self):
|
||||
"""配额维度数量 >= 内置维度"""
|
||||
# 至少有 storage_gb, videos_per_month, max_concurrent, max_templates 等
|
||||
assert len(QuotaDimension) >= 7
|
||||
|
||||
def test_str_enum_behavior(self):
|
||||
"""是 str 枚举,可直接当字符串用"""
|
||||
def test_builtin_dimensions_exist(self):
|
||||
"""测试内置维度存在"""
|
||||
assert QuotaDimension.STORAGE_GB == "storage_gb"
|
||||
assert QuotaDimension.VIDEOS_PER_MONTH == "videos_per_month"
|
||||
assert QuotaDimension.MAX_CONCURRENT == "max_concurrent"
|
||||
assert QuotaDimension.MAX_TEMPLATES == "max_templates"
|
||||
assert QuotaDimension.MAX_TITLES == "max_titles"
|
||||
assert QuotaDimension.MAX_VOICEOVERS == "max_voiceovers"
|
||||
assert QuotaDimension.AI_VOICE_ENABLED == "ai_voice_enabled"
|
||||
|
||||
def test_extended_dimensions_exist(self):
|
||||
"""测试扩展维度存在"""
|
||||
assert QuotaDimension.AI_VOICE_CREDITS == "ai_voice_credits"
|
||||
assert QuotaDimension.BATCH_EXPORT_ENABLED == "batch_export_enabled"
|
||||
assert QuotaDimension.MULTI_PLATFORM_ENABLED == "multi_platform_enabled"
|
||||
assert QuotaDimension.DEDUP_REPORT_ENABLED == "dedup_report_enabled"
|
||||
|
||||
def test_dimension_is_string(self):
|
||||
"""测试枚举值是字符串"""
|
||||
assert isinstance(QuotaDimension.STORAGE_GB, str)
|
||||
assert QuotaDimension.STORAGE_GB == "storage_gb"
|
||||
|
||||
|
||||
class TestQuotaTier:
|
||||
"""QuotaTier 测试"""
|
||||
"""配额等级测试"""
|
||||
|
||||
def test_get_limit_defined(self):
|
||||
"""已定义的维度返回正确值"""
|
||||
tier = QuotaTier(name="test", limits={"storage": 10, "videos": 5})
|
||||
assert tier.get_limit("storage") == 10
|
||||
assert tier.get_limit("videos") == 5
|
||||
"""测试获取已定义的配额限制"""
|
||||
tier = QuotaTier(name="test", limits={"storage_gb": 10, "videos_per_month": 50})
|
||||
assert tier.get_limit("storage_gb") == 10
|
||||
assert tier.get_limit("videos_per_month") == 50
|
||||
|
||||
def test_get_limit_undefined_returns_zero(self):
|
||||
"""未定义的维度返回 0"""
|
||||
tier = QuotaTier(name="test", limits={"storage": 10})
|
||||
assert tier.get_limit("unknown") == 0
|
||||
"""测试未定义维度返回 0"""
|
||||
tier = QuotaTier(name="test", limits={"storage_gb": 10})
|
||||
assert tier.get_limit("unknown_dim") == 0
|
||||
|
||||
def test_is_unlimited_true(self):
|
||||
"""不限量判断 - inf"""
|
||||
def test_is_unlimited_with_inf(self):
|
||||
"""测试不限量判断(inf)"""
|
||||
tier = QuotaTier(name="test", limits={"templates": float("inf")})
|
||||
assert tier.is_unlimited("templates") is True
|
||||
|
||||
def test_is_unlimited_false(self):
|
||||
"""限量判断"""
|
||||
tier = QuotaTier(name="test", limits={"storage": 10})
|
||||
assert tier.is_unlimited("storage") is False
|
||||
def test_is_unlimited_with_finite(self):
|
||||
"""测试有限量判断"""
|
||||
tier = QuotaTier(name="test", limits={"storage_gb": 10})
|
||||
assert tier.is_unlimited("storage_gb") is False
|
||||
|
||||
def test_is_unlimited_undefined_returns_true(self):
|
||||
"""未定义的维度默认 inf,is_unlimited 返回 True"""
|
||||
def test_is_unlimited_undefined(self):
|
||||
"""测试未定义维度默认不限量(因为默认值是 inf)"""
|
||||
tier = QuotaTier(name="test", limits={})
|
||||
# get_limit 用 dict.get 默认 0,但 is_unlimited 用 dict.get 默认 inf
|
||||
assert tier.is_unlimited("unknown") is True
|
||||
# is_unlimited 使用 limits.get(dim, float("inf")) == float("inf")
|
||||
# 未定义时默认是 inf,所以返回 True
|
||||
assert tier.is_unlimited("undefined") is True
|
||||
|
||||
def test_default_limits_empty(self):
|
||||
"""测试默认 limits 为空 dict"""
|
||||
tier = QuotaTier(name="test")
|
||||
assert tier.limits == {}
|
||||
|
||||
|
||||
class TestQuotaTiers:
|
||||
"""内置套餐配额测试"""
|
||||
"""预定义配额等级测试"""
|
||||
|
||||
def test_free_tier_limits(self):
|
||||
"""测试 free 套餐限制"""
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
free = QUOTA_TIERS["free"]
|
||||
assert free.name == "free"
|
||||
assert free.get_limit("storage_gb") == 2
|
||||
assert free.get_limit("videos_per_month") == 5
|
||||
assert free.get_limit("max_concurrent") == 3
|
||||
assert free.get_limit("max_templates") == 3
|
||||
assert free.get_limit("max_titles") == 50
|
||||
assert free.get_limit("max_voiceovers") == 10
|
||||
assert free.get_limit("ai_voice_enabled") == 0
|
||||
assert free.get_limit("ai_voice_credits") == 0
|
||||
|
||||
def test_basic_tier_limits(self):
|
||||
"""测试 basic 套餐限制"""
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
basic = QUOTA_TIERS["basic"]
|
||||
assert basic.name == "basic"
|
||||
assert basic.get_limit("storage_gb") == 20
|
||||
assert basic.get_limit("videos_per_month") == 30
|
||||
assert basic.get_limit("max_concurrent") == 10
|
||||
assert basic.get_limit("max_templates") == 15
|
||||
assert basic.get_limit("max_titles") == 500
|
||||
assert basic.get_limit("max_voiceovers") == 100
|
||||
assert basic.get_limit("ai_voice_enabled") == 1
|
||||
assert basic.get_limit("ai_voice_credits") == 100
|
||||
assert basic.get_limit("batch_export_enabled") == 1
|
||||
|
||||
def test_premium_tier_limits(self):
|
||||
"""测试 premium 套餐限制"""
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
premium = QUOTA_TIERS["premium"]
|
||||
assert premium.name == "premium"
|
||||
assert premium.get_limit("storage_gb") == 100
|
||||
assert premium.get_limit("videos_per_month") == 100
|
||||
assert premium.get_limit("max_concurrent") == 20
|
||||
assert premium.is_unlimited("max_templates") is True
|
||||
assert premium.get_limit("max_titles") == 500
|
||||
assert premium.get_limit("max_voiceovers") == 100
|
||||
assert premium.get_limit("ai_voice_enabled") == 1
|
||||
assert premium.get_limit("ai_voice_credits") == 500
|
||||
assert premium.get_limit("batch_export_enabled") == 1
|
||||
assert premium.get_limit("multi_platform_enabled") == 1
|
||||
assert premium.get_limit("dedup_report_enabled") == 1
|
||||
|
||||
def test_three_tiers_exist(self):
|
||||
"""三个套餐等级都存在"""
|
||||
"""测试三个套餐等级都存在"""
|
||||
from packages.domain.quota import QUOTA_TIERS
|
||||
|
||||
assert "free" in QUOTA_TIERS
|
||||
assert "basic" in QUOTA_TIERS
|
||||
assert "premium" in QUOTA_TIERS
|
||||
|
||||
def test_free_tier_storage(self):
|
||||
"""free 套餐 2GB 存储"""
|
||||
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.STORAGE_GB) == 2
|
||||
|
||||
def test_basic_tier_storage(self):
|
||||
"""basic 套餐 20GB 存储"""
|
||||
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.STORAGE_GB) == 20
|
||||
|
||||
def test_premium_tier_storage(self):
|
||||
"""premium 套餐 100GB 存储"""
|
||||
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.STORAGE_GB) == 100
|
||||
|
||||
def test_free_no_ai_voice(self):
|
||||
"""free 套餐没有 AI 配音"""
|
||||
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 0
|
||||
|
||||
def test_basic_has_ai_voice(self):
|
||||
"""basic 套餐有 AI 配音"""
|
||||
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 1
|
||||
|
||||
def test_premium_templates_unlimited(self):
|
||||
"""premium 套餐模板不限量"""
|
||||
assert QUOTA_TIERS["premium"].is_unlimited(QuotaDimension.MAX_TEMPLATES) is True
|
||||
|
||||
def test_free_videos_per_month(self):
|
||||
"""free 每月 5 个视频"""
|
||||
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.VIDEOS_PER_MONTH) == 5
|
||||
|
||||
def test_premium_multi_platform_enabled(self):
|
||||
"""premium 支持多平台发布"""
|
||||
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.MULTI_PLATFORM_ENABLED) == 1
|
||||
|
||||
|
||||
class TestQuotaWarningLevel:
|
||||
"""告警级别常量测试"""
|
||||
"""告警级别测试"""
|
||||
|
||||
def test_level_values(self):
|
||||
"""四个告警级别都有定义"""
|
||||
def test_warning_level_values(self):
|
||||
"""测试告警级别常量值"""
|
||||
assert QuotaWarningLevel.NORMAL == "normal"
|
||||
assert QuotaWarningLevel.WARNING == "warning"
|
||||
assert QuotaWarningLevel.CRITICAL == "critical"
|
||||
@@ -123,25 +155,25 @@ class TestQuotaWarningLevel:
|
||||
|
||||
|
||||
class TestQuotaCheckResult:
|
||||
"""QuotaCheckResult 测试"""
|
||||
"""配额检查结果测试"""
|
||||
|
||||
def test_usage_percent_normal(self):
|
||||
"""正常使用百分比计算"""
|
||||
"""测试正常使用率计算"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="storage",
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=30,
|
||||
remaining=70,
|
||||
used=50,
|
||||
remaining=50,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 30.0
|
||||
assert result.usage_percent == 50.0
|
||||
|
||||
def test_usage_percent_capped_at_100(self):
|
||||
"""超过 100% 时截断为 100%"""
|
||||
def test_usage_percent_over_limit(self):
|
||||
"""测试超出限制时 capped at 100%"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=False,
|
||||
dimension="storage",
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=150,
|
||||
remaining=0,
|
||||
@@ -150,10 +182,10 @@ class TestQuotaCheckResult:
|
||||
assert result.usage_percent == 100.0
|
||||
|
||||
def test_usage_percent_zero_limit_with_usage(self):
|
||||
"""limit=0 但有使用量,返回 100%"""
|
||||
"""测试限制为 0 但有使用量时返回 100%"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=False,
|
||||
dimension="storage",
|
||||
dimension="ai_voice",
|
||||
limit=0,
|
||||
used=5,
|
||||
remaining=0,
|
||||
@@ -162,10 +194,10 @@ class TestQuotaCheckResult:
|
||||
assert result.usage_percent == 100.0
|
||||
|
||||
def test_usage_percent_zero_limit_no_usage(self):
|
||||
"""limit=0 且无使用量,返回 0%"""
|
||||
"""测试限制为 0 且无使用量时返回 0%"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="storage",
|
||||
dimension="ai_voice",
|
||||
limit=0,
|
||||
used=0,
|
||||
remaining=0,
|
||||
@@ -174,223 +206,283 @@ class TestQuotaCheckResult:
|
||||
assert result.usage_percent == 0.0
|
||||
|
||||
def test_usage_percent_unlimited(self):
|
||||
"""不限量时使用百分比为 0"""
|
||||
"""测试不限量时返回 0%"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=True,
|
||||
dimension="templates",
|
||||
limit=float("inf"),
|
||||
used=50,
|
||||
used=1000,
|
||||
remaining=float("inf"),
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 0.0
|
||||
|
||||
def test_usage_percent_exactly_100(self):
|
||||
"""测试刚好 100% 使用"""
|
||||
result = QuotaCheckResult(
|
||||
allowed=False,
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=100,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.EXCEEDED,
|
||||
)
|
||||
assert result.usage_percent == 100.0
|
||||
|
||||
|
||||
class TestQuotaRegistry:
|
||||
"""QuotaRegistry 测试"""
|
||||
"""配额注册表测试"""
|
||||
|
||||
def test_initial_dimensions(self):
|
||||
"""初始化时内置维度已注册"""
|
||||
def test_initial_builtin_dimensions(self):
|
||||
"""测试初始化后内置维度已注册"""
|
||||
registry = QuotaRegistry()
|
||||
dims = registry.list_dimensions()
|
||||
assert QuotaDimension.STORAGE_GB in dims
|
||||
assert QuotaDimension.VIDEOS_PER_MONTH in dims
|
||||
|
||||
def test_initial_tiers(self):
|
||||
"""初始化时三个套餐已注册"""
|
||||
registry = QuotaRegistry()
|
||||
tiers = registry.list_tiers()
|
||||
assert "free" in tiers
|
||||
assert "basic" in tiers
|
||||
assert "premium" in tiers
|
||||
assert "storage_gb" in dims
|
||||
assert "videos_per_month" in dims
|
||||
assert "max_concurrent" in dims
|
||||
assert "max_templates" in dims
|
||||
assert "max_titles" in dims
|
||||
assert "max_voiceovers" in dims
|
||||
assert "ai_voice_enabled" in dims
|
||||
|
||||
def test_register_new_dimension(self):
|
||||
"""注册新的配额维度"""
|
||||
"""测试注册新维度"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension("custom_dim", "自定义维度")
|
||||
|
||||
dims = registry.list_dimensions()
|
||||
assert "custom_dim" in dims
|
||||
assert dims["custom_dim"] == "自定义维度"
|
||||
|
||||
def test_register_dimension_with_default_limits(self):
|
||||
"""测试注册带默认限制的新维度"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension(
|
||||
"custom_feature",
|
||||
"自定义功能",
|
||||
default_limits={"free": 0, "basic": 1, "premium": 5},
|
||||
)
|
||||
|
||||
assert registry.get_limit("free", "custom_feature") == 0
|
||||
assert registry.get_limit("basic", "custom_feature") == 1
|
||||
assert registry.get_limit("premium", "custom_feature") == 5
|
||||
|
||||
def test_register_dimension_without_default_limits(self):
|
||||
"""测试注册不带默认限制的新维度(所有套餐默认 0)"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension("new_feature", "新功能")
|
||||
|
||||
assert registry.get_limit("free", "new_feature") == 0
|
||||
assert registry.get_limit("basic", "new_feature") == 0
|
||||
assert registry.get_limit("premium", "new_feature") == 0
|
||||
|
||||
def test_register_dimension_idempotent(self):
|
||||
"""重复注册是幂等的"""
|
||||
"""测试重复注册是幂等的"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension("custom", "描述1")
|
||||
registry.register_dimension("custom", "描述2")
|
||||
# 保留第一次注册的描述
|
||||
assert registry.list_dimensions()["custom"] == "描述1"
|
||||
registry.register_dimension("test_dim", "测试维度", default_limits={"free": 10})
|
||||
# 第二次注册不应该改变任何东西
|
||||
registry.register_dimension("test_dim", "另一个描述", default_limits={"free": 999})
|
||||
|
||||
def test_register_with_default_limits(self):
|
||||
"""注册时指定各套餐的默认限制"""
|
||||
dims = registry.list_dimensions()
|
||||
assert dims["test_dim"] == "测试维度" # 保留第一次的描述
|
||||
assert registry.get_limit("free", "test_dim") == 10 # 保留第一次的限制
|
||||
|
||||
def test_register_unknown_plan_ignored(self):
|
||||
"""测试未知套餐的默认限制被忽略"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension(
|
||||
"custom",
|
||||
"自定义",
|
||||
default_limits={"free": 1, "basic": 10, "premium": 100},
|
||||
"test_dim",
|
||||
"测试",
|
||||
default_limits={"free": 1, "enterprise": 100},
|
||||
)
|
||||
assert registry.get_limit("free", "custom") == 1
|
||||
assert registry.get_limit("basic", "custom") == 10
|
||||
assert registry.get_limit("premium", "custom") == 100
|
||||
|
||||
def test_register_without_default_limits_defaults_to_zero(self):
|
||||
"""不指定默认限制时各套餐该维度为 0"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension("custom_no_limit", "自定义")
|
||||
assert registry.get_limit("free", "custom_no_limit") == 0
|
||||
assert registry.get_limit("basic", "custom_no_limit") == 0
|
||||
|
||||
def test_register_default_limits_ignores_unknown_plan(self):
|
||||
"""默认限制中未知的套餐名被忽略"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension(
|
||||
"custom",
|
||||
"自定义",
|
||||
default_limits={"nonexistent": 999},
|
||||
)
|
||||
# 不报错,但也不会创建新套餐
|
||||
assert registry.get_tier("nonexistent") is None
|
||||
assert registry.get_limit("free", "test_dim") == 1
|
||||
# enterprise 套餐不存在,不影响
|
||||
assert "enterprise" not in registry.list_tiers()
|
||||
|
||||
def test_get_tier_existing(self):
|
||||
"""获取存在的套餐"""
|
||||
"""测试获取存在的套餐"""
|
||||
registry = QuotaRegistry()
|
||||
tier = registry.get_tier("free")
|
||||
assert tier is not None
|
||||
assert tier.name == "free"
|
||||
|
||||
def test_get_tier_nonexistent(self):
|
||||
"""获取不存在的套餐返回 None"""
|
||||
"""测试获取不存在的套餐返回 None"""
|
||||
registry = QuotaRegistry()
|
||||
assert registry.get_tier("enterprise") is None
|
||||
|
||||
def test_get_limit_existing(self):
|
||||
"""获取存在的套餐和维度的限制"""
|
||||
registry = QuotaRegistry()
|
||||
assert registry.get_limit("free", QuotaDimension.STORAGE_GB) == 2
|
||||
assert registry.get_tier("nonexistent") is None
|
||||
|
||||
def test_get_limit_nonexistent_plan(self):
|
||||
"""不存在的套餐返回 0"""
|
||||
"""测试不存在套餐的限制返回 0"""
|
||||
registry = QuotaRegistry()
|
||||
assert registry.get_limit("unknown", QuotaDimension.STORAGE_GB) == 0
|
||||
assert registry.get_limit("enterprise", "storage_gb") == 0
|
||||
|
||||
def test_list_dimensions_returns_copy(self):
|
||||
"""list_dimensions 返回副本,修改不影响内部"""
|
||||
registry = QuotaRegistry()
|
||||
dims = registry.list_dimensions()
|
||||
dims["fake"] = "fake"
|
||||
assert "fake" not in registry.list_dimensions()
|
||||
|
||||
def test_list_tiers_returns_all_three(self):
|
||||
"""列出所有套餐"""
|
||||
def test_list_tiers(self):
|
||||
"""测试列出所有套餐"""
|
||||
registry = QuotaRegistry()
|
||||
tiers = registry.list_tiers()
|
||||
assert "free" in tiers
|
||||
assert "basic" in tiers
|
||||
assert "premium" in tiers
|
||||
assert len(tiers) == 3
|
||||
assert set(tiers) == {"free", "basic", "premium"}
|
||||
|
||||
def test_list_dimensions_returns_copy(self):
|
||||
"""测试 list_dimensions 返回副本(修改不影响内部)"""
|
||||
registry = QuotaRegistry()
|
||||
dims = registry.list_dimensions()
|
||||
dims["fake_dim"] = "fake"
|
||||
|
||||
# 原始注册表不应被修改
|
||||
assert "fake_dim" not in registry.list_dimensions()
|
||||
|
||||
|
||||
class TestQuotaChecker:
|
||||
"""QuotaChecker 测试"""
|
||||
"""配额检查器测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def checker(self):
|
||||
return QuotaChecker()
|
||||
|
||||
# ===== 基础检查 =====
|
||||
|
||||
def test_check_free_storage_under_limit(self, checker):
|
||||
"""测试 free 套餐存储未超限"""
|
||||
result = checker.check("free", "storage_gb", 1.0)
|
||||
|
||||
def test_check_under_limit_allowed(self):
|
||||
"""使用量低于限制,允许"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
|
||||
assert result.allowed is True
|
||||
assert result.limit == 2
|
||||
assert result.used == 1.0
|
||||
assert result.remaining == 1.0
|
||||
assert result.warning_level == QuotaWarningLevel.NORMAL
|
||||
assert result.dimension == "storage_gb"
|
||||
|
||||
def test_check_free_storage_over_limit(self, checker):
|
||||
"""测试 free 套餐存储超限"""
|
||||
result = checker.check("free", "storage_gb", 3.0)
|
||||
|
||||
def test_check_at_limit_not_allowed(self):
|
||||
"""使用量等于限制,不允许(used < limit 判定)"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("free", QuotaDimension.STORAGE_GB, 2.0)
|
||||
assert result.allowed is False
|
||||
assert result.remaining == 0
|
||||
assert result.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_over_limit(self):
|
||||
"""使用量超过限制"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("free", QuotaDimension.STORAGE_GB, 3.0)
|
||||
def test_check_free_storage_exactly_at_limit(self, checker):
|
||||
"""测试刚好达到限制(不允许)"""
|
||||
result = checker.check("free", "storage_gb", 2.0)
|
||||
|
||||
# used < limit → 2 < 2 → False
|
||||
assert result.allowed is False
|
||||
assert result.remaining == 0
|
||||
assert result.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_warning_level_80_percent(self):
|
||||
"""80% 触发 WARNING"""
|
||||
checker = QuotaChecker()
|
||||
# 100GB 的 80% = 80GB
|
||||
result = checker.check("premium", QuotaDimension.STORAGE_GB, 80.0)
|
||||
assert result.warning_level == QuotaWarningLevel.WARNING
|
||||
# ===== 告警级别 =====
|
||||
|
||||
def test_check_warning_level_95_percent(self):
|
||||
"""95% 触发 CRITICAL"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("premium", QuotaDimension.STORAGE_GB, 95.0)
|
||||
assert result.warning_level == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_check_warning_level_exceeded(self):
|
||||
"""100% 及以上触发 EXCEEDED"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("premium", QuotaDimension.STORAGE_GB, 100.0)
|
||||
assert result.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_unlimited_always_allowed(self):
|
||||
"""不限量的维度始终允许"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("premium", QuotaDimension.MAX_TEMPLATES, 9999)
|
||||
assert result.allowed is True
|
||||
assert math.isinf(result.remaining)
|
||||
def test_warning_level_normal(self, checker):
|
||||
"""测试正常级别(< 80%)"""
|
||||
result = checker.check("free", "storage_gb", 1.0) # 50%
|
||||
assert result.warning_level == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_check_unknown_plan_zero_limit(self):
|
||||
"""未知套餐限制为 0,used=0 时不允许(0 < 0 为 False)"""
|
||||
checker = QuotaChecker()
|
||||
result = checker.check("unknown", QuotaDimension.STORAGE_GB, 0)
|
||||
assert result.limit == 0
|
||||
assert result.allowed is False
|
||||
def test_warning_level_warning(self, checker):
|
||||
"""测试警告级别(80% ~ 95%)"""
|
||||
result = checker.check("free", "storage_gb", 1.7) # 85%
|
||||
assert result.warning_level == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_check_multiple(self):
|
||||
"""批量检查多个维度"""
|
||||
checker = QuotaChecker()
|
||||
results = checker.check_multiple(
|
||||
"free",
|
||||
{
|
||||
QuotaDimension.STORAGE_GB: 1.0,
|
||||
QuotaDimension.VIDEOS_PER_MONTH: 3,
|
||||
},
|
||||
)
|
||||
assert len(results) == 2
|
||||
assert all(r.allowed for r in results)
|
||||
dims = {r.dimension for r in results}
|
||||
assert QuotaDimension.STORAGE_GB in dims
|
||||
assert QuotaDimension.VIDEOS_PER_MONTH in dims
|
||||
def test_warning_level_critical(self, checker):
|
||||
"""测试严重级别(95% ~ 100%)"""
|
||||
result = checker.check("free", "storage_gb", 1.95) # 97.5%
|
||||
assert result.warning_level == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_warning_level_exceeded(self, checker):
|
||||
"""测试超限级别(>= 100%)"""
|
||||
result = checker.check("free", "storage_gb", 2.0) # 100%
|
||||
assert result.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
# ===== 不限量 =====
|
||||
|
||||
def test_check_unlimited_templates_premium(self, checker):
|
||||
"""测试 premium 套餐模板不限量"""
|
||||
result = checker.check("premium", "max_templates", 9999)
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.limit == float("inf")
|
||||
assert result.remaining == float("inf")
|
||||
assert result.warning_level == QuotaWarningLevel.NORMAL
|
||||
|
||||
# ===== 0 限制 =====
|
||||
|
||||
def test_check_zero_limit_with_usage(self, checker):
|
||||
"""测试限制为 0 但有使用量"""
|
||||
result = checker.check("free", "ai_voice_enabled", 1)
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.warning_level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_check_zero_limit_no_usage(self, checker):
|
||||
"""测试限制为 0 且无使用量"""
|
||||
result = checker.check("free", "ai_voice_enabled", 0)
|
||||
|
||||
# used < limit → 0 < 0 → False? 让我们看看...
|
||||
# 实际上 0 < 0 是 False,所以 allowed = False
|
||||
# 但 warning_level: limit <= 0 and used == 0 → NORMAL
|
||||
# 等一下,看看代码逻辑:
|
||||
# if limit <= 0: return EXCEEDED if used > 0 else NORMAL
|
||||
assert result.warning_level == QuotaWarningLevel.NORMAL
|
||||
|
||||
# ===== 多维度检查 =====
|
||||
|
||||
def test_check_multiple(self, checker):
|
||||
"""测试批量检查多个维度"""
|
||||
usage = {
|
||||
"storage_gb": 1.0,
|
||||
"videos_per_month": 3,
|
||||
"max_concurrent": 2,
|
||||
}
|
||||
results = checker.check_multiple("free", usage)
|
||||
|
||||
assert len(results) == 3
|
||||
dims = {r.dimension: r for r in results}
|
||||
assert dims["storage_gb"].allowed is True
|
||||
assert dims["videos_per_month"].allowed is True
|
||||
assert dims["max_concurrent"].allowed is True
|
||||
|
||||
def test_check_multiple_some_exceeded(self, checker):
|
||||
"""测试批量检查中有超限的"""
|
||||
usage = {
|
||||
"storage_gb": 5.0, # 超限
|
||||
"videos_per_month": 3, # 正常
|
||||
}
|
||||
results = checker.check_multiple("free", usage)
|
||||
|
||||
dims = {r.dimension: r for r in results}
|
||||
assert dims["storage_gb"].allowed is False
|
||||
assert dims["videos_per_month"].allowed is True
|
||||
|
||||
# ===== 自定义 registry =====
|
||||
|
||||
def test_check_with_custom_registry(self):
|
||||
"""使用自定义注册表"""
|
||||
"""测试使用自定义 registry"""
|
||||
registry = QuotaRegistry()
|
||||
registry.register_dimension("custom", "自定义", default_limits={"free": 5})
|
||||
registry.register_dimension(
|
||||
"custom_feature",
|
||||
"自定义",
|
||||
default_limits={"free": 5, "basic": 20},
|
||||
)
|
||||
checker = QuotaChecker(registry)
|
||||
result = checker.check("free", "custom", 3)
|
||||
|
||||
result = checker.check("free", "custom_feature", 3)
|
||||
assert result.allowed is True
|
||||
assert result.limit == 5
|
||||
|
||||
def test_compute_warning_level_zero_limit_no_usage(self):
|
||||
"""limit=0, used=0 → NORMAL"""
|
||||
level = QuotaChecker._compute_warning_level(0, 0)
|
||||
assert level == QuotaWarningLevel.NORMAL
|
||||
result = checker.check("basic", "custom_feature", 25)
|
||||
assert result.allowed is False
|
||||
|
||||
def test_compute_warning_level_zero_limit_with_usage(self):
|
||||
"""limit=0, used>0 → EXCEEDED"""
|
||||
level = QuotaChecker._compute_warning_level(1, 0)
|
||||
assert level == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_compute_warning_level_negative_limit(self):
|
||||
"""limit<0 视同 0 处理"""
|
||||
level = QuotaChecker._compute_warning_level(1, -1)
|
||||
assert level == QuotaWarningLevel.EXCEEDED
|
||||
def test_check_unknown_plan(self, checker):
|
||||
"""测试未知套餐(限制为 0)"""
|
||||
result = checker.check("enterprise", "storage_gb", 1)
|
||||
assert result.allowed is False
|
||||
assert result.limit == 0
|
||||
|
||||
|
||||
class TestGetWarningLevel:
|
||||
"""get_warning_level 便捷函数测试"""
|
||||
"""便捷函数 get_warning_level 测试"""
|
||||
|
||||
def test_normal(self):
|
||||
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
|
||||
@@ -399,26 +491,61 @@ class TestGetWarningLevel:
|
||||
assert get_warning_level(85, 100) == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_critical(self):
|
||||
assert get_warning_level(97, 100) == QuotaWarningLevel.CRITICAL
|
||||
assert get_warning_level(96, 100) == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_exceeded(self):
|
||||
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
|
||||
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_zero_limit_with_usage(self):
|
||||
assert get_warning_level(5, 0) == QuotaWarningLevel.EXCEEDED
|
||||
|
||||
def test_zero_limit_no_usage(self):
|
||||
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_unlimited(self):
|
||||
assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_boundary_79_percent(self):
|
||||
"""测试 79% 仍是 normal"""
|
||||
assert get_warning_level(79, 100) == QuotaWarningLevel.NORMAL
|
||||
|
||||
def test_boundary_80_percent(self):
|
||||
"""测试 80% 是 warning"""
|
||||
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_boundary_94_percent(self):
|
||||
"""测试 94% 仍是 warning"""
|
||||
assert get_warning_level(94, 100) == QuotaWarningLevel.WARNING
|
||||
|
||||
def test_boundary_95_percent(self):
|
||||
"""测试 95% 是 critical"""
|
||||
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_boundary_99_percent(self):
|
||||
"""测试 99% 仍是 critical"""
|
||||
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
|
||||
|
||||
def test_zero_usage(self):
|
||||
"""测试 0 使用量"""
|
||||
assert get_warning_level(0, 100) == QuotaWarningLevel.NORMAL
|
||||
|
||||
|
||||
class TestGlobalSingletons:
|
||||
"""全局单例测试"""
|
||||
|
||||
def test_quota_registry_is_instance(self):
|
||||
def test_quota_registry_exists(self):
|
||||
"""测试全局 quota_registry 存在"""
|
||||
assert quota_registry is not None
|
||||
assert isinstance(quota_registry, QuotaRegistry)
|
||||
assert "free" in quota_registry.list_tiers()
|
||||
|
||||
def test_quota_checker_is_instance(self):
|
||||
def test_quota_checker_exists(self):
|
||||
"""测试全局 quota_checker 存在"""
|
||||
assert quota_checker is not None
|
||||
assert isinstance(quota_checker, QuotaChecker)
|
||||
|
||||
def test_global_checker_uses_global_registry(self):
|
||||
"""全局 checker 使用全局 registry"""
|
||||
# 验证能正常工作
|
||||
result = quota_checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
|
||||
assert result.allowed is True
|
||||
"""测试全局 checker 使用全局 registry"""
|
||||
result = quota_checker.check("free", "storage_gb", 1.0)
|
||||
assert result.limit == 2
|
||||
|
||||
+293
-250
@@ -1,17 +1,22 @@
|
||||
"""Recipe Use Cases 单测 — 配方业务逻辑."""
|
||||
"""配方 Recipe UseCase 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.recipe.commands import (
|
||||
CreateRecipeCommand,
|
||||
RecipeItemCommand,
|
||||
UpdateRecipeCommand,
|
||||
)
|
||||
from packages.application.recipe.use_cases import (
|
||||
CreateRecipeUseCase,
|
||||
DeleteRecipeUseCase,
|
||||
FeatureDisabledError,
|
||||
GetRecipeUseCase,
|
||||
ListRecipesUseCase,
|
||||
MissingAssetWarning,
|
||||
UpdateRecipeUseCase,
|
||||
UseRecipeResult,
|
||||
UseRecipeUseCase,
|
||||
@@ -20,35 +25,28 @@ from packages.domain.exceptions import NotFoundError
|
||||
from packages.domain.recipe import Recipe, RecipeItem
|
||||
|
||||
|
||||
# ── Fixtures / Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_recipe(**kwargs) -> Recipe:
|
||||
defaults = dict(
|
||||
id="recipe-1",
|
||||
user_id="user-1",
|
||||
name="Test Recipe",
|
||||
description="Test description",
|
||||
template_id="tpl-1",
|
||||
generation_params={},
|
||||
items=[],
|
||||
def _make_recipe(id: str, name: str, user_id: str = "user_1", item_count: int = 0) -> Recipe:
|
||||
items = [
|
||||
RecipeItem(
|
||||
id=f"item_{i}",
|
||||
recipe_id=id,
|
||||
item_type="asset",
|
||||
item_id=f"asset_{i}",
|
||||
position=i,
|
||||
)
|
||||
for i in range(item_count)
|
||||
]
|
||||
return Recipe(
|
||||
id=id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description="测试配方",
|
||||
template_id="tmpl_1",
|
||||
generation_params={"resolution": "1080p"},
|
||||
items=items,
|
||||
is_active=True,
|
||||
metadata_={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Recipe(**defaults)
|
||||
|
||||
|
||||
def make_recipe_item(**kwargs) -> RecipeItem:
|
||||
defaults = dict(
|
||||
id="item-1",
|
||||
recipe_id="recipe-1",
|
||||
item_type="video",
|
||||
item_id="asset-1",
|
||||
position=0,
|
||||
metadata_={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return RecipeItem(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -56,303 +54,348 @@ def mock_repo():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
# ── CreateRecipeUseCase ────────────────────────────────────────────────────
|
||||
class TestListRecipesUseCase:
|
||||
"""ListRecipesUseCase 测试"""
|
||||
|
||||
def test_list_returns_results(self, mock_repo):
|
||||
"""正常返回配方列表"""
|
||||
recipe = _make_recipe("r1", "配方1")
|
||||
mock_repo.list_by_user.return_value = [recipe]
|
||||
use_case = ListRecipesUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("user_1")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "r1"
|
||||
mock_repo.list_by_user.assert_called_once_with("user_1", skip=0, limit=50)
|
||||
|
||||
def test_list_with_pagination(self, mock_repo):
|
||||
"""带分页参数"""
|
||||
mock_repo.list_by_user.return_value = []
|
||||
use_case = ListRecipesUseCase(mock_repo)
|
||||
|
||||
use_case.execute("user_1", skip=5, limit=10)
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with("user_1", skip=5, limit=10)
|
||||
|
||||
def test_empty_list(self, mock_repo):
|
||||
"""空列表"""
|
||||
mock_repo.list_by_user.return_value = []
|
||||
use_case = ListRecipesUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("user_1")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetRecipeUseCase:
|
||||
"""GetRecipeUseCase 测试"""
|
||||
|
||||
def test_get_existing(self, mock_repo):
|
||||
"""获取存在的配方"""
|
||||
recipe = _make_recipe("r1", "配方1", item_count=3)
|
||||
mock_repo.get.return_value = recipe
|
||||
use_case = GetRecipeUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("r1", "user_1")
|
||||
|
||||
assert result is not None
|
||||
assert result.id == "r1"
|
||||
assert len(result.items) == 3
|
||||
mock_repo.get.assert_called_once_with("r1", "user_1")
|
||||
|
||||
def test_get_nonexistent_returns_none(self, mock_repo):
|
||||
"""获取不存在的配方返回 None"""
|
||||
mock_repo.get.return_value = None
|
||||
use_case = GetRecipeUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("noexist", "user_1")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCreateRecipeUseCase:
|
||||
"""创建配方."""
|
||||
"""CreateRecipeUseCase 测试"""
|
||||
|
||||
def test_create_without_items(self, mock_repo):
|
||||
mock_repo.create.return_value = make_recipe()
|
||||
uc = CreateRecipeUseCase(mock_repo)
|
||||
"""创建不带items的配方"""
|
||||
mock_repo.create.side_effect = lambda x: x
|
||||
use_case = CreateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import CreateRecipeCommand
|
||||
|
||||
cmd = CreateRecipeCommand(
|
||||
user_id="user-1",
|
||||
name="My Recipe",
|
||||
description="My description",
|
||||
template_id="tpl-1",
|
||||
generation_params={"key": "val"},
|
||||
command = CreateRecipeCommand(
|
||||
user_id="user_1",
|
||||
name="新配方",
|
||||
description="测试",
|
||||
template_id="tmpl_1",
|
||||
generation_params={"key": "value"},
|
||||
items=[],
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
result = use_case.execute(command)
|
||||
|
||||
assert result.id == "recipe-1"
|
||||
assert isinstance(result, Recipe)
|
||||
assert result.name == "新配方"
|
||||
assert result.user_id == "user_1"
|
||||
assert result.template_id == "tmpl_1"
|
||||
assert result.generation_params == {"key": "value"}
|
||||
assert result.items == []
|
||||
mock_repo.create.assert_called_once()
|
||||
mock_repo.create_items.assert_not_called()
|
||||
|
||||
def test_create_with_items(self, mock_repo):
|
||||
recipe = make_recipe()
|
||||
mock_repo.create.return_value = recipe
|
||||
uc = CreateRecipeUseCase(mock_repo)
|
||||
"""创建带items的配方"""
|
||||
mock_repo.create.side_effect = lambda x: x
|
||||
mock_repo.create_items.side_effect = lambda items: items
|
||||
use_case = CreateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import (
|
||||
CreateRecipeCommand,
|
||||
RecipeItemCommand,
|
||||
)
|
||||
|
||||
cmd = CreateRecipeCommand(
|
||||
user_id="user-1",
|
||||
name="My Recipe",
|
||||
description="",
|
||||
template_id="tpl-1",
|
||||
command = CreateRecipeCommand(
|
||||
user_id="user_1",
|
||||
name="带素材配方",
|
||||
items=[
|
||||
RecipeItemCommand(item_type="video", item_id="v1", position=0),
|
||||
RecipeItemCommand(item_type="audio", item_id="a1", position=1),
|
||||
RecipeItemCommand(item_type="asset", item_id="a1", position=0),
|
||||
RecipeItemCommand(item_type="title", item_id="t1", position=1),
|
||||
RecipeItemCommand(item_type="voice", item_id="v1", position=2),
|
||||
],
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
result = use_case.execute(command)
|
||||
|
||||
assert len(result.items) == 3
|
||||
assert result.items[0].item_type == "asset"
|
||||
assert result.items[1].item_type == "title"
|
||||
assert result.items[2].item_type == "voice"
|
||||
mock_repo.create.assert_called_once()
|
||||
mock_repo.create_items.assert_called_once()
|
||||
items_arg = mock_repo.create_items.call_args[0][0]
|
||||
assert len(items_arg) == 2
|
||||
assert items_arg[0].item_type == "video"
|
||||
assert items_arg[1].item_type == "audio"
|
||||
# items 被赋值到 recipe
|
||||
assert len(result.items) == 2
|
||||
created_items = mock_repo.create_items.call_args[0][0]
|
||||
assert len(created_items) == 3
|
||||
|
||||
def test_create_with_default_values(self, mock_repo):
|
||||
"""使用默认值创建"""
|
||||
mock_repo.create.side_effect = lambda x: x
|
||||
use_case = CreateRecipeUseCase(mock_repo)
|
||||
|
||||
# ── ListRecipesUseCase ─────────────────────────────────────────────────────
|
||||
command = CreateRecipeCommand(user_id="user_1", name="极简配方")
|
||||
result = use_case.execute(command)
|
||||
|
||||
|
||||
class TestListRecipesUseCase:
|
||||
"""列表查询."""
|
||||
|
||||
def test_list_passes_params(self, mock_repo):
|
||||
mock_repo.list_by_user.return_value = [make_recipe()]
|
||||
uc = ListRecipesUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("user-1", skip=10, limit=20)
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with(
|
||||
"user-1", skip=10, limit=20
|
||||
)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_list_default_params(self, mock_repo):
|
||||
mock_repo.list_by_user.return_value = []
|
||||
uc = ListRecipesUseCase(mock_repo)
|
||||
|
||||
uc.execute("user-1")
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with(
|
||||
"user-1", skip=0, limit=50
|
||||
)
|
||||
|
||||
|
||||
# ── GetRecipeUseCase ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetRecipeUseCase:
|
||||
"""单个查询."""
|
||||
|
||||
def test_get_found(self, mock_repo):
|
||||
mock_repo.get.return_value = make_recipe()
|
||||
uc = GetRecipeUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("recipe-1", "user-1")
|
||||
assert result.id == "recipe-1"
|
||||
mock_repo.get.assert_called_once_with("recipe-1", "user-1")
|
||||
|
||||
def test_get_not_found(self, mock_repo):
|
||||
mock_repo.get.return_value = None
|
||||
uc = GetRecipeUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── UpdateRecipeUseCase ────────────────────────────────────────────────────
|
||||
assert result.description == ""
|
||||
assert result.template_id == ""
|
||||
assert result.generation_params == {}
|
||||
assert result.items == []
|
||||
assert result.metadata_ == {}
|
||||
|
||||
|
||||
class TestUpdateRecipeUseCase:
|
||||
"""更新配方."""
|
||||
"""UpdateRecipeUseCase 测试"""
|
||||
|
||||
def test_update_basic_fields(self, mock_repo):
|
||||
existing = make_recipe(name="Old Name", description="Old desc")
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateRecipeUseCase(mock_repo)
|
||||
def test_update_name(self, mock_repo):
|
||||
"""更新配方名称"""
|
||||
recipe = _make_recipe("r1", "旧名称")
|
||||
mock_repo.get.return_value = recipe
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
mock_repo.list_items.return_value = []
|
||||
use_case = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import UpdateRecipeCommand
|
||||
command = UpdateRecipeCommand(recipe_id="r1", user_id="user_1", name="新名称")
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = UpdateRecipeCommand(
|
||||
recipe_id="recipe-1",
|
||||
user_id="user-1",
|
||||
name="New Name",
|
||||
description="New desc",
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.name == "New Name"
|
||||
assert result.description == "New desc"
|
||||
assert result.name == "新名称"
|
||||
# 其他不变
|
||||
assert result.description == "测试配方"
|
||||
assert result.template_id == "tmpl_1"
|
||||
mock_repo.get.assert_called_once_with("r1", "user_1")
|
||||
mock_repo.update.assert_called_once()
|
||||
# 没传items时从repository加载
|
||||
mock_repo.list_items.assert_called_once_with("r1")
|
||||
|
||||
def test_update_not_found_raises(self, mock_repo):
|
||||
mock_repo.get.return_value = None
|
||||
uc = UpdateRecipeUseCase(mock_repo)
|
||||
def test_update_multiple_fields(self, mock_repo):
|
||||
"""同时更新多个字段"""
|
||||
recipe = _make_recipe("r1", "旧")
|
||||
mock_repo.get.return_value = recipe
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
mock_repo.list_items.return_value = []
|
||||
use_case = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import UpdateRecipeCommand
|
||||
|
||||
cmd = UpdateRecipeCommand(
|
||||
recipe_id="nonexistent", user_id="user-1", name="X"
|
||||
command = UpdateRecipeCommand(
|
||||
recipe_id="r1",
|
||||
user_id="user_1",
|
||||
description="新描述",
|
||||
template_id="tmpl_new",
|
||||
generation_params={"new": "params"},
|
||||
)
|
||||
with pytest.raises(NotFoundError):
|
||||
uc.execute(cmd)
|
||||
mock_repo.update.assert_not_called()
|
||||
result = use_case.execute(command)
|
||||
|
||||
def test_update_template_and_params(self, mock_repo):
|
||||
existing = make_recipe(template_id="old-tpl", generation_params={"a": 1})
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateRecipeUseCase(mock_repo)
|
||||
assert result.description == "新描述"
|
||||
assert result.template_id == "tmpl_new"
|
||||
assert result.generation_params == {"new": "params"}
|
||||
|
||||
from packages.application.recipe.commands import UpdateRecipeCommand
|
||||
def test_update_items_replaces_old(self, mock_repo):
|
||||
"""更新items时删除旧的并创建新的"""
|
||||
recipe = _make_recipe("r1", "配方", item_count=2)
|
||||
mock_repo.get.return_value = recipe
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
mock_repo.create_items.side_effect = lambda items: items
|
||||
use_case = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
cmd = UpdateRecipeCommand(
|
||||
recipe_id="recipe-1",
|
||||
user_id="user-1",
|
||||
template_id="new-tpl",
|
||||
generation_params={"b": 2},
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.template_id == "new-tpl"
|
||||
assert result.generation_params == {"b": 2}
|
||||
|
||||
def test_update_replaces_items(self, mock_repo):
|
||||
"""提供 items 时,删除旧的并创建新的."""
|
||||
existing = make_recipe(items=[make_recipe_item(id="old-item")])
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import (
|
||||
UpdateRecipeCommand,
|
||||
RecipeItemCommand,
|
||||
)
|
||||
|
||||
cmd = UpdateRecipeCommand(
|
||||
recipe_id="recipe-1",
|
||||
user_id="user-1",
|
||||
command = UpdateRecipeCommand(
|
||||
recipe_id="r1",
|
||||
user_id="user_1",
|
||||
items=[
|
||||
RecipeItemCommand(item_type="video", item_id="v1", position=0),
|
||||
RecipeItemCommand(item_type="audio", item_id="a1", position=1),
|
||||
RecipeItemCommand(item_type="asset", item_id="new_a", position=0),
|
||||
RecipeItemCommand(item_type="title", item_id="new_t", position=1),
|
||||
],
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
result = use_case.execute(command)
|
||||
|
||||
mock_repo.delete_items_by_recipe.assert_called_once_with("recipe-1")
|
||||
mock_repo.delete_items_by_recipe.assert_called_once_with("r1")
|
||||
mock_repo.create_items.assert_called_once()
|
||||
items_arg = mock_repo.create_items.call_args[0][0]
|
||||
assert len(items_arg) == 2
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0].item_id == "new_a"
|
||||
|
||||
def test_update_without_items_reloads_from_repo(self, mock_repo):
|
||||
"""不提供 items 时,从 repository 加载."""
|
||||
existing = make_recipe()
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
mock_repo.list_items.return_value = [make_recipe_item()]
|
||||
uc = UpdateRecipeUseCase(mock_repo)
|
||||
def test_update_empty_items_list(self, mock_repo):
|
||||
"""更新为空items列表也会替换"""
|
||||
recipe = _make_recipe("r1", "配方", item_count=3)
|
||||
mock_repo.get.return_value = recipe
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
mock_repo.create_items.return_value = []
|
||||
use_case = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
from packages.application.recipe.commands import UpdateRecipeCommand
|
||||
command = UpdateRecipeCommand(recipe_id="r1", user_id="user_1", items=[])
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = UpdateRecipeCommand(
|
||||
recipe_id="recipe-1", user_id="user-1", name="New Name"
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
mock_repo.delete_items_by_recipe.assert_called_once()
|
||||
mock_repo.create_items.assert_called_once_with([])
|
||||
assert result.items == []
|
||||
|
||||
mock_repo.list_items.assert_called_once_with("recipe-1")
|
||||
assert len(result.items) == 1
|
||||
mock_repo.delete_items_by_recipe.assert_not_called()
|
||||
def test_update_nonexistent_raises(self, mock_repo):
|
||||
"""更新不存在的配方抛出 NotFoundError"""
|
||||
mock_repo.get.return_value = None
|
||||
use_case = UpdateRecipeUseCase(mock_repo)
|
||||
|
||||
command = UpdateRecipeCommand(recipe_id="noexist", user_id="user_1", name="新名称")
|
||||
with pytest.raises(NotFoundError, match="not found"):
|
||||
use_case.execute(command)
|
||||
|
||||
# ── DeleteRecipeUseCase ────────────────────────────────────────────────────
|
||||
mock_repo.update.assert_not_called()
|
||||
|
||||
|
||||
class TestDeleteRecipeUseCase:
|
||||
"""删除配方."""
|
||||
"""DeleteRecipeUseCase 测试"""
|
||||
|
||||
def test_delete_success(self, mock_repo):
|
||||
"""删除成功"""
|
||||
mock_repo.delete.return_value = True
|
||||
uc = DeleteRecipeUseCase(mock_repo)
|
||||
use_case = DeleteRecipeUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("r1", "user_1")
|
||||
|
||||
result = uc.execute("recipe-1", "user-1")
|
||||
assert result is True
|
||||
mock_repo.delete.assert_called_once_with("recipe-1", "user-1")
|
||||
mock_repo.delete.assert_called_once_with("r1", "user_1")
|
||||
|
||||
def test_delete_not_found(self, mock_repo):
|
||||
def test_delete_nonexistent_returns_false(self, mock_repo):
|
||||
"""删除不存在的返回 False"""
|
||||
mock_repo.delete.return_value = False
|
||||
uc = DeleteRecipeUseCase(mock_repo)
|
||||
use_case = DeleteRecipeUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("noexist", "user_1")
|
||||
|
||||
result = uc.execute("nonexistent", "user-1")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── UseRecipeUseCase ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUseRecipeUseCase:
|
||||
"""使用配方(feature flag + 校验)."""
|
||||
"""UseRecipeUseCase 使用配方测试"""
|
||||
|
||||
def test_feature_disabled_for_free_users(self, mock_repo):
|
||||
"""free 套餐没有配方复用功能."""
|
||||
uc = UseRecipeUseCase(mock_repo)
|
||||
|
||||
with pytest.raises(FeatureDisabledError) as exc_info:
|
||||
uc.execute("recipe-1", "user-1", user_plan="free")
|
||||
|
||||
assert "基础版" in str(exc_info.value) or "仅对" in str(exc_info.value)
|
||||
mock_repo.get.assert_not_called()
|
||||
|
||||
def test_success_for_premium_users(self, mock_repo):
|
||||
"""premium 套餐可以使用."""
|
||||
recipe = make_recipe(items=[make_recipe_item()])
|
||||
def test_use_recipe_premium_enabled(self, mock_repo):
|
||||
"""premium用户可以使用配方"""
|
||||
recipe = _make_recipe("r1", "配方1", item_count=2)
|
||||
mock_repo.get.return_value = recipe
|
||||
uc = UseRecipeUseCase(mock_repo)
|
||||
use_case = UseRecipeUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("recipe-1", "user-1", user_plan="premium")
|
||||
# 用 patch mock feature_flags
|
||||
with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff:
|
||||
mock_ff.is_enabled.return_value = True
|
||||
result = use_case.execute("r1", "user_1", user_plan="premium")
|
||||
|
||||
assert isinstance(result, UseRecipeResult)
|
||||
assert result.recipe.id == "recipe-1"
|
||||
assert result.recipe.id == "r1"
|
||||
assert isinstance(result.warnings, list)
|
||||
mock_repo.get.assert_called_once_with("r1", "user_1")
|
||||
|
||||
def test_success_for_basic_users(self, mock_repo):
|
||||
"""basic 套餐也可以使用."""
|
||||
recipe = make_recipe()
|
||||
def test_use_recipe_basic_enabled(self, mock_repo):
|
||||
"""basic用户可以使用配方"""
|
||||
recipe = _make_recipe("r1", "配方1")
|
||||
mock_repo.get.return_value = recipe
|
||||
uc = UseRecipeUseCase(mock_repo)
|
||||
use_case = UseRecipeUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("recipe-1", "user-1", user_plan="basic")
|
||||
assert result.recipe.id == "recipe-1"
|
||||
with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff:
|
||||
mock_ff.is_enabled.return_value = True
|
||||
result = use_case.execute("r1", "user_1", user_plan="basic")
|
||||
|
||||
def test_recipe_not_found(self, mock_repo):
|
||||
assert result.recipe.id == "r1"
|
||||
|
||||
def test_use_recipe_feature_disabled(self, mock_repo):
|
||||
"""功能未启用时抛出 FeatureDisabledError"""
|
||||
use_case = UseRecipeUseCase(mock_repo)
|
||||
|
||||
with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff:
|
||||
mock_ff.is_enabled.return_value = False
|
||||
with pytest.raises(FeatureDisabledError, match="仅对基础版和高级版"):
|
||||
use_case.execute("r1", "user_1", user_plan="free")
|
||||
|
||||
mock_repo.get.assert_not_called()
|
||||
|
||||
def test_use_recipe_not_found(self, mock_repo):
|
||||
"""配方不存在时抛出 NotFoundError"""
|
||||
mock_repo.get.return_value = None
|
||||
uc = UseRecipeUseCase(mock_repo)
|
||||
use_case = UseRecipeUseCase(mock_repo)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
uc.execute("nonexistent", "user-1", user_plan="premium")
|
||||
|
||||
def test_warnings_returns_list(self, mock_repo):
|
||||
"""返回的 warnings 是列表(即使为空)."""
|
||||
recipe = make_recipe()
|
||||
mock_repo.get.return_value = recipe
|
||||
uc = UseRecipeUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("recipe-1", "user-1", user_plan="basic")
|
||||
assert isinstance(result.warnings, list)
|
||||
with patch("packages.application.recipe.use_cases.feature_flags") as mock_ff:
|
||||
mock_ff.is_enabled.return_value = True
|
||||
with pytest.raises(NotFoundError, match="not found"):
|
||||
use_case.execute("noexist", "user_1", user_plan="premium")
|
||||
|
||||
|
||||
# ── MissingAssetWarning ────────────────────────────────────────────────────
|
||||
class TestRecipeCommands:
|
||||
"""命令数据类测试"""
|
||||
|
||||
def test_create_recipe_command_fields(self):
|
||||
"""CreateRecipeCommand 字段"""
|
||||
cmd = CreateRecipeCommand(
|
||||
user_id="u1",
|
||||
name="测试",
|
||||
description="desc",
|
||||
template_id="t1",
|
||||
generation_params={"a": 1},
|
||||
items=[RecipeItemCommand(item_type="asset", item_id="a1", position=0)],
|
||||
metadata_={"key": "val"},
|
||||
)
|
||||
assert cmd.user_id == "u1"
|
||||
assert cmd.name == "测试"
|
||||
assert cmd.description == "desc"
|
||||
assert cmd.template_id == "t1"
|
||||
assert cmd.generation_params == {"a": 1}
|
||||
assert len(cmd.items) == 1
|
||||
assert cmd.items[0].item_type == "asset"
|
||||
assert cmd.metadata_ == {"key": "val"}
|
||||
|
||||
class TestMissingAssetWarning:
|
||||
"""MissingAssetWarning 数据类."""
|
||||
def test_recipe_item_command_defaults(self):
|
||||
"""RecipeItemCommand 默认值"""
|
||||
cmd = RecipeItemCommand(item_type="asset", item_id="a1")
|
||||
assert cmd.position == 0
|
||||
assert cmd.metadata_ == {}
|
||||
|
||||
def test_creation(self):
|
||||
w = MissingAssetWarning(item_type="video", item_id="v1", position=0)
|
||||
assert w.item_type == "video"
|
||||
assert w.item_id == "v1"
|
||||
assert w.position == 0
|
||||
def test_update_recipe_command_defaults_none(self):
|
||||
"""UpdateRecipeCommand 字段默认None"""
|
||||
cmd = UpdateRecipeCommand(recipe_id="r1", user_id="u1")
|
||||
assert cmd.name is None
|
||||
assert cmd.description is None
|
||||
assert cmd.template_id is None
|
||||
assert cmd.generation_params is None
|
||||
assert cmd.items is None
|
||||
assert cmd.metadata_ is None
|
||||
|
||||
def test_commands_are_dataclasses(self):
|
||||
"""都是 dataclass"""
|
||||
from dataclasses import is_dataclass
|
||||
|
||||
assert is_dataclass(CreateRecipeCommand)
|
||||
assert is_dataclass(UpdateRecipeCommand)
|
||||
assert is_dataclass(RecipeItemCommand)
|
||||
assert is_dataclass(UseRecipeResult)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
render_subtitles ASS 字幕纯函数测试.
|
||||
|
||||
覆盖 _hex_to_ass_color / _position_to_ass_alignment / _build_ass_style / _escape_ass_text / _format_ass_time 等纯逻辑.
|
||||
文件生成与 FFmpeg 渲染由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.render_subtitles import (
|
||||
_build_ass_style,
|
||||
_escape_ass_text,
|
||||
_format_ass_time,
|
||||
_hex_to_ass_color,
|
||||
_position_to_ass_alignment,
|
||||
)
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
"""HEX → ASS 颜色转换(不含 alpha 前缀版本)."""
|
||||
|
||||
def test_white(self):
|
||||
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
|
||||
|
||||
def test_black(self):
|
||||
assert _hex_to_ass_color("#000000") == "&H000000"
|
||||
|
||||
def test_red(self):
|
||||
# #FF0000 → R=FF, G=00, B=00 → BGR=0000FF
|
||||
assert _hex_to_ass_color("#FF0000") == "&H0000FF"
|
||||
|
||||
def test_blue(self):
|
||||
# #0000FF → R=00, G=00, B=FF → BGR=FF0000
|
||||
assert _hex_to_ass_color("#0000FF") == "&HFF0000"
|
||||
|
||||
def test_green(self):
|
||||
# #00FF00 → R=00, G=FF, B=00 → BGR=00FF00
|
||||
assert _hex_to_ass_color("#00FF00") == "&H00FF00"
|
||||
|
||||
def test_without_hash(self):
|
||||
assert _hex_to_ass_color("FF0000") == "&H0000FF"
|
||||
|
||||
def test_lowercase(self):
|
||||
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
|
||||
|
||||
def test_invalid_length_returns_default(self):
|
||||
assert _hex_to_ass_color("#FFF") == "&H000000" # 3位
|
||||
assert _hex_to_ass_color("") == "&H000000" # 空
|
||||
|
||||
def test_mixed_case(self):
|
||||
result = _hex_to_ass_color("#aBcDeF")
|
||||
assert result == "&HEFCDAB"
|
||||
|
||||
|
||||
class TestPositionToAssAlignment:
|
||||
"""位置 → ASS 对齐编号映射."""
|
||||
|
||||
def test_top(self):
|
||||
assert _position_to_ass_alignment("top") == 8
|
||||
|
||||
def test_center(self):
|
||||
assert _position_to_ass_alignment("center") == 5
|
||||
|
||||
def test_bottom(self):
|
||||
assert _position_to_ass_alignment("bottom") == 2
|
||||
|
||||
def test_unknown_returns_top_default(self):
|
||||
assert _position_to_ass_alignment("unknown") == 8
|
||||
assert _position_to_ass_alignment("") == 8
|
||||
assert _position_to_ass_alignment("left") == 8
|
||||
assert _position_to_ass_alignment(None) == 8
|
||||
|
||||
|
||||
class TestBuildAssStyle:
|
||||
"""构建 ASS Style 行."""
|
||||
|
||||
def test_basic_style(self):
|
||||
style = _build_ass_style("Default")
|
||||
assert style.startswith("Style: Default,")
|
||||
assert "思源黑体" in style
|
||||
assert "48" in style # font_size
|
||||
|
||||
def test_custom_font(self):
|
||||
style = _build_ass_style("Custom", font_name="Arial", font_size=32)
|
||||
assert "Arial" in style
|
||||
assert ",32," in style
|
||||
|
||||
def test_bold(self):
|
||||
style = _build_ass_style("Bold", bold=True)
|
||||
assert ",-1," in style # bold = -1 (true)
|
||||
|
||||
def test_not_bold(self):
|
||||
style = _build_ass_style("Normal", bold=False)
|
||||
parts = style.split(",")
|
||||
# Bold 是第 8 个字段(index 7)
|
||||
assert parts[7] == "0"
|
||||
|
||||
def test_italic(self):
|
||||
style = _build_ass_style("Italic", italic=True)
|
||||
parts = style.split(",")
|
||||
# Italic 是第 9 个字段(index 8)
|
||||
assert parts[8] == "-1"
|
||||
|
||||
def test_alignment(self):
|
||||
style = _build_ass_style("Bottom", alignment=2)
|
||||
parts = style.split(",")
|
||||
# Alignment 是第 19 个字段(index 18)
|
||||
assert parts[18] == "2"
|
||||
|
||||
def test_margins(self):
|
||||
style = _build_ass_style(
|
||||
"Margins",
|
||||
margin_v=80,
|
||||
margin_l=60,
|
||||
margin_r=60,
|
||||
)
|
||||
parts = style.split(",")
|
||||
# MarginL = parts[19], MarginR = parts[20], MarginV = parts[21]
|
||||
assert parts[19] == "60"
|
||||
assert parts[20] == "60"
|
||||
assert parts[21] == "80"
|
||||
|
||||
def test_outline_width(self):
|
||||
style = _build_ass_style("Outline", outline_width=3.0)
|
||||
parts = style.split(",")
|
||||
# Outline 是第 17 个字段(index 16)
|
||||
assert parts[16] == "3.0"
|
||||
|
||||
def test_shadow_with_blur(self):
|
||||
style = _build_ass_style(
|
||||
"Shadow",
|
||||
shadow_blur=1.0,
|
||||
shadow_offset=(2, 3),
|
||||
)
|
||||
parts = style.split(",")
|
||||
# Shadow 是第 18 个字段(index 17)
|
||||
assert parts[17] == "3" # shadow_offset[1]
|
||||
|
||||
def test_shadow_without_blur(self):
|
||||
style = _build_ass_style(
|
||||
"NoShadow",
|
||||
shadow_blur=0.0,
|
||||
shadow_offset=(2, 3),
|
||||
)
|
||||
parts = style.split(",")
|
||||
assert parts[17] == "0" # 无模糊时阴影深度为0
|
||||
|
||||
def test_style_format_has_correct_field_count(self):
|
||||
"""ASS Style 行应该有 23 个字段."""
|
||||
style = _build_ass_style("Test")
|
||||
parts = style.split(",")
|
||||
assert len(parts) >= 22 # 至少22个字段(Format定义的)
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
"""ASS 文本转义."""
|
||||
|
||||
def test_plain_text(self):
|
||||
assert _escape_ass_text("hello") == "hello"
|
||||
|
||||
def test_newline_unix(self):
|
||||
assert _escape_ass_text("a\nb") == "a\\Nb"
|
||||
|
||||
def test_newline_windows(self):
|
||||
assert _escape_ass_text("a\r\nb") == "a\\Nb"
|
||||
|
||||
def test_newline_mac(self):
|
||||
assert _escape_ass_text("a\rb") == "a\\Nb"
|
||||
|
||||
def test_curly_braces(self):
|
||||
assert _escape_ass_text("{text}") == "(text)"
|
||||
|
||||
def test_multiple_braces(self):
|
||||
assert _escape_ass_text("{a}b{c}") == "(a)b(c)"
|
||||
|
||||
def test_mixed_special_chars(self):
|
||||
result = _escape_ass_text("line1\n{bold}\nline3")
|
||||
assert "\\N" in result
|
||||
assert "(bold)" in result
|
||||
assert "{" not in result
|
||||
|
||||
def test_empty(self):
|
||||
assert _escape_ass_text("") == ""
|
||||
|
||||
|
||||
class TestFormatAssTime:
|
||||
"""秒 → ASS 时间格式."""
|
||||
|
||||
def test_zero(self):
|
||||
assert _format_ass_time(0.0) == "0:00:00.00"
|
||||
|
||||
def test_seconds(self):
|
||||
assert _format_ass_time(5.5) == "0:00:05.50"
|
||||
|
||||
def test_minutes(self):
|
||||
assert _format_ass_time(65.25) == "0:01:05.25"
|
||||
|
||||
def test_hours(self):
|
||||
assert _format_ass_time(3661.5) == "1:01:01.50"
|
||||
|
||||
def test_exact_minute(self):
|
||||
assert _format_ass_time(60.0) == "0:01:00.00"
|
||||
|
||||
def test_exact_hour(self):
|
||||
assert _format_ass_time(3600.0) == "1:00:00.00"
|
||||
|
||||
def test_sub_second_precision(self):
|
||||
result = _format_ass_time(1.234)
|
||||
parts = result.split(":")
|
||||
sec_part = parts[2]
|
||||
decimals = sec_part.split(".")[1]
|
||||
assert len(decimals) == 2 # 两位小数(厘秒)
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Subtitle 领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
def test_duration_normal(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="啊", start=3.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
word = SubtitleWord(text="test", start=5.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=3.0)
|
||||
assert seg.duration == pytest.approx(3.0)
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="今天天气真好", start=0.0, end=5.0)
|
||||
assert seg.char_count == 6
|
||||
|
||||
def test_empty_text(self):
|
||||
seg = SubtitleSegment(text="", start=0.0, end=1.0)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_default_words_empty(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
assert seg.words == []
|
||||
|
||||
|
||||
class TestSubtitleTimeline:
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
assert tl.total_chars == 0
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="第一段", start=0.0, end=2.0),
|
||||
SubtitleSegment(text="第二段", start=2.0, end=5.0),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 2
|
||||
assert tl.total_chars == 6
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="abc", start=0, end=1),
|
||||
SubtitleSegment(text="defg", start=1, end=2),
|
||||
]
|
||||
)
|
||||
assert tl.total_chars == 7
|
||||
|
||||
|
||||
class TestMergeShortSegments:
|
||||
def test_single_segment_no_change(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_merge_short_segments(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="今天", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="天气", start=2.0, end=3.0),
|
||||
SubtitleSegment(text="真好", start=3.0, end=4.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# 每段2字,min=4,应该每2段合并
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "你好今天"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 2.0
|
||||
assert result.segments[1].text == "天气真好"
|
||||
assert result.segments[1].start == 2.0
|
||||
assert result.segments[1].end == 4.0
|
||||
|
||||
def test_remaining_merged_to_last(self):
|
||||
# 3段,每段2字,min=5 → 前5字合并,剩余1字并到最后
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二", start=0, end=1),
|
||||
SubtitleSegment(text="三四", start=1, end=2),
|
||||
SubtitleSegment(text="五", start=2, end=3),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五"
|
||||
|
||||
def test_merge_with_words(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
text="你好",
|
||||
start=0.0,
|
||||
end=1.0,
|
||||
words=[
|
||||
SubtitleWord(text="你", start=0.0, end=0.5),
|
||||
SubtitleWord(text="好", start=0.5, end=1.0),
|
||||
],
|
||||
),
|
||||
SubtitleSegment(
|
||||
text="世界",
|
||||
start=1.0,
|
||||
end=2.0,
|
||||
words=[
|
||||
SubtitleWord(text="世", start=1.0, end=1.5),
|
||||
SubtitleWord(text="界", start=1.5, end=2.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
def test_short_segments_no_split(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短文本", start=0.0, end=1.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_split_by_punctuation(self):
|
||||
text = "今天天气真好。我们出去玩吧!"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=5.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 合并起来应该等于原文
|
||||
assert "".join(s.text for s in result.segments) == text
|
||||
|
||||
def test_split_preserves_time_order(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八九十。十一二三四五六七八九十。", start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 时间应该是递增的
|
||||
for i in range(len(result.segments) - 1):
|
||||
assert result.segments[i].end <= result.segments[i + 1].start + 0.001
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
def test_no_punctuation_short(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 20)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "你好世界"
|
||||
|
||||
def test_sentence_end_punctuation_long_enough(self):
|
||||
# 每段超过 max_chars//2 才会在句末标点断开
|
||||
text = "今天天气真的非常好。明天天气也不错。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_short_text_with_punctuation_no_split(self):
|
||||
# 文本太短(< max_chars//2),即使有标点也不断开
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 20)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_long_text_hard_split(self):
|
||||
text = "一二三四五六七八九十十一二三四五六七八九十"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_empty_text(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
def test_merge_two_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Tag 领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tag import Tag
|
||||
|
||||
|
||||
class TestTagCreate:
|
||||
def test_create_normal(self):
|
||||
tag = Tag.create(user_id="user1", name=" 美食 ")
|
||||
assert tag.id
|
||||
assert tag.user_id == "user1"
|
||||
assert tag.name == "美食" # 自动 strip
|
||||
assert tag.created_at is not None
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name=" ")
|
||||
|
||||
def test_create_generates_unique_ids(self):
|
||||
tag1 = Tag.create(user_id="u1", name="tag1")
|
||||
tag2 = Tag.create(user_id="u1", name="tag2")
|
||||
assert tag1.id != tag2.id
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
缩略图生成器纯函数测试.
|
||||
|
||||
覆盖 _format_seek_time 等纯逻辑.
|
||||
FFmpeg 抽帧与 OSS 上传由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.thumbnail_generator import _format_seek_time
|
||||
|
||||
|
||||
class TestFormatSeekTime:
|
||||
"""_format_seek_time 时间格式化."""
|
||||
|
||||
def test_zero(self):
|
||||
assert _format_seek_time(0.0) == "00:00:00.00"
|
||||
|
||||
def test_seconds_only(self):
|
||||
assert _format_seek_time(5.5) == "00:00:05.50"
|
||||
|
||||
def test_minutes(self):
|
||||
assert _format_seek_time(65.25) == "00:01:05.25"
|
||||
|
||||
def test_hours(self):
|
||||
assert _format_seek_time(3661.5) == "01:01:01.50"
|
||||
|
||||
def test_exact_minute(self):
|
||||
assert _format_seek_time(60.0) == "00:01:00.00"
|
||||
|
||||
def test_exact_hour(self):
|
||||
assert _format_seek_time(3600.0) == "01:00:00.00"
|
||||
|
||||
def test_very_short(self):
|
||||
assert _format_seek_time(0.1) == "00:00:00.10"
|
||||
|
||||
def test_long_video(self):
|
||||
# 超过1小时
|
||||
assert _format_seek_time(7200.0) == "02:00:00.00"
|
||||
|
||||
def test_sub_second_precision(self):
|
||||
result = _format_seek_time(1.234)
|
||||
parts = result.split(":")
|
||||
assert len(parts) == 3
|
||||
sec_part = parts[2]
|
||||
assert "." in sec_part
|
||||
decimals = sec_part.split(".")[1]
|
||||
assert len(decimals) == 2
|
||||
|
||||
def test_zero_padded_hours(self):
|
||||
# 小时始终是2位
|
||||
result = _format_seek_time(5.0)
|
||||
assert result.startswith("00:")
|
||||
|
||||
def test_zero_padded_minutes(self):
|
||||
# 分钟始终是2位
|
||||
result = _format_seek_time(5.0)
|
||||
parts = result.split(":")
|
||||
assert len(parts[1]) == 2
|
||||
@@ -1,10 +1,17 @@
|
||||
"""Title Library Use Cases 单测 — 标题库业务逻辑."""
|
||||
"""标题库 UseCase 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.title_library.commands import (
|
||||
CreateTitleLibraryCommand,
|
||||
IncrementTitleUsageCommand,
|
||||
PickTitleCommand,
|
||||
UpdateTitleLibraryCommand,
|
||||
)
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
@@ -18,23 +25,19 @@ from packages.domain.exceptions import NotFoundError, QuotaExceededError
|
||||
from packages.domain.title_library import TitleLibraryItem
|
||||
|
||||
|
||||
# ── Fixtures / Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_item(**kwargs) -> TitleLibraryItem:
|
||||
defaults = dict(
|
||||
id="title-1",
|
||||
user_id="user-1",
|
||||
name="Test Title",
|
||||
text="This is a test title",
|
||||
category="default",
|
||||
def _make_item(id: str, name: str, text: str, usage_count: int = 0, category: str = "default") -> TitleLibraryItem:
|
||||
return TitleLibraryItem(
|
||||
id=id,
|
||||
user_id="user_1",
|
||||
name=name,
|
||||
text=text,
|
||||
category=category,
|
||||
description="",
|
||||
tags=[],
|
||||
usage_count=0,
|
||||
usage_count=usage_count,
|
||||
is_active=True,
|
||||
metadata_={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return TitleLibraryItem(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -42,414 +45,363 @@ def mock_repo():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
# ── ListTitleLibraryUseCase ────────────────────────────────────────────────
|
||||
@pytest.fixture
|
||||
def sample_item():
|
||||
return _make_item("title_1", "爆款标题", "这是一个爆款标题文案", usage_count=5)
|
||||
|
||||
|
||||
class TestListTitleLibraryUseCase:
|
||||
"""列表查询."""
|
||||
"""ListTitleLibraryUseCase 测试"""
|
||||
|
||||
def test_list_passes_params(self, mock_repo):
|
||||
mock_repo.list_by_user.return_value = [make_item()]
|
||||
uc = ListTitleLibraryUseCase(mock_repo)
|
||||
def test_list_returns_results(self, mock_repo, sample_item):
|
||||
"""正常返回标题列表"""
|
||||
mock_repo.list_by_user.return_value = [sample_item]
|
||||
use_case = ListTitleLibraryUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("user-1", category="vlog", skip=5, limit=10)
|
||||
result = use_case.execute("user_1")
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with(
|
||||
"user-1", category="vlog", skip=5, limit=10
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "title_1"
|
||||
mock_repo.list_by_user.assert_called_once_with("user_1", category=None, skip=0, limit=50)
|
||||
|
||||
def test_list_default_params(self, mock_repo):
|
||||
def test_list_with_category(self, mock_repo, sample_item):
|
||||
"""按分类过滤"""
|
||||
mock_repo.list_by_user.return_value = [sample_item]
|
||||
use_case = ListTitleLibraryUseCase(mock_repo)
|
||||
|
||||
use_case.execute("user_1", category="电商")
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with("user_1", category="电商", skip=0, limit=50)
|
||||
|
||||
def test_list_with_pagination(self, mock_repo, sample_item):
|
||||
"""带分页参数"""
|
||||
mock_repo.list_by_user.return_value = [sample_item]
|
||||
use_case = ListTitleLibraryUseCase(mock_repo)
|
||||
|
||||
use_case.execute("user_1", skip=10, limit=20)
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with("user_1", category=None, skip=10, limit=20)
|
||||
|
||||
def test_empty_list(self, mock_repo):
|
||||
"""空列表"""
|
||||
mock_repo.list_by_user.return_value = []
|
||||
uc = ListTitleLibraryUseCase(mock_repo)
|
||||
use_case = ListTitleLibraryUseCase(mock_repo)
|
||||
|
||||
uc.execute("user-1")
|
||||
result = use_case.execute("user_1")
|
||||
|
||||
mock_repo.list_by_user.assert_called_once_with(
|
||||
"user-1", category=None, skip=0, limit=50
|
||||
)
|
||||
|
||||
|
||||
# ── GetTitleLibraryUseCase ─────────────────────────────────────────────────
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetTitleLibraryUseCase:
|
||||
"""单个查询."""
|
||||
"""GetTitleLibraryUseCase 测试"""
|
||||
|
||||
def test_get_found(self, mock_repo):
|
||||
item = make_item()
|
||||
mock_repo.get.return_value = item
|
||||
uc = GetTitleLibraryUseCase(mock_repo)
|
||||
def test_get_existing(self, mock_repo, sample_item):
|
||||
"""获取存在的标题"""
|
||||
mock_repo.get.return_value = sample_item
|
||||
use_case = GetTitleLibraryUseCase(mock_repo)
|
||||
|
||||
result = uc.execute("title-1", "user-1")
|
||||
result = use_case.execute("title_1", "user_1")
|
||||
|
||||
mock_repo.get.assert_called_once_with("title-1", "user-1")
|
||||
assert result.id == "title-1"
|
||||
assert result is not None
|
||||
assert result.id == "title_1"
|
||||
mock_repo.get.assert_called_once_with("title_1", "user_1")
|
||||
|
||||
def test_get_not_found(self, mock_repo):
|
||||
def test_get_nonexistent_returns_none(self, mock_repo):
|
||||
"""获取不存在的标题返回 None"""
|
||||
mock_repo.get.return_value = None
|
||||
uc = GetTitleLibraryUseCase(mock_repo)
|
||||
use_case = GetTitleLibraryUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("nonexistent", "user_1")
|
||||
|
||||
result = uc.execute("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── CreateTitleLibraryUseCase ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateTitleLibraryUseCase:
|
||||
"""创建标题."""
|
||||
"""CreateTitleLibraryUseCase 测试"""
|
||||
|
||||
def test_create_success_within_quota(self, mock_repo):
|
||||
mock_repo.count_by_user.return_value = 2
|
||||
mock_repo.create.return_value = make_item(id="new-id")
|
||||
uc = CreateTitleLibraryUseCase(mock_repo)
|
||||
def test_create_success(self, mock_repo, sample_item):
|
||||
"""创建成功"""
|
||||
mock_repo.count_by_user.return_value = 0
|
||||
mock_repo.create.return_value = sample_item
|
||||
use_case = CreateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import CreateTitleLibraryCommand
|
||||
|
||||
cmd = CreateTitleLibraryCommand(
|
||||
user_id="user-1",
|
||||
name="New Title",
|
||||
text="New title text",
|
||||
category="vlog",
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id="user_1",
|
||||
name="新标题",
|
||||
text="新标题文案",
|
||||
category="default",
|
||||
description="",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
result = uc.execute(cmd, plan_name="free")
|
||||
result = use_case.execute(command, plan_name="free")
|
||||
|
||||
assert result.id == "new-id"
|
||||
mock_repo.count_by_user.assert_called_once_with("user-1")
|
||||
assert result.id == "title_1"
|
||||
mock_repo.count_by_user.assert_called_once_with("user_1")
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
def test_create_quota_exceeded(self, mock_repo):
|
||||
"""超过配额时抛 QuotaExceededError."""
|
||||
# free 计划 MAX_TITLES 假设很小,或者 count 很大
|
||||
"""超过配额时抛出 QuotaExceededError"""
|
||||
mock_repo.count_by_user.return_value = 9999
|
||||
uc = CreateTitleLibraryUseCase(mock_repo)
|
||||
use_case = CreateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import CreateTitleLibraryCommand
|
||||
|
||||
cmd = CreateTitleLibraryCommand(
|
||||
user_id="user-1", name="Title", text="Text", category="default"
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id="user_1",
|
||||
name="新标题",
|
||||
text="文案",
|
||||
category="default",
|
||||
description="",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
with pytest.raises(QuotaExceededError):
|
||||
uc.execute(cmd, plan_name="free")
|
||||
use_case.execute(command, plan_name="free")
|
||||
|
||||
mock_repo.create.assert_not_called()
|
||||
|
||||
def test_create_item_fields(self, mock_repo):
|
||||
"""创建时所有字段正确传递."""
|
||||
def test_create_with_tags_and_metadata(self, mock_repo, sample_item):
|
||||
"""创建时带 tags 和 metadata_"""
|
||||
mock_repo.count_by_user.return_value = 0
|
||||
mock_repo.create.return_value = make_item()
|
||||
uc = CreateTitleLibraryUseCase(mock_repo)
|
||||
mock_repo.create.return_value = sample_item
|
||||
use_case = CreateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import CreateTitleLibraryCommand
|
||||
|
||||
cmd = CreateTitleLibraryCommand(
|
||||
user_id="user-1",
|
||||
name="My Title",
|
||||
text="Title text content",
|
||||
category="food",
|
||||
description="A food title",
|
||||
tags=["t1", "t2"],
|
||||
metadata_={"source": "import"},
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id="user_1",
|
||||
name="带标签标题",
|
||||
text="文案",
|
||||
category="电商",
|
||||
description="测试描述",
|
||||
tags=["爆款", "促销"],
|
||||
metadata_={"source": "manual"},
|
||||
)
|
||||
uc.execute(cmd, plan_name="premium")
|
||||
use_case.execute(command, plan_name="premium")
|
||||
|
||||
created = mock_repo.create.call_args[0][0]
|
||||
assert created.name == "My Title"
|
||||
assert created.text == "Title text content"
|
||||
assert created.category == "food"
|
||||
assert created.description == "A food title"
|
||||
assert created.tags == ["t1", "t2"]
|
||||
assert created.metadata_ == {"source": "import"}
|
||||
assert created.user_id == "user-1"
|
||||
|
||||
|
||||
# ── UpdateTitleLibraryUseCase ──────────────────────────────────────────────
|
||||
assert isinstance(created, TitleLibraryItem)
|
||||
assert created.name == "带标签标题"
|
||||
assert created.category == "电商"
|
||||
assert created.tags == ["爆款", "促销"]
|
||||
assert created.metadata_ == {"source": "manual"}
|
||||
|
||||
|
||||
class TestUpdateTitleLibraryUseCase:
|
||||
"""更新标题."""
|
||||
"""UpdateTitleLibraryUseCase 测试"""
|
||||
|
||||
def test_update_success(self, mock_repo):
|
||||
existing = make_item(name="Old Name", text="Old text")
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateTitleLibraryUseCase(mock_repo)
|
||||
def test_update_name(self, mock_repo, sample_item):
|
||||
"""更新标题名称"""
|
||||
mock_repo.get.return_value = sample_item
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import UpdateTitleLibraryCommand
|
||||
command = UpdateTitleLibraryCommand(title_id="title_1", user_id="user_1", name="新名称")
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = UpdateTitleLibraryCommand(
|
||||
title_id="title-1",
|
||||
user_id="user-1",
|
||||
name="New Name",
|
||||
text="New text",
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.name == "New Name"
|
||||
assert result.text == "New text"
|
||||
assert result.name == "新名称"
|
||||
# 其他字段不变
|
||||
assert result.text == "这是一个爆款标题文案"
|
||||
mock_repo.get.assert_called_once_with("title_1", "user_1")
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_update_not_found_raises(self, mock_repo):
|
||||
mock_repo.get.return_value = None
|
||||
uc = UpdateTitleLibraryUseCase(mock_repo)
|
||||
def test_update_multiple_fields(self, mock_repo, sample_item):
|
||||
"""同时更新多个字段"""
|
||||
mock_repo.get.return_value = sample_item
|
||||
mock_repo.update.side_effect = lambda x: x
|
||||
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import UpdateTitleLibraryCommand
|
||||
|
||||
cmd = UpdateTitleLibraryCommand(
|
||||
title_id="nonexistent", user_id="user-1"
|
||||
)
|
||||
with pytest.raises(NotFoundError):
|
||||
uc.execute(cmd)
|
||||
mock_repo.update.assert_not_called()
|
||||
|
||||
def test_update_partial_fields(self, mock_repo):
|
||||
"""只更新传了的字段,其他保持不变."""
|
||||
existing = make_item(
|
||||
name="Original",
|
||||
text="Original text",
|
||||
category="default",
|
||||
tags=["old"],
|
||||
is_active=True,
|
||||
)
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import UpdateTitleLibraryCommand
|
||||
|
||||
# 只更新 name 和 is_active
|
||||
cmd = UpdateTitleLibraryCommand(
|
||||
title_id="title-1",
|
||||
user_id="user-1",
|
||||
name="New Name",
|
||||
command = UpdateTitleLibraryCommand(
|
||||
title_id="title_1",
|
||||
user_id="user_1",
|
||||
text="新文案内容",
|
||||
category="美食",
|
||||
is_active=False,
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
result = use_case.execute(command)
|
||||
|
||||
# 更新了的字段
|
||||
assert result.name == "New Name"
|
||||
assert result.text == "新文案内容"
|
||||
assert result.category == "美食"
|
||||
assert result.is_active is False
|
||||
# 没更新的保持原样
|
||||
assert result.text == "Original text"
|
||||
assert result.category == "default"
|
||||
assert result.tags == ["old"]
|
||||
|
||||
def test_update_metadata(self, mock_repo):
|
||||
existing = make_item(metadata_={"old_key": "old_val"})
|
||||
mock_repo.get.return_value = existing
|
||||
mock_repo.update.return_value = existing
|
||||
uc = UpdateTitleLibraryUseCase(mock_repo)
|
||||
def test_update_nonexistent_raises(self, mock_repo):
|
||||
"""更新不存在的标题抛出 NotFoundError"""
|
||||
mock_repo.get.return_value = None
|
||||
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import UpdateTitleLibraryCommand
|
||||
command = UpdateTitleLibraryCommand(title_id="noexist", user_id="user_1", name="新名称")
|
||||
with pytest.raises(NotFoundError, match="not found"):
|
||||
use_case.execute(command)
|
||||
|
||||
cmd = UpdateTitleLibraryCommand(
|
||||
title_id="title-1",
|
||||
user_id="user-1",
|
||||
metadata_={"new_key": "new_val"},
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
assert result.metadata_ == {"new_key": "new_val"}
|
||||
|
||||
|
||||
# ── DeleteTitleLibraryUseCase ──────────────────────────────────────────────
|
||||
mock_repo.update.assert_not_called()
|
||||
|
||||
|
||||
class TestDeleteTitleLibraryUseCase:
|
||||
"""删除标题."""
|
||||
"""DeleteTitleLibraryUseCase 测试"""
|
||||
|
||||
def test_delete_success(self, mock_repo):
|
||||
"""删除成功"""
|
||||
mock_repo.delete.return_value = True
|
||||
uc = DeleteTitleLibraryUseCase(mock_repo)
|
||||
use_case = DeleteTitleLibraryUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("title_1", "user_1")
|
||||
|
||||
result = uc.execute("title-1", "user-1")
|
||||
assert result is True
|
||||
mock_repo.delete.assert_called_once_with("title-1", "user-1")
|
||||
mock_repo.delete.assert_called_once_with("title_1", "user_1")
|
||||
|
||||
def test_delete_not_found(self, mock_repo):
|
||||
def test_delete_nonexistent_returns_false(self, mock_repo):
|
||||
"""删除不存在的返回 False"""
|
||||
mock_repo.delete.return_value = False
|
||||
uc = DeleteTitleLibraryUseCase(mock_repo)
|
||||
use_case = DeleteTitleLibraryUseCase(mock_repo)
|
||||
|
||||
result = use_case.execute("noexist", "user_1")
|
||||
|
||||
result = uc.execute("nonexistent", "user-1")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── IncrementTitleUsageUseCase ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIncrementTitleUsageUseCase:
|
||||
"""递增使用次数."""
|
||||
"""IncrementTitleUsageUseCase 测试"""
|
||||
|
||||
def test_increment_positive(self, mock_repo):
|
||||
"""正增量时调用 repository"""
|
||||
mock_repo.increment_usage_count.return_value = True
|
||||
uc = IncrementTitleUsageUseCase(mock_repo)
|
||||
use_case = IncrementTitleUsageUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import IncrementTitleUsageCommand
|
||||
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=1)
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = IncrementTitleUsageCommand(
|
||||
title_id="title-1", user_id="user-1", increment=1
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
assert result is True
|
||||
mock_repo.increment_usage_count.assert_called_once_with(
|
||||
"title-1", "user-1", increment=1
|
||||
)
|
||||
mock_repo.increment_usage_count.assert_called_once_with("title_1", "user_1", increment=1)
|
||||
|
||||
def test_increment_zero_returns_false(self, mock_repo):
|
||||
"""increment <= 0 直接返回 False,不调 repository."""
|
||||
uc = IncrementTitleUsageUseCase(mock_repo)
|
||||
"""增量为0返回False,不调用repository"""
|
||||
use_case = IncrementTitleUsageUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import IncrementTitleUsageCommand
|
||||
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=0)
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = IncrementTitleUsageCommand(
|
||||
title_id="title-1", user_id="user-1", increment=0
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
assert result is False
|
||||
mock_repo.increment_usage_count.assert_not_called()
|
||||
|
||||
def test_increment_negative_returns_false(self, mock_repo):
|
||||
uc = IncrementTitleUsageUseCase(mock_repo)
|
||||
"""负增量返回False"""
|
||||
use_case = IncrementTitleUsageUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import IncrementTitleUsageCommand
|
||||
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=-1)
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = IncrementTitleUsageCommand(
|
||||
title_id="title-1", user_id="user-1", increment=-5
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
assert result is False
|
||||
mock_repo.increment_usage_count.assert_not_called()
|
||||
|
||||
def test_increment_larger_number(self, mock_repo):
|
||||
def test_increment_large_number(self, mock_repo):
|
||||
"""大增量值"""
|
||||
mock_repo.increment_usage_count.return_value = True
|
||||
uc = IncrementTitleUsageUseCase(mock_repo)
|
||||
use_case = IncrementTitleUsageUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import IncrementTitleUsageCommand
|
||||
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=10)
|
||||
use_case.execute(command)
|
||||
|
||||
cmd = IncrementTitleUsageCommand(
|
||||
title_id="title-1", user_id="user-1", increment=10
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
assert result is True
|
||||
mock_repo.increment_usage_count.assert_called_once_with(
|
||||
"title-1", "user-1", increment=10
|
||||
)
|
||||
|
||||
|
||||
# ── PickTitleUseCase ───────────────────────────────────────────────────────
|
||||
mock_repo.increment_usage_count.assert_called_once_with("title_1", "user_1", increment=10)
|
||||
|
||||
|
||||
class TestPickTitleUseCase:
|
||||
"""智能选择标题."""
|
||||
"""PickTitleUseCase 智能选标题测试"""
|
||||
|
||||
def test_empty_list_returns_none(self, mock_repo):
|
||||
def test_pick_from_multiple(self, mock_repo):
|
||||
"""从多个标题中选一个(最少使用的前5个中随机)"""
|
||||
items = [_make_item(f"t{i}", f"标题{i}", f"文案{i}", usage_count=i) for i in range(10)]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
command = PickTitleCommand(user_id="user_1")
|
||||
result = use_case.execute(command)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, TitleLibraryItem)
|
||||
# 选出的应该是使用次数最少的前5个之一(0-4)
|
||||
assert result.usage_count <= 4
|
||||
mock_repo.list_by_user.assert_called_once()
|
||||
|
||||
def test_pick_empty_returns_none(self, mock_repo):
|
||||
"""空标题库返回 None"""
|
||||
mock_repo.list_by_user.return_value = []
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
command = PickTitleCommand(user_id="user_1")
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = PickTitleCommand(user_id="user-1")
|
||||
result = uc.execute(cmd)
|
||||
assert result is None
|
||||
|
||||
def test_picks_from_available(self, mock_repo):
|
||||
items = [make_item(id=f"t{i}", usage_count=i) for i in range(3)]
|
||||
def test_pick_with_category(self, mock_repo):
|
||||
"""按分类选标题"""
|
||||
items = [_make_item("t1", "标题1", "文案1", category="美食")]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
command = PickTitleCommand(user_id="user_1", category="美食")
|
||||
result = use_case.execute(command)
|
||||
|
||||
cmd = PickTitleCommand(user_id="user-1")
|
||||
result = uc.execute(cmd)
|
||||
|
||||
# 结果应该是候选池中之一(最少使用的前5个)
|
||||
assert result in items
|
||||
assert result.usage_count <= 2 # 肯定是前3个里的
|
||||
|
||||
def test_exclude_ids(self, mock_repo):
|
||||
"""排除指定ID后从剩余中选."""
|
||||
items = [make_item(id=f"t{i}", usage_count=i) for i in range(10)]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
|
||||
# 排除前5个
|
||||
cmd = PickTitleCommand(
|
||||
user_id="user-1", exclude_ids=[f"t{i}" for i in range(5)]
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
# 应该从后5个里选
|
||||
assert result.id in [f"t{i}" for i in range(5, 10)]
|
||||
|
||||
def test_exclude_all_falls_back_to_all(self, mock_repo):
|
||||
"""排除全部时,回退到从全部里选."""
|
||||
items = [make_item(id=f"t{i}", usage_count=i) for i in range(3)]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
|
||||
cmd = PickTitleCommand(
|
||||
user_id="user-1", exclude_ids=["t0", "t1", "t2"]
|
||||
)
|
||||
result = uc.execute(cmd)
|
||||
# 回退到全部,还是能选出一个
|
||||
assert result is not None
|
||||
assert result.id in ["t0", "t1", "t2"]
|
||||
|
||||
def test_category_filter(self, mock_repo):
|
||||
"""按分类过滤."""
|
||||
items = [make_item(id="t1", category="vlog"), make_item(id="t2", category="food")]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
|
||||
cmd = PickTitleCommand(user_id="user-1", category="vlog")
|
||||
uc.execute(cmd)
|
||||
|
||||
# 传给 repo 的参数里带了 category
|
||||
call_kwargs = mock_repo.list_by_user.call_args[1]
|
||||
assert call_kwargs["category"] == "vlog"
|
||||
|
||||
def test_only_active_titles(self, mock_repo):
|
||||
"""只从活跃标题中选."""
|
||||
items = [make_item(id="t1"), make_item(id="t2")]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
|
||||
cmd = PickTitleCommand(user_id="user-1")
|
||||
uc.execute(cmd)
|
||||
|
||||
call_kwargs = mock_repo.list_by_user.call_args[1]
|
||||
assert call_kwargs["category"] == "美食"
|
||||
assert call_kwargs["is_active"] is True
|
||||
|
||||
def test_candidate_pool_size(self, mock_repo):
|
||||
"""候选池大小限制为5个最少使用的."""
|
||||
items = [make_item(id=f"t{i}", usage_count=10 - i) for i in range(20)]
|
||||
def test_pick_exclude_ids(self, mock_repo):
|
||||
"""排除指定ID"""
|
||||
items = [
|
||||
_make_item("t1", "标题1", "文案1", usage_count=1),
|
||||
_make_item("t2", "标题2", "文案2", usage_count=2),
|
||||
_make_item("t3", "标题3", "文案3", usage_count=3),
|
||||
]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
uc = PickTitleUseCase(mock_repo)
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
from packages.application.title_library.commands import PickTitleCommand
|
||||
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
|
||||
result = use_case.execute(command)
|
||||
|
||||
# 多次运行,确保选的都是使用次数最少的
|
||||
selected_ids = set()
|
||||
for _ in range(50):
|
||||
result = uc.execute(PickTitleCommand(user_id="user-1"))
|
||||
selected_ids.add(result.id)
|
||||
# 排除两个后只剩t3
|
||||
assert result.id == "t3"
|
||||
|
||||
# 选出来的都应该是 usage_count 最高的那5个(因为升序取前5,
|
||||
# items[0]有usage_count=10,items[1]有9...items[4]有6,
|
||||
# 都是"使用次数最少"的前5个)
|
||||
# 不对,items[i] 的 usage_count = 10 - i
|
||||
# items[0]=10, items[1]=9, ... items[9]=1, items[10]=0, items[11]=-1...
|
||||
# 升序排列的话,items[19]=-9 最小,items[18]=-8 次之 ...
|
||||
# 前5个最小的是 items[19], items[18], items[17], items[16], items[15]
|
||||
# 即id为 t19, t18, t17, t16, t15
|
||||
expected_pool = {f"t{i}" for i in range(15, 20)}
|
||||
assert selected_ids.issubset(expected_pool)
|
||||
assert len(selected_ids) > 0
|
||||
def test_pick_exclude_all_falls_back(self, mock_repo):
|
||||
"""排除全部时从所有标题中选"""
|
||||
items = [
|
||||
_make_item("t1", "标题1", "文案1", usage_count=1),
|
||||
_make_item("t2", "标题2", "文案2", usage_count=2),
|
||||
]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
|
||||
result = use_case.execute(command)
|
||||
|
||||
# 排除全部后fallback到全部,所以还是能选出一个
|
||||
assert result is not None
|
||||
assert result.id in ("t1", "t2")
|
||||
|
||||
def test_pick_single_item(self, mock_repo):
|
||||
"""只有一个标题时选它"""
|
||||
item = _make_item("only", "唯一标题", "唯一文案", usage_count=10)
|
||||
mock_repo.list_by_user.return_value = [item]
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
command = PickTitleCommand(user_id="user_1")
|
||||
result = use_case.execute(command)
|
||||
|
||||
assert result.id == "only"
|
||||
|
||||
def test_pick_prefers_less_used(self, mock_repo):
|
||||
"""倾向于选择使用次数少的"""
|
||||
items = [
|
||||
_make_item("t_used", "常用", "常用", usage_count=100),
|
||||
_make_item("t_fresh", "新的", "新的", usage_count=0),
|
||||
]
|
||||
mock_repo.list_by_user.return_value = items
|
||||
use_case = PickTitleUseCase(mock_repo)
|
||||
|
||||
# 跑多次,验证使用少的出现在候选池里
|
||||
results = set()
|
||||
for _ in range(20):
|
||||
command = PickTitleCommand(user_id="user_1")
|
||||
r = use_case.execute(command)
|
||||
if r:
|
||||
results.add(r.id)
|
||||
|
||||
# 两个都在候选池(少于5个),所以都可能被选中
|
||||
assert "t_used" in results or "t_fresh" in results
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"""transition_presets 领域层单元测试 - 转场预设库"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
TRANSITION_PRESET_LIBRARY,
|
||||
TransitionPreset,
|
||||
get_default_transition,
|
||||
get_transition_preset,
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
|
||||
class TestTransitionPreset:
|
||||
"""TransitionPreset 数据类测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic", transition="fade")
|
||||
assert preset.id == "test"
|
||||
assert preset.name == "测试"
|
||||
assert preset.category == "basic"
|
||||
assert preset.transition == "fade"
|
||||
assert preset.description == ""
|
||||
assert preset.tags == []
|
||||
assert preset.default_duration == 0.5
|
||||
assert preset.min_duration == 0.1
|
||||
assert preset.max_duration == 3.0
|
||||
assert preset.has_custom_params is False
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
preset = TransitionPreset(
|
||||
id="custom",
|
||||
name="自定义转场",
|
||||
category="special",
|
||||
description="炫酷特效",
|
||||
tags=["炫酷", "特效"],
|
||||
transition="custom",
|
||||
default_duration=1.0,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
has_custom_params=True,
|
||||
)
|
||||
assert preset.description == "炫酷特效"
|
||||
assert preset.tags == ["炫酷", "特效"]
|
||||
assert preset.default_duration == 1.0
|
||||
assert preset.min_duration == 0.5
|
||||
assert preset.max_duration == 5.0
|
||||
assert preset.has_custom_params is True
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen dataclass 不可修改"""
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(Exception):
|
||||
preset.name = "改名"
|
||||
|
||||
def test_tags_default_empty_list(self):
|
||||
preset = TransitionPreset(id="t1", name="t1", category="basic")
|
||||
preset2 = TransitionPreset(id="t2", name="t2", category="basic")
|
||||
assert preset.tags == []
|
||||
assert preset.tags is not preset2.tags
|
||||
|
||||
def test_default_transition_is_fade(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
assert preset.transition == "fade"
|
||||
|
||||
|
||||
class TestTransitionPresetLibrary:
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库测试"""
|
||||
|
||||
def test_library_not_empty(self):
|
||||
assert len(TRANSITION_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_presets_have_unique_ids(self):
|
||||
"""所有预设 ID 唯一"""
|
||||
ids = [p.id for p in TRANSITION_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_all_presets_have_required_fields(self):
|
||||
"""所有预设都有必填字段"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.id, f"missing id"
|
||||
assert preset.name, f"{preset.id} missing name"
|
||||
assert preset.category, f"{preset.id} missing category"
|
||||
assert preset.transition, f"{preset.id} missing transition"
|
||||
|
||||
def test_transition_none_exists(self):
|
||||
"""无转场预设存在"""
|
||||
none_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_none"), None)
|
||||
assert none_preset is not None
|
||||
assert none_preset.name == "无转场"
|
||||
assert none_preset.transition == "none"
|
||||
|
||||
def test_transition_random_exists(self):
|
||||
"""随机转场预设存在"""
|
||||
random_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_random"), None)
|
||||
assert random_preset is not None
|
||||
assert random_preset.name == "随机"
|
||||
|
||||
def test_fade_category_exists(self):
|
||||
"""淡入淡出分类有预设"""
|
||||
fade_presets = [p for p in TRANSITION_PRESET_LIBRARY if p.category == "fade"]
|
||||
assert len(fade_presets) >= 2
|
||||
|
||||
def test_duration_constraints_valid(self):
|
||||
"""时长约束:min <= default <= max"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.min_duration <= preset.default_duration, f"{preset.id}: min > default"
|
||||
assert preset.default_duration <= preset.max_duration, f"{preset.id}: default > max"
|
||||
assert preset.min_duration >= 0, f"{preset.id}: min < 0"
|
||||
|
||||
def test_known_categories_exist(self):
|
||||
"""已知分类都有预设"""
|
||||
categories = {p.category for p in TRANSITION_PRESET_LIBRARY}
|
||||
assert "basic" in categories
|
||||
assert "fade" in categories
|
||||
|
||||
|
||||
class TestGetTransitionPreset:
|
||||
"""get_transition_preset 函数测试"""
|
||||
|
||||
def test_get_existing_preset(self):
|
||||
preset = get_transition_preset("transition_none")
|
||||
assert preset is not None
|
||||
assert preset.id == "transition_none"
|
||||
|
||||
def test_get_fade_preset(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert preset is not None
|
||||
assert preset.transition == "fade"
|
||||
|
||||
def test_get_nonexistent_preset(self):
|
||||
assert get_transition_preset("nonexistent_transition") is None
|
||||
|
||||
def test_returns_transitionpreset_type(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
|
||||
|
||||
class TestListTransitionPresets:
|
||||
"""list_transition_presets 函数测试"""
|
||||
|
||||
def test_list_all(self):
|
||||
"""不带参数返回所有预设"""
|
||||
all_presets = list_transition_presets()
|
||||
assert len(all_presets) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category(self):
|
||||
"""按分类筛选"""
|
||||
fade_presets = list_transition_presets(category="fade")
|
||||
assert len(fade_presets) > 0
|
||||
assert all(p.category == "fade" for p in fade_presets)
|
||||
|
||||
def test_filter_by_basic_category(self):
|
||||
basic_presets = list_transition_presets(category="basic")
|
||||
assert len(basic_presets) >= 2
|
||||
|
||||
def test_filter_by_nonexistent_category(self):
|
||||
result = list_transition_presets(category="nonexistent")
|
||||
assert result == []
|
||||
|
||||
def test_search_by_name(self):
|
||||
"""按名称搜索"""
|
||||
result = list_transition_presets(keyword="淡入")
|
||||
assert len(result) >= 1
|
||||
assert any("淡入" in p.name for p in result)
|
||||
|
||||
def test_search_by_tag(self):
|
||||
"""按标签搜索"""
|
||||
tagged = [p for p in TRANSITION_PRESET_LIBRARY if p.tags]
|
||||
if tagged:
|
||||
tag = tagged[0].tags[0]
|
||||
result = list_transition_presets(keyword=tag)
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_search_empty_returns_all(self):
|
||||
result = list_transition_presets(keyword="")
|
||||
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_combined_category_and_search(self):
|
||||
result = list_transition_presets(category="fade", keyword="淡入")
|
||||
assert all(p.category == "fade" for p in result)
|
||||
|
||||
def test_returns_list_of_transitionpreset(self):
|
||||
result = list_transition_presets()
|
||||
assert all(isinstance(p, TransitionPreset) for p in result)
|
||||
|
||||
|
||||
class TestGetDefaultTransition:
|
||||
"""get_default_transition 函数测试"""
|
||||
|
||||
def test_returns_preset(self):
|
||||
preset = get_default_transition()
|
||||
assert preset is not None
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
|
||||
def test_default_is_none(self):
|
||||
"""默认转场是无转场(硬切)"""
|
||||
preset = get_default_transition()
|
||||
assert preset.id == "transition_none"
|
||||
assert preset.transition == "none"
|
||||
|
||||
def test_default_has_zero_duration(self):
|
||||
"""无转场默认时长为 0"""
|
||||
preset = get_default_transition()
|
||||
assert preset.default_duration == 0.0
|
||||
assert preset.min_duration == 0.0
|
||||
assert preset.max_duration == 0.0
|
||||
@@ -1,153 +0,0 @@
|
||||
"""TtsConfig 配音配置模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
assert config.text == ""
|
||||
assert config.align_mode == "full"
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigParse:
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config.enabled is False
|
||||
assert isinstance(config, TtsConfig)
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_not_dict(self):
|
||||
config = TtsConfig.parse("not a dict")
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_enabled_false_returns_disabled(self):
|
||||
# 即使传了其他参数,enabled=False 就直接返回禁用
|
||||
config = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_enabled_true_with_all_fields(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "female_warm",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.0,
|
||||
"volume": 0.9,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "female_warm"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_enabled_not_bool(self):
|
||||
config = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
|
||||
assert config.enabled is False # 非 bool 值视为 False
|
||||
|
||||
def test_parse_voice_id_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_invalid_align_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_invalid_overlap_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
def test_speed_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_speed_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1.2})
|
||||
assert config.speed == 1.2
|
||||
|
||||
def test_speed_boundary_values(self):
|
||||
config_low = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config_low.speed == 0.5
|
||||
config_high = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config_high.speed == 2.0
|
||||
|
||||
def test_pitch_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_pitch_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -3.5})
|
||||
assert config.pitch == -3.5
|
||||
|
||||
def test_volume_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_volume_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.5})
|
||||
assert config.volume == 0.5
|
||||
|
||||
def test_int_speed_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1})
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_int_pitch_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 2})
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == 2.0
|
||||
|
||||
def test_int_volume_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
+292
-243
@@ -1,281 +1,242 @@
|
||||
"""TTSJob 领域层单元测试 - tts_job.py"""
|
||||
"""tts_job 领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_job import (
|
||||
TERMINAL_STATUSES,
|
||||
TTSJob,
|
||||
TTSJobStatus,
|
||||
)
|
||||
from domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
|
||||
|
||||
|
||||
class TestTTSJobStatus:
|
||||
"""TTSJobStatus 枚举测试"""
|
||||
"""TTSJobStatus 枚举测试."""
|
||||
|
||||
def test_all_statuses_have_values(self):
|
||||
for s in TTSJobStatus:
|
||||
assert isinstance(s.value, str)
|
||||
assert s.value
|
||||
|
||||
def test_str_enum_behavior(self):
|
||||
def test_values(self):
|
||||
assert TTSJobStatus.PENDING == "pending"
|
||||
assert isinstance(TTSJobStatus.PENDING, str)
|
||||
assert TTSJobStatus.PROCESSING == "processing"
|
||||
assert TTSJobStatus.COMPLETED == "completed"
|
||||
assert TTSJobStatus.FAILED == "failed"
|
||||
assert TTSJobStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
|
||||
|
||||
def test_non_terminal_statuses(self):
|
||||
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestTTSJobCreate:
|
||||
"""TTSJob.create 工厂方法测试"""
|
||||
"""TTSJob.create 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
job = TTSJob.create(user_id="user-1", input_text="你好世界")
|
||||
def test_create_with_required_fields(self):
|
||||
job = TTSJob.create(user_id="user_001", input_text="你好世界")
|
||||
assert job.id
|
||||
assert len(job.id) == 32
|
||||
assert job.user_id == "user-1"
|
||||
assert job.user_id == "user_001"
|
||||
assert job.input_text == "你好世界"
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.voice_id == ""
|
||||
assert job.voice_model == ""
|
||||
assert job.format == "mp3"
|
||||
assert job.sample_rate == 22050
|
||||
assert job.format == "mp3"
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.metadata == {}
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
job = TTSJob.create(
|
||||
user_id="user_002",
|
||||
input_text="测试文本",
|
||||
voice_id="voice_001",
|
||||
voice_model="cosyvoice",
|
||||
project_id="proj_001",
|
||||
voice_clone_profile_id="clone_001",
|
||||
sample_rate=16000,
|
||||
format="wav",
|
||||
max_retries=5,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.voice_clone_profile_id == "clone_001"
|
||||
assert job.sample_rate == 16000
|
||||
assert job.format == "wav"
|
||||
assert job.max_retries == 5
|
||||
assert job.metadata == {"key": "value"}
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
job = TTSJob.create(
|
||||
user_id=" user_003 ",
|
||||
input_text=" 测试文本 ",
|
||||
voice_id=" voice_001 ",
|
||||
voice_model=" cosyvoice ",
|
||||
project_id=" proj_001 ",
|
||||
voice_clone_profile_id=" clone_001 ",
|
||||
format="wav",
|
||||
)
|
||||
assert job.user_id == "user_003"
|
||||
assert job.input_text == "测试文本"
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.voice_clone_profile_id == "clone_001"
|
||||
assert job.format == "wav"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id 不能为空"):
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
TTSJob.create(user_id="", input_text="test")
|
||||
|
||||
def test_create_whitespace_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
TTSJob.create(user_id=" ", input_text="test")
|
||||
|
||||
def test_create_empty_input_text_raises(self):
|
||||
with pytest.raises(ValueError, match="input_text 不能为空"):
|
||||
TTSJob.create(user_id="u1", input_text=" ")
|
||||
with pytest.raises(ValueError, match="input_text"):
|
||||
TTSJob.create(user_id="u", input_text="")
|
||||
|
||||
def test_create_text_too_long_raises(self):
|
||||
def test_create_input_text_too_long_raises(self):
|
||||
long_text = "a" * 10001
|
||||
with pytest.raises(ValueError, match="input_text 长度不能超过 10000"):
|
||||
TTSJob.create(user_id="u1", input_text=long_text)
|
||||
with pytest.raises(ValueError, match="10000"):
|
||||
TTSJob.create(user_id="u", input_text=long_text)
|
||||
|
||||
def test_create_text_exactly_10000_ok(self):
|
||||
def test_create_input_text_at_limit_ok(self):
|
||||
text = "a" * 10000
|
||||
job = TTSJob.create(user_id="u1", input_text=text)
|
||||
assert len(job.input_text) == 10000
|
||||
job = TTSJob.create(user_id="u", input_text=text)
|
||||
assert job.input_text == text
|
||||
|
||||
def test_create_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError, match="不支持的输出格式"):
|
||||
TTSJob.create(user_id="u1", input_text="test", format="flac")
|
||||
TTSJob.create(user_id="u", input_text="t", format="flac")
|
||||
|
||||
def test_create_mp3_format(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", format="mp3")
|
||||
assert job.format == "mp3"
|
||||
def test_create_supported_formats(self):
|
||||
for fmt in ["mp3", "wav", "pcm"]:
|
||||
job = TTSJob.create(user_id="u", input_text="t", format=fmt)
|
||||
assert job.format == fmt
|
||||
|
||||
def test_create_wav_format(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", format="wav")
|
||||
assert job.format == "wav"
|
||||
|
||||
def test_create_pcm_format(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", format="pcm")
|
||||
assert job.format == "pcm"
|
||||
|
||||
def test_create_with_voice_id(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", voice_id="voice-1")
|
||||
assert job.voice_id == "voice-1"
|
||||
|
||||
def test_create_with_project_id(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", project_id="proj-1")
|
||||
assert job.project_id == "proj-1"
|
||||
|
||||
def test_create_with_clone_profile(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", voice_clone_profile_id="vc-1")
|
||||
assert job.voice_clone_profile_id == "vc-1"
|
||||
|
||||
def test_create_with_metadata(self):
|
||||
meta = {"source": "api", "priority": "high"}
|
||||
job = TTSJob.create(user_id="u1", input_text="test", metadata=meta)
|
||||
assert job.metadata == meta
|
||||
|
||||
def test_create_none_metadata_defaults_empty(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", metadata=None)
|
||||
def test_create_none_metadata_defaults_to_empty_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", metadata=None)
|
||||
assert job.metadata == {}
|
||||
|
||||
def test_create_input_text_stripped(self):
|
||||
job = TTSJob.create(user_id=" u1 ", input_text=" hello ")
|
||||
assert job.user_id == "u1"
|
||||
assert job.input_text == "hello"
|
||||
|
||||
def test_create_custom_max_retries(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=5)
|
||||
assert job.max_retries == 5
|
||||
def test_create_ids_are_unique(self):
|
||||
j1 = TTSJob.create(user_id="u", input_text="t")
|
||||
j2 = TTSJob.create(user_id="u", input_text="t")
|
||||
assert j1.id != j2.id
|
||||
|
||||
|
||||
class TestTTSJobProperties:
|
||||
"""属性测试"""
|
||||
class TestTTSJobStateMachine:
|
||||
"""TTSJob 状态机测试."""
|
||||
|
||||
def test_is_terminal_pending(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
assert job.is_terminal is False
|
||||
@pytest.fixture
|
||||
def pending_job(self):
|
||||
return TTSJob.create(user_id="user_001", input_text="测试")
|
||||
|
||||
def test_is_terminal_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
job.mark_completed("http://audio.com/a.mp3")
|
||||
assert job.is_terminal is True
|
||||
def test_initial_status_is_pending(self, pending_job):
|
||||
assert pending_job.status == TTSJobStatus.PENDING
|
||||
assert not pending_job.is_terminal
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
assert job.is_terminal is True
|
||||
def test_pending_to_processing(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
assert pending_job.status == TTSJobStatus.PROCESSING
|
||||
assert pending_job.started_at is not None
|
||||
assert pending_job.error_message == ""
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
assert job.is_retryable is True
|
||||
def test_pending_can_fail_directly(self, pending_job):
|
||||
"""pending 可以直接到 failed(比如入参校验失败)"""
|
||||
pending_job.mark_failed("校验失败")
|
||||
assert pending_job.status == TTSJobStatus.FAILED
|
||||
assert pending_job.error_message == "校验失败"
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=1)
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
job.retry_count = 1
|
||||
assert job.is_retryable is False
|
||||
def test_pending_can_be_cancelled(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_is_completed_true(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
job.mark_completed("http://audio.com/a.mp3")
|
||||
assert job.is_completed is True
|
||||
def test_processing_to_completed(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert pending_job.status == TTSJobStatus.COMPLETED
|
||||
assert pending_job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert pending_job.completed_at is not None
|
||||
assert pending_job.error_message == ""
|
||||
|
||||
def test_is_completed_no_url(self):
|
||||
"""completed 状态但没有 output_audio_url 为空,is_completed 为 False"""
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.status = TTSJobStatus.COMPLETED
|
||||
job.output_audio_url = ""
|
||||
assert job.is_completed is False
|
||||
def test_processing_to_failed(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_failed("API 超时")
|
||||
assert pending_job.status == TTSJobStatus.FAILED
|
||||
assert pending_job.error_message == "API 超时"
|
||||
|
||||
def test_processing_can_be_cancelled(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
class TestTTSJobTransitions:
|
||||
"""状态转换测试"""
|
||||
def test_completed_is_terminal(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert pending_job.is_terminal
|
||||
assert pending_job.is_completed
|
||||
|
||||
def test_pending_to_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
def test_failed_is_terminal_but_retryable(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_failed("error")
|
||||
assert pending_job.is_terminal
|
||||
assert pending_job.is_retryable
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
def test_cancelled_is_terminal_and_not_retryable(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.is_terminal
|
||||
assert not pending_job.is_retryable
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.CANCELLED)
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_processing_to_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.COMPLETED)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
job.transition_to(TTSJobStatus.FAILED)
|
||||
job.transition_to(TTSJobStatus.PENDING)
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
def test_invalid_transition_completed_to_processing_raises(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
job.transition_to(TTSJobStatus.COMPLETED) # pending 不能直接到 completed
|
||||
pending_job.mark_processing()
|
||||
|
||||
def test_transition_with_string(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.transition_to("processing")
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
def test_invalid_transition_completed_to_failed_raises(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
pending_job.mark_failed("test")
|
||||
|
||||
def test_transition_invalid_string_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
def test_cancelled_cannot_transition(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
with pytest.raises(ValueError):
|
||||
pending_job.mark_processing()
|
||||
with pytest.raises(ValueError):
|
||||
pending_job.mark_failed("test")
|
||||
|
||||
def test_transition_to_with_string(self, pending_job):
|
||||
"""transition_to 支持字符串参数"""
|
||||
pending_job.transition_to("processing")
|
||||
assert pending_job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self, pending_job):
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
job.transition_to("invalid")
|
||||
pending_job.transition_to("invalid_status")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
old = job.updated_at
|
||||
def test_state_transition_updates_updated_at(self, pending_job):
|
||||
old_updated = pending_job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.transition_to(TTSJobStatus.PROCESSING)
|
||||
assert job.updated_at >= old
|
||||
|
||||
|
||||
class TestTTSJobMarkMethods:
|
||||
"""便捷标记方法测试"""
|
||||
|
||||
def test_mark_processing(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.error_message = "previous error"
|
||||
job.mark_processing()
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
assert job.started_at is not None
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_completed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
"http://example.com/audio.mp3",
|
||||
output_audio_key="audio/key.mp3",
|
||||
duration=5.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "http://example.com/audio.mp3"
|
||||
assert job.output_audio_key == "audio/key.mp3"
|
||||
assert job.duration == 5.5
|
||||
assert job.file_size == 102400
|
||||
assert job.completed_at is not None
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_mark_completed_empty_url_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
|
||||
job.mark_completed(" ")
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_processing()
|
||||
job.mark_failed("API 调用超时")
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert job.error_message == "API 调用超时"
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
job.mark_cancelled()
|
||||
assert job.status == TTSJobStatus.CANCELLED
|
||||
pending_job.mark_processing()
|
||||
assert pending_job.updated_at > old_updated
|
||||
|
||||
|
||||
class TestTTSJobRetry:
|
||||
"""重试逻辑测试"""
|
||||
"""TTSJob 重试逻辑测试."""
|
||||
|
||||
def test_prepare_retry(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
|
||||
def test_failed_can_retry(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("超时")
|
||||
job.mark_failed("error")
|
||||
assert job.is_retryable
|
||||
assert job.retry_count == 0
|
||||
|
||||
def test_prepare_retry_resets_to_pending(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
|
||||
job.prepare_retry()
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
@@ -283,62 +244,150 @@ class TestTTSJobRetry:
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
def test_prepare_retry_not_retryable_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=0)
|
||||
def test_retry_up_to_max_retries(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=2)
|
||||
# 第 1 次失败 + 重试 → retry_count=1,还可以重试
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
job.mark_failed("e1")
|
||||
assert job.is_retryable
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 1
|
||||
|
||||
# 第 2 次失败 → retry_count=1,还是 failed 状态,还可以重试(max_retries=2)
|
||||
job.mark_processing()
|
||||
job.mark_failed("e2")
|
||||
assert job.is_retryable # retry_count=1 < max_retries=2
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 2
|
||||
|
||||
# 第 3 次失败 → retry_count=2,达到上限,不可重试
|
||||
job.mark_processing()
|
||||
job.mark_failed("e3")
|
||||
assert not job.is_retryable # retry_count=2 == max_retries=2
|
||||
|
||||
def test_retry_exceed_max_raises(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=1)
|
||||
job.mark_processing()
|
||||
job.mark_failed("e")
|
||||
job.prepare_retry() # 第 1 次重试,用完了
|
||||
|
||||
job.mark_processing()
|
||||
job.mark_failed("e2")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_multiple_retries(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
|
||||
for i in range(3):
|
||||
job.mark_processing()
|
||||
job.mark_failed(f"error-{i}")
|
||||
def test_pending_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == i + 1
|
||||
# 第3次重试后 retry_count=3,等于 max_retries=3,不可再重试
|
||||
assert job.is_retryable is False
|
||||
|
||||
def test_completed_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_cancelled_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_cancelled()
|
||||
assert not job.is_retryable
|
||||
|
||||
|
||||
class TestTTSJobMarkCompleted:
|
||||
"""mark_completed 方法测试."""
|
||||
|
||||
def test_requires_output_url(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
with pytest.raises(ValueError, match="output_audio_url"):
|
||||
job.mark_completed(output_audio_url="")
|
||||
|
||||
def test_sets_all_fields(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://example.com/out.mp3",
|
||||
output_audio_key="audio/001.mp3",
|
||||
duration=30.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert job.output_audio_key == "audio/001.mp3"
|
||||
assert job.duration == 30.5
|
||||
assert job.file_size == 102400
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url=" https://example.com/out.mp3 ",
|
||||
output_audio_key=" audio/001.mp3 ",
|
||||
)
|
||||
assert job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert job.output_audio_key == "audio/001.mp3"
|
||||
|
||||
|
||||
class TestTTSJobIsCompleted:
|
||||
"""is_completed 属性测试."""
|
||||
|
||||
def test_completed_with_url_is_completed(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert job.is_completed
|
||||
|
||||
def test_completed_without_url_not_completed(self):
|
||||
"""极端情况:completed 状态但没有 URL(理论不会发生)"""
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.transition_to(TTSJobStatus.COMPLETED) # 直接转,不设 URL
|
||||
assert not job.is_completed
|
||||
|
||||
def test_pending_not_completed(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
assert not job.is_completed
|
||||
|
||||
|
||||
class TestTTSJobToDict:
|
||||
"""to_dict 序列化测试"""
|
||||
"""to_dict 序列化测试."""
|
||||
|
||||
def test_to_dict_contains_fields(self):
|
||||
job = TTSJob.create(
|
||||
user_id="u1",
|
||||
input_text="test",
|
||||
voice_id="voice-1",
|
||||
project_id="proj-1",
|
||||
)
|
||||
def test_pending_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="user_001", input_text="测试文本", voice_id="v001")
|
||||
d = job.to_dict()
|
||||
assert d["id"] == job.id
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["input_text"] == "test"
|
||||
assert d["voice_id"] == "voice-1"
|
||||
assert d["user_id"] == "user_001"
|
||||
assert d["status"] == "pending"
|
||||
assert d["input_text"] == "测试文本"
|
||||
assert d["voice_id"] == "v001"
|
||||
assert d["retry_count"] == 0
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_completed"] is False
|
||||
|
||||
def test_to_dict_datetime_are_strings(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
d = job.to_dict()
|
||||
assert isinstance(d["created_at"], str)
|
||||
assert isinstance(d["updated_at"], str)
|
||||
|
||||
def test_to_dict_none_datetime(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
d = job.to_dict()
|
||||
assert d["metadata"] == {}
|
||||
assert d["started_at"] is None
|
||||
assert d["completed_at"] is None
|
||||
assert d["created_at"] is not None
|
||||
assert d["updated_at"] is not None
|
||||
|
||||
def test_to_dict_after_completion(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="test")
|
||||
def test_completed_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed("http://test.mp3", duration=10.0)
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3", duration=10.0)
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "completed"
|
||||
assert d["output_audio_url"] == "https://example.com/out.mp3"
|
||||
assert d["duration"] == 10.0
|
||||
assert d["is_completed"] is True
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
|
||||
def test_failed_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_failed("出错了")
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "出错了"
|
||||
assert d["is_retryable"] is True
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""VerificationCode 领域实体单测."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
def test_create_default_ttl(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.id
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.code_type == "email_bind"
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
assert code.used_at is None
|
||||
assert code.attempts == 0
|
||||
# 默认5分钟过期
|
||||
assert code.expires_at > code.created_at
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(300, abs=1)
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
code = VerificationCode.create(recipient="13800138000", code_type="phone_login", ttl_seconds=60)
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(60, abs=1)
|
||||
|
||||
def test_create_custom_code(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="reset_password", custom_code="123456")
|
||||
assert code.code == "123456"
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
code = VerificationCode.create(recipient=" test@example.com ", code_type="email_bind")
|
||||
assert code.recipient == "test@example.com"
|
||||
|
||||
|
||||
class TestVerificationCodeStatus:
|
||||
def test_is_valid_initial(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.is_valid is True
|
||||
assert code.is_expired is False
|
||||
assert code.is_used is False
|
||||
|
||||
def test_mark_used(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
code.mark_used()
|
||||
assert code.is_used is True
|
||||
assert code.used_at is not None
|
||||
assert code.is_valid is False
|
||||
|
||||
def test_is_expired_future(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=3600)
|
||||
assert code.is_expired is False
|
||||
|
||||
def test_increment_attempts(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.attempts == 0
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 1
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 2
|
||||
|
||||
def test_is_valid_after_expired(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=0)
|
||||
# 0秒TTL,立即可能过期(有极小概率因时间差没过)
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
assert code.is_expired is True
|
||||
assert code.is_valid is False
|
||||
@@ -1,225 +0,0 @@
|
||||
"""VideoShare 领域层单元测试 - video_share.py"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.video_share import (
|
||||
VideoShare,
|
||||
_hash_password,
|
||||
generate_share_token,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateShareToken:
|
||||
"""generate_share_token 函数测试"""
|
||||
|
||||
def test_default_length(self):
|
||||
token = generate_share_token()
|
||||
assert len(token) == 12
|
||||
|
||||
def test_custom_length(self):
|
||||
token = generate_share_token(20)
|
||||
assert len(token) == 20
|
||||
|
||||
def test_url_friendly_characters(self):
|
||||
"""token 只包含 URL 友好的字符(没有 l, i, o, 0, 1 等易混字符)"""
|
||||
token = generate_share_token(100)
|
||||
# 不应包含易混淆字符
|
||||
assert "l" not in token
|
||||
assert "i" not in token
|
||||
assert "o" not in token
|
||||
assert "0" not in token
|
||||
assert "1" not in token
|
||||
|
||||
def test_randomness(self):
|
||||
"""两次生成的 token 不同(概率上)"""
|
||||
tokens = {generate_share_token() for _ in range(100)}
|
||||
# 100 次应该几乎不可能重复
|
||||
assert len(tokens) > 95
|
||||
|
||||
|
||||
class TestHashPassword:
|
||||
"""_hash_password 函数测试"""
|
||||
|
||||
def test_empty_password_returns_empty(self):
|
||||
assert _hash_password("") == ""
|
||||
|
||||
def test_none_password_returns_empty(self):
|
||||
assert _hash_password(None) == "" # type: ignore
|
||||
|
||||
def test_hash_is_deterministic(self):
|
||||
"""相同密码哈希结果相同"""
|
||||
h1 = _hash_password("mypassword")
|
||||
h2 = _hash_password("mypassword")
|
||||
assert h1 == h2
|
||||
|
||||
def test_hash_differs_for_different_passwords(self):
|
||||
"""不同密码哈希结果不同"""
|
||||
h1 = _hash_password("password1")
|
||||
h2 = _hash_password("password2")
|
||||
assert h1 != h2
|
||||
|
||||
def test_hash_is_hex_string(self):
|
||||
"""哈希是 64 位十六进制字符串(SHA-256)"""
|
||||
h = _hash_password("test")
|
||||
assert len(h) == 64
|
||||
int(h, 16) # 应该能解析为十六进制
|
||||
|
||||
def test_hash_includes_salt(self):
|
||||
"""加盐后与直接 SHA-256 不同"""
|
||||
from hashlib import sha256
|
||||
|
||||
direct = sha256("mypass".encode()).hexdigest()
|
||||
salted = _hash_password("mypass")
|
||||
assert direct != salted
|
||||
|
||||
|
||||
class TestVideoShareCreate:
|
||||
"""VideoShare.create 工厂方法测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
share = VideoShare.create(video_id="video-1", user_id="user-1")
|
||||
assert share.id
|
||||
assert len(share.id) == 32
|
||||
assert share.video_id == "video-1"
|
||||
assert share.user_id == "user-1"
|
||||
assert share.share_token
|
||||
assert len(share.share_token) == 12
|
||||
assert share.password_hash is None
|
||||
assert share.expires_at is None
|
||||
assert share.view_count == 0
|
||||
assert share.download_count == 0
|
||||
assert share.is_active is True
|
||||
assert share.created_at is not None
|
||||
assert share.updated_at is not None
|
||||
|
||||
def test_create_empty_video_id_raises(self):
|
||||
with pytest.raises(ValueError, match="video_id cannot be empty"):
|
||||
VideoShare.create(video_id=" ", user_id="u1")
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
VideoShare.create(video_id="v1", user_id=" ")
|
||||
|
||||
def test_create_with_password(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
|
||||
assert share.password_hash is not None
|
||||
assert share.password_hash != "secret123" # 已哈希
|
||||
assert len(share.password_hash) == 64 # SHA-256 hex
|
||||
|
||||
def test_create_with_expires_at(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
||||
assert share.expires_at == future
|
||||
|
||||
def test_create_past_expires_at_raises(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
with pytest.raises(ValueError, match="expires_at cannot be in the past"):
|
||||
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
|
||||
|
||||
def test_create_fields_stripped(self):
|
||||
share = VideoShare.create(video_id=" v1 ", user_id=" u1 ")
|
||||
assert share.video_id == "v1"
|
||||
assert share.user_id == "u1"
|
||||
|
||||
def test_unique_tokens(self):
|
||||
"""不同分享有不同的 token"""
|
||||
shares = [VideoShare.create(video_id="v1", user_id="u1") for _ in range(20)]
|
||||
tokens = [s.share_token for s in shares]
|
||||
assert len(set(tokens)) == 20
|
||||
|
||||
|
||||
class TestVideoShareProperties:
|
||||
"""属性测试"""
|
||||
|
||||
def test_has_password_true(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret")
|
||||
assert share.has_password is True
|
||||
|
||||
def test_has_password_false(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.has_password is False
|
||||
|
||||
def test_is_expired_no_expiry(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_is_expired_future_expiry(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_is_expired_past_expiry(self):
|
||||
# 直接设置 expires_at 为过去时间(绕过 create 的校验)
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert share.is_expired is True
|
||||
|
||||
def test_is_accessible_active_not_expired(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_accessible is True
|
||||
|
||||
def test_is_accessible_inactive(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.is_active = False
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_is_accessible_expired(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_is_accessible_inactive_and_expired(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
share.is_active = False
|
||||
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
assert share.is_accessible is False
|
||||
|
||||
|
||||
class TestVideoSharePassword:
|
||||
"""密码验证测试"""
|
||||
|
||||
def test_verify_password_correct(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
|
||||
assert share.verify_password("secret123") is True
|
||||
|
||||
def test_verify_password_wrong(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
|
||||
assert share.verify_password("wrongpass") is False
|
||||
|
||||
def test_verify_no_password_always_true(self):
|
||||
"""没有设置密码时,任何密码都通过(包括空密码)"""
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.verify_password("") is True
|
||||
assert share.verify_password("anything") is True
|
||||
|
||||
def test_verify_empty_password_with_password_set(self):
|
||||
"""有密码时,空密码不通过"""
|
||||
share = VideoShare.create(video_id="v1", user_id="u1", password="secret")
|
||||
assert share.verify_password("") is False
|
||||
|
||||
|
||||
class TestVideoShareCounters:
|
||||
"""计数方法测试"""
|
||||
|
||||
def test_increment_view_count(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.view_count == 0
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 1
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 2
|
||||
|
||||
def test_increment_download_count(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.download_count == 0
|
||||
share.increment_download_count()
|
||||
assert share.download_count == 1
|
||||
|
||||
def test_revoke(self):
|
||||
share = VideoShare.create(video_id="v1", user_id="u1")
|
||||
assert share.is_active is True
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
assert share.is_accessible is False
|
||||
Executable → Regular
+274
-244
@@ -1,4 +1,6 @@
|
||||
"""VoiceCloneProfile 领域层单元测试 - voice_clone_profile.py"""
|
||||
"""
|
||||
VoiceCloneProfile 音色克隆档案领域模型单元测试
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,348 +14,376 @@ from packages.domain.voice_clone_profile import (
|
||||
class TestVoiceCloneStatus:
|
||||
"""VoiceCloneStatus 枚举测试"""
|
||||
|
||||
def test_all_statuses_have_values(self):
|
||||
for s in VoiceCloneStatus:
|
||||
assert isinstance(s.value, str)
|
||||
assert s.value
|
||||
|
||||
def test_str_enum(self):
|
||||
def test_status_values(self):
|
||||
assert VoiceCloneStatus.PENDING == "pending"
|
||||
assert isinstance(VoiceCloneStatus.PENDING, str)
|
||||
assert VoiceCloneStatus.PROCESSING == "processing"
|
||||
assert VoiceCloneStatus.READY == "ready"
|
||||
assert VoiceCloneStatus.FAILED == "failed"
|
||||
assert VoiceCloneStatus.DISABLED == "disabled"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert VoiceCloneStatus.READY in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES
|
||||
|
||||
def test_non_terminal(self):
|
||||
assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestVoiceCloneProfileCreate:
|
||||
"""create 工厂方法测试"""
|
||||
"""VoiceCloneProfile.create 工厂方法测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
profile = VoiceCloneProfile.create(user_id="user-1", name="我的音色")
|
||||
assert profile.id
|
||||
assert len(profile.id) == 32
|
||||
assert profile.user_id == "user-1"
|
||||
def test_create_minimal(self):
|
||||
profile = VoiceCloneProfile.create(user_id="user123", name="我的音色")
|
||||
assert profile.id is not None
|
||||
assert len(profile.id) == 32 # uuid4 hex
|
||||
assert profile.user_id == "user123"
|
||||
assert profile.name == "我的音色"
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.description == ""
|
||||
assert profile.source_audio_url == ""
|
||||
assert profile.voice_id == ""
|
||||
assert profile.language == "zh-CN"
|
||||
assert profile.gender == "unknown"
|
||||
assert profile.retry_count == 0
|
||||
assert profile.max_retries == 3
|
||||
assert profile.metadata == {}
|
||||
assert profile.gender == "unknown"
|
||||
assert profile.language == "zh-CN"
|
||||
assert profile.created_at is not None
|
||||
assert profile.updated_at is not None
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user456",
|
||||
name="测试音色",
|
||||
description="这是一个测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
voice_model="cosyvoice-v2",
|
||||
language="en-US",
|
||||
gender="MALE",
|
||||
max_retries=5,
|
||||
metadata={"source": "upload"},
|
||||
)
|
||||
assert profile.user_id == "user456"
|
||||
assert profile.name == "测试音色"
|
||||
assert profile.description == "这是一个测试音色"
|
||||
assert profile.source_audio_url == "https://example.com/audio.wav"
|
||||
assert profile.voice_model == "cosyvoice-v2"
|
||||
assert profile.language == "en-US"
|
||||
assert profile.gender == "male" # 转小写
|
||||
assert profile.max_retries == 5
|
||||
assert profile.metadata == {"source": "upload"}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=" user789 ",
|
||||
name=" 我的音色 ",
|
||||
description=" 描述 ",
|
||||
)
|
||||
assert profile.user_id == "user789"
|
||||
assert profile.name == "我的音色"
|
||||
assert profile.description == "描述"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id 不能为空"):
|
||||
VoiceCloneProfile.create(user_id=" ", name="test")
|
||||
VoiceCloneProfile.create(user_id=" ", name="测试")
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="name 不能为空"):
|
||||
VoiceCloneProfile.create(user_id="u1", name=" ")
|
||||
VoiceCloneProfile.create(user_id="user1", name=" ")
|
||||
|
||||
def test_create_name_too_long_raises(self):
|
||||
long_name = "a" * 101
|
||||
with pytest.raises(ValueError, match="name 长度不能超过 100"):
|
||||
VoiceCloneProfile.create(user_id="u1", name=long_name)
|
||||
with pytest.raises(ValueError, match="name 长度不能超过 100 字符"):
|
||||
VoiceCloneProfile.create(user_id="user1", name=long_name)
|
||||
|
||||
def test_create_name_exactly_100_ok(self):
|
||||
def test_create_name_exactly_100_chars_ok(self):
|
||||
name = "a" * 100
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name=name)
|
||||
profile = VoiceCloneProfile.create(user_id="user1", name=name)
|
||||
assert profile.name == name
|
||||
|
||||
def test_create_with_description(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", description="温暖男声")
|
||||
assert profile.description == "温暖男声"
|
||||
|
||||
def test_create_with_source_audio(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", source_audio_url="http://audio.com/source.wav")
|
||||
assert profile.source_audio_url == "http://audio.com/source.wav"
|
||||
|
||||
def test_create_with_language(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", language="en-US")
|
||||
assert profile.language == "en-US"
|
||||
|
||||
def test_create_gender_lowercased(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", gender="MALE")
|
||||
assert profile.gender == "male"
|
||||
|
||||
def test_create_with_metadata(self):
|
||||
meta = {"source": "upload", "duration": 30}
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=meta)
|
||||
assert profile.metadata == meta
|
||||
|
||||
def test_create_none_metadata_defaults_empty(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=None)
|
||||
def test_create_default_metadata_is_dict(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
assert profile.metadata == {}
|
||||
|
||||
def test_create_fields_stripped(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=" u1 ",
|
||||
name=" test ",
|
||||
description=" desc ",
|
||||
source_audio_url=" url ",
|
||||
voice_model=" model ",
|
||||
language=" zh-CN ",
|
||||
)
|
||||
assert profile.user_id == "u1"
|
||||
assert profile.name == "test"
|
||||
assert profile.description == "desc"
|
||||
assert profile.source_audio_url == "url"
|
||||
assert profile.voice_model == "model"
|
||||
assert profile.language == "zh-CN"
|
||||
|
||||
def test_create_custom_max_retries(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=5)
|
||||
assert profile.max_retries == 5
|
||||
# 不应该共享同一个 dict
|
||||
p2 = VoiceCloneProfile.create(user_id="u2", name="test2")
|
||||
assert profile.metadata is not p2.metadata
|
||||
|
||||
|
||||
class TestVoiceCloneProfileProperties:
|
||||
"""属性测试"""
|
||||
|
||||
def test_is_terminal_pending(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
assert p.is_terminal is False
|
||||
|
||||
def test_is_terminal_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice-123")
|
||||
assert p.is_terminal is True
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
assert profile.is_terminal is True
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
assert p.is_terminal is True
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
assert profile.is_terminal is True
|
||||
|
||||
def test_is_terminal_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_disabled()
|
||||
assert p.is_terminal is True
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.DISABLED
|
||||
assert profile.is_terminal is True
|
||||
|
||||
def test_is_terminal_pending(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
assert profile.is_terminal is False
|
||||
|
||||
def test_is_terminal_processing(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
assert profile.is_terminal is False
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
assert p.is_retryable is True
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.retry_count = 1
|
||||
assert profile.is_retryable is True
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=1)
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
p.retry_count = 1
|
||||
assert p.is_retryable is False
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.retry_count = 3
|
||||
assert profile.is_retryable is False
|
||||
|
||||
def test_is_ready_true(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice-123")
|
||||
assert p.is_ready is True
|
||||
def test_is_retryable_pending(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
assert profile.is_retryable is False
|
||||
|
||||
def test_is_ready_no_voice_id(self):
|
||||
"""ready 状态但没有 voice_id,is_ready 为 False"""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.status = VoiceCloneStatus.READY
|
||||
p.voice_id = ""
|
||||
assert p.is_ready is False
|
||||
def test_is_ready_with_voice_id(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
profile.voice_id = "voice_123"
|
||||
assert profile.is_ready is True
|
||||
|
||||
def test_is_ready_not_ready_status(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.voice_id = "some-id"
|
||||
assert p.is_ready is False # 状态是 PENDING
|
||||
def test_is_ready_without_voice_id(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
profile.voice_id = ""
|
||||
assert profile.is_ready is False
|
||||
|
||||
def test_is_ready_wrong_status(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.voice_id = "voice_123"
|
||||
assert profile.is_ready is False # pending status
|
||||
|
||||
|
||||
class TestVoiceCloneProfileTransitions:
|
||||
class TestStateTransitions:
|
||||
"""状态转换测试"""
|
||||
|
||||
def test_pending_to_processing(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
old_updated = profile.updated_at
|
||||
profile.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
assert profile.updated_at >= old_updated
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_pending_to_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_processing_to_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.transition_to(VoiceCloneStatus.READY)
|
||||
assert profile.status == VoiceCloneStatus.READY
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_processing_to_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_ready_to_disabled(self):
|
||||
"""已就绪音色可以被禁用"""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice-1")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.transition_to(VoiceCloneStatus.PENDING)
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
p.transition_to(VoiceCloneStatus.READY) # pending 不能直接到 ready
|
||||
|
||||
def test_disabled_to_pending_raises(self):
|
||||
"""禁用后不能回到 pending"""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_disabled()
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
def test_ready_to_disabled(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
profile.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_transition_with_string(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.transition_to("processing")
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.transition_to("processing")
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_transition_invalid_string_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
p.transition_to("invalid")
|
||||
profile.transition_to("invalid_status")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
old = p.updated_at
|
||||
import time
|
||||
def test_invalid_transition_pending_to_ready(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
profile.transition_to(VoiceCloneStatus.READY)
|
||||
|
||||
time.sleep(0.001)
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.updated_at >= old
|
||||
def test_invalid_transition_ready_to_processing(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
profile.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
|
||||
def test_invalid_transition_failed_to_ready(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
profile.transition_to(VoiceCloneStatus.READY)
|
||||
|
||||
def test_invalid_transition_disabled_to_pending(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.DISABLED
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
profile.transition_to(VoiceCloneStatus.PENDING)
|
||||
|
||||
|
||||
class TestVoiceCloneProfileMarkMethods:
|
||||
"""便捷标记方法测试"""
|
||||
class TestMarkMethods:
|
||||
"""标记方法测试"""
|
||||
|
||||
def test_mark_processing(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.error_message = "prev error"
|
||||
p.mark_processing()
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
assert p.error_message == ""
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.error_message = "some error"
|
||||
profile.mark_processing()
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
assert profile.error_message == ""
|
||||
|
||||
def test_mark_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice-abc123")
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
assert p.voice_id == "voice-abc123"
|
||||
assert p.error_message == ""
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.error_message = "old error"
|
||||
profile.mark_ready("voice_abc123")
|
||||
assert profile.status == VoiceCloneStatus.READY
|
||||
assert profile.voice_id == "voice_abc123"
|
||||
assert profile.error_message == ""
|
||||
|
||||
def test_mark_ready_empty_voice_id_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
with pytest.raises(ValueError, match="voice_id 不能为空"):
|
||||
p.mark_ready(" ")
|
||||
profile.mark_ready(" ")
|
||||
|
||||
def test_mark_ready_strips_whitespace(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.mark_ready(" voice_123 ")
|
||||
assert profile.voice_id == "voice_123"
|
||||
|
||||
def test_mark_failed(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_failed("音频质量太差")
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
assert p.error_message == "音频质量太差"
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.mark_failed("音频质量太差")
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert profile.error_message == "音频质量太差"
|
||||
|
||||
def test_mark_disabled(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_disabled()
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
def test_mark_disabled_from_pending(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.mark_disabled()
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_mark_disabled_from_ready(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
profile.voice_id = "v1"
|
||||
profile.mark_disabled()
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
assert profile.voice_id == "v1" # 禁用不清除voice_id
|
||||
|
||||
|
||||
class TestVoiceCloneProfileRetry:
|
||||
"""重试逻辑测试"""
|
||||
class TestPrepareRetry:
|
||||
"""重试准备测试"""
|
||||
|
||||
def test_prepare_retry(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("超时")
|
||||
p.voice_id = "partial-id"
|
||||
p.prepare_retry()
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
assert p.retry_count == 1
|
||||
assert p.error_message == ""
|
||||
assert p.voice_id == "" # 重试时清空 voice_id
|
||||
def test_prepare_retry_success(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.retry_count = 1
|
||||
profile.error_message = "timeout"
|
||||
profile.voice_id = "old_voice"
|
||||
profile.prepare_retry()
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.retry_count == 2
|
||||
assert profile.error_message == ""
|
||||
assert profile.voice_id == ""
|
||||
|
||||
def test_prepare_retry_not_retryable_raises(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=0)
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
def test_prepare_retry_first_time(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.prepare_retry()
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.retry_count == 1
|
||||
|
||||
def test_prepare_retry_exceeds_max_raises(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
profile.status = VoiceCloneStatus.FAILED
|
||||
profile.retry_count = 3
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
p.prepare_retry()
|
||||
profile.prepare_retry()
|
||||
|
||||
def test_multiple_retries(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
|
||||
for i in range(3):
|
||||
p.mark_processing()
|
||||
p.mark_failed(f"error-{i}")
|
||||
p.prepare_retry()
|
||||
assert p.retry_count == i + 1
|
||||
assert p.is_retryable is False
|
||||
def test_prepare_retry_from_pending_raises(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
profile.prepare_retry()
|
||||
|
||||
def test_prepare_retry_from_ready_raises(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
profile.prepare_retry()
|
||||
|
||||
|
||||
class TestVoiceCloneProfileToDict:
|
||||
"""to_dict 序列化测试"""
|
||||
class TestToDict:
|
||||
"""序列化测试"""
|
||||
|
||||
def test_to_dict_contains_fields(self):
|
||||
p = VoiceCloneProfile.create(
|
||||
user_id="u1",
|
||||
name="我的音色",
|
||||
description="测试",
|
||||
language="en-US",
|
||||
gender="female",
|
||||
def test_to_dict_basic(self):
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user1",
|
||||
name="测试音色",
|
||||
description="desc",
|
||||
max_retries=2,
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d["id"] == p.id
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["name"] == "我的音色"
|
||||
assert d["description"] == "测试"
|
||||
d = profile.to_dict()
|
||||
assert d["id"] == profile.id
|
||||
assert d["user_id"] == "user1"
|
||||
assert d["name"] == "测试音色"
|
||||
assert d["description"] == "desc"
|
||||
assert d["status"] == "pending"
|
||||
assert d["language"] == "en-US"
|
||||
assert d["gender"] == "female"
|
||||
assert d["retry_count"] == 0
|
||||
assert d["max_retries"] == 2
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_ready"] is False
|
||||
|
||||
def test_to_dict_datetime_are_strings(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
d = p.to_dict()
|
||||
assert isinstance(d["created_at"], str)
|
||||
assert isinstance(d["updated_at"], str)
|
||||
|
||||
def test_to_dict_after_ready(self):
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice-123")
|
||||
d = p.to_dict()
|
||||
def test_to_dict_ready_state(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.status = VoiceCloneStatus.PROCESSING
|
||||
profile.mark_ready("voice_123")
|
||||
d = profile.to_dict()
|
||||
assert d["status"] == "ready"
|
||||
assert d["voice_id"] == "voice-123"
|
||||
assert d["voice_id"] == "voice_123"
|
||||
assert d["is_ready"] is True
|
||||
assert d["is_retryable"] is False
|
||||
|
||||
def test_to_dict_failed_state(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test")
|
||||
profile.mark_failed("some error")
|
||||
d = profile.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "some error"
|
||||
assert d["is_retryable"] is True # retry_count=0, max_retries=3
|
||||
|
||||
def test_to_dict_includes_metadata(self):
|
||||
profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata={"key": "value", "num": 42})
|
||||
d = profile.to_dict()
|
||||
assert d["metadata"] == {"key": "value", "num": 42}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""voice_presets 音色预设单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_presets import (
|
||||
MOCK_VOICES,
|
||||
VoiceGender,
|
||||
VoicePreset,
|
||||
VoiceStyle,
|
||||
get_default_voice,
|
||||
get_voice,
|
||||
list_voices,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceGender:
|
||||
def test_values(self):
|
||||
assert VoiceGender.MALE.value == "male"
|
||||
assert VoiceGender.FEMALE.value == "female"
|
||||
assert VoiceGender.CHILD.value == "child"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(VoiceGender.FEMALE, str)
|
||||
|
||||
|
||||
class TestVoiceStyle:
|
||||
def test_values(self):
|
||||
assert VoiceStyle.STABLE.value == "stable"
|
||||
assert VoiceStyle.LIVELY.value == "lively"
|
||||
assert VoiceStyle.NARRATION.value == "narration"
|
||||
assert VoiceStyle.NEWS.value == "news"
|
||||
assert VoiceStyle.STORY.value == "story"
|
||||
|
||||
|
||||
class TestVoicePreset:
|
||||
def test_default_values(self):
|
||||
v = VoicePreset(voice_id="test", name="测试音色")
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.NARRATION
|
||||
assert v.provider == "mock"
|
||||
assert v.default_speed == 1.0
|
||||
assert v.default_pitch == 0.0
|
||||
assert v.sample_rate == 22050
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_custom_values(self):
|
||||
v = VoicePreset(
|
||||
voice_id="male1",
|
||||
name="男声",
|
||||
gender=VoiceGender.MALE,
|
||||
style=VoiceStyle.STABLE,
|
||||
provider="aliyun",
|
||||
default_speed=0.9,
|
||||
)
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
assert v.provider == "aliyun"
|
||||
assert v.default_speed == 0.9
|
||||
|
||||
|
||||
class TestMockVoices:
|
||||
def test_mock_voices_not_empty(self):
|
||||
assert len(MOCK_VOICES) > 0
|
||||
|
||||
def test_all_mock_voices_have_ids(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.voice_id
|
||||
assert v.name
|
||||
assert v.provider == "mock"
|
||||
|
||||
def test_unique_voice_ids(self):
|
||||
ids = [v.voice_id for v in MOCK_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
class TestGetVoice:
|
||||
def test_get_existing_voice(self):
|
||||
v = get_voice("female_warm")
|
||||
assert v is not None
|
||||
assert v.voice_id == "female_warm"
|
||||
assert v.name == "温暖女声"
|
||||
|
||||
def test_get_nonexistent_voice(self):
|
||||
v = get_voice("nonexistent")
|
||||
assert v is None
|
||||
|
||||
def test_non_mock_provider_returns_none(self):
|
||||
v = get_voice("female_warm", provider="aliyun")
|
||||
assert v is None
|
||||
|
||||
|
||||
class TestListVoices:
|
||||
def test_list_all(self):
|
||||
voices = list_voices()
|
||||
assert len(voices) == len(MOCK_VOICES)
|
||||
|
||||
def test_filter_by_gender(self):
|
||||
female_voices = list_voices(gender="female")
|
||||
assert len(female_voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in female_voices)
|
||||
|
||||
def test_filter_by_style(self):
|
||||
story_voices = list_voices(style="story")
|
||||
assert len(story_voices) > 0
|
||||
assert all(v.style == VoiceStyle.STORY for v in story_voices)
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
voices = list_voices(keyword="女声")
|
||||
assert len(voices) > 0
|
||||
assert all("女声" in v.name for v in voices)
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
voices = list_voices(keyword="商务")
|
||||
assert len(voices) > 0
|
||||
assert any("商务" in v.description for v in voices)
|
||||
|
||||
def test_filter_by_provider_non_mock(self):
|
||||
voices = list_voices(provider="aliyun")
|
||||
assert len(voices) == 0
|
||||
|
||||
def test_filter_multiple_conditions(self):
|
||||
voices = list_voices(gender="female", style="narration")
|
||||
assert len(voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in voices)
|
||||
assert all(v.style == VoiceStyle.NARRATION for v in voices)
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
voices1 = list_voices(keyword="FEMALE")
|
||||
voices2 = list_voices(keyword="female")
|
||||
assert len(voices1) == len(voices2)
|
||||
|
||||
|
||||
class TestGetDefaultVoice:
|
||||
def test_default_voice_exists(self):
|
||||
v = get_default_voice()
|
||||
assert v is not None
|
||||
assert v == MOCK_VOICES[0]
|
||||
@@ -1,275 +0,0 @@
|
||||
"""
|
||||
水印引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 WatermarkConfig.from_dict / validate / 位置枚举等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from video_processing.watermark_engine import WATERMARK_POSITIONS, WatermarkConfig
|
||||
|
||||
|
||||
class TestWatermarkPositions:
|
||||
"""水印位置枚举."""
|
||||
|
||||
def test_nine_positions_exist(self):
|
||||
assert len(WATERMARK_POSITIONS) == 9
|
||||
assert "top_left" in WATERMARK_POSITIONS
|
||||
assert "top_center" in WATERMARK_POSITIONS
|
||||
assert "top_right" in WATERMARK_POSITIONS
|
||||
assert "center_left" in WATERMARK_POSITIONS
|
||||
assert "center" in WATERMARK_POSITIONS
|
||||
assert "center_right" in WATERMARK_POSITIONS
|
||||
assert "bottom_left" in WATERMARK_POSITIONS
|
||||
assert "bottom_center" in WATERMARK_POSITIONS
|
||||
assert "bottom_right" in WATERMARK_POSITIONS
|
||||
|
||||
def test_position_values_are_chinese_labels(self):
|
||||
for key, label in WATERMARK_POSITIONS.items():
|
||||
assert isinstance(label, str)
|
||||
assert len(label) >= 2
|
||||
|
||||
|
||||
class TestWatermarkConfigFromDict:
|
||||
"""from_dict 构造逻辑."""
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert WatermarkConfig.from_dict(None) is None
|
||||
|
||||
def test_empty_dict_returns_none(self):
|
||||
assert WatermarkConfig.from_dict({}) is None
|
||||
|
||||
def test_enabled_false_returns_none(self):
|
||||
assert WatermarkConfig.from_dict({"enabled": False}) is None
|
||||
|
||||
def test_image_mode_without_path_returns_none(self):
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_image_mode_with_empty_path_returns_none(self):
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_text_mode_without_text_returns_none(self):
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_text_mode_with_empty_text_returns_none(self):
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_image_mode_success(self):
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "/tmp/logo.png",
|
||||
"scale": 0.3,
|
||||
"opacity": 0.9,
|
||||
"position": "top_left",
|
||||
"margin_x": 30,
|
||||
"margin_y": 30,
|
||||
}
|
||||
)
|
||||
assert cfg is not None
|
||||
assert cfg.mode == "image"
|
||||
assert cfg.image_path == "/tmp/logo.png"
|
||||
assert cfg.scale == 0.3
|
||||
assert cfg.opacity == 0.9
|
||||
assert cfg.position == "top_left"
|
||||
assert cfg.margin_x == 30
|
||||
assert cfg.margin_y == 30
|
||||
|
||||
def test_image_mode_image_key_fallback(self):
|
||||
"""image 字段作为 image_path 的 fallback."""
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image": "/tmp/fallback.png",
|
||||
}
|
||||
)
|
||||
assert cfg is not None
|
||||
assert cfg.image_path == "/tmp/fallback.png"
|
||||
|
||||
def test_text_mode_success(self):
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "hello world",
|
||||
"font_size": 32,
|
||||
"font_color": "red",
|
||||
"position": "bottom_left",
|
||||
"scroll": True,
|
||||
"scroll_speed": 100,
|
||||
}
|
||||
)
|
||||
assert cfg is not None
|
||||
assert cfg.mode == "text"
|
||||
assert cfg.text == "hello world"
|
||||
assert cfg.font_size == 32
|
||||
assert cfg.font_color == "red"
|
||||
assert cfg.position == "bottom_left"
|
||||
assert cfg.scroll is True
|
||||
assert cfg.scroll_speed == 100
|
||||
|
||||
def test_invalid_position_falls_back_to_bottom_right(self):
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
"position": "invalid_position",
|
||||
}
|
||||
)
|
||||
assert cfg is not None
|
||||
assert cfg.position == "bottom_right"
|
||||
|
||||
def test_default_values_applied(self):
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
}
|
||||
)
|
||||
assert cfg is not None
|
||||
assert cfg.position == "bottom_right"
|
||||
assert cfg.opacity == 0.8
|
||||
assert cfg.scale == 0.2
|
||||
assert cfg.font_size == 24
|
||||
assert cfg.font_color == "white"
|
||||
assert cfg.margin_x == 20
|
||||
assert cfg.margin_y == 20
|
||||
assert cfg.scroll is False
|
||||
assert cfg.scroll_speed == 50
|
||||
|
||||
|
||||
class TestWatermarkConfigValidate:
|
||||
"""validate 校验逻辑."""
|
||||
|
||||
def test_valid_image_config(self):
|
||||
cfg = WatermarkConfig(
|
||||
mode="image",
|
||||
image_path="/tmp/logo.png",
|
||||
position="top_right",
|
||||
opacity=0.5,
|
||||
scale=0.5,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_text_config(self):
|
||||
cfg = WatermarkConfig(
|
||||
mode="text",
|
||||
text="hello",
|
||||
position="center",
|
||||
opacity=1.0,
|
||||
font_size=48,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_invalid_position(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", position="nowhere")
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持的位置" in msg
|
||||
|
||||
def test_opacity_below_zero(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", opacity=-0.1)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "透明度" in msg
|
||||
|
||||
def test_opacity_above_one(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", opacity=1.5)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "透明度" in msg
|
||||
|
||||
def test_opacity_zero_is_valid(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", opacity=0.0)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_opacity_one_is_valid(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", opacity=1.0)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_image_missing_path(self):
|
||||
cfg = WatermarkConfig(mode="image", image_path="")
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "图片路径" in msg
|
||||
|
||||
def test_image_scale_too_small(self):
|
||||
cfg = WatermarkConfig(mode="image", image_path="/tmp/a.png", scale=0.001)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "缩放比例" in msg
|
||||
|
||||
def test_image_scale_too_large(self):
|
||||
cfg = WatermarkConfig(mode="image", image_path="/tmp/a.png", scale=2.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "缩放比例" in msg
|
||||
|
||||
def test_image_scale_boundary_valid(self):
|
||||
cfg = WatermarkConfig(mode="image", image_path="/tmp/a.png", scale=0.01)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
cfg2 = WatermarkConfig(mode="image", image_path="/tmp/a.png", scale=1.0)
|
||||
ok2, _ = cfg2.validate()
|
||||
assert ok2 is True
|
||||
|
||||
def test_text_missing_text(self):
|
||||
cfg = WatermarkConfig(mode="text", text="")
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "文字内容" in msg
|
||||
|
||||
def test_text_font_size_zero(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", font_size=0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "字体大小" in msg
|
||||
|
||||
def test_text_font_size_negative(self):
|
||||
cfg = WatermarkConfig(mode="text", text="test", font_size=-5)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "字体大小" in msg
|
||||
|
||||
def test_unsupported_mode(self):
|
||||
cfg = WatermarkConfig(mode="video", text="test")
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持的水印模式" in msg
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
Worker 配置测试.
|
||||
|
||||
覆盖 WorkerSettings 默认值、属性别名等纯逻辑.
|
||||
环境变量加载由集成测试覆盖.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.config.worker_settings import WorkerSettings
|
||||
|
||||
|
||||
class TestWorkerSettingsDefaults:
|
||||
"""WorkerSettings 默认值."""
|
||||
|
||||
def test_worker_name_default(self):
|
||||
settings = WorkerSettings()
|
||||
assert settings.worker_name == "xiaoxia-saas-worker"
|
||||
|
||||
def test_worker_concurrency_default(self):
|
||||
settings = WorkerSettings()
|
||||
assert settings.worker_concurrency == 4
|
||||
|
||||
def test_worker_max_tasks_per_child_default(self):
|
||||
settings = WorkerSettings()
|
||||
assert settings.worker_max_tasks_per_child == 1000
|
||||
|
||||
def test_inherits_shared_settings(self):
|
||||
"""继承 SharedSettings 的字段."""
|
||||
settings = WorkerSettings()
|
||||
# 验证至少有一些 SharedSettings 的字段存在
|
||||
assert hasattr(settings, "celery_broker_url")
|
||||
assert hasattr(settings, "celery_result_backend")
|
||||
|
||||
def test_custom_values(self):
|
||||
settings = WorkerSettings(
|
||||
worker_name="test-worker",
|
||||
worker_concurrency=8,
|
||||
worker_max_tasks_per_child=500,
|
||||
)
|
||||
assert settings.worker_name == "test-worker"
|
||||
assert settings.worker_concurrency == 8
|
||||
assert settings.worker_max_tasks_per_child == 500
|
||||
|
||||
|
||||
class TestWorkerSettingsAliases:
|
||||
"""Celery 字段名向后兼容别名."""
|
||||
|
||||
def test_broker_url_alias(self):
|
||||
settings = WorkerSettings(celery_broker_url="redis://localhost:6379/0")
|
||||
assert settings.broker_url == settings.celery_broker_url
|
||||
assert settings.broker_url == "redis://localhost:6379/0"
|
||||
|
||||
def test_result_backend_alias(self):
|
||||
settings = WorkerSettings(celery_result_backend="redis://localhost:6379/1")
|
||||
assert settings.result_backend == settings.celery_result_backend
|
||||
assert settings.result_backend == "redis://localhost:6379/1"
|
||||
|
||||
def test_broker_url_is_property(self):
|
||||
"""broker_url 是 property,每次读取都返回最新值."""
|
||||
settings = WorkerSettings()
|
||||
# 验证是 property 描述符
|
||||
assert isinstance(type(settings).broker_url, property)
|
||||
assert isinstance(type(settings).result_backend, property)
|
||||
|
||||
|
||||
class TestWorkerSettingsType:
|
||||
"""类型验证."""
|
||||
|
||||
def test_worker_concurrency_is_int(self):
|
||||
settings = WorkerSettings()
|
||||
assert isinstance(settings.worker_concurrency, int)
|
||||
|
||||
def test_worker_max_tasks_is_int(self):
|
||||
settings = WorkerSettings()
|
||||
assert isinstance(settings.worker_max_tasks_per_child, int)
|
||||
|
||||
def test_worker_name_is_str(self):
|
||||
settings = WorkerSettings()
|
||||
assert isinstance(settings.worker_name, str)
|
||||
assert len(settings.worker_name) > 0
|
||||
Reference in New Issue
Block a user