Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 5b42ff0e96 style: black格式化test_api_settings.py格式修复
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m59s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
AI Code Review / AI Code Review (pull_request) Successful in 3m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m17s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49m52s
2026-07-24 18:54:03 +08:00
xiaoxia 38617515ee fix(ci): unit tests脚本兜底JWT_SECRET_KEY
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m25s
AI Code Review / AI Code Review (pull_request) Successful in 5m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m12s
2026-07-24 17:53:20 +08:00
xiaoxia a1ba05d869 fix(ci): 补充unit/integration tests补充JWT_SECRET_KEY环境变量
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m51s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m41s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m30s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m16s
2026-07-24 17:38:16 +08:00
xiaoxia 824222de87 perf(ci): Worker基础镜像三级缓存策略优化\n\n- L1 本地daemon缓存:DooD模式8runner共享宿主机daemon,命中即秒过\n- L2 Registry缓存:从Gitea registry拉取后重tag供Dockerfile使用\n- L3 本地构建:构建成功后推送回Registry供后续复用\n- 修复pre-build构建镜像tag与Dockerfile FROM不一致的bug\n- 普通docker build改用DOCKER_BUILDKIT=1加速\n- Worker Dockerfile层顺序优化,变化少的文件放前面\n- 移除builder阶段冗余的全量strip(base镜像已strip过)
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m33s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m49s
2026-07-24 16:57:52 +08:00
76 changed files with 5019 additions and 12415 deletions
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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 端口 |
-118
View File
@@ -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
View File
@@ -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):
"""统一存储服务 — 实现 StoragePortAPI 和 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()
+1 -1
View File
@@ -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", "")
# ── 常量 ──────────────────────────────────────────────
+1 -1
View File
@@ -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()
-115
View File
@@ -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
-271
View File
@@ -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
View File
@@ -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
-510
View File
@@ -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 == ["产品", "介绍"]
-305
View File
@@ -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
-78
View File
@@ -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
-177
View File
@@ -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("")
-68
View File
@@ -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
File diff suppressed because it is too large Load Diff
-230
View File
@@ -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
-139
View File
@@ -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
-300
View File
@@ -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
-201
View File
@@ -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
-174
View File
@@ -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
-301
View File
@@ -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
View File
@@ -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
-403
View File
@@ -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
View File
@@ -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):
"""未定义维度默认 infis_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
View File
@@ -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)
-215
View File
@@ -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 # 两位小数(厘秒)
-227
View File
@@ -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
-27
View File
@@ -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
-60
View File
@@ -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
+262 -310
View File
@@ -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=10items[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
-207
View File
@@ -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
-153
View File
@@ -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
View File
@@ -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
-70
View File
@@ -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
-225
View File
@@ -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
+274 -244
View File
@@ -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_idis_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}
-137
View File
@@ -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]
-275
View File
@@ -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
-83
View File
@@ -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