feat:Strengthen the testing framework
This commit is contained in:
142
tests/system/generate-image.system.test.ts
Normal file
142
tests/system/generate-image.system.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { callRoute } from '../integration/api/helpers/call-route'
|
||||
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||
import { resetSystemState } from '../helpers/db-reset'
|
||||
import { prisma } from '../helpers/prisma'
|
||||
import { seedMinimalDomainState } from './helpers/seed'
|
||||
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||
|
||||
const imageState = vi.hoisted(() => ({
|
||||
mode: 'success' as 'success' | 'fatal',
|
||||
cosKey: 'cos/system-image-generated.png',
|
||||
errorMessage: 'IMAGE_GENERATION_FATAL',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: vi.fn(async () => {
|
||||
if (imageState.mode === 'fatal') {
|
||||
throw new Error(imageState.errorMessage)
|
||||
}
|
||||
return imageState.cosKey
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/media/outbound-image', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
|
||||
return {
|
||||
...actual,
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('system - generate image', () => {
|
||||
let workers: SystemWorkers = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
imageState.mode = 'success'
|
||||
imageState.cosKey = 'cos/system-image-generated.png'
|
||||
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
|
||||
await resetSystemState()
|
||||
installAuthMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopSystemWorkers(workers)
|
||||
workers = {}
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('route -> queue -> worker -> db writes imageUrl and lifecycle events', async () => {
|
||||
const seeded = await seedMinimalDomainState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
workers = await startSystemWorkers(['image'])
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{
|
||||
locale: 'zh',
|
||||
type: 'character',
|
||||
id: seeded.character.id,
|
||||
appearanceId: seeded.appearance.id,
|
||||
count: 1,
|
||||
},
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { async: boolean; taskId: string }
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
|
||||
const task = await waitForTaskTerminalState(json.taskId)
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.type).toBe('image_character')
|
||||
expect(task.targetId).toBe(seeded.appearance.id)
|
||||
|
||||
const appearance = await prisma.characterAppearance.findUnique({
|
||||
where: { id: seeded.appearance.id },
|
||||
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||
})
|
||||
expect(appearance).toEqual({
|
||||
imageUrl: imageState.cosKey,
|
||||
imageUrls: JSON.stringify([imageState.cosKey]),
|
||||
selectedIndex: 0,
|
||||
})
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'completed')
|
||||
})
|
||||
|
||||
it('fatal provider path -> task fails and existing appearance images stay unchanged', async () => {
|
||||
const seeded = await seedMinimalDomainState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
imageState.mode = 'fatal'
|
||||
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
|
||||
workers = await startSystemWorkers(['image'])
|
||||
|
||||
const originalAppearance = await prisma.characterAppearance.findUnique({
|
||||
where: { id: seeded.appearance.id },
|
||||
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{
|
||||
locale: 'zh',
|
||||
type: 'character',
|
||||
id: seeded.character.id,
|
||||
appearanceId: seeded.appearance.id,
|
||||
count: 1,
|
||||
},
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { taskId: string }
|
||||
const task = await waitForTaskTerminalState(json.taskId)
|
||||
expect(task.status).toBe('failed')
|
||||
expect(task.errorMessage).toContain('IMAGE_GENERATION_FATAL')
|
||||
|
||||
const appearance = await prisma.characterAppearance.findUnique({
|
||||
where: { id: seeded.appearance.id },
|
||||
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||
})
|
||||
expect(appearance).toEqual(originalAppearance)
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'failed')
|
||||
})
|
||||
})
|
||||
121
tests/system/generate-video.system.test.ts
Normal file
121
tests/system/generate-video.system.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { callRoute } from '../integration/api/helpers/call-route'
|
||||
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||
import { resetSystemState } from '../helpers/db-reset'
|
||||
import { prisma } from '../helpers/prisma'
|
||||
import { seedMinimalDomainState } from './helpers/seed'
|
||||
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||
|
||||
type PollState = {
|
||||
status: 'processing' | 'completed'
|
||||
resultUrl?: string
|
||||
}
|
||||
|
||||
const videoState = vi.hoisted(() => ({
|
||||
pollResponses: new Map<string, PollState[]>(),
|
||||
uploadedCosKey: 'video/system-video.mp4',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/generator-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/generator-api')>('@/lib/generator-api')
|
||||
return {
|
||||
...actual,
|
||||
generateVideo: vi.fn(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
externalId: 'video-ext-1',
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/async-poll', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/async-poll')>('@/lib/async-poll')
|
||||
return {
|
||||
...actual,
|
||||
pollAsyncTask: vi.fn(async (externalId: string) => {
|
||||
const queue = videoState.pollResponses.get(externalId) || []
|
||||
const next = queue.shift()
|
||||
if (!next) {
|
||||
return { status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' }
|
||||
}
|
||||
videoState.pollResponses.set(externalId, queue)
|
||||
return next
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/media/outbound-image', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
|
||||
return {
|
||||
...actual,
|
||||
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workers/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/utils')>('@/lib/workers/utils')
|
||||
return {
|
||||
...actual,
|
||||
uploadVideoSourceToCos: vi.fn(async () => videoState.uploadedCosKey),
|
||||
}
|
||||
})
|
||||
|
||||
describe('system - generate video', () => {
|
||||
let workers: SystemWorkers = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
videoState.uploadedCosKey = 'video/system-video.mp4'
|
||||
videoState.pollResponses.clear()
|
||||
videoState.pollResponses.set('video-ext-1', [
|
||||
{ status: 'processing' },
|
||||
{ status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' },
|
||||
])
|
||||
await resetSystemState()
|
||||
installAuthMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopSystemWorkers(workers)
|
||||
workers = {}
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('queued external generation -> polling -> videoUrl persisted', async () => {
|
||||
const seeded = await seedMinimalDomainState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
workers = await startSystemWorkers(['video'])
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-video/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{
|
||||
locale: 'zh',
|
||||
storyboardId: seeded.storyboard.id,
|
||||
panelIndex: 0,
|
||||
videoModel: 'fal::seedance/video',
|
||||
},
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { async: boolean; taskId: string }
|
||||
const task = await waitForTaskTerminalState(json.taskId)
|
||||
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.type).toBe('video_panel')
|
||||
expect(task.externalId).toBe('video-ext-1')
|
||||
|
||||
const panel = await prisma.novelPromotionPanel.findUnique({
|
||||
where: { id: seeded.panel.id },
|
||||
select: { videoUrl: true },
|
||||
})
|
||||
expect(panel?.videoUrl).toBe(videoState.uploadedCosKey)
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'completed')
|
||||
})
|
||||
})
|
||||
184
tests/system/helpers/seed.ts
Normal file
184
tests/system/helpers/seed.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import {
|
||||
createFixtureEpisode,
|
||||
createFixtureNovelProject,
|
||||
createFixtureProject,
|
||||
createFixtureUser,
|
||||
} from '../../helpers/fixtures'
|
||||
|
||||
function nextSuffix() {
|
||||
return randomUUID().slice(0, 8)
|
||||
}
|
||||
|
||||
export async function seedMinimalDomainState() {
|
||||
const user = await createFixtureUser()
|
||||
const project = await createFixtureProject(user.id)
|
||||
const novelProject = await createFixtureNovelProject(project.id)
|
||||
const episode = await createFixtureEpisode(novelProject.id)
|
||||
|
||||
const clip = await prisma.novelPromotionClip.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
summary: 'seed clip',
|
||||
content: 'seed clip content',
|
||||
screenplay: 'seed screenplay',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})
|
||||
|
||||
const storyboard = await prisma.novelPromotionStoryboard.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
clipId: clip.id,
|
||||
panelCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const panel = await prisma.novelPromotionPanel.create({
|
||||
data: {
|
||||
storyboardId: storyboard.id,
|
||||
panelIndex: 0,
|
||||
panelNumber: 1,
|
||||
shotType: '中景',
|
||||
cameraMove: '固定',
|
||||
description: 'seed panel',
|
||||
videoPrompt: 'seed video prompt',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
imageUrl: 'https://provider.example/panel.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
const character = await prisma.novelPromotionCharacter.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelProject.id,
|
||||
name: 'Narrator',
|
||||
},
|
||||
})
|
||||
|
||||
const appearance = await prisma.characterAppearance.create({
|
||||
data: {
|
||||
characterId: character.id,
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'default',
|
||||
description: 'Narrator appearance',
|
||||
imageUrls: JSON.stringify(['images/character-seed.jpg']),
|
||||
imageUrl: 'images/character-seed.jpg',
|
||||
selectedIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const location = await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelProject.id,
|
||||
name: 'Office',
|
||||
summary: 'Office summary',
|
||||
},
|
||||
})
|
||||
|
||||
const locationImage = await prisma.locationImage.create({
|
||||
data: {
|
||||
locationId: location.id,
|
||||
imageIndex: 0,
|
||||
description: 'Office image',
|
||||
imageUrl: 'images/location-seed.jpg',
|
||||
isSelected: true,
|
||||
},
|
||||
})
|
||||
|
||||
const voiceLine = await prisma.novelPromotionVoiceLine.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
matchedPanelId: panel.id,
|
||||
matchedStoryboardId: storyboard.id,
|
||||
matchedPanelIndex: panel.panelIndex,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.novelPromotionEpisode.update({
|
||||
where: { id: episode.id },
|
||||
data: {
|
||||
speakerVoices: JSON.stringify({
|
||||
Narrator: {
|
||||
provider: 'fal',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: 'https://provider.example/reference.wav',
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const secondaryPanel = await prisma.novelPromotionPanel.create({
|
||||
data: {
|
||||
storyboardId: storyboard.id,
|
||||
panelIndex: 1,
|
||||
panelNumber: 2,
|
||||
shotType: '近景',
|
||||
cameraMove: '推镜',
|
||||
description: 'secondary panel',
|
||||
videoPrompt: 'secondary prompt',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.novelPromotionStoryboard.update({
|
||||
where: { id: storyboard.id },
|
||||
data: { panelCount: 2 },
|
||||
})
|
||||
|
||||
const foreignStoryboard = await prisma.novelPromotionStoryboard.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
clipId: (await prisma.novelPromotionClip.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
summary: 'foreign clip',
|
||||
content: 'foreign clip content',
|
||||
screenplay: 'foreign screenplay',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})).id,
|
||||
panelCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const foreignPanel = await prisma.novelPromotionPanel.create({
|
||||
data: {
|
||||
id: `panel-foreign-${nextSuffix()}`,
|
||||
storyboardId: foreignStoryboard.id,
|
||||
panelIndex: 0,
|
||||
panelNumber: 1,
|
||||
shotType: '远景',
|
||||
cameraMove: '固定',
|
||||
description: 'foreign panel',
|
||||
videoPrompt: 'foreign prompt',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
project,
|
||||
novelProject,
|
||||
episode,
|
||||
clip,
|
||||
storyboard,
|
||||
panel,
|
||||
secondaryPanel,
|
||||
foreignStoryboard,
|
||||
foreignPanel,
|
||||
character,
|
||||
appearance,
|
||||
location,
|
||||
locationImage,
|
||||
voiceLine,
|
||||
}
|
||||
}
|
||||
52
tests/system/helpers/tasks.ts
Normal file
52
tests/system/helpers/tasks.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TASK_EVENT_TYPE, TASK_STATUS, type TaskEventType, type TaskStatus } from '@/lib/task/types'
|
||||
import { expect } from 'vitest'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
|
||||
type WaitTaskOptions = {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.FAILED,
|
||||
TASK_STATUS.DISMISSED,
|
||||
])
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function waitForTaskTerminalState(taskId: string, options: WaitTaskOptions = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 15_000
|
||||
const intervalMs = options.intervalMs ?? 100
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
})
|
||||
if (task && TERMINAL_STATUSES.has(task.status as TaskStatus)) {
|
||||
return task
|
||||
}
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error(`TASK_WAIT_TIMEOUT: ${taskId}`)
|
||||
}
|
||||
|
||||
export async function listTaskEventTypes(taskId: string): Promise<TaskEventType[]> {
|
||||
const events = await prisma.taskEvent.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { eventType: true },
|
||||
})
|
||||
return events.map((event) => event.eventType as TaskEventType)
|
||||
}
|
||||
|
||||
export function expectLifecycleEvents(types: ReadonlyArray<TaskEventType>, terminal: 'completed' | 'failed') {
|
||||
const expectedTerminal = terminal === 'completed' ? TASK_EVENT_TYPE.COMPLETED : TASK_EVENT_TYPE.FAILED
|
||||
expect(types).toContain(TASK_EVENT_TYPE.CREATED)
|
||||
expect(types).toContain(TASK_EVENT_TYPE.PROCESSING)
|
||||
expect(types).toContain(expectedTerminal)
|
||||
}
|
||||
40
tests/system/helpers/workers.ts
Normal file
40
tests/system/helpers/workers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Worker } from 'bullmq'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
|
||||
export type SystemWorkerScope = 'image' | 'video' | 'voice' | 'text'
|
||||
|
||||
export type SystemWorkers = Partial<Record<SystemWorkerScope, Worker<TaskJobData>>>
|
||||
|
||||
async function createWorker(scope: SystemWorkerScope): Promise<Worker<TaskJobData>> {
|
||||
if (scope === 'image') {
|
||||
const mod = await import('@/lib/workers/image.worker')
|
||||
return mod.createImageWorker()
|
||||
}
|
||||
if (scope === 'video') {
|
||||
const mod = await import('@/lib/workers/video.worker')
|
||||
return mod.createVideoWorker()
|
||||
}
|
||||
if (scope === 'voice') {
|
||||
const mod = await import('@/lib/workers/voice.worker')
|
||||
return mod.createVoiceWorker()
|
||||
}
|
||||
const mod = await import('@/lib/workers/text.worker')
|
||||
return mod.createTextWorker()
|
||||
}
|
||||
|
||||
export async function startSystemWorkers(scopes: ReadonlyArray<SystemWorkerScope>): Promise<SystemWorkers> {
|
||||
const started: SystemWorkers = {}
|
||||
for (const scope of scopes) {
|
||||
const worker = await createWorker(scope)
|
||||
await worker.waitUntilReady()
|
||||
started[scope] = worker
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
export async function stopSystemWorkers(workers: SystemWorkers): Promise<void> {
|
||||
for (const worker of Object.values(workers)) {
|
||||
if (!worker) continue
|
||||
await worker.close()
|
||||
}
|
||||
}
|
||||
351
tests/system/text-workflow.system.test.ts
Normal file
351
tests/system/text-workflow.system.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { callRoute } from '../integration/api/helpers/call-route'
|
||||
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||
import { resetSystemState } from '../helpers/db-reset'
|
||||
import { prisma } from '../helpers/prisma'
|
||||
import { seedMinimalDomainState } from './helpers/seed'
|
||||
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||
import { createFixtureEpisode, createFixtureNovelProject, createFixtureProject, createFixtureUser } from '../helpers/fixtures'
|
||||
|
||||
type FakeAiResult = {
|
||||
text: string
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
type FakeVoiceLineRow = {
|
||||
lineIndex: number
|
||||
speaker: string
|
||||
content: string
|
||||
emotionStrength: number
|
||||
matchedPanel: {
|
||||
storyboardId: string
|
||||
panelIndex: number
|
||||
}
|
||||
}
|
||||
|
||||
const textState = vi.hoisted(() => ({
|
||||
aiResults: [] as FakeAiResult[],
|
||||
voiceLineResults: [] as FakeVoiceLineRow[],
|
||||
parseFailureCount: 0,
|
||||
orchestratorClipId: 'clip-seed',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ai-runtime', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/ai-runtime')>('@/lib/ai-runtime')
|
||||
return {
|
||||
...actual,
|
||||
executeAiTextStep: vi.fn(async () => {
|
||||
const next = textState.aiResults.shift()
|
||||
if (!next) {
|
||||
return {
|
||||
text: '{"ok":true}',
|
||||
reasoning: '',
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: next.text,
|
||||
reasoning: next.reasoning || '',
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/novel-promotion/script-to-storyboard/orchestrator')>(
|
||||
'@/lib/novel-promotion/script-to-storyboard/orchestrator',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
runScriptToStoryboardOrchestrator: vi.fn(async () => ({
|
||||
clipPanels: [
|
||||
{
|
||||
clipId: textState.orchestratorClipId,
|
||||
panels: [
|
||||
{
|
||||
panelIndex: 1,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'system generated panel',
|
||||
videoPrompt: 'system video prompt',
|
||||
location: 'Office',
|
||||
characters: ['Narrator'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalPanelCount: 1,
|
||||
totalStepCount: 4,
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/script-to-storyboard-helpers')>(
|
||||
'@/lib/workers/handlers/script-to-storyboard-helpers',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
parseVoiceLinesJson: vi.fn(() => {
|
||||
if (textState.parseFailureCount > 0) {
|
||||
textState.parseFailureCount -= 1
|
||||
throw new Error('invalid voice json')
|
||||
}
|
||||
return textState.voiceLineResults
|
||||
}),
|
||||
persistStoryboardsAndPanels: vi.fn(async (input: { episodeId: string }) => {
|
||||
const clip = await prisma.novelPromotionClip.findFirst({
|
||||
where: { episodeId: input.episodeId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
if (!clip) {
|
||||
throw new Error(`TEST_CLIP_NOT_FOUND: ${input.episodeId}`)
|
||||
}
|
||||
const storyboard = await prisma.novelPromotionStoryboard.create({
|
||||
data: {
|
||||
id: 'storyboard-1',
|
||||
episodeId: input.episodeId,
|
||||
clipId: clip.id,
|
||||
panelCount: 1,
|
||||
},
|
||||
})
|
||||
const panel = await prisma.novelPromotionPanel.create({
|
||||
data: {
|
||||
id: 'panel-1',
|
||||
storyboardId: storyboard.id,
|
||||
panelIndex: 1,
|
||||
panelNumber: 1,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'system generated panel',
|
||||
videoPrompt: 'system video prompt',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})
|
||||
return [{ storyboardId: storyboard.id, panels: [{ id: panel.id, panelIndex: 1 }] }]
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
onStage: vi.fn(),
|
||||
onChunk: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
})),
|
||||
}))
|
||||
|
||||
async function seedScriptToStoryboardState() {
|
||||
const user = await createFixtureUser()
|
||||
const project = await createFixtureProject(user.id)
|
||||
const novelProject = await createFixtureNovelProject(project.id)
|
||||
const episode = await createFixtureEpisode(novelProject.id)
|
||||
const clip = await prisma.novelPromotionClip.create({
|
||||
data: {
|
||||
episodeId: episode.id,
|
||||
summary: 'script clip',
|
||||
content: 'clip content',
|
||||
screenplay: 'screenplay text',
|
||||
location: 'Office',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
},
|
||||
})
|
||||
await prisma.novelPromotionCharacter.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelProject.id,
|
||||
name: 'Narrator',
|
||||
},
|
||||
})
|
||||
await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelProject.id,
|
||||
name: 'Office',
|
||||
summary: 'Office',
|
||||
},
|
||||
})
|
||||
textState.orchestratorClipId = clip.id
|
||||
return { user, project, novelProject, episode, clip }
|
||||
}
|
||||
|
||||
describe('system - text workflows', () => {
|
||||
let workers: SystemWorkers = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
textState.aiResults = []
|
||||
textState.voiceLineResults = []
|
||||
textState.parseFailureCount = 0
|
||||
textState.orchestratorClipId = 'clip-seed'
|
||||
await resetSystemState()
|
||||
installAuthMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopSystemWorkers(workers)
|
||||
workers = {}
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('script-to-storyboard success -> persists storyboard/panel/voiceLine and completes task', async () => {
|
||||
const seeded = await seedScriptToStoryboardState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
textState.aiResults = [{ text: 'voice-lines-json' }]
|
||||
textState.voiceLineResults = [
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
workers = await startSystemWorkers(['text'])
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{ locale: 'zh', episodeId: seeded.episode.id },
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { taskId: string }
|
||||
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.type).toBe('script_to_storyboard_run')
|
||||
expect(task.result).toEqual(expect.objectContaining({
|
||||
episodeId: seeded.episode.id,
|
||||
panelCount: 1,
|
||||
voiceLineCount: 1,
|
||||
}))
|
||||
|
||||
const storyboards = await prisma.novelPromotionStoryboard.findMany({
|
||||
where: { episodeId: seeded.episode.id },
|
||||
select: { id: true, panelCount: true },
|
||||
})
|
||||
expect(storyboards.length).toBeGreaterThan(0)
|
||||
|
||||
const persistedVoiceLines = await prisma.novelPromotionVoiceLine.findMany({
|
||||
where: { episodeId: seeded.episode.id },
|
||||
orderBy: { lineIndex: 'asc' },
|
||||
select: {
|
||||
lineIndex: true,
|
||||
speaker: true,
|
||||
content: true,
|
||||
matchedPanelId: true,
|
||||
matchedPanelIndex: true,
|
||||
},
|
||||
})
|
||||
expect(persistedVoiceLines).toEqual([
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
matchedPanelId: expect.any(String),
|
||||
matchedPanelIndex: 1,
|
||||
},
|
||||
])
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'completed')
|
||||
})
|
||||
|
||||
it('script-to-storyboard parse retry -> second attempt succeeds', async () => {
|
||||
const seeded = await seedScriptToStoryboardState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
textState.aiResults = [
|
||||
{ text: 'invalid-voice-json' },
|
||||
{ text: 'valid-voice-json' },
|
||||
]
|
||||
textState.voiceLineResults = [
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Retry success',
|
||||
emotionStrength: 0.4,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
textState.parseFailureCount = 1
|
||||
workers = await startSystemWorkers(['text'])
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{ locale: 'zh', episodeId: seeded.episode.id },
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
const json = await response.json() as { taskId: string }
|
||||
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.result).toEqual(expect.objectContaining({
|
||||
voiceLineCount: 1,
|
||||
}))
|
||||
|
||||
const voiceLines = await prisma.novelPromotionVoiceLine.findMany({
|
||||
where: { episodeId: seeded.episode.id },
|
||||
select: { content: true },
|
||||
})
|
||||
expect(voiceLines).toEqual([{ content: 'Retry success' }])
|
||||
})
|
||||
|
||||
it('insert-panel invalid ai payload -> task fails and no dirty panel remains', async () => {
|
||||
const seeded = await seedMinimalDomainState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
textState.aiResults = [{ text: 'not-json' }]
|
||||
workers = await startSystemWorkers(['text'])
|
||||
|
||||
const beforeCount = await prisma.novelPromotionPanel.count({
|
||||
where: { storyboardId: seeded.storyboard.id },
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/insert-panel/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{
|
||||
locale: 'zh',
|
||||
storyboardId: seeded.storyboard.id,
|
||||
insertAfterPanelId: seeded.panel.id,
|
||||
},
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { taskId: string }
|
||||
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||
expect(task.status).toBe('failed')
|
||||
|
||||
const afterCount = await prisma.novelPromotionPanel.count({
|
||||
where: { storyboardId: seeded.storyboard.id },
|
||||
})
|
||||
expect(afterCount).toBe(beforeCount)
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'failed')
|
||||
})
|
||||
})
|
||||
108
tests/system/voice-generate.system.test.ts
Normal file
108
tests/system/voice-generate.system.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { callRoute } from '../integration/api/helpers/call-route'
|
||||
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||
import { resetSystemState } from '../helpers/db-reset'
|
||||
import { prisma } from '../helpers/prisma'
|
||||
import { seedMinimalDomainState } from './helpers/seed'
|
||||
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||
|
||||
const voiceState = vi.hoisted(() => ({
|
||||
audioUrl: 'voice/system-line.wav',
|
||||
audioDuration: 1200,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api-config')>('@/lib/api-config')
|
||||
return {
|
||||
...actual,
|
||||
resolveModelSelectionOrSingle: vi.fn(async () => ({
|
||||
provider: 'fal',
|
||||
modelId: 'fal-audio-model',
|
||||
modelKey: 'fal::audio-model',
|
||||
mediaType: 'audio',
|
||||
})),
|
||||
getProviderKey: vi.fn((providerId: string) => providerId),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/voice/generate-voice-line', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/voice/generate-voice-line')>('@/lib/voice/generate-voice-line')
|
||||
return {
|
||||
...actual,
|
||||
generateVoiceLine: vi.fn(async (params: {
|
||||
lineId: string
|
||||
}) => {
|
||||
await prisma.novelPromotionVoiceLine.update({
|
||||
where: { id: params.lineId },
|
||||
data: {
|
||||
audioUrl: voiceState.audioUrl,
|
||||
audioDuration: voiceState.audioDuration,
|
||||
},
|
||||
})
|
||||
return {
|
||||
lineId: params.lineId,
|
||||
audioUrl: voiceState.audioUrl,
|
||||
storageKey: voiceState.audioUrl,
|
||||
audioDuration: voiceState.audioDuration,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('system - voice generate', () => {
|
||||
let workers: SystemWorkers = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
voiceState.audioUrl = 'voice/system-line.wav'
|
||||
voiceState.audioDuration = 1200
|
||||
await resetSystemState()
|
||||
installAuthMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopSystemWorkers(workers)
|
||||
workers = {}
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('route -> voice worker -> line audio persisted', async () => {
|
||||
const seeded = await seedMinimalDomainState()
|
||||
mockAuthenticated(seeded.user.id)
|
||||
workers = await startSystemWorkers(['voice'])
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const response = await callRoute(
|
||||
mod.POST,
|
||||
'POST',
|
||||
{
|
||||
locale: 'zh',
|
||||
episodeId: seeded.episode.id,
|
||||
lineId: seeded.voiceLine.id,
|
||||
audioModel: 'fal::audio-model',
|
||||
},
|
||||
{ params: { projectId: seeded.project.id } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json() as { success: boolean; async: boolean; taskId: string }
|
||||
expect(json.success).toBe(true)
|
||||
const task = await waitForTaskTerminalState(json.taskId)
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.type).toBe('voice_line')
|
||||
|
||||
const voiceLine = await prisma.novelPromotionVoiceLine.findUnique({
|
||||
where: { id: seeded.voiceLine.id },
|
||||
select: { audioUrl: true, audioDuration: true },
|
||||
})
|
||||
expect(voiceLine).toEqual({
|
||||
audioUrl: voiceState.audioUrl,
|
||||
audioDuration: voiceState.audioDuration,
|
||||
})
|
||||
|
||||
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||
expectLifecycleEvents(eventTypes, 'completed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user