Files
waooplus/tests/unit/voice/generate-voice-line.test.ts
2026-03-08 17:10:06 +08:00

187 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
novelPromotionVoiceLine: {
findUnique: vi.fn(),
update: vi.fn(async () => undefined),
},
novelPromotionProject: {
findUnique: vi.fn(),
},
novelPromotionEpisode: {
findUnique: vi.fn(),
},
}))
const resolveModelSelectionOrSingleMock = vi.hoisted(() => vi.fn())
const getProviderKeyMock = vi.hoisted(() => vi.fn((providerId: string) => providerId))
const getAudioApiKeyMock = vi.hoisted(() => vi.fn())
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn())
const extractStorageKeyMock = vi.hoisted(() => vi.fn())
const getSignedUrlMock = vi.hoisted(() => vi.fn((storageKey: string) => `signed://${storageKey}`))
const toFetchableUrlMock = vi.hoisted(() => vi.fn((url: string) => url))
const uploadObjectMock = vi.hoisted(() => vi.fn(async () => 'voice/storage/line-1.wav'))
const resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn())
const synthesizeWithBailianTTSMock = vi.hoisted(() => vi.fn())
const falSubscribeMock = vi.hoisted(() => vi.fn())
const getProviderConfigMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/api-config', () => ({
getAudioApiKey: getAudioApiKeyMock,
getProviderConfig: getProviderConfigMock,
getProviderKey: getProviderKeyMock,
resolveModelSelectionOrSingle: resolveModelSelectionOrSingleMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
}))
vi.mock('@/lib/storage', () => ({
extractStorageKey: extractStorageKeyMock,
getSignedUrl: getSignedUrlMock,
toFetchableUrl: toFetchableUrlMock,
uploadObject: uploadObjectMock,
}))
vi.mock('@/lib/media/service', () => ({
resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,
}))
vi.mock('@/lib/providers/bailian', () => ({
synthesizeWithBailianTTS: synthesizeWithBailianTTSMock,
}))
vi.mock('@fal-ai/client', () => ({
fal: {
config: vi.fn(),
subscribe: falSubscribeMock,
},
}))
import { generateVoiceLine } from '@/lib/voice/generate-voice-line'
describe('generate voice line with bailian provider', () => {
beforeEach(() => {
vi.clearAllMocks()
const audioBytes = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
id: 'line-1',
episodeId: 'episode-1',
speaker: 'Narrator',
content: '你好,世界',
emotionPrompt: null,
emotionStrength: null,
})
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
characters: [],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
speakerVoices: JSON.stringify({
Narrator: {
audioUrl: 'voice/reference.wav',
voiceId: 'voice_abc123',
},
}),
})
resolveModelSelectionOrSingleMock.mockResolvedValue({
provider: 'bailian',
modelId: 'qwen3-tts-vd-2026-01-26',
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
mediaType: 'audio',
})
getProviderConfigMock.mockResolvedValue({
id: 'bailian',
name: 'Alibaba Bailian',
apiKey: 'bl-key',
})
synthesizeWithBailianTTSMock.mockResolvedValue({
success: true,
audioData: Buffer.from(audioBytes),
audioDuration: 1,
})
})
it('uses speaker voiceId to generate and persists uploaded audio', async () => {
const result = await generateVoiceLine({
projectId: 'project-1',
episodeId: 'episode-1',
lineId: 'line-1',
userId: 'user-1',
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
})
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(synthesizeWithBailianTTSMock).toHaveBeenCalledWith({
text: '你好,世界',
voiceId: 'voice_abc123',
modelId: 'qwen3-tts-vd-2026-01-26',
languageType: 'Chinese',
}, 'bl-key')
expect(uploadObjectMock).toHaveBeenCalledTimes(1)
expect(prismaMock.novelPromotionVoiceLine.update).toHaveBeenCalledWith({
where: { id: 'line-1' },
data: {
audioUrl: 'voice/storage/line-1.wav',
audioDuration: 1,
},
})
expect(result).toEqual({
lineId: 'line-1',
audioUrl: 'signed://voice/storage/line-1.wav',
storageKey: 'voice/storage/line-1.wav',
audioDuration: 1,
})
})
it('fails explicitly when bailian speaker binding only has uploaded audio', async () => {
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValueOnce({
speakerVoices: JSON.stringify({
Narrator: {
audioUrl: 'voice/reference.wav',
},
}),
})
await expect(
generateVoiceLine({
projectId: 'project-1',
episodeId: 'episode-1',
lineId: 'line-1',
userId: 'user-1',
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
}),
).rejects.toThrow('无音色IDQwenTTS 必须使用 AI 设计音色')
expect(synthesizeWithBailianTTSMock).not.toHaveBeenCalled()
expect(uploadObjectMock).not.toHaveBeenCalled()
})
it('maps bailian invalid parameter to a qwen voice guidance error', async () => {
synthesizeWithBailianTTSMock.mockResolvedValueOnce({
success: false,
error: 'BAILIAN_TTS_FAILED(400): InvalidParameter',
})
await expect(
generateVoiceLine({
projectId: 'project-1',
episodeId: 'episode-1',
lineId: 'line-1',
userId: 'user-1',
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
}),
).rejects.toThrow('无效音色IDQwenTTS 必须使用 AI 设计音色')
expect(uploadObjectMock).not.toHaveBeenCalled()
})
})