feat: initial release v0.3.0
This commit is contained in:
74
tests/unit/novel-promotion/character-voice-mutations.test.ts
Normal file
74
tests/unit/novel-promotion/character-voice-mutations.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useQueryClientMock,
|
||||
useMutationMock,
|
||||
requestJsonWithErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useQueryClientMock: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
||||
useMutationMock: vi.fn((options: unknown) => options),
|
||||
requestJsonWithErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
useMutation: (options: unknown) => useMutationMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
|
||||
'@/lib/query/mutations/mutation-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
invalidateQueryTemplates: vi.fn(),
|
||||
requestJsonWithError: requestJsonWithErrorMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { useUpdateProjectCharacterVoiceSettings } from '@/lib/query/mutations/character-voice-mutations'
|
||||
|
||||
interface UpdateVoiceMutation {
|
||||
mutationFn: (variables: {
|
||||
characterId: string
|
||||
voiceType: 'qwen-designed' | 'uploaded' | 'custom' | null
|
||||
voiceId?: string
|
||||
customVoiceUrl?: string
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
|
||||
describe('project character voice mutations', () => {
|
||||
beforeEach(() => {
|
||||
useQueryClientMock.mockClear()
|
||||
useMutationMock.mockClear()
|
||||
requestJsonWithErrorMock.mockReset()
|
||||
requestJsonWithErrorMock.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('routes voice setting updates to the character-voice endpoint after designed voice save', async () => {
|
||||
const mutation = useUpdateProjectCharacterVoiceSettings('project-1') as unknown as UpdateVoiceMutation
|
||||
|
||||
await mutation.mutationFn({
|
||||
characterId: 'character-1',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/audio.wav',
|
||||
})
|
||||
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledWith(
|
||||
'/api/novel-promotion/project-1/character-voice',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
characterId: 'character-1',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/audio.wav',
|
||||
}),
|
||||
},
|
||||
'更新音色失败',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildVideoSubmissionKey,
|
||||
createVideoSubmissionBaseline,
|
||||
shouldResolveVideoSubmissionLock,
|
||||
} from '@/lib/novel-promotion/stages/video-stage-runtime/immediate-video-submission'
|
||||
|
||||
describe('immediate video submission lock', () => {
|
||||
it('regenerating an existing video -> keeps local lock until task state or output changes', () => {
|
||||
const panel = {
|
||||
panelId: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
videoUrl: 'https://example.com/original.mp4',
|
||||
videoErrorMessage: null,
|
||||
videoTaskRunning: false,
|
||||
}
|
||||
const baseline = createVideoSubmissionBaseline(panel)
|
||||
|
||||
expect(buildVideoSubmissionKey(panel)).toBe('panel-1')
|
||||
expect(
|
||||
shouldResolveVideoSubmissionLock(
|
||||
{
|
||||
...panel,
|
||||
videoTaskRunning: false,
|
||||
},
|
||||
baseline,
|
||||
baseline.startedAt + 1_000,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldResolveVideoSubmissionLock(
|
||||
{
|
||||
...panel,
|
||||
videoTaskRunning: true,
|
||||
},
|
||||
baseline,
|
||||
baseline.startedAt + 1_000,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldResolveVideoSubmissionLock(
|
||||
{
|
||||
...panel,
|
||||
videoUrl: 'https://example.com/regenerated.mp4',
|
||||
},
|
||||
baseline,
|
||||
baseline.startedAt + 1_000,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { usePanelTaskStatus } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelTaskStatus'
|
||||
|
||||
describe('panel task status error code mapping', () => {
|
||||
it('uses explicit error code for user-facing panel error display', () => {
|
||||
const result = usePanelTaskStatus({
|
||||
panel: {
|
||||
storyboardId: 'sb-1',
|
||||
panelIndex: 0,
|
||||
videoErrorCode: 'EXTERNAL_ERROR',
|
||||
videoErrorMessage: 'raw upstream message',
|
||||
},
|
||||
hasVisibleBaseVideo: false,
|
||||
tCommon: (key) => key,
|
||||
})
|
||||
|
||||
expect(result.panelErrorDisplay?.code).toBe('EXTERNAL_ERROR')
|
||||
expect(result.panelErrorDisplay?.message).toBe('raw upstream message')
|
||||
})
|
||||
|
||||
it('shows fixed unsupported-format message for VIDEO_API_FORMAT_UNSUPPORTED', () => {
|
||||
const result = usePanelTaskStatus({
|
||||
panel: {
|
||||
storyboardId: 'sb-1',
|
||||
panelIndex: 0,
|
||||
videoErrorCode: 'VIDEO_API_FORMAT_UNSUPPORTED',
|
||||
videoErrorMessage: 'VIDEO_API_FORMAT_UNSUPPORTED: OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND',
|
||||
},
|
||||
hasVisibleBaseVideo: false,
|
||||
tCommon: (key) => key,
|
||||
})
|
||||
|
||||
expect(result.panelErrorDisplay?.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')
|
||||
expect(result.panelErrorDisplay?.message).toBe('当前视频接口格式暂不支持。')
|
||||
})
|
||||
})
|
||||
112
tests/unit/novel-promotion/use-tts-generation.test.ts
Normal file
112
tests/unit/novel-promotion/use-tts-generation.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useStateMock,
|
||||
logErrorMock,
|
||||
refreshAssetsMock,
|
||||
updateVoiceSettingsMutateAsyncMock,
|
||||
saveDesignedVoiceMutateAsyncMock,
|
||||
setVoiceDesignCharacterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useStateMock: vi.fn(),
|
||||
logErrorMock: vi.fn(),
|
||||
refreshAssetsMock: vi.fn(),
|
||||
updateVoiceSettingsMutateAsyncMock: vi.fn(),
|
||||
saveDesignedVoiceMutateAsyncMock: vi.fn(),
|
||||
setVoiceDesignCharacterMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useState: useStateMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'tts.voiceDesignSaved') {
|
||||
return `voice saved:${String(values?.name ?? '')}`
|
||||
}
|
||||
if (key === 'tts.saveVoiceDesignFailed') {
|
||||
return `save failed:${String(values?.error ?? '')}`
|
||||
}
|
||||
if (key === 'common.unknownError') {
|
||||
return 'unknown error'
|
||||
}
|
||||
return key
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
logError: (...args: unknown[]) => logErrorMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useProjectAssets: () => ({
|
||||
data: {
|
||||
characters: [{
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
customVoiceUrl: null,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
useRefreshProjectAssets: () => refreshAssetsMock,
|
||||
useUpdateProjectCharacterVoiceSettings: () => ({
|
||||
mutateAsync: updateVoiceSettingsMutateAsyncMock,
|
||||
}),
|
||||
useSaveProjectDesignedVoice: () => ({
|
||||
mutateAsync: saveDesignedVoiceMutateAsyncMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useTTSGeneration } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useTTSGeneration'
|
||||
|
||||
describe('useTTSGeneration', () => {
|
||||
const originalAlert = globalThis.alert
|
||||
|
||||
beforeEach(() => {
|
||||
useStateMock.mockReset()
|
||||
logErrorMock.mockReset()
|
||||
refreshAssetsMock.mockReset()
|
||||
updateVoiceSettingsMutateAsyncMock.mockReset()
|
||||
saveDesignedVoiceMutateAsyncMock.mockReset()
|
||||
setVoiceDesignCharacterMock.mockReset()
|
||||
saveDesignedVoiceMutateAsyncMock.mockResolvedValue({
|
||||
success: true,
|
||||
audioUrl: 'https://signed.example.com/audio.wav',
|
||||
})
|
||||
globalThis.alert = vi.fn()
|
||||
useStateMock.mockReturnValue([
|
||||
{
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
hasExistingVoice: false,
|
||||
},
|
||||
setVoiceDesignCharacterMock,
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.alert = originalAlert
|
||||
})
|
||||
|
||||
it('does not send a second voice update request after designed voice save succeeds', async () => {
|
||||
const hook = useTTSGeneration({ projectId: 'project-1' })
|
||||
|
||||
await hook.handleVoiceDesignSave('voice-1', 'base64-audio')
|
||||
|
||||
expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledWith({
|
||||
characterId: 'character-1',
|
||||
voiceId: 'voice-1',
|
||||
audioBase64: 'base64-audio',
|
||||
})
|
||||
expect(updateVoiceSettingsMutateAsyncMock).not.toHaveBeenCalled()
|
||||
expect(refreshAssetsMock).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('voice saved:Hero')
|
||||
expect(setVoiceDesignCharacterMock).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
67
tests/unit/novel-promotion/video-model-options.test.ts
Normal file
67
tests/unit/novel-promotion/video-model-options.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
filterNormalVideoModelOptions,
|
||||
isFirstLastFrameOnlyModel,
|
||||
supportsFirstLastFrame,
|
||||
} from '@/lib/model-capabilities/video-model-options'
|
||||
import type { VideoModelOption } from '@/lib/novel-promotion/stages/video-stage-runtime/types'
|
||||
|
||||
describe('video model options partition', () => {
|
||||
const models: VideoModelOption[] = [
|
||||
{
|
||||
value: 'p::normal',
|
||||
label: 'normal',
|
||||
capabilities: {
|
||||
video: {
|
||||
generationModeOptions: ['normal'],
|
||||
firstlastframe: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'p::firstlast-only',
|
||||
label: 'firstlast-only',
|
||||
capabilities: {
|
||||
video: {
|
||||
generationModeOptions: ['firstlastframe'],
|
||||
firstlastframe: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'p::both',
|
||||
label: 'both',
|
||||
capabilities: {
|
||||
video: {
|
||||
generationModeOptions: ['normal', 'firstlastframe'],
|
||||
firstlastframe: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'p::custom-no-capability',
|
||||
label: 'custom-no-capability',
|
||||
},
|
||||
]
|
||||
|
||||
it('detects firstlastframe support and firstlastframe-only capability', () => {
|
||||
expect(supportsFirstLastFrame(models[0])).toBe(false)
|
||||
expect(supportsFirstLastFrame(models[1])).toBe(true)
|
||||
expect(supportsFirstLastFrame(models[2])).toBe(true)
|
||||
expect(supportsFirstLastFrame(models[3])).toBe(false)
|
||||
|
||||
expect(isFirstLastFrameOnlyModel(models[0])).toBe(false)
|
||||
expect(isFirstLastFrameOnlyModel(models[1])).toBe(true)
|
||||
expect(isFirstLastFrameOnlyModel(models[2])).toBe(false)
|
||||
expect(isFirstLastFrameOnlyModel(models[3])).toBe(false)
|
||||
})
|
||||
|
||||
it('filters out firstlastframe-only models from normal video model list', () => {
|
||||
const normalModels = filterNormalVideoModelOptions(models)
|
||||
expect(normalModels.map((item) => item.value)).toEqual([
|
||||
'p::normal',
|
||||
'p::both',
|
||||
'p::custom-no-capability',
|
||||
])
|
||||
})
|
||||
})
|
||||
167
tests/unit/novel-promotion/video-panel-card-body.test.ts
Normal file
167
tests/unit/novel-promotion/video-panel-card-body.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import VideoPanelCardBody from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardBody'
|
||||
import type { VideoPanelRuntime } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/hooks/useVideoPanelActions'
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => React.createElement('span', null, 'task-status'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/config-modals/ModelCapabilityDropdown', () => ({
|
||||
ModelCapabilityDropdown: () => React.createElement('div', null, 'model-dropdown'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: ({ name }: { name: string }) => React.createElement('span', null, name),
|
||||
}))
|
||||
|
||||
function createRuntime(overrides: Partial<VideoPanelRuntime> = {}): VideoPanelRuntime {
|
||||
const translate = (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'firstLastFrame.asLastFrameFor') {
|
||||
return `作为镜头 ${String(values?.number ?? '')} 的尾帧`
|
||||
}
|
||||
if (key === 'firstLastFrame.asFirstFrameFor') {
|
||||
return `作为镜头 ${String(values?.number ?? '')} 的首帧`
|
||||
}
|
||||
if (key === 'firstLastFrame.generate') return '生成首尾帧视频'
|
||||
if (key === 'firstLastFrame.generated') return '首尾帧视频已生成'
|
||||
if (key === 'promptModal.promptLabel') return '视频提示词'
|
||||
if (key === 'promptModal.placeholder') return '输入首尾帧视频提示词...'
|
||||
if (key === 'panelCard.clickToEditPrompt') return '点击编辑提示词...'
|
||||
if (key === 'panelCard.selectModel') return '选择模型'
|
||||
if (key === 'panelCard.generateVideo') return '生成视频'
|
||||
if (key === 'panelCard.unknownShotType') return '未知镜头'
|
||||
if (key === 'stage.hasSynced') return '已生成'
|
||||
if (key === 'promptModal.duration') return '秒'
|
||||
return key
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
t: translate,
|
||||
tCommon: (key: string) => key,
|
||||
panel: {
|
||||
storyboardId: 'sb-1',
|
||||
panelIndex: 2,
|
||||
panelId: 'panel-2',
|
||||
imageUrl: 'https://example.com/frame-2.jpg',
|
||||
videoUrl: null,
|
||||
videoGenerationMode: null,
|
||||
lipSyncVideoUrl: null,
|
||||
textPanel: {
|
||||
shot_type: '平视中景',
|
||||
description: '谢俞站在宴席中央',
|
||||
duration: 3,
|
||||
},
|
||||
},
|
||||
panelIndex: 2,
|
||||
panelKey: 'sb-1-2',
|
||||
media: {
|
||||
showLipSyncVideo: true,
|
||||
onToggleLipSyncVideo: () => undefined,
|
||||
onPreviewImage: () => undefined,
|
||||
baseVideoUrl: undefined,
|
||||
currentVideoUrl: undefined,
|
||||
},
|
||||
taskStatus: {
|
||||
isVideoTaskRunning: false,
|
||||
isLipSyncTaskRunning: false,
|
||||
taskRunningVideoLabel: '生成中',
|
||||
lipSyncInlineState: null,
|
||||
},
|
||||
videoModel: {
|
||||
selectedModel: 'veo-3.1',
|
||||
setSelectedModel: () => undefined,
|
||||
capabilityFields: [],
|
||||
generationOptions: {},
|
||||
setCapabilityValue: () => undefined,
|
||||
missingCapabilityFields: [],
|
||||
videoModelOptions: [],
|
||||
},
|
||||
player: {
|
||||
isPlaying: false,
|
||||
},
|
||||
promptEditor: {
|
||||
isEditing: false,
|
||||
editingPrompt: '',
|
||||
setEditingPrompt: () => undefined,
|
||||
handleStartEdit: () => undefined,
|
||||
handleSave: () => undefined,
|
||||
handleCancelEdit: () => undefined,
|
||||
isSavingPrompt: false,
|
||||
localPrompt: '人物从席间回身,接到下一镜头',
|
||||
},
|
||||
voiceManager: {
|
||||
hasMatchedAudio: false,
|
||||
hasMatchedVoiceLines: false,
|
||||
audioGenerateError: null,
|
||||
localVoiceLines: [],
|
||||
isVoiceLineTaskRunning: () => false,
|
||||
handlePlayVoiceLine: () => undefined,
|
||||
handleGenerateAudio: async () => undefined,
|
||||
playingVoiceLineId: null,
|
||||
},
|
||||
lipSync: {
|
||||
handleStartLipSync: () => undefined,
|
||||
executingLipSync: false,
|
||||
},
|
||||
layout: {
|
||||
isLinked: true,
|
||||
isLastFrame: true,
|
||||
nextPanel: {
|
||||
storyboardId: 'sb-1',
|
||||
panelIndex: 3,
|
||||
imageUrl: 'https://example.com/frame-3.jpg',
|
||||
},
|
||||
prevPanel: {
|
||||
storyboardId: 'sb-1',
|
||||
panelIndex: 1,
|
||||
imageUrl: 'https://example.com/frame-1.jpg',
|
||||
},
|
||||
hasNext: true,
|
||||
flModel: 'veo-3.1',
|
||||
flModelOptions: [],
|
||||
flGenerationOptions: {},
|
||||
flCapabilityFields: [],
|
||||
flMissingCapabilityFields: [],
|
||||
flCustomPrompt: '',
|
||||
defaultFlPrompt: '',
|
||||
videoRatio: '9:16',
|
||||
},
|
||||
actions: {
|
||||
onGenerateVideo: () => undefined,
|
||||
onUpdatePanelVideoModel: () => undefined,
|
||||
onToggleLink: () => undefined,
|
||||
onFlModelChange: () => undefined,
|
||||
onFlCapabilityChange: () => undefined,
|
||||
onFlCustomPromptChange: () => undefined,
|
||||
onResetFlPrompt: () => undefined,
|
||||
onGenerateFirstLastFrame: () => undefined,
|
||||
},
|
||||
computed: {
|
||||
showLipSyncSection: false,
|
||||
canLipSync: false,
|
||||
hasVisibleBaseVideo: false,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...runtime,
|
||||
...overrides,
|
||||
} as unknown as VideoPanelRuntime
|
||||
}
|
||||
|
||||
describe('VideoPanelCardBody', () => {
|
||||
it('renders incoming and outgoing first-last-frame UI for chained panel', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(VideoPanelCardBody, {
|
||||
runtime: createRuntime(),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(markup).toContain('作为镜头 2 的尾帧')
|
||||
expect(markup).toContain('作为镜头 4 的首帧')
|
||||
expect(markup).toContain('视频提示词')
|
||||
expect(markup).toContain('生成首尾帧视频')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo: <T,>(factory: () => T) => factory(),
|
||||
}
|
||||
})
|
||||
|
||||
import { useVideoPanelsProjection } from '@/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelsProjection'
|
||||
|
||||
describe('video panels projection error code', () => {
|
||||
it('projects failed task lastError code/message onto panel fields', () => {
|
||||
const result = useVideoPanelsProjection({
|
||||
clips: [{ id: 'clip-1', start: 0, end: 5, summary: 'clip' }],
|
||||
storyboards: [{
|
||||
id: 'sb-1',
|
||||
clipId: 'clip-1',
|
||||
panels: [{
|
||||
id: 'panel-1',
|
||||
panelIndex: 0,
|
||||
description: 'panel',
|
||||
}],
|
||||
}],
|
||||
panelVideoStates: {
|
||||
getTaskState: () => ({
|
||||
phase: 'failed',
|
||||
lastError: {
|
||||
code: 'EXTERNAL_ERROR',
|
||||
message: 'upstream failed',
|
||||
},
|
||||
}),
|
||||
},
|
||||
panelLipStates: {
|
||||
getTaskState: () => null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.allPanels).toHaveLength(1)
|
||||
expect(result.allPanels[0]?.videoErrorCode).toBe('EXTERNAL_ERROR')
|
||||
expect(result.allPanels[0]?.videoErrorMessage).toBe('upstream failed')
|
||||
})
|
||||
})
|
||||
92
tests/unit/novel-promotion/voice-generation-actions.test.ts
Normal file
92
tests/unit/novel-promotion/voice-generation-actions.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useStateMock,
|
||||
useCallbackMock,
|
||||
useQueryClientMock,
|
||||
upsertTaskTargetOverlayMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useStateMock: vi.fn(),
|
||||
useCallbackMock: vi.fn((fn: unknown) => fn),
|
||||
useQueryClientMock: vi.fn(() => ({ id: 'query-client' })),
|
||||
upsertTaskTargetOverlayMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useState: useStateMock,
|
||||
useCallback: useCallbackMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/task-target-overlay', () => ({
|
||||
upsertTaskTargetOverlay: (...args: unknown[]) => upsertTaskTargetOverlayMock(...args),
|
||||
}))
|
||||
|
||||
import { useVoiceGenerationActions } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceGenerationActions'
|
||||
|
||||
describe('useVoiceGenerationActions', () => {
|
||||
beforeEach(() => {
|
||||
useStateMock.mockReset()
|
||||
useCallbackMock.mockClear()
|
||||
useQueryClientMock.mockClear()
|
||||
upsertTaskTargetOverlayMock.mockReset()
|
||||
|
||||
useStateMock
|
||||
.mockImplementationOnce(() => [false, vi.fn()])
|
||||
.mockImplementationOnce(() => [false, vi.fn()])
|
||||
.mockImplementationOnce(() => [false, vi.fn()])
|
||||
})
|
||||
|
||||
it('adds an optimistic task overlay for async single-line generation', async () => {
|
||||
const setPendingVoiceGenerationByLineId = vi.fn()
|
||||
const notifyVoiceLinesChanged = vi.fn()
|
||||
const generateVoiceMutation = {
|
||||
mutateAsync: vi.fn(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-voice-1',
|
||||
})),
|
||||
}
|
||||
|
||||
const runtime = useVoiceGenerationActions({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
t: (key: string) => key,
|
||||
voiceLines: [],
|
||||
linesWithAudio: 0,
|
||||
speakerCharacterMap: {},
|
||||
speakerVoices: {},
|
||||
analyzeVoiceMutation: { mutateAsync: vi.fn() },
|
||||
generateVoiceMutation,
|
||||
downloadVoicesMutation: { mutateAsync: vi.fn() },
|
||||
loadData: vi.fn(),
|
||||
notifyVoiceLinesChanged,
|
||||
setPendingVoiceGenerationByLineId,
|
||||
})
|
||||
|
||||
await runtime.handleGenerateLine('line-1')
|
||||
|
||||
expect(upsertTaskTargetOverlayMock).toHaveBeenCalledWith(
|
||||
{ id: 'query-client' },
|
||||
{
|
||||
projectId: 'project-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
phase: 'queued',
|
||||
runningTaskId: 'task-voice-1',
|
||||
runningTaskType: 'voice_line',
|
||||
intent: 'generate',
|
||||
hasOutputAtStart: false,
|
||||
},
|
||||
)
|
||||
expect(notifyVoiceLinesChanged).toHaveBeenCalledTimes(1)
|
||||
expect(setPendingVoiceGenerationByLineId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
212
tests/unit/novel-promotion/voice-runtime-sync.test.ts
Normal file
212
tests/unit/novel-promotion/voice-runtime-sync.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { useEffectMock, useRefMock } = vi.hoisted(() => ({
|
||||
useEffectMock: vi.fn(),
|
||||
useRefMock: vi.fn(),
|
||||
}))
|
||||
|
||||
const { apiFetchMock } = vi.hoisted(() => ({
|
||||
apiFetchMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: useEffectMock,
|
||||
useRef: useRefMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api-fetch', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}))
|
||||
|
||||
import { useVoiceRuntimeSync } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceRuntimeSync'
|
||||
import type { VoiceLine } from '@/lib/novel-promotion/stages/voice-stage-runtime/types'
|
||||
|
||||
function buildVoiceLine(overrides: Partial<VoiceLine>): VoiceLine {
|
||||
return {
|
||||
id: 'line-1',
|
||||
lineIndex: 1,
|
||||
speaker: '旁白',
|
||||
content: '测试台词',
|
||||
emotionPrompt: null,
|
||||
emotionStrength: null,
|
||||
audioUrl: null,
|
||||
updatedAt: '2026-03-07T12:00:00.000Z',
|
||||
lineTaskRunning: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useVoiceRuntimeSync', () => {
|
||||
beforeEach(() => {
|
||||
useEffectMock.mockReset()
|
||||
useRefMock.mockReset()
|
||||
apiFetchMock.mockReset()
|
||||
useRefMock.mockImplementation((initialValue: unknown) => ({
|
||||
current: initialValue,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps pending regeneration until the line updatedAt advances', () => {
|
||||
const loadData = vi.fn(async () => undefined)
|
||||
const setPendingVoiceGenerationByLineId = vi.fn()
|
||||
const effectCallbacks: Array<() => void | (() => void)> = []
|
||||
|
||||
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
})
|
||||
|
||||
const pendingGeneration = {
|
||||
'line-1': {
|
||||
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
|
||||
startedAt: '2026-03-07T11:59:59.000Z',
|
||||
taskId: 'task-1',
|
||||
taskStatus: 'completed' as const,
|
||||
taskErrorMessage: null,
|
||||
},
|
||||
}
|
||||
|
||||
useVoiceRuntimeSync({
|
||||
loadData,
|
||||
voiceLines: [buildVoiceLine({
|
||||
audioUrl: '/m/voice-old.wav',
|
||||
updatedAt: '2026-03-07T12:00:00.000Z',
|
||||
})],
|
||||
activeVoiceTaskLineIds: new Set(),
|
||||
pendingVoiceGenerationByLineId: pendingGeneration,
|
||||
setPendingVoiceGenerationByLineId,
|
||||
})
|
||||
|
||||
const firstRenderEffects = effectCallbacks.splice(0)
|
||||
firstRenderEffects[2]?.()
|
||||
|
||||
const keepPendingUpdater = setPendingVoiceGenerationByLineId.mock.calls[0]?.[0] as
|
||||
| ((prev: typeof pendingGeneration) => typeof pendingGeneration)
|
||||
| undefined
|
||||
expect(keepPendingUpdater?.(pendingGeneration)).toBe(pendingGeneration)
|
||||
|
||||
useVoiceRuntimeSync({
|
||||
loadData,
|
||||
voiceLines: [buildVoiceLine({
|
||||
audioUrl: '/m/voice-new.wav',
|
||||
updatedAt: '2026-03-07T12:00:03.000Z',
|
||||
})],
|
||||
activeVoiceTaskLineIds: new Set(),
|
||||
pendingVoiceGenerationByLineId: pendingGeneration,
|
||||
setPendingVoiceGenerationByLineId,
|
||||
})
|
||||
|
||||
const secondRenderEffects = effectCallbacks.splice(0)
|
||||
secondRenderEffects[2]?.()
|
||||
|
||||
const settleUpdater = setPendingVoiceGenerationByLineId.mock.calls[1]?.[0] as
|
||||
| ((prev: typeof pendingGeneration) => Record<string, never>)
|
||||
| undefined
|
||||
expect(settleUpdater?.(pendingGeneration)).toEqual({})
|
||||
})
|
||||
|
||||
it('polls task status for pending generations with task ids', async () => {
|
||||
const loadData = vi.fn(async () => undefined)
|
||||
const setPendingVoiceGenerationByLineId = vi.fn()
|
||||
const effectCallbacks: Array<() => void | (() => void)> = []
|
||||
const windowStub = {
|
||||
setInterval: vi.fn(() => 123 as unknown as number),
|
||||
clearInterval: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('window', windowStub)
|
||||
apiFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
task: {
|
||||
status: 'processing',
|
||||
errorMessage: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
})
|
||||
|
||||
useVoiceRuntimeSync({
|
||||
loadData,
|
||||
voiceLines: [buildVoiceLine({
|
||||
audioUrl: '/m/voice-old.wav',
|
||||
updatedAt: '2026-03-07T12:00:00.000Z',
|
||||
})],
|
||||
activeVoiceTaskLineIds: new Set(),
|
||||
pendingVoiceGenerationByLineId: {
|
||||
'line-1': {
|
||||
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
|
||||
startedAt: '2026-03-07T12:24:10.000Z',
|
||||
taskId: 'task-1',
|
||||
taskStatus: 'queued',
|
||||
taskErrorMessage: null,
|
||||
},
|
||||
},
|
||||
setPendingVoiceGenerationByLineId,
|
||||
})
|
||||
|
||||
const renderEffects = effectCallbacks.splice(0)
|
||||
const cleanup = renderEffects[3]?.()
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/api/tasks/task-1', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
expect(windowStub.setInterval).toHaveBeenCalledWith(expect.any(Function), 1200)
|
||||
|
||||
cleanup?.()
|
||||
expect(windowStub.clearInterval).toHaveBeenCalledWith(123)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('notifies task failure with backend error message', () => {
|
||||
const loadData = vi.fn(async () => undefined)
|
||||
const setPendingVoiceGenerationByLineId = vi.fn()
|
||||
const onTaskFailure = vi.fn()
|
||||
const effectCallbacks: Array<() => void | (() => void)> = []
|
||||
|
||||
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
})
|
||||
|
||||
useVoiceRuntimeSync({
|
||||
loadData,
|
||||
voiceLines: [buildVoiceLine({
|
||||
id: 'line-9',
|
||||
lineIndex: 9,
|
||||
})],
|
||||
activeVoiceTaskLineIds: new Set(),
|
||||
pendingVoiceGenerationByLineId: {
|
||||
'line-9': {
|
||||
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
|
||||
startedAt: '2026-03-07T12:24:10.000Z',
|
||||
taskId: 'task-failed-1',
|
||||
taskStatus: 'failed',
|
||||
taskErrorMessage: 'QwenTTS voiceId missing',
|
||||
},
|
||||
},
|
||||
setPendingVoiceGenerationByLineId,
|
||||
onTaskFailure,
|
||||
})
|
||||
|
||||
const renderEffects = effectCallbacks.splice(0)
|
||||
renderEffects[1]?.()
|
||||
|
||||
expect(onTaskFailure).toHaveBeenCalledWith({
|
||||
lineId: 'line-9',
|
||||
line: expect.objectContaining({
|
||||
id: 'line-9',
|
||||
lineIndex: 9,
|
||||
}),
|
||||
taskId: 'task-failed-1',
|
||||
errorMessage: 'QwenTTS voiceId missing',
|
||||
})
|
||||
})
|
||||
})
|
||||
88
tests/unit/novel-promotion/voice-stage-data-loader.test.ts
Normal file
88
tests/unit/novel-promotion/voice-stage-data-loader.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useStateMock,
|
||||
useRefMock,
|
||||
useCallbackMock,
|
||||
useEffectMock,
|
||||
mutateAsyncMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useStateMock: vi.fn(),
|
||||
useRefMock: vi.fn((value: unknown) => ({ current: value })),
|
||||
useCallbackMock: vi.fn((fn: unknown) => fn),
|
||||
useEffectMock: vi.fn(),
|
||||
mutateAsyncMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useState: useStateMock,
|
||||
useRef: useRefMock,
|
||||
useCallback: useCallbackMock,
|
||||
useEffect: useEffectMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useFetchProjectVoiceStageData: () => ({
|
||||
mutateAsync: mutateAsyncMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useVoiceStageDataLoader } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceStageDataLoader'
|
||||
|
||||
describe('useVoiceStageDataLoader', () => {
|
||||
beforeEach(() => {
|
||||
useStateMock.mockReset()
|
||||
useRefMock.mockClear()
|
||||
useCallbackMock.mockClear()
|
||||
useEffectMock.mockClear()
|
||||
mutateAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('keeps background reloads from re-entering blocking loading state', async () => {
|
||||
const setVoiceLines = vi.fn()
|
||||
const setSpeakerVoices = vi.fn()
|
||||
const setProjectSpeakers = vi.fn()
|
||||
const setLoading = vi.fn()
|
||||
|
||||
useStateMock
|
||||
.mockImplementationOnce(() => [[], setVoiceLines])
|
||||
.mockImplementationOnce(() => [{}, setSpeakerVoices])
|
||||
.mockImplementationOnce(() => [[], setProjectSpeakers])
|
||||
.mockImplementationOnce(() => [true, setLoading])
|
||||
|
||||
mutateAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
voiceLines: [{ id: 'line-1' }],
|
||||
speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-1' } },
|
||||
speakers: ['Narrator'],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
voiceLines: [{ id: 'line-1' }],
|
||||
speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-2' } },
|
||||
speakers: ['Narrator'],
|
||||
})
|
||||
|
||||
const hook = useVoiceStageDataLoader({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
})
|
||||
|
||||
await hook.loadData()
|
||||
await hook.loadData()
|
||||
|
||||
expect(
|
||||
setLoading.mock.calls.filter(([value]) => value === true),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
setLoading.mock.calls.filter(([value]) => value === false),
|
||||
).toHaveLength(2)
|
||||
expect(setVoiceLines).toHaveBeenNthCalledWith(1, [{ id: 'line-1' }])
|
||||
expect(setVoiceLines).toHaveBeenNthCalledWith(2, [{ id: 'line-1' }])
|
||||
expect(mutateAsyncMock).toHaveBeenNthCalledWith(1, { episodeId: 'episode-1' })
|
||||
expect(mutateAsyncMock).toHaveBeenNthCalledWith(2, { episodeId: 'episode-1' })
|
||||
})
|
||||
})
|
||||
71
tests/unit/novel-promotion/workspace-video-actions.test.ts
Normal file
71
tests/unit/novel-promotion/workspace-video-actions.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
generateVideoMutateAsyncMock,
|
||||
batchGenerateVideosMutateAsyncMock,
|
||||
updateProjectPanelVideoPromptMutateAsyncMock,
|
||||
updateProjectClipMutateAsyncMock,
|
||||
updateProjectConfigMutateAsyncMock,
|
||||
} = vi.hoisted(() => ({
|
||||
generateVideoMutateAsyncMock: vi.fn(),
|
||||
batchGenerateVideosMutateAsyncMock: vi.fn(),
|
||||
updateProjectPanelVideoPromptMutateAsyncMock: vi.fn(),
|
||||
updateProjectClipMutateAsyncMock: vi.fn(),
|
||||
updateProjectConfigMutateAsyncMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/hooks/useStoryboards', () => ({
|
||||
useGenerateVideo: () => ({
|
||||
mutateAsync: generateVideoMutateAsyncMock,
|
||||
}),
|
||||
useBatchGenerateVideos: () => ({
|
||||
mutateAsync: batchGenerateVideosMutateAsyncMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useUpdateProjectPanelVideoPrompt: () => ({
|
||||
mutateAsync: updateProjectPanelVideoPromptMutateAsyncMock,
|
||||
}),
|
||||
useUpdateProjectClip: () => ({
|
||||
mutateAsync: updateProjectClipMutateAsyncMock,
|
||||
}),
|
||||
useUpdateProjectConfig: () => ({
|
||||
mutateAsync: updateProjectConfigMutateAsyncMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useWorkspaceVideoActions } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceVideoActions'
|
||||
|
||||
describe('useWorkspaceVideoActions', () => {
|
||||
const originalAlert = globalThis.alert
|
||||
|
||||
beforeEach(() => {
|
||||
generateVideoMutateAsyncMock.mockReset()
|
||||
batchGenerateVideosMutateAsyncMock.mockReset()
|
||||
updateProjectPanelVideoPromptMutateAsyncMock.mockReset()
|
||||
updateProjectClipMutateAsyncMock.mockReset()
|
||||
updateProjectConfigMutateAsyncMock.mockReset()
|
||||
globalThis.alert = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.alert = originalAlert
|
||||
})
|
||||
|
||||
it('single video mutation fails -> rethrows error for immediate lock cleanup', async () => {
|
||||
generateVideoMutateAsyncMock.mockRejectedValueOnce(new Error('video submit failed'))
|
||||
|
||||
const actions = useWorkspaceVideoActions({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
t: (key: string) => key,
|
||||
})
|
||||
|
||||
await expect(
|
||||
actions.handleGenerateVideo('storyboard-1', 0, 'veo-3.1'),
|
||||
).rejects.toThrow('video submit failed')
|
||||
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('execution.generationFailed: video submit failed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user