first commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const aiRuntimeMock = vi.hoisted(() => ({
|
||||
executeAiTextStep: vi.fn(async () => ({
|
||||
text: '扩写后的完整故事内容',
|
||||
reasoning: '',
|
||||
})),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ai-runtime', () => aiRuntimeMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AI_STORY_EXPAND: 'np_ai_story_expand' },
|
||||
buildPrompt: vi.fn(() => 'story-expand-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
|
||||
import { handleAiStoryExpandTask } from '@/lib/workers/handlers/ai-story-expand'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-ai-story-expand-1',
|
||||
type: TASK_TYPE.AI_STORY_EXPAND,
|
||||
locale: 'zh',
|
||||
projectId: 'home-ai-write',
|
||||
targetType: 'HomeAiStoryExpand',
|
||||
targetId: 'user-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker ai-story-expand behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('missing prompt -> explicit error', async () => {
|
||||
const job = buildJob({ prompt: ' ', analysisModel: 'provider::analysis-model' })
|
||||
await expect(handleAiStoryExpandTask(job)).rejects.toThrow('prompt is required')
|
||||
})
|
||||
|
||||
it('missing analysis model -> explicit error', async () => {
|
||||
const job = buildJob({ prompt: '宫廷复仇女主回京' })
|
||||
await expect(handleAiStoryExpandTask(job)).rejects.toThrow('analysisModel is required')
|
||||
})
|
||||
|
||||
it('success path -> returns expanded text without touching episode persistence', async () => {
|
||||
const job = buildJob({ prompt: '宫廷复仇女主回京', analysisModel: 'provider::analysis-model' })
|
||||
const result = await handleAiStoryExpandTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
expandedText: '扩写后的完整故事内容',
|
||||
})
|
||||
expect(aiRuntimeMock.executeAiTextStep).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
model: 'provider::analysis-model',
|
||||
projectId: 'home-ai-write',
|
||||
action: 'ai_story_expand',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => '{"ok":true}'),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const parseMock = vi.hoisted(() => ({
|
||||
chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),
|
||||
safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),
|
||||
safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),
|
||||
safeParsePropsResponse: vi.fn(() => ({ props: [] })),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
createAnalyzeGlobalStats: vi.fn((totalChunks: number) => ({
|
||||
totalChunks,
|
||||
processedChunks: 0,
|
||||
newCharacters: 0,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 0,
|
||||
newProps: 0,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
skippedProps: 0,
|
||||
})),
|
||||
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number; newProps: number } }) => {
|
||||
args.stats.newCharacters += 1
|
||||
args.stats.newLocations += 1
|
||||
args.stats.newProps += 1
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({
|
||||
CHUNK_SIZE: 3000,
|
||||
chunkContent: parseMock.chunkContent,
|
||||
parseAliases: vi.fn(() => []),
|
||||
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
safeParseCharactersResponse: parseMock.safeParseCharactersResponse,
|
||||
safeParseLocationsResponse: parseMock.safeParseLocationsResponse,
|
||||
safeParsePropsResponse: parseMock.safeParsePropsResponse,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({
|
||||
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l', propTemplate: 'p' })),
|
||||
buildAnalyzeGlobalPrompts: vi.fn(() => ({
|
||||
characterPrompt: 'character prompt',
|
||||
locationPrompt: 'location prompt',
|
||||
propPrompt: 'prop prompt',
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({
|
||||
createAnalyzeGlobalStats: persistMock.createAnalyzeGlobalStats,
|
||||
persistAnalyzeGlobalChunk: persistMock.persistAnalyzeGlobalChunk,
|
||||
}))
|
||||
|
||||
import { handleAnalyzeGlobalTask } from '@/lib/workers/handlers/analyze-global'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-analyze-global-1',
|
||||
type: TASK_TYPE.ANALYZE_GLOBAL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'np-project-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker analyze-global behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1' })
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
globalAssetText: '全局设定',
|
||||
characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary', assetKind: 'location' }],
|
||||
episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('no analyzable content -> explicit error', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
globalAssetText: '',
|
||||
characters: [],
|
||||
locations: [],
|
||||
episodes: [{ id: 'ep-1', name: '第一集', novelText: '' }],
|
||||
})
|
||||
|
||||
await expect(handleAnalyzeGlobalTask(buildJob())).rejects.toThrow('没有可分析的内容')
|
||||
})
|
||||
|
||||
it('success path -> persists every chunk and returns stats summary', async () => {
|
||||
const result = await handleAnalyzeGlobalTask(buildJob())
|
||||
|
||||
expect(parseMock.chunkContent).toHaveBeenCalled()
|
||||
expect(persistMock.persistAnalyzeGlobalChunk).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stats: {
|
||||
totalChunks: 2,
|
||||
newCharacters: 2,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 2,
|
||||
newProps: 2,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
skippedProps: 0,
|
||||
totalCharacters: 1,
|
||||
totalLocations: 1,
|
||||
totalProps: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionEpisode: { findFirst: vi.fn() },
|
||||
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
|
||||
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },
|
||||
locationImage: {
|
||||
create: vi.fn(async () => ({})),
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/constants')>()
|
||||
return {
|
||||
...actual,
|
||||
getArtStylePrompt: vi.fn(() => 'cinematic style'),
|
||||
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
|
||||
removePropPromptSuffix: vi.fn((text: string) => text),
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'char',
|
||||
NP_SELECT_LOCATION: 'loc',
|
||||
NP_SELECT_PROP: 'prop',
|
||||
},
|
||||
buildPrompt: vi.fn(() => 'analysis-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAnalyzeNovelTask } from '@/lib/workers/handlers/analyze-novel'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-analyze-novel-1',
|
||||
type: TASK_TYPE.ANALYZE_NOVEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'np-project-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker analyze-novel behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionLocation.create
|
||||
.mockResolvedValueOnce({ id: 'loc-new-1' })
|
||||
.mockResolvedValueOnce({ id: 'prop-new-1' })
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
artStyle: 'cinematic',
|
||||
globalAssetText: '全局设定文本',
|
||||
characters: [{ id: 'char-existing', name: '已有角色' }],
|
||||
locations: [{ id: 'loc-existing', name: '已有场景', summary: 'old' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValue({
|
||||
novelText: '首集内容',
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
characters: [
|
||||
{
|
||||
name: '新角色',
|
||||
aliases: ['别名A'],
|
||||
role_level: 'main',
|
||||
personality_tags: ['冷静'],
|
||||
visual_keywords: ['黑发'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
locations: [
|
||||
{
|
||||
name: '新地点',
|
||||
summary: '雨夜街道',
|
||||
descriptions: ['雨夜街道 [SUFFIX]'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
props: [
|
||||
{
|
||||
name: '金箍棒',
|
||||
summary: '孙悟空随身铁棍法器',
|
||||
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('no global text and no episode text -> explicit error', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
artStyle: 'cinematic',
|
||||
globalAssetText: '',
|
||||
characters: [],
|
||||
locations: [],
|
||||
})
|
||||
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValueOnce({ novelText: '' })
|
||||
|
||||
await expect(handleAnalyzeNovelTask(buildJob())).rejects.toThrow('请先填写全局资产设定或剧本内容')
|
||||
})
|
||||
|
||||
it('success path -> creates character/location and persists cleaned location descriptions', async () => {
|
||||
const result = await handleAnalyzeNovelTask(buildJob())
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
characters: [{ id: 'char-new-1' }],
|
||||
locations: [{ id: 'loc-new-1' }],
|
||||
props: [{ id: 'prop-new-1' }],
|
||||
characterCount: 1,
|
||||
locationCount: 1,
|
||||
propCount: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
name: '新角色',
|
||||
aliases: JSON.stringify(['别名A']),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionLocation.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
name: '新地点',
|
||||
summary: '雨夜街道',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.locationImage.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'prop-new-1',
|
||||
imageIndex: 0,
|
||||
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||
where: { id: 'np-project-1' },
|
||||
data: { artStylePrompt: 'cinematic style' },
|
||||
})
|
||||
|
||||
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
60,
|
||||
expect.objectContaining({
|
||||
stepId: 'analyze_characters',
|
||||
done: true,
|
||||
output: expect.stringContaining('"characters"'),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
70,
|
||||
expect.objectContaining({
|
||||
stepId: 'analyze_locations',
|
||||
done: true,
|
||||
output: expect.stringContaining('"locations"'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const assetUtilsMock = vi.hoisted(() => ({
|
||||
aiDesign: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/asset-utils', () => assetUtilsMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
|
||||
import { handleAssetHubAIDesignTask } from '@/lib/workers/handlers/asset-hub-ai-design'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-ai-design-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker asset-hub-ai-design behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-default' })
|
||||
assetUtilsMock.aiDesign.mockResolvedValue({
|
||||
success: true,
|
||||
prompt: 'generated prompt',
|
||||
})
|
||||
})
|
||||
|
||||
it('missing userInstruction -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {})
|
||||
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('userInstruction is required')
|
||||
})
|
||||
|
||||
it('unsupported task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, { userInstruction: 'design a hero' })
|
||||
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('Unsupported asset hub ai design task type')
|
||||
})
|
||||
|
||||
it('success uses payload analysisModel override and character assetType', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {
|
||||
userInstruction: ' design a heroic character ',
|
||||
analysisModel: ' llm::analysis-override ',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIDesignTask(job)
|
||||
|
||||
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
analysisModel: 'llm::analysis-override',
|
||||
userInstruction: 'design a heroic character',
|
||||
assetType: 'character',
|
||||
projectId: 'global-asset-hub',
|
||||
skipBilling: true,
|
||||
}))
|
||||
expect(result).toEqual({
|
||||
prompt: 'generated prompt',
|
||||
availableSlots: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('location type success -> passes location assetType', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION, {
|
||||
userInstruction: 'design a rainy alley',
|
||||
})
|
||||
|
||||
await handleAssetHubAIDesignTask(job)
|
||||
|
||||
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
|
||||
assetType: 'location',
|
||||
analysisModel: 'llm::analysis-default',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const streamContextMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => {
|
||||
const flush = vi.fn(async () => undefined)
|
||||
return {
|
||||
flush,
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
onStage: vi.fn(),
|
||||
onChunk: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
flush,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamContextMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
|
||||
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_CHARACTER_MODIFY: 'np_character_modify',
|
||||
NP_LOCATION_MODIFY: 'np_location_modify',
|
||||
},
|
||||
buildPrompt: vi.fn((_args: unknown) => 'final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAssetHubAIModifyTask } from '@/lib/workers/handlers/asset-hub-ai-modify'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-ai-modify-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker asset-hub-ai-modify behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-1' })
|
||||
llmMock.chatCompletion.mockResolvedValue({ id: 'completion-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('{"prompt":"modified description"}')
|
||||
})
|
||||
|
||||
it('missing analysisModel in user config -> explicit error', async () => {
|
||||
configMock.getUserModelConfig.mockResolvedValueOnce({ analysisModel: '' })
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old',
|
||||
modifyInstruction: 'new',
|
||||
})
|
||||
|
||||
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('请先在用户配置中设置分析模型')
|
||||
})
|
||||
|
||||
it('unsupported type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old',
|
||||
modifyInstruction: 'new',
|
||||
})
|
||||
|
||||
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('Unsupported task type')
|
||||
})
|
||||
|
||||
it('character success -> parses JSON prompt and returns modifiedDescription', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old character description',
|
||||
modifyInstruction: 'add armor details',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIModifyTask(job)
|
||||
|
||||
expect(llmMock.chatCompletion).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'llm::analysis-1',
|
||||
[{ role: 'user', content: 'final-prompt' }],
|
||||
expect.objectContaining({
|
||||
projectId: 'asset-hub',
|
||||
action: 'ai_modify_character',
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedDescription: 'modified description',
|
||||
availableSlots: [],
|
||||
})
|
||||
expect(llmStreamMock.flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('location success -> requires locationName and returns modifiedDescription', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION, {
|
||||
locationId: 'loc-1',
|
||||
locationName: 'Old Town',
|
||||
currentDescription: 'old location description',
|
||||
modifyInstruction: 'add more fog',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIModifyTask(job)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedDescription: 'modified description',
|
||||
availableSlots: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_ASSET_IMAGE_RATIO, CHARACTER_PROMPT_SUFFIX, PROP_IMAGE_RATIO, PROP_PROMPT_SUFFIX } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const workersUtilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getUserModels: vi.fn(async () => ({
|
||||
characterModel: 'character-model-1',
|
||||
locationModel: 'location-model-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateCleanImageToStorage: vi.fn(async () => 'cos/generated-character.png'),
|
||||
parseJsonStringArray: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
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,
|
||||
generateCleanImageToStorage: sharedMock.generateCleanImageToStorage,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleAssetHubImageTask } from '@/lib/workers/handlers/asset-hub-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-hub-image-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
if (!target) return 0
|
||||
return input.split(target).length - 1
|
||||
}
|
||||
|
||||
describe('asset hub character image prompt suffix regression', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: '主角,黑发,冷静',
|
||||
descriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps character prompt suffix in actual generation prompt', async () => {
|
||||
const job = buildJob({
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
})
|
||||
|
||||
await handleAssetHubImageTask(job)
|
||||
|
||||
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
|
||||
prompt?: string
|
||||
options?: { aspectRatio?: string }
|
||||
label?: string
|
||||
}] | undefined
|
||||
const callArg = generationCall?.[0]
|
||||
const prompt = callArg?.prompt || ''
|
||||
|
||||
expect(prompt).toContain('主角,黑发,冷静')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: CHARACTER_ASSET_IMAGE_RATIO }))
|
||||
expect(callArg?.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors requested count for global location generation', async () => {
|
||||
prismaMock.globalLocation.findFirst.mockResolvedValueOnce({
|
||||
id: 'global-location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{ id: 'global-location-image-1', description: '雨夜街道 A' },
|
||||
{ id: 'global-location-image-2', description: '雨夜街道 B' },
|
||||
{ id: 'global-location-image-3', description: '雨夜街道 C' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await handleAssetHubImageTask(buildJob({
|
||||
type: 'location',
|
||||
id: 'global-location-1',
|
||||
count: 1,
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'location',
|
||||
locationId: 'global-location-1',
|
||||
imageCount: 1,
|
||||
})
|
||||
expect(sharedMock.generateCleanImageToStorage).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'global-location-image-1' },
|
||||
data: { imageUrl: 'cos/generated-character.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the prop prompt suffix in global prop generation prompts', async () => {
|
||||
prismaMock.globalLocation.findFirst.mockResolvedValueOnce({
|
||||
id: 'global-prop-1',
|
||||
name: 'Silver Cutlery',
|
||||
images: [
|
||||
{
|
||||
id: 'global-prop-image-1',
|
||||
description: '银质餐具套装,包含刀叉与汤匙,线条简洁,金属冷白光泽',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await handleAssetHubImageTask(buildJob({
|
||||
type: 'prop',
|
||||
id: 'global-prop-1',
|
||||
}))
|
||||
|
||||
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
|
||||
prompt?: string
|
||||
options?: { aspectRatio?: string }
|
||||
label?: string
|
||||
}] | undefined
|
||||
const callArg = generationCall?.[0]
|
||||
const prompt = callArg?.prompt || ''
|
||||
|
||||
expect(prompt).toContain(PROP_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(prompt, PROP_PROMPT_SUFFIX)).toBe(1)
|
||||
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }))
|
||||
expect(callArg?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX, getArtStylePrompt } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ characterModel: 'image-model-1', artStyle: 'realistic' })),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-primary-ref']),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionCharacter: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateProjectLabeledImageToStorage: vi.fn<(input: {
|
||||
prompt: string
|
||||
label: string
|
||||
options?: { referenceImages?: string[]; aspectRatio?: string }
|
||||
}) => Promise<string>>(async () => 'cos/character-generated-0.png'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
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,
|
||||
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleCharacterImageTask } from '@/lib/workers/handlers/character-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'appearance-2'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-character-image-1',
|
||||
type: TASK_TYPE.IMAGE_CHARACTER,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker character-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-2',
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 1,
|
||||
descriptions: JSON.stringify(['角色描述A']),
|
||||
description: '角色描述A',
|
||||
imageUrls: JSON.stringify([]),
|
||||
selectedIndex: 0,
|
||||
imageUrl: null,
|
||||
changeReason: '战斗形态',
|
||||
character: { name: 'Hero' },
|
||||
})
|
||||
|
||||
prismaMock.characterAppearance.findFirst.mockResolvedValue({
|
||||
imageUrl: 'cos/primary.png',
|
||||
imageUrls: JSON.stringify(['cos/primary.png']),
|
||||
})
|
||||
})
|
||||
|
||||
it('characterModel not configured -> explicit error', async () => {
|
||||
utilsMock.getProjectModels.mockResolvedValueOnce({ characterModel: '', artStyle: 'realistic' })
|
||||
await expect(handleCharacterImageTask(buildJob({}))).rejects.toThrow('Character model not configured')
|
||||
})
|
||||
|
||||
it('success path -> uses primary appearance as reference and persists imageUrls', async () => {
|
||||
const job = buildJob({ imageIndex: 0 })
|
||||
const result = await handleCharacterImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
appearanceId: 'appearance-2',
|
||||
imageCount: 1,
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
})
|
||||
|
||||
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
|
||||
prompt: string
|
||||
label: string
|
||||
options?: { referenceImages?: string[]; aspectRatio?: string }
|
||||
}
|
||||
const realisticStylePrompt = getArtStylePrompt('realistic', 'zh')
|
||||
|
||||
expect(generationInput.prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(generationInput.prompt).toContain(realisticStylePrompt)
|
||||
expect(generationInput.prompt.split(CHARACTER_PROMPT_SUFFIX).length - 1).toBe(1)
|
||||
expect(generationInput.prompt.split(realisticStylePrompt).length - 1).toBe(1)
|
||||
expect(generationInput.label).toBe('Hero - 战斗形态')
|
||||
expect(generationInput.options).toEqual(expect.objectContaining({
|
||||
referenceImages: ['normalized-primary-ref'],
|
||||
aspectRatio: '3:2',
|
||||
}))
|
||||
|
||||
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-2' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify(['cos/character-generated-0.png']),
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('payload artStyle overrides project artStyle in prompt', async () => {
|
||||
const job = buildJob({ imageIndex: 0, artStyle: 'japanese-anime' })
|
||||
await handleCharacterImageTask(job)
|
||||
|
||||
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
|
||||
prompt: string
|
||||
}
|
||||
expect(generationInput.prompt).toContain(getArtStylePrompt('japanese-anime', 'zh'))
|
||||
expect(generationInput.prompt).not.toContain(getArtStylePrompt('realistic', 'zh'))
|
||||
})
|
||||
|
||||
it('invalid payload artStyle -> explicit error', async () => {
|
||||
await expect(handleCharacterImageTask(buildJob({ imageIndex: 0, artStyle: 'noir' }))).rejects.toThrow(
|
||||
'Invalid artStyle in IMAGE_CHARACTER payload',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses requested count for grouped generation and expands imageUrls to requested size', async () => {
|
||||
sharedMock.generateProjectLabeledImageToStorage
|
||||
.mockResolvedValueOnce('cos/character-generated-0.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-1.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-2.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-3.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-4.png')
|
||||
|
||||
const result = await handleCharacterImageTask(buildJob({ count: 5 }))
|
||||
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(5)
|
||||
expect(result).toEqual({
|
||||
appearanceId: 'appearance-2',
|
||||
imageCount: 5,
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
})
|
||||
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-2' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify([
|
||||
'cos/character-generated-0.png',
|
||||
'cos/character-generated-1.png',
|
||||
'cos/character-generated-2.png',
|
||||
'cos/character-generated-3.png',
|
||||
'cos/character-generated-4.png',
|
||||
]),
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
novelPromotionCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({})),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
resolveProjectModel: vi.fn(async () => ({
|
||||
id: 'project-1',
|
||||
novelPromotionData: {
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/types/character-profile', () => ({
|
||||
validateProfileData: vi.fn(() => true),
|
||||
stringifyProfileData: vi.fn((value: unknown) => JSON.stringify(value)),
|
||||
}))
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/character-profile-helpers', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/character-profile-helpers')>(
|
||||
'@/lib/workers/handlers/character-profile-helpers',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
resolveProjectModel: helperMock.resolveProjectModel,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_CHARACTER_VISUAL: 'np_agent_character_visual' },
|
||||
buildPrompt: vi.fn(() => 'character-visual-prompt'),
|
||||
}))
|
||||
|
||||
import { handleCharacterProfileTask } from '@/lib/workers/handlers/character-profile'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-character-profile-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionCharacter',
|
||||
targetId: 'character-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker character-profile behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
|
||||
return await callback(prismaMock)
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify({
|
||||
characters: [
|
||||
{
|
||||
appearances: [
|
||||
{
|
||||
change_reason: '默认形象',
|
||||
descriptions: ['黑发,冷静,风衣'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
prismaMock.novelPromotionCharacter.findFirst.mockImplementation(async (args: { where: { id: string } }) => ({
|
||||
id: args.where.id,
|
||||
name: args.where.id === 'character-2' ? 'Villain' : 'Hero',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
}))
|
||||
|
||||
prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
},
|
||||
{
|
||||
id: 'character-2',
|
||||
name: 'Villain',
|
||||
profileData: JSON.stringify({ archetype: 'antagonist' }),
|
||||
profileConfirmed: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('unsupported task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.AI_CREATE_CHARACTER, {})
|
||||
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
|
||||
})
|
||||
|
||||
it('confirm profile success -> rebuilds appearances and marks profileConfirmed', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: '默认形象',
|
||||
description: '黑发,冷静,风衣',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'character-1' },
|
||||
data: {
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
character: expect.objectContaining({
|
||||
id: 'character-1',
|
||||
profileConfirmed: true,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('batch confirm -> loops through all unconfirmed characters and returns count', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM, {})
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
count: 2,
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reconfirm with existing appearances -> replaces old rows instead of colliding on unique index', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
|
||||
await expect(handleCharacterProfileTask(job)).resolves.toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
novelPromotionClip: {
|
||||
findMany: vi.fn(async () => []),
|
||||
update: vi.fn(async () => ({ id: 'clip-row-1' })),
|
||||
deleteMany: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ id: 'clip-row-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_CLIP: 'np_agent_clip' },
|
||||
buildPrompt: vi.fn(() => 'clip-split-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createClipContentMatcher: (content: string) => ({
|
||||
matchBoundary: (start: string, end: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(start, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
const endStart = content.indexOf(end, startIndex)
|
||||
if (endStart === -1) return null
|
||||
return {
|
||||
startIndex,
|
||||
endIndex: endStart + end.length,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { handleClipsBuildTask } from '@/lib/workers/handlers/clips-build'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-clips-build-1',
|
||||
type: TASK_TYPE.CLIPS_BUILD,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker clips-build behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1' })
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
name: '第一集',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'A START one END B START two END C',
|
||||
})
|
||||
prismaMock.novelPromotionClip.findMany.mockResolvedValue([])
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
start: 'START one',
|
||||
end: 'END',
|
||||
summary: 'first clip',
|
||||
location: 'Old Town',
|
||||
characters: ['Hero'],
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleClipsBuildTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> creates clip row with concrete boundaries and characters payload', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleClipsBuildTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
count: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionClip.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
episodeId: 'episode-1',
|
||||
startText: 'START one',
|
||||
endText: 'END',
|
||||
summary: 'first clip',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify(['Hero']),
|
||||
props: null,
|
||||
content: 'START one END',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('AI boundaries cannot be matched -> explicit boundary error', async () => {
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
start: 'NOT_FOUND_START',
|
||||
end: 'NOT_FOUND_END',
|
||||
summary: 'bad clip',
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
await expect(handleClipsBuildTask(job)).rejects.toThrow('split_clips boundary matching failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ id: 'project-1' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmClientMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => JSON.stringify({
|
||||
episodes: [
|
||||
{
|
||||
number: 1,
|
||||
title: '第一集',
|
||||
summary: '开端',
|
||||
startMarker: 'START_MARKER',
|
||||
endMarker: 'END_MARKER',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis-model',
|
||||
})),
|
||||
}))
|
||||
|
||||
const internalStreamMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'stream-1' })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
flush: vi.fn(async () => {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
|
||||
buildPrompt: vi.fn(() => 'EPISODE_SPLIT_PROMPT'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmClientMock)
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => internalStreamMock)
|
||||
vi.mock('@/lib/workers/shared', () => sharedMock)
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => llmStreamMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptMock)
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createTextMarkerMatcher: (content: string) => ({
|
||||
matchMarker: (marker: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(marker, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
return {
|
||||
startIndex,
|
||||
endIndex: startIndex + marker.length,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { handleEpisodeSplitTask } from '@/lib/workers/handlers/episode-split'
|
||||
|
||||
function buildJob(content: string): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-episode-split-1',
|
||||
type: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { content },
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker episode-split', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when content is too short', async () => {
|
||||
const job = buildJob('short text')
|
||||
await expect(handleEpisodeSplitTask(job)).rejects.toThrow('文本太短,至少需要 100 字')
|
||||
})
|
||||
|
||||
it('returns matched episodes when ai boundaries are valid', async () => {
|
||||
const content = [
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'START_MARKER',
|
||||
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于单元测试验证。',
|
||||
'END_MARKER',
|
||||
'后置内容用于确保边界外还有文本,并继续补足长度。',
|
||||
].join('')
|
||||
|
||||
const job = buildJob(content)
|
||||
const result = await handleEpisodeSplitTask(job)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.episodes).toHaveLength(1)
|
||||
expect(result.episodes[0]?.number).toBe(1)
|
||||
expect(result.episodes[0]?.title).toBe('第一集')
|
||||
expect(result.episodes[0]?.content).toContain('START_MARKER')
|
||||
expect(result.episodes[0]?.content).toContain('END_MARKER')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
|
||||
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
|
||||
stripLabelBar: vi.fn(async () => 'required-reference-image'),
|
||||
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
|
||||
withLabelBar: vi.fn(async (source: unknown) => source),
|
||||
}))
|
||||
|
||||
const outboundImageMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),
|
||||
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-required-reference'),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
resolveNovelData: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
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,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function readUpdateData(arg: unknown): Record<string, unknown> {
|
||||
if (!arg || typeof arg !== 'object') return {}
|
||||
const data = (arg as { data?: unknown }).data
|
||||
if (!data || typeof data !== 'object') return {}
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('worker image-task-handlers-core', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when modify task payload is incomplete', async () => {
|
||||
const job = buildJob({})
|
||||
await expect(handleModifyAssetImageTask(job)).rejects.toThrow('modify task missing type/modifyPrompt')
|
||||
})
|
||||
|
||||
it('updates location image with expected generation options and persistence payload', async () => {
|
||||
prismaMock.locationImage.findUnique.mockResolvedValue({
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageUrl: 'cos/location-old.png',
|
||||
location: { name: 'Old Town' },
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: 'location',
|
||||
locationImageId: 'location-image-1',
|
||||
modifyPrompt: 'add heavy rain',
|
||||
extraImageUrls: [' https://example.com/location-ref.png '],
|
||||
generationOptions: { resolution: '1536x1024' },
|
||||
})
|
||||
|
||||
const result = await handleModifyAssetImageTask(job)
|
||||
expect(result).toEqual({
|
||||
type: 'location',
|
||||
locationImageId: 'location-image-1',
|
||||
imageUrl: 'cos/new-image.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: LOCATION_IMAGE_RATIO,
|
||||
resolution: '1536x1024',
|
||||
referenceImages: ['required-reference-image', 'normalized-reference-image'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = locationUpdateCall?.[0]
|
||||
const updateData = readUpdateData(updateArg)
|
||||
expect(updateData.previousImageUrl).toBe('cos/location-old.png')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('uses the character-matching aspect ratio when modifying project prop images', async () => {
|
||||
prismaMock.locationImage.findUnique.mockResolvedValueOnce({
|
||||
id: 'prop-image-1',
|
||||
locationId: 'prop-1',
|
||||
imageUrl: 'cos/prop-old.png',
|
||||
description: 'silver prop',
|
||||
previousDescription: null,
|
||||
location: { name: 'Silver Prop' },
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(buildJob({
|
||||
type: 'prop',
|
||||
locationImageId: 'prop-image-1',
|
||||
modifyPrompt: 'make it brushed silver',
|
||||
generationOptions: { resolution: '1536x1024' },
|
||||
}))
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
resolution: '1536x1024',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('updates storyboard panel image and keeps candidateImages reset', async () => {
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
imageUrl: 'cos/panel-old.png',
|
||||
previousImageUrl: null,
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: 'storyboard',
|
||||
panelId: 'panel-1',
|
||||
modifyPrompt: 'cinematic backlight',
|
||||
selectedAssets: [{ imageUrl: 'https://example.com/asset-ref.png' }],
|
||||
extraImageUrls: ['https://example.com/extra-ref.png'],
|
||||
generationOptions: { resolution: '2048x1152' },
|
||||
})
|
||||
|
||||
const result = await handleModifyAssetImageTask(job)
|
||||
expect(result).toEqual({
|
||||
type: 'storyboard',
|
||||
panelId: 'panel-1',
|
||||
imageUrl: 'cos/new-image.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '16:9',
|
||||
resolution: '2048x1152',
|
||||
referenceImages: [
|
||||
'base64-required-reference',
|
||||
'normalized-reference-image',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const panelUpdateCall = prismaMock.novelPromotionPanel.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = panelUpdateCall?.[0]
|
||||
const updateData = readUpdateData(updateArg)
|
||||
expect(updateData.previousImageUrl).toBe('cos/panel-old.png')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
expect(updateData.candidateImages).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as WorkerProcessor | null,
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),
|
||||
}))
|
||||
|
||||
const handlerMock = vi.hoisted(() => ({
|
||||
handleAssetHubImageTask: vi.fn(async () => ({ ok: true })),
|
||||
handleAssetHubModifyTask: vi.fn(async () => ({ ok: true })),
|
||||
handleCharacterImageTask: vi.fn(async () => ({ ok: true })),
|
||||
handleLocationImageTask: vi.fn(async () => ({ ok: true })),
|
||||
handleModifyAssetImageTask: vi.fn(async () => ({ ok: true })),
|
||||
handlePanelImageTask: vi.fn(async () => ({ ok: true })),
|
||||
handlePanelVariantTask: vi.fn(async () => ({ ok: true })),
|
||||
}))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 5,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
|
||||
const gateMock = vi.hoisted(() => ({
|
||||
withUserConcurrencyGate: vi.fn(async <T>(input: {
|
||||
run: () => Promise<T>
|
||||
}) => await input.run()),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
constructor(_name: string) {}
|
||||
|
||||
async add() {
|
||||
return { id: 'job-1' }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: WorkerProcessor) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/shared', () => sharedMock)
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/workers/user-concurrency-gate', () => gateMock)
|
||||
vi.mock('@/lib/workers/handlers/image-task-handlers', () => handlerMock)
|
||||
|
||||
function buildJob(type: TaskJobData['type']): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-image-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker image concurrency behavior', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
workerState.processor = null
|
||||
|
||||
const mod = await import('@/lib/workers/image.worker')
|
||||
mod.createImageWorker()
|
||||
})
|
||||
|
||||
it('reads user image concurrency and applies gate before processing', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const job = buildJob(TASK_TYPE.IMAGE_PANEL)
|
||||
await processor!(job)
|
||||
|
||||
expect(configServiceMock.getUserWorkflowConcurrencyConfig).toHaveBeenCalledWith('user-1')
|
||||
expect(gateMock.withUserConcurrencyGate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scope: 'image',
|
||||
userId: 'user-1',
|
||||
limit: 5,
|
||||
}))
|
||||
expect(handlerMock.handlePanelImageTask).toHaveBeenCalledWith(job)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
import { handleLLMProxyTask, isLLMProxyTaskType } from '@/lib/workers/handlers/llm-proxy'
|
||||
|
||||
function buildJob(type: TaskJobData['type']): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-llm-proxy-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker llm-proxy behavior', () => {
|
||||
it('current route map has no enabled proxy task type', () => {
|
||||
expect(isLLMProxyTaskType(TASK_TYPE.STORY_TO_SCRIPT_RUN)).toBe(false)
|
||||
expect(isLLMProxyTaskType(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)).toBe(false)
|
||||
})
|
||||
|
||||
it('unsupported proxy task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.STORY_TO_SCRIPT_RUN)
|
||||
await expect(handleLLMProxyTask(job)).rejects.toThrow('Unsupported llm proxy task type')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Job } from 'bullmq'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const reportTaskStreamChunkMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const isTaskActiveMock = vi.hoisted(() => vi.fn(async () => true))
|
||||
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
reportTaskStreamChunk: reportTaskStreamChunkMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: assertTaskActiveMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/service', () => ({
|
||||
isTaskActive: isTaskActiveMock,
|
||||
}))
|
||||
|
||||
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from '@/lib/workers/handlers/llm-stream'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
const data: TaskJobData = {
|
||||
taskId: 'task-1',
|
||||
type: 'story_to_script_run',
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: {},
|
||||
trace: null,
|
||||
}
|
||||
return {
|
||||
data,
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('createWorkerLLMStreamCallbacks', () => {
|
||||
beforeEach(() => {
|
||||
reportTaskProgressMock.mockReset()
|
||||
reportTaskStreamChunkMock.mockReset()
|
||||
assertTaskActiveMock.mockReset()
|
||||
isTaskActiveMock.mockReset()
|
||||
isTaskActiveMock.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('publishes final step output on onComplete for replay recovery', async () => {
|
||||
const job = buildJob()
|
||||
const context = createWorkerLLMStreamContext(job, 'story_to_script')
|
||||
const callbacks = createWorkerLLMStreamCallbacks(job, context)
|
||||
|
||||
expect(callbacks.onStage).toBeTruthy()
|
||||
callbacks.onStage?.({
|
||||
stage: 'streaming',
|
||||
provider: 'ark',
|
||||
step: {
|
||||
id: 'screenplay_clip_1',
|
||||
attempt: 2,
|
||||
title: 'progress.streamStep.screenplayConversion',
|
||||
index: 1,
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
expect(callbacks.onComplete).toBeTruthy()
|
||||
callbacks.onComplete?.('final screenplay text', {
|
||||
id: 'screenplay_clip_1',
|
||||
attempt: 2,
|
||||
title: 'progress.streamStep.screenplayConversion',
|
||||
index: 1,
|
||||
total: 1,
|
||||
})
|
||||
await callbacks.flush()
|
||||
|
||||
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
|
||||
const payload = (call as unknown as [unknown, unknown, Record<string, unknown> | undefined])[2]
|
||||
return payload?.stage === 'worker_llm_complete'
|
||||
})
|
||||
|
||||
expect(finalProgressCall).toBeDefined()
|
||||
const payload = (finalProgressCall as unknown as [unknown, unknown, Record<string, unknown>])[2]
|
||||
expect(payload.done).toBe(true)
|
||||
expect(payload.output).toBe('final screenplay text')
|
||||
expect(payload.stepId).toBe('screenplay_clip_1')
|
||||
expect(payload.stepAttempt).toBe(2)
|
||||
expect(payload.stepTitle).toBe('progress.streamStep.screenplayConversion')
|
||||
expect(payload.stepIndex).toBe(1)
|
||||
expect(payload.stepTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps completion payload bound to provided step under interleaved steps', async () => {
|
||||
const job = buildJob()
|
||||
const context = createWorkerLLMStreamContext(job, 'story_to_script')
|
||||
const callbacks = createWorkerLLMStreamCallbacks(job, context)
|
||||
|
||||
expect(callbacks.onChunk).toBeTruthy()
|
||||
callbacks.onChunk?.({
|
||||
kind: 'text',
|
||||
delta: 'A-',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
step: { id: 'analyze_characters', attempt: 1, title: 'A', index: 1, total: 2 },
|
||||
})
|
||||
callbacks.onChunk?.({
|
||||
kind: 'text',
|
||||
delta: 'B-',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
step: { id: 'analyze_locations', attempt: 1, title: 'B', index: 2, total: 2 },
|
||||
})
|
||||
expect(callbacks.onComplete).toBeTruthy()
|
||||
callbacks.onComplete?.('characters-final', {
|
||||
id: 'analyze_characters',
|
||||
attempt: 1,
|
||||
title: 'A',
|
||||
index: 1,
|
||||
total: 2,
|
||||
})
|
||||
await callbacks.flush()
|
||||
|
||||
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
|
||||
const payload = (call as unknown as [unknown, unknown, Record<string, unknown> | undefined])[2]
|
||||
return payload?.stage === 'worker_llm_complete'
|
||||
})
|
||||
|
||||
expect(finalProgressCall).toBeDefined()
|
||||
const payload = (finalProgressCall as unknown as [unknown, unknown, Record<string, unknown>])[2]
|
||||
expect(payload.stepId).toBe('analyze_characters')
|
||||
expect(payload.stepTitle).toBe('A')
|
||||
expect(payload.output).toBe('characters-final')
|
||||
})
|
||||
|
||||
it('uses injected active controller for run-owned workflows', async () => {
|
||||
const job = buildJob()
|
||||
const context = createWorkerLLMStreamContext(job, 'story_to_script')
|
||||
const assertActive = vi.fn(async (_stage: string) => undefined)
|
||||
const isActive = vi.fn(async () => true)
|
||||
const callbacks = createWorkerLLMStreamCallbacks(job, context, {
|
||||
assertActive,
|
||||
isActive,
|
||||
})
|
||||
|
||||
callbacks.onChunk?.({
|
||||
kind: 'text',
|
||||
delta: 'hello',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
step: { id: 'split_clips', attempt: 1, title: 'split', index: 1, total: 1 },
|
||||
})
|
||||
await callbacks.flush()
|
||||
|
||||
expect(assertActive).toHaveBeenCalledWith('worker_llm_stream')
|
||||
expect(assertTaskActiveMock).not.toHaveBeenCalled()
|
||||
expect(reportTaskStreamChunkMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
delta: 'hello',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
stepId: 'split_clips',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, getArtStylePrompt } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ locationModel: 'location-model-1', artStyle: 'japanese-anime' })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionLocation: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateProjectLabeledImageToStorage: vi.fn(async () => 'cos/location-generated-1.png'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
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,
|
||||
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleLocationImageTask } from '@/lib/workers/handlers/location-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'location-image-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-location-image-1',
|
||||
type: TASK_TYPE.IMAGE_LOCATION,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'LocationImage',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker location-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.locationImage.findUnique.mockResolvedValue({
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
availableSlots: JSON.stringify([
|
||||
'街道左侧靠墙的留白位置',
|
||||
]),
|
||||
location: { name: 'Old Town' },
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
availableSlots: JSON.stringify([
|
||||
'街道左侧靠墙的留白位置',
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('locationModel missing -> explicit error', async () => {
|
||||
utilsMock.getProjectModels.mockResolvedValueOnce({ locationModel: '', artStyle: 'japanese-anime' })
|
||||
await expect(handleLocationImageTask(buildJob({}))).rejects.toThrow('Location model not configured')
|
||||
})
|
||||
|
||||
it('success path -> generates and persists concrete location image url', async () => {
|
||||
const result = await handleLocationImageTask(buildJob({ imageIndex: 0 }))
|
||||
const animeStylePrompt = getArtStylePrompt('japanese-anime', 'zh')
|
||||
|
||||
expect(result).toEqual({
|
||||
updated: 1,
|
||||
locationIds: ['location-1'],
|
||||
})
|
||||
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('雨夜街道'),
|
||||
label: 'Old Town',
|
||||
targetId: 'location-image-1',
|
||||
options: expect.objectContaining({ aspectRatio: LOCATION_IMAGE_RATIO }),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('可站位置:'),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('街道左侧靠墙的留白位置'),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('必须使用宽广完整的场景全景构图'),
|
||||
}),
|
||||
)
|
||||
const generationCall = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0] as unknown as [{ prompt: string }] | undefined
|
||||
expect(generationCall).toBeTruthy()
|
||||
if (!generationCall) throw new Error('expected generateProjectLabeledImageToStorage call')
|
||||
const generationInput = generationCall[0]
|
||||
expect(generationInput.prompt.split(animeStylePrompt).length - 1).toBe(1)
|
||||
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-image-1' },
|
||||
data: { imageUrl: 'cos/location-generated-1.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('payload artStyle overrides project artStyle in prompt', async () => {
|
||||
await handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'realistic' }))
|
||||
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining(getArtStylePrompt('realistic', 'zh')),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('invalid payload artStyle -> explicit error', async () => {
|
||||
await expect(handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'anime' }))).rejects.toThrow(
|
||||
'Invalid artStyle in IMAGE_LOCATION payload',
|
||||
)
|
||||
})
|
||||
|
||||
it('honors requested count when location already has more slots', async () => {
|
||||
prismaMock.locationImage.findUnique.mockResolvedValueOnce(null)
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValueOnce({
|
||||
id: 'location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{ id: 'location-image-1', locationId: 'location-1', imageIndex: 0, description: '雨夜街道 A' },
|
||||
{ id: 'location-image-2', locationId: 'location-1', imageIndex: 1, description: '雨夜街道 B' },
|
||||
{ id: 'location-image-3', locationId: 'location-1', imageIndex: 2, description: '雨夜街道 C' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await handleLocationImageTask(buildJob({ locationId: 'location-1', count: 1 }, 'location-1'))
|
||||
|
||||
expect(result).toEqual({
|
||||
updated: 1,
|
||||
locationIds: ['location-1'],
|
||||
})
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-image-1' },
|
||||
data: { imageUrl: 'cos/location-generated-1.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same aspect ratio as character generation for prop images', async () => {
|
||||
await handleLocationImageTask(buildJob({ type: 'prop', imageIndex: 0 }))
|
||||
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
|
||||
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
|
||||
stripLabelBar: vi.fn(async () => 'required-reference-image'),
|
||||
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
|
||||
withLabelBar: vi.fn(async (source: unknown) => source),
|
||||
}))
|
||||
|
||||
const outboundImageMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async (input?: string[]) => input?.map((item) => item.trim()) || []),
|
||||
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-reference'),
|
||||
}))
|
||||
|
||||
const aiRuntimeMock = vi.hoisted(() => ({
|
||||
executeAiTextStep: vi.fn(async () => ({ text: '{"prompt":"TEXT_UPDATED_DESCRIPTION"}' })),
|
||||
executeAiVisionStep: vi.fn(async () => ({ text: '{"prompt":"VISION_UPDATED_DESCRIPTION"}' })),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: {
|
||||
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
|
||||
NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',
|
||||
NP_PROP_DESCRIPTION_UPDATE: 'np_prop_description_update',
|
||||
},
|
||||
buildPrompt: vi.fn(({ promptId }: { promptId: string }) => `${promptId}-prompt`),
|
||||
}))
|
||||
|
||||
const loggerWarnMock = vi.hoisted(() => vi.fn())
|
||||
const loggingMock = vi.hoisted(() => ({
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
warn: loggerWarnMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
|
||||
vi.mock('@/lib/ai-runtime', () => aiRuntimeMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptMock)
|
||||
vi.mock('@/lib/logging/core', () => loggingMock)
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
|
||||
import { handleAssetHubModifyTask } from '@/lib/workers/handlers/asset-hub-modify-task-handler'
|
||||
|
||||
function buildJob(type: TaskType, payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function getUpdateData(callArg: unknown): Record<string, unknown> {
|
||||
if (!callArg || typeof callArg !== 'object') return {}
|
||||
const maybeData = (callArg as { data?: unknown }).data
|
||||
if (!maybeData || typeof maybeData !== 'object') return {}
|
||||
return maybeData as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('modify image syncs descriptions after edit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
imageUrls: JSON.stringify(['cos/original-image.png', 'cos/original-image-2.png']),
|
||||
imageUrl: 'cos/original-image.png',
|
||||
selectedIndex: 1,
|
||||
changeReason: 'base',
|
||||
description: 'old primary description',
|
||||
descriptions: JSON.stringify(['old primary description', 'old variant description']),
|
||||
character: { name: 'Hero' },
|
||||
})
|
||||
|
||||
prismaMock.locationImage.findFirst.mockResolvedValue({
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
description: 'old location description',
|
||||
imageUrl: 'cos/original-location.png',
|
||||
previousDescription: null,
|
||||
location: { name: 'Old Town' },
|
||||
})
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'global-appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: 'global primary description',
|
||||
descriptions: JSON.stringify(['global primary description', 'global variant description']),
|
||||
imageUrl: 'cos/original-global.png',
|
||||
imageUrls: JSON.stringify(['cos/original-global.png', 'cos/original-global-2.png']),
|
||||
selectedIndex: 1,
|
||||
previousDescription: null,
|
||||
previousDescriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
prismaMock.globalLocation.findFirst.mockResolvedValue({
|
||||
id: 'global-location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{
|
||||
id: 'global-location-image-1',
|
||||
imageIndex: 0,
|
||||
description: 'global location description',
|
||||
imageUrl: 'cos/original-global-location.png',
|
||||
previousDescription: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs project character descriptions for pure text edits', async () => {
|
||||
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
|
||||
type: 'character',
|
||||
appearanceId: 'appearance-1',
|
||||
imageIndex: 1,
|
||||
modifyPrompt: '给角色增加更复杂的甲胄细节',
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(job)
|
||||
|
||||
expect(aiRuntimeMock.executeAiTextStep).toHaveBeenCalledTimes(1)
|
||||
expect(aiRuntimeMock.executeAiVisionStep).not.toHaveBeenCalled()
|
||||
|
||||
const characterUpdateCall = prismaMock.characterAppearance.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = characterUpdateCall?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.previousDescription).toBe('old primary description')
|
||||
expect(updateData.previousDescriptions).toBe(JSON.stringify(['old primary description', 'old variant description']))
|
||||
expect(updateData.description).toBe('old primary description')
|
||||
expect(updateData.descriptions).toBe(JSON.stringify(['old primary description', 'TEXT_UPDATED_DESCRIPTION']))
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('syncs asset-hub character descriptions for reference-image edits and preserves sibling variants', async () => {
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-image.png')
|
||||
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 1,
|
||||
modifyPrompt: '把服装改成更锐利的深色铠甲',
|
||||
extraImageUrls: ['https://ref.example/b.png'],
|
||||
})
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
expect(aiRuntimeMock.executeAiVisionStep).toHaveBeenCalledTimes(1)
|
||||
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['https://signed/current-image.png', 'https://ref.example/b.png'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const globalCharacterUpdateCall = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = globalCharacterUpdateCall?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.previousDescription).toBe('global primary description')
|
||||
expect(updateData.previousDescriptions).toBe(JSON.stringify(['global primary description', 'global variant description']))
|
||||
expect(updateData.description).toBe('global primary description')
|
||||
expect(updateData.descriptions).toBe(JSON.stringify(['global primary description', 'VISION_UPDATED_DESCRIPTION']))
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-image.png')
|
||||
expect(updateData.imageUrls).toBe(JSON.stringify(['cos/original-global.png', 'cos/new-global-image.png']))
|
||||
})
|
||||
|
||||
it('syncs project location descriptions for pure text edits', async () => {
|
||||
aiRuntimeMock.executeAiTextStep.mockResolvedValueOnce({ text: '{"prompt":"TEXT_UPDATED_LOCATION"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
|
||||
type: 'location',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '增加更浓的晨雾和老城石墙细节',
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(job)
|
||||
|
||||
const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = locationUpdateCall?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.previousDescription).toBe('old location description')
|
||||
expect(updateData.description).toBe('TEXT_UPDATED_LOCATION')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('syncs asset-hub location descriptions for reference-image edits', async () => {
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-location-image.png')
|
||||
aiRuntimeMock.executeAiVisionStep.mockResolvedValueOnce({ text: '{"prompt":"VISION_UPDATED_LOCATION"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
|
||||
type: 'location',
|
||||
id: 'global-location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '改成潮湿阴冷的石砌街道',
|
||||
extraImageUrls: ['https://ref.example/location.png'],
|
||||
})
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['https://signed/current-image.png', 'https://ref.example/location.png'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = globalLocationUpdateCall?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.previousDescription).toBe('global location description')
|
||||
expect(updateData.description).toBe('VISION_UPDATED_LOCATION')
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-location-image.png')
|
||||
})
|
||||
|
||||
it('syncs project prop descriptions for pure text edits', async () => {
|
||||
aiRuntimeMock.executeAiTextStep.mockResolvedValueOnce({ text: '{"prompt":"TEXT_UPDATED_PROP"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
|
||||
type: 'prop',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '把表面改成拉丝银色,并增加刻纹',
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(job)
|
||||
|
||||
const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateData = getUpdateData(locationUpdateCall?.[0])
|
||||
expect(updateData.previousDescription).toBe('old location description')
|
||||
expect(updateData.description).toBe('TEXT_UPDATED_PROP')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('syncs asset-hub prop descriptions for reference-image edits', async () => {
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-prop-image.png')
|
||||
aiRuntimeMock.executeAiVisionStep.mockResolvedValueOnce({ text: '{"prompt":"VISION_UPDATED_PROP"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
|
||||
type: 'prop',
|
||||
id: 'global-location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '改成磨砂银色餐具,去掉多余反光',
|
||||
extraImageUrls: ['https://ref.example/prop.png'],
|
||||
})
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateData = getUpdateData(globalLocationUpdateCall?.[0])
|
||||
expect(updateData.previousDescription).toBe('global location description')
|
||||
expect(updateData.description).toBe('VISION_UPDATED_PROP')
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-prop-image.png')
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'realistic' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(),
|
||||
uploadImageSourceToCos: vi.fn(),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-1.png']),
|
||||
resolveNovelData: vi.fn(async () => ({
|
||||
videoRatio: '16:9',
|
||||
characters: [],
|
||||
locations: [
|
||||
{
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{
|
||||
isSelected: true,
|
||||
description: '雨夜街道',
|
||||
availableSlots: JSON.stringify([
|
||||
'街道左侧靠墙的留白位置',
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
buildPrompt: vi.fn(() => 'panel-image-prompt'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
logInfo: vi.fn(),
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
event: vi.fn(),
|
||||
child: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
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,
|
||||
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },
|
||||
buildPrompt: promptMock.buildPrompt,
|
||||
}))
|
||||
|
||||
import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'panel-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-panel-image-1',
|
||||
type: TASK_TYPE.IMAGE_PANEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker panel-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'hero close-up',
|
||||
imagePrompt: 'panel anchor prompt',
|
||||
videoPrompt: 'dramatic',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
|
||||
srtSegment: '台词片段',
|
||||
photographyRules: null,
|
||||
actingNotes: null,
|
||||
sketchImageUrl: null,
|
||||
imageUrl: null,
|
||||
})
|
||||
|
||||
utilsMock.resolveImageSourceFromGeneration
|
||||
.mockResolvedValueOnce('generated-source-1')
|
||||
.mockResolvedValueOnce('generated-source-2')
|
||||
|
||||
utilsMock.uploadImageSourceToCos
|
||||
.mockResolvedValueOnce('cos/panel-candidate-1.png')
|
||||
.mockResolvedValueOnce('cos/panel-candidate-2.png')
|
||||
})
|
||||
|
||||
it('missing panelId -> explicit error', async () => {
|
||||
const job = buildJob({}, '')
|
||||
await expect(handlePanelImageTask(job)).rejects.toThrow('panelId missing')
|
||||
})
|
||||
|
||||
it('first generation -> persists main image and candidate list', async () => {
|
||||
const job = buildJob({ candidateCount: 2 })
|
||||
const result = await handlePanelImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
candidateCount: 2,
|
||||
imageUrl: 'cos/panel-candidate-1.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
modelId: 'storyboard-model-1',
|
||||
prompt: 'panel-image-prompt',
|
||||
allowTaskExternalIdResume: false,
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['normalized-ref-1'],
|
||||
aspectRatio: '16:9',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
storyboard_text_json_input: expect.stringContaining('"slot": "街道左侧靠墙的留白位置"'),
|
||||
}),
|
||||
}))
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
storyboard_text_json_input: expect.stringContaining('"available_slots"'),
|
||||
}),
|
||||
}))
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
imageUrl: 'cos/panel-candidate-1.png',
|
||||
candidateImages: JSON.stringify(['cos/panel-candidate-1.png', 'cos/panel-candidate-2.png']),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('regeneration branch -> keeps old image in previousImageUrl and stores candidates only', async () => {
|
||||
utilsMock.resolveImageSourceFromGeneration.mockReset()
|
||||
utilsMock.uploadImageSourceToCos.mockReset()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'hero close-up',
|
||||
imagePrompt: null,
|
||||
videoPrompt: 'dramatic',
|
||||
location: 'Old Town',
|
||||
characters: '[]',
|
||||
srtSegment: null,
|
||||
photographyRules: null,
|
||||
actingNotes: null,
|
||||
sketchImageUrl: null,
|
||||
imageUrl: 'cos/panel-old.png',
|
||||
})
|
||||
|
||||
utilsMock.resolveImageSourceFromGeneration.mockResolvedValueOnce('generated-source-regen')
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/panel-regenerated.png')
|
||||
|
||||
const job = buildJob({ candidateCount: 1 })
|
||||
const result = await handlePanelImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
candidateCount: 1,
|
||||
imageUrl: null,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
previousImageUrl: 'cos/panel-old.png',
|
||||
candidateImages: JSON.stringify(['cos/panel-regenerated.png']),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'realistic' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-variant-source'),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/panel-variant-new.png'),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']),
|
||||
resolveNovelData: vi.fn(async () => ({
|
||||
videoRatio: '16:9',
|
||||
characters: [{
|
||||
name: 'Hero',
|
||||
introduction: '主角',
|
||||
appearances: [{
|
||||
changeReason: 'default',
|
||||
imageUrls: JSON.stringify(['cos/hero-default.png']),
|
||||
imageUrl: 'cos/hero-default.png',
|
||||
}],
|
||||
}],
|
||||
locations: [{
|
||||
name: 'Old Town',
|
||||
images: [{
|
||||
isSelected: true,
|
||||
description: '老街中央留出明确人物站位',
|
||||
availableSlots: JSON.stringify([
|
||||
'街道左侧靠墙的留白位置',
|
||||
]),
|
||||
}],
|
||||
}],
|
||||
})),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
buildPrompt: vi.fn(() => 'panel-variant-prompt'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/logging/core', () => ({ logInfo: vi.fn() }))
|
||||
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,
|
||||
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' },
|
||||
buildPrompt: promptMock.buildPrompt,
|
||||
}))
|
||||
|
||||
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
|
||||
|
||||
function buildJob(
|
||||
payload: Record<string, unknown>,
|
||||
locale: TaskJobData['locale'] = 'zh',
|
||||
): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-panel-variant-1',
|
||||
type: TASK_TYPE.PANEL_VARIANT,
|
||||
locale,
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-new',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker panel-variant-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockImplementation(async (args: { where: { id: string } }) => {
|
||||
if (args.where.id === 'panel-new') {
|
||||
return {
|
||||
id: 'panel-new',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: null,
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
|
||||
}
|
||||
}
|
||||
if (args.where.id === 'panel-source') {
|
||||
return {
|
||||
id: 'panel-source',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: 'cos/panel-source.png',
|
||||
description: 'source description',
|
||||
shotType: 'medium',
|
||||
cameraMove: 'pan',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero' }]),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('missing source/new panel ids -> explicit error', async () => {
|
||||
const job = buildJob({})
|
||||
await expect(handlePanelVariantTask(job)).rejects.toThrow('panel_variant missing newPanelId/sourcePanelId')
|
||||
})
|
||||
|
||||
it('success path -> includes source panel image in referenceImages and persists new image', async () => {
|
||||
const payload = {
|
||||
newPanelId: 'panel-new',
|
||||
sourcePanelId: 'panel-source',
|
||||
variant: {
|
||||
title: '雨夜版本',
|
||||
description: '加强雨夜氛围',
|
||||
},
|
||||
}
|
||||
|
||||
const result = await handlePanelVariantTask(buildJob(payload))
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
modelId: 'storyboard-model-1',
|
||||
prompt: 'panel-variant-prompt',
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '16:9',
|
||||
referenceImages: [
|
||||
'normalized:https://signed.example/cos/panel-source.png',
|
||||
'normalized:https://signed.example/cos/hero-default.png',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-new' },
|
||||
data: { imageUrl: 'cos/panel-variant-new.png' },
|
||||
})
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
characters_info: expect.stringContaining('固定位置:街道左侧靠墙的留白位置'),
|
||||
location_asset: expect.stringContaining('街道左侧靠墙的留白位置'),
|
||||
}),
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-new',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: 'cos/panel-variant-new.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('respects reference asset toggles when character/location assets are disabled', async () => {
|
||||
const payload = {
|
||||
newPanelId: 'panel-new',
|
||||
sourcePanelId: 'panel-source',
|
||||
includeCharacterAssets: false,
|
||||
includeLocationAsset: false,
|
||||
variant: {
|
||||
title: '禁用资产版本',
|
||||
description: '只参考原镜头',
|
||||
video_prompt: '只参考原镜头',
|
||||
},
|
||||
}
|
||||
|
||||
await handlePanelVariantTask(buildJob(payload))
|
||||
|
||||
expect(outboundMock.normalizeReferenceImagesForGeneration).toHaveBeenCalledWith([
|
||||
'https://signed.example/cos/panel-source.png',
|
||||
])
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
character_assets: '未使用角色参考图',
|
||||
location_asset: '未使用场景参考图',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses localized slot labels in english variant prompts', async () => {
|
||||
const payload = {
|
||||
newPanelId: 'panel-new',
|
||||
sourcePanelId: 'panel-source',
|
||||
variant: {
|
||||
title: 'Rainy night version',
|
||||
description: 'Keep the same staging but change the mood',
|
||||
video_prompt: 'Keep the same staging but change the mood',
|
||||
},
|
||||
}
|
||||
|
||||
await handlePanelVariantTask(buildJob(payload, 'en'))
|
||||
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
locale: 'en',
|
||||
variables: expect.objectContaining({
|
||||
location_asset: expect.stringContaining('Available character slots:'),
|
||||
}),
|
||||
}))
|
||||
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
location_asset: expect.not.stringContaining('可站位置:'),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX, CHARACTER_IMAGE_BANANA_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
|
||||
|
||||
const sharpMock = vi.hoisted(() =>
|
||||
vi.fn(() => {
|
||||
const chain = {
|
||||
metadata: vi.fn(async () => ({ width: 2160, height: 2160 })),
|
||||
extend: vi.fn(() => chain),
|
||||
composite: vi.fn(() => chain),
|
||||
jpeg: vi.fn(() => chain),
|
||||
toBuffer: vi.fn(async () => Buffer.from('processed-image')),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
)
|
||||
|
||||
const generatorApiMock = vi.hoisted(() => ({
|
||||
generateImage: vi.fn<(userId: string, modelId: string, prompt: string, options?: Record<string, unknown>) => Promise<{
|
||||
success: boolean
|
||||
imageUrl: string
|
||||
async: boolean
|
||||
}>>(async () => ({
|
||||
success: true,
|
||||
imageUrl: 'https://example.com/generated.jpg',
|
||||
async: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
const asyncSubmitMock = vi.hoisted(() => ({
|
||||
queryFalStatus: vi.fn(async () => ({ completed: false, failed: false, resultUrl: null })),
|
||||
}))
|
||||
|
||||
const arkApiMock = vi.hoisted(() => ({
|
||||
fetchWithTimeoutAndRetry: vi.fn(async () => ({
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})),
|
||||
}))
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
|
||||
}))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'character-model-1',
|
||||
analysisModel: 'analysis-model-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const llmClientMock = vi.hoisted(() => ({
|
||||
chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),
|
||||
getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),
|
||||
}))
|
||||
|
||||
const cosMock = vi.hoisted(() => {
|
||||
let keyIndex = 0
|
||||
return {
|
||||
generateUniqueKey: vi.fn(() => `reference-key-${++keyIndex}.jpg`),
|
||||
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
|
||||
uploadObject: vi.fn(async (_buffer: Buffer, key: string) => `cos/${key}`),
|
||||
}
|
||||
})
|
||||
|
||||
const fontsMock = vi.hoisted(() => ({
|
||||
initializeFonts: vi.fn(async () => {}),
|
||||
createLabelSVG: vi.fn(async () => Buffer.from('<svg />')),
|
||||
}))
|
||||
|
||||
const workersSharedMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const workersUtilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const promptI18nMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: {
|
||||
CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',
|
||||
CHARACTER_REFERENCE_TO_SHEET: 'character_reference_to_sheet',
|
||||
},
|
||||
buildPrompt: vi.fn((input: { promptId: string }) => (
|
||||
input.promptId === 'character_reference_to_sheet'
|
||||
? 'BASE_REFERENCE_PROMPT'
|
||||
: 'ANALYSIS_PROMPT'
|
||||
)),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn<(input: { data?: Record<string, unknown>; where?: Record<string, unknown> }) => Promise<Record<string, never>>>(
|
||||
async () => ({}),
|
||||
),
|
||||
},
|
||||
characterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sharp', () => ({
|
||||
default: sharpMock,
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/generator-api', () => generatorApiMock)
|
||||
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
|
||||
vi.mock('@/lib/ark-api', () => arkApiMock)
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/llm-client', () => llmClientMock)
|
||||
vi.mock('@/lib/storage', () => cosMock)
|
||||
vi.mock('@/lib/fonts', () => fontsMock)
|
||||
vi.mock('@/lib/workers/shared', () => workersSharedMock)
|
||||
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptI18nMock)
|
||||
|
||||
import { handleReferenceToCharacterTask } from '@/lib/workers/handlers/reference-to-character'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, type: TaskType): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function readGenerateCall(index: number) {
|
||||
const call = generatorApiMock.generateImage.mock.calls[index]
|
||||
if (!call) {
|
||||
return {
|
||||
prompt: '',
|
||||
options: {} as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
const prompt = typeof call[2] === 'string' ? call[2] : ''
|
||||
const options = (typeof call[3] === 'object' && call[3]) ? call[3] as Record<string, unknown> : {}
|
||||
return { prompt, options }
|
||||
}
|
||||
|
||||
describe('worker reference-to-character', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when reference images are missing', async () => {
|
||||
const job = buildJob({}, TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER)
|
||||
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Missing referenceImageUrl or referenceImageUrls')
|
||||
})
|
||||
|
||||
it('fails fast on unsupported task type', async () => {
|
||||
const job = buildJob(
|
||||
{ referenceImageUrl: 'https://example.com/ref.png' },
|
||||
'unsupported-task' as TaskType,
|
||||
)
|
||||
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Unsupported task type')
|
||||
})
|
||||
|
||||
it('uses suffix prompt and disables reference-image injection when customDescription is provided', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: ['https://example.com/ref-a.png', 'https://example.com/ref-b.png'],
|
||||
customDescription: '冷静黑发角色',
|
||||
characterName: 'Hero',
|
||||
},
|
||||
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
const result = await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
|
||||
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('冷静黑发角色')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
|
||||
expect(options.referenceImages).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps three-view suffix in template flow and writes extracted description in background mode', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: [' https://example.com/ref-a.png ', 'https://example.com/ref-b.png'],
|
||||
isBackgroundJob: true,
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
characterName: 'Hero',
|
||||
},
|
||||
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
const result = await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
|
||||
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('BASE_REFERENCE_PROMPT')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(options.referenceImages).toEqual(['https://example.com/ref-a.png', 'https://example.com/ref-b.png'])
|
||||
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
|
||||
|
||||
const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls[0]?.[0] as {
|
||||
data?: Record<string, unknown>
|
||||
where?: Record<string, unknown>
|
||||
} | undefined
|
||||
const updateData = updateArg?.data || {}
|
||||
expect(updateArg?.where).toEqual({ id: 'appearance-1' })
|
||||
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
|
||||
expect(typeof updateData.imageUrls).toBe('string')
|
||||
expect(updateData.imageUrl).toMatch(/^cos\/reference-key-\d+\.jpg$/)
|
||||
})
|
||||
|
||||
it('uses requested count when generating reference character sheets', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: ['https://example.com/ref-a.png'],
|
||||
characterName: 'Hero',
|
||||
count: 5,
|
||||
},
|
||||
TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
const result = await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(5)
|
||||
const cosKeys = (result as { cosKeys?: string[] }).cosKeys
|
||||
expect(cosKeys).toHaveLength(5)
|
||||
expect(cosKeys?.every((item) => item.startsWith('cos/reference-key-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds project label bars only for project reference generation', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: ['https://example.com/ref-a.png'],
|
||||
characterName: 'Hero',
|
||||
count: 1,
|
||||
},
|
||||
TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(fontsMock.initializeFonts).toHaveBeenCalledTimes(1)
|
||||
expect(fontsMock.createLabelSVG).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
import { resolveAnalysisModel } from '@/lib/workers/handlers/resolve-analysis-model'
|
||||
|
||||
describe('resolveAnalysisModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.userPreference.findUnique.mockResolvedValue({
|
||||
analysisModel: 'openai-compatible:pref::gpt-4.1-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses inputModel override when provided', async () => {
|
||||
const result = await resolveAnalysisModel({
|
||||
userId: 'user-1',
|
||||
inputModel: 'openai-compatible:input::gpt-4.1',
|
||||
projectAnalysisModel: 'openai-compatible:project::gpt-4.1',
|
||||
})
|
||||
|
||||
expect(result).toBe('openai-compatible:input::gpt-4.1')
|
||||
expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses project analysisModel when inputModel is missing', async () => {
|
||||
const result = await resolveAnalysisModel({
|
||||
userId: 'user-1',
|
||||
projectAnalysisModel: 'openai-compatible:project::gpt-4.1',
|
||||
})
|
||||
|
||||
expect(result).toBe('openai-compatible:project::gpt-4.1')
|
||||
expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to user preference analysisModel when project is missing', async () => {
|
||||
const result = await resolveAnalysisModel({
|
||||
userId: 'user-1',
|
||||
projectAnalysisModel: null,
|
||||
})
|
||||
|
||||
expect(result).toBe('openai-compatible:pref::gpt-4.1-mini')
|
||||
expect(prismaMock.userPreference.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
select: { analysisModel: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('skips invalid input/project model keys and still falls back to user preference', async () => {
|
||||
const result = await resolveAnalysisModel({
|
||||
userId: 'user-1',
|
||||
inputModel: 'gpt-4.1',
|
||||
projectAnalysisModel: 'invalid-model-key',
|
||||
})
|
||||
|
||||
expect(result).toBe('openai-compatible:pref::gpt-4.1-mini')
|
||||
expect(prismaMock.userPreference.findUnique).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws explicit error when all levels are missing', async () => {
|
||||
prismaMock.userPreference.findUnique.mockResolvedValueOnce({ analysisModel: null })
|
||||
|
||||
await expect(resolveAnalysisModel({
|
||||
userId: 'user-1',
|
||||
inputModel: '',
|
||||
projectAnalysisModel: null,
|
||||
})).rejects.toThrow('ANALYSIS_MODEL_NOT_CONFIGURED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
novelPromotionClip: { update: vi.fn(async () => ({})) },
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => '{"scenes":[{"index":1}]}'),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const helpersMock = vi.hoisted(() => ({
|
||||
parseScreenplayPayload: vi.fn(() => ({ scenes: [{ index: 1 }] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters introduction'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
|
||||
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/screenplay-convert-helpers', () => ({
|
||||
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
parseScreenplayPayload: helpersMock.parseScreenplayPayload,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion' },
|
||||
getPromptTemplate: vi.fn(() => 'screenplay-template-{clip_content}-{clip_id}'),
|
||||
}))
|
||||
|
||||
import { handleScreenplayConvertTask } from '@/lib/workers/handlers/screenplay-convert'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-screenplay-1',
|
||||
type: TASK_TYPE.SCREENPLAY_CONVERT,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker screenplay-convert behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ name: 'Hero' }],
|
||||
locations: [{ name: 'Old Town' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: 'clip 1 content',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> writes screenplay json to clip row', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleScreenplayConvertTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
total: 1,
|
||||
successCount: 1,
|
||||
failCount: 0,
|
||||
totalScenes: 1,
|
||||
}))
|
||||
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-1' },
|
||||
data: {
|
||||
screenplay: JSON.stringify({
|
||||
scenes: [{ index: 1 }],
|
||||
clip_id: 'clip-1',
|
||||
original_text: 'clip 1 content',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('clip parse failed -> throws partial failure error with code prefix', async () => {
|
||||
helpersMock.parseScreenplayPayload.mockImplementation(() => {
|
||||
throw new Error('invalid screenplay payload')
|
||||
})
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('SCREENPLAY_CONVERT_PARTIAL_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
parseStoryboardRetryTarget,
|
||||
runScriptToStoryboardAtomicRetry,
|
||||
} from '@/lib/workers/handlers/script-to-storyboard-atomic-retry'
|
||||
|
||||
const listArtifactsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/run-runtime/service', () => ({
|
||||
listArtifacts: listArtifactsMock,
|
||||
}))
|
||||
|
||||
describe('script-to-storyboard atomic retry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('解析 clip+phase stepKey', () => {
|
||||
expect(parseStoryboardRetryTarget('clip_clip-1_phase3_detail')).toEqual({
|
||||
stepKey: 'clip_clip-1_phase3_detail',
|
||||
clipId: 'clip-1',
|
||||
phase: 'phase3_detail',
|
||||
})
|
||||
expect(parseStoryboardRetryTarget('voice_analyze')).toBeNull()
|
||||
expect(parseStoryboardRetryTarget('clip__phase3')).toBeNull()
|
||||
})
|
||||
|
||||
it('phase3 重试只执行 phase3 并读取 phase1/phase2 artifact 续跑', async () => {
|
||||
listArtifactsMock.mockImplementation(async (params: {
|
||||
runId: string
|
||||
artifactType?: string
|
||||
refId?: string
|
||||
}) => {
|
||||
if (params.refId !== 'clip-1') return []
|
||||
if (params.artifactType === 'storyboard.clip.phase1') {
|
||||
return [{
|
||||
id: 'a1',
|
||||
runId: params.runId,
|
||||
stepKey: 'clip_clip-1_phase1',
|
||||
artifactType: 'storyboard.clip.phase1',
|
||||
refId: 'clip-1',
|
||||
versionHash: null,
|
||||
payload: {
|
||||
panels: [{ panel_number: 1, description: 'p1', location: 'Office', source_text: 'src', characters: [] }],
|
||||
},
|
||||
createdAt: '2026-03-03T00:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
if (params.artifactType === 'storyboard.clip.phase2.cine') {
|
||||
return [{
|
||||
id: 'a2',
|
||||
runId: params.runId,
|
||||
stepKey: 'clip_clip-1_phase2_cinematography',
|
||||
artifactType: 'storyboard.clip.phase2.cine',
|
||||
refId: 'clip-1',
|
||||
versionHash: null,
|
||||
payload: {
|
||||
rules: [{
|
||||
panel_number: 1,
|
||||
composition: '居中',
|
||||
lighting: '顶光',
|
||||
color_palette: '冷色',
|
||||
atmosphere: '紧张',
|
||||
technical_notes: 'note',
|
||||
}],
|
||||
},
|
||||
createdAt: '2026-03-03T00:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
if (params.artifactType === 'storyboard.clip.phase2.acting') {
|
||||
return [{
|
||||
id: 'a3',
|
||||
runId: params.runId,
|
||||
stepKey: 'clip_clip-1_phase2_acting',
|
||||
artifactType: 'storyboard.clip.phase2.acting',
|
||||
refId: 'clip-1',
|
||||
versionHash: null,
|
||||
payload: {
|
||||
directions: [{ panel_number: 1, characters: [{ name: 'Narrator', expression: 'serious' }] }],
|
||||
},
|
||||
createdAt: '2026-03-03T00:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
if (params.artifactType === 'storyboard.clip.phase3') {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
if (action !== 'storyboard_phase3_detail') {
|
||||
throw new Error(`unexpected action ${action}`)
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: 'phase3-new', location: 'Office', source_text: 'src', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardAtomicRetry({
|
||||
runId: 'run-1',
|
||||
retryTarget: {
|
||||
stepKey: 'clip_clip-1_phase3_detail',
|
||||
clipId: 'clip-1',
|
||||
phase: 'phase3_detail',
|
||||
},
|
||||
retryStepAttempt: 4,
|
||||
clip: {
|
||||
id: 'clip-1',
|
||||
content: 'clip content',
|
||||
characters: JSON.stringify([{ name: 'Narrator' }]),
|
||||
location: 'Office',
|
||||
screenplay: null,
|
||||
},
|
||||
clipIndex: 0,
|
||||
totalClipCount: 1,
|
||||
novelPromotionData: {
|
||||
characters: [{ name: 'Narrator', appearances: [] }],
|
||||
locations: [{ name: 'Office', images: [{ description: 'room desc' }] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(runStep).toHaveBeenCalledTimes(1)
|
||||
expect(runStep.mock.calls[0]?.[2]).toBe('storyboard_phase3_detail')
|
||||
expect(result.phase1PanelsByClipId).toEqual({})
|
||||
expect(result.phase2CinematographyByClipId).toEqual({})
|
||||
expect(result.phase2ActingByClipId).toEqual({})
|
||||
expect(result.phase3PanelsByClipId['clip-1']).toEqual([
|
||||
{ panel_number: 1, description: 'phase3-new', location: 'Office', source_text: 'src', characters: [] },
|
||||
])
|
||||
expect(result.clipPanels).toHaveLength(1)
|
||||
expect(result.clipPanels[0]?.finalPanels[0]).toEqual(expect.objectContaining({
|
||||
panel_number: 1,
|
||||
description: 'phase3-new',
|
||||
photographyPlan: expect.objectContaining({
|
||||
composition: '居中',
|
||||
lighting: '顶光',
|
||||
}),
|
||||
actingNotes: [{ name: 'Narrator', expression: 'serious' }],
|
||||
}))
|
||||
expect(result.totalPanelCount).toBe(1)
|
||||
})
|
||||
|
||||
it('phase2 重试缺少 phase3 artifact 时显式失败', async () => {
|
||||
listArtifactsMock.mockImplementation(async (params: {
|
||||
runId: string
|
||||
artifactType?: string
|
||||
refId?: string
|
||||
}) => {
|
||||
if (params.refId !== 'clip-1') return []
|
||||
if (params.artifactType === 'storyboard.clip.phase1') {
|
||||
return [{
|
||||
id: 'a1',
|
||||
runId: params.runId,
|
||||
stepKey: 'clip_clip-1_phase1',
|
||||
artifactType: 'storyboard.clip.phase1',
|
||||
refId: 'clip-1',
|
||||
versionHash: null,
|
||||
payload: { panels: [{ panel_number: 1, description: 'p1', location: 'Office' }] },
|
||||
createdAt: '2026-03-03T00:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
if (params.artifactType === 'storyboard.clip.phase2.acting') {
|
||||
return [{
|
||||
id: 'a2',
|
||||
runId: params.runId,
|
||||
stepKey: 'clip_clip-1_phase2_acting',
|
||||
artifactType: 'storyboard.clip.phase2.acting',
|
||||
refId: 'clip-1',
|
||||
versionHash: null,
|
||||
payload: { directions: [{ panel_number: 1, characters: [] }] },
|
||||
createdAt: '2026-03-03T00:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
if (action !== 'storyboard_phase2_cinematography') {
|
||||
throw new Error(`unexpected action ${action}`)
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, composition: '居中' }]),
|
||||
reasoning: '',
|
||||
}
|
||||
})
|
||||
|
||||
await expect(runScriptToStoryboardAtomicRetry({
|
||||
runId: 'run-2',
|
||||
retryTarget: {
|
||||
stepKey: 'clip_clip-1_phase2_cinematography',
|
||||
clipId: 'clip-1',
|
||||
phase: 'phase2_cinematography',
|
||||
},
|
||||
retryStepAttempt: 2,
|
||||
clip: {
|
||||
id: 'clip-1',
|
||||
content: 'clip content',
|
||||
characters: JSON.stringify([{ name: 'Narrator' }]),
|
||||
location: 'Office',
|
||||
screenplay: null,
|
||||
},
|
||||
clipIndex: 0,
|
||||
totalClipCount: 1,
|
||||
novelPromotionData: {
|
||||
characters: [{ name: 'Narrator', appearances: [] }],
|
||||
locations: [{ name: 'Office', images: [{ description: 'room desc' }] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})).rejects.toThrow('missing dependency artifact: storyboard.clip.phase3')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runScriptToStoryboardOrchestrator } from '@/lib/novel-promotion/script-to-storyboard/orchestrator'
|
||||
|
||||
describe('script-to-storyboard orchestrator retry', () => {
|
||||
it('retries retryable step failures up to 3 attempts', async () => {
|
||||
const attemptsByAction = new Map<string, number>()
|
||||
const phase1Metas: Array<{ stepId: string; stepAttempt?: number }> = []
|
||||
const runStep = vi.fn(async (meta, _prompt, action: string) => {
|
||||
attemptsByAction.set(action, (attemptsByAction.get(action) || 0) + 1)
|
||||
|
||||
if (action === 'storyboard_phase1_plan') {
|
||||
phase1Metas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
|
||||
const attempt = attemptsByAction.get(action) || 0
|
||||
if (attempt < 3) {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase2_cinematography') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }
|
||||
}
|
||||
if (action === 'storyboard_phase2_acting') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
expect(runStep).toHaveBeenCalled()
|
||||
expect(attemptsByAction.get('storyboard_phase1_plan')).toBe(3)
|
||||
expect(phase1Metas).toEqual([
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: undefined },
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: 2 },
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry non-retryable step failure', async () => {
|
||||
let callCount = 0
|
||||
const runStep = vi.fn(async () => {
|
||||
callCount += 1
|
||||
throw new Error('SENSITIVE_CONTENT: blocked')
|
||||
})
|
||||
|
||||
await expect(
|
||||
runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('SENSITIVE_CONTENT')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not retry Ark invalid parameter error even when message contains json', async () => {
|
||||
let callCount = 0
|
||||
const runStep = vi.fn(async () => {
|
||||
callCount += 1
|
||||
throw new Error(
|
||||
'Ark Responses 调用失败: 400 - {"error":{"code":"InvalidParameter","message":"json: unknown field \\"reasoning_effort\\""}}',
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('unknown field')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('enforces topology: phase3 runs after both phase2 steps complete', async () => {
|
||||
const actionOrder: string[] = []
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
actionOrder.push(action)
|
||||
if (action === 'storyboard_phase1_plan') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'storyboard_phase2_cinematography') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }
|
||||
}
|
||||
if (action === 'storyboard_phase2_acting') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
|
||||
}
|
||||
if (action === 'storyboard_phase3_detail') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`)
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
const phase3Index = actionOrder.indexOf('storyboard_phase3_detail')
|
||||
const phase2CineIndex = actionOrder.indexOf('storyboard_phase2_cinematography')
|
||||
const phase2ActingIndex = actionOrder.indexOf('storyboard_phase2_acting')
|
||||
expect(phase3Index).toBeGreaterThan(phase2CineIndex)
|
||||
expect(phase3Index).toBeGreaterThan(phase2ActingIndex)
|
||||
})
|
||||
|
||||
it('limits clip fan-out by configured concurrency', async () => {
|
||||
let activePhase1 = 0
|
||||
let maxActivePhase1 = 0
|
||||
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
if (action === 'storyboard_phase1_plan') {
|
||||
activePhase1 += 1
|
||||
maxActivePhase1 = Math.max(maxActivePhase1, activePhase1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
activePhase1 -= 1
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'storyboard_phase2_cinematography') {
|
||||
return {
|
||||
text: JSON.stringify([{
|
||||
panel_number: 1,
|
||||
composition: '居中',
|
||||
lighting: '顶光',
|
||||
color_palette: '冷色',
|
||||
atmosphere: '紧张',
|
||||
technical_notes: 'note',
|
||||
}]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'storyboard_phase2_acting') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
|
||||
}
|
||||
if (action === 'storyboard_phase3_detail') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`)
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardOrchestrator({
|
||||
concurrency: 1,
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本1',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
{
|
||||
id: 'clip-2',
|
||||
content: '文本2',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
{
|
||||
id: 'clip-3',
|
||||
content: '文本3',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(3)
|
||||
expect(maxActivePhase1).toBe(1)
|
||||
})
|
||||
|
||||
it('pipelines clips so one clip can enter phase2 before another clip finishes phase1', async () => {
|
||||
let releaseClip1Phase1: (() => void) | null = null
|
||||
const clip1Phase1Gate = new Promise<void>((resolve) => {
|
||||
releaseClip1Phase1 = resolve
|
||||
})
|
||||
let clip2Phase2Started = false
|
||||
let clip1Phase1ResolvedAfterClip2Phase2 = false
|
||||
|
||||
const runStep = vi.fn(async (meta, _prompt, action: string) => {
|
||||
const stepId = String(meta.stepId)
|
||||
|
||||
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-1_phase1') {
|
||||
await clip1Phase1Gate
|
||||
clip1Phase1ResolvedAfterClip2Phase2 = clip2Phase2Started
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头1', location: '场景A', source_text: '原文1', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-2_phase1') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头2', location: '场景A', source_text: '原文2', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase2_cinematography' && stepId === 'clip_clip-2_phase2_cinematography') {
|
||||
clip2Phase2Started = true
|
||||
releaseClip1Phase1?.()
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase2_acting') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase2_cinematography') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase3_detail') {
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '细化镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unexpected action: ${action}:${stepId}`)
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardOrchestrator({
|
||||
concurrency: 2,
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本1',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
{
|
||||
id: 'clip-2',
|
||||
content: '文本2',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(2)
|
||||
expect(clip2Phase2Started).toBe(true)
|
||||
expect(clip1Phase1ResolvedAfterClip2Phase2).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type VoiceLineInput = {
|
||||
lineIndex: number
|
||||
speaker: string
|
||||
content: string
|
||||
emotionStrength: number
|
||||
matchedPanel: {
|
||||
storyboardId: string
|
||||
panelIndex: number
|
||||
}
|
||||
}
|
||||
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ responseId: 'resp-1' })))
|
||||
const getCompletionPartsMock = vi.hoisted(() => vi.fn(() => ({ text: 'voice lines json', reasoning: '' })))
|
||||
const withInternalLLMStreamCallbacksMock = vi.hoisted(() =>
|
||||
vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
)
|
||||
const resolveProjectModelCapabilityGenerationOptionsMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ reasoningEffort: 'high' })),
|
||||
)
|
||||
const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
clipPanels: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
clipIndex: 0,
|
||||
finalPanels: [
|
||||
{
|
||||
panel_number: 1,
|
||||
shot_type: 'close-up',
|
||||
camera_move: 'static',
|
||||
description: 'panel desc',
|
||||
video_prompt: 'panel prompt',
|
||||
location: 'room',
|
||||
characters: ['Narrator'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalPanelCount: 1,
|
||||
totalStepCount: 4,
|
||||
},
|
||||
})),
|
||||
)
|
||||
const parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())
|
||||
const persistStoryboardOutputsMock = vi.hoisted(() => vi.fn())
|
||||
const parseStoryboardRetryTargetMock = vi.hoisted(() => vi.fn())
|
||||
const runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())
|
||||
const workflowLeaseMock = vi.hoisted(() => ({
|
||||
assertWorkflowRunActive: vi.fn(async () => undefined),
|
||||
withWorkflowRunLease: vi.fn(async (params: { run: () => Promise<unknown> }) => ({
|
||||
claimed: true,
|
||||
result: await params.run(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const txState = vi.hoisted(() => ({
|
||||
createdRows: [] as Array<Record<string, unknown>>,
|
||||
deletedWhereClauses: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
vi.mock('@/lib/llm-client', () => ({
|
||||
chatCompletion: chatCompletionMock,
|
||||
getCompletionParts: getCompletionPartsMock,
|
||||
getCompletionContent: vi.fn(() => 'voice lines json'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/config-service', () => ({
|
||||
resolveProjectModelCapabilityGenerationOptions: resolveProjectModelCapabilityGenerationOptionsMock,
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 2,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: withInternalLLMStreamCallbacksMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/semantic', () => ({
|
||||
logAIAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/file-writer', () => ({
|
||||
onProjectNameAvailable: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: assertTaskActiveMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', () => ({
|
||||
runScriptToStoryboardOrchestrator: runScriptToStoryboardOrchestratorMock,
|
||||
JsonParseError: class JsonParseError extends Error {
|
||||
rawText: string
|
||||
|
||||
constructor(message: string, rawText: string) {
|
||||
super(message)
|
||||
this.name = 'JsonParseError'
|
||||
this.rawText = rawText
|
||||
}
|
||||
},
|
||||
}))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_STORYBOARD_PLAN: 'plan',
|
||||
NP_AGENT_CINEMATOGRAPHER: 'cinematographer',
|
||||
NP_AGENT_ACTING_DIRECTION: 'acting',
|
||||
NP_AGENT_STORYBOARD_DETAIL: 'detail',
|
||||
NP_VOICE_ANALYSIS: 'voice-analysis',
|
||||
},
|
||||
getPromptTemplate: vi.fn(() => 'prompt-template'),
|
||||
buildPrompt: vi.fn(() => 'voice-analysis-prompt'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({
|
||||
asJsonRecord: (value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
},
|
||||
buildStoryboardJsonFromClipPanels: vi.fn(() => '[]'),
|
||||
parseEffort: vi.fn(() => null),
|
||||
parseTemperature: vi.fn(() => 0.7),
|
||||
parseVoiceLinesJson: parseVoiceLinesJsonMock,
|
||||
persistStoryboardOutputs: persistStoryboardOutputsMock,
|
||||
toPositiveInt: (value: unknown) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
const n = Math.floor(value)
|
||||
return n > 0 ? n : null
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/script-to-storyboard-atomic-retry', () => ({
|
||||
parseStoryboardRetryTarget: parseStoryboardRetryTargetMock,
|
||||
runScriptToStoryboardAtomicRetry: runScriptToStoryboardAtomicRetryMock,
|
||||
}))
|
||||
vi.mock('@/lib/run-runtime/workflow-lease', () => workflowLeaseMock)
|
||||
|
||||
import { handleScriptToStoryboardTask } from '@/lib/workers/handlers/script-to-storyboard'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
const runId = typeof payload.runId === 'string' && payload.runId.trim() ? payload.runId.trim() : 'run-test-storyboard'
|
||||
const payloadMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta)
|
||||
? (payload.meta as Record<string, unknown>)
|
||||
: {}
|
||||
const normalizedPayload: Record<string, unknown> = {
|
||||
...payload,
|
||||
runId,
|
||||
meta: {
|
||||
...payloadMeta,
|
||||
runId,
|
||||
},
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: normalizedPayload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function baseVoiceRows(): VoiceLineInput[] {
|
||||
return [
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('worker script-to-storyboard behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
txState.createdRows = []
|
||||
txState.deletedWhereClauses = []
|
||||
parseStoryboardRetryTargetMock.mockReturnValue(null)
|
||||
runScriptToStoryboardAtomicRetryMock.mockReset()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-model',
|
||||
characters: [{ id: 'char-1', name: 'Narrator' }],
|
||||
locations: [{ id: 'loc-1', name: 'Office' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'A complete chapter text for voice analyze.',
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: 'clip content',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
location: 'Office',
|
||||
screenplay: 'Screenplay text',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
prismaMock.$transaction.mockReset()
|
||||
|
||||
persistStoryboardOutputsMock.mockImplementation(async ({ voiceLineRows }: { voiceLineRows: VoiceLineInput[] | null }) => {
|
||||
const rows = voiceLineRows || []
|
||||
txState.createdRows = rows.map((row) => ({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: row.lineIndex,
|
||||
speaker: row.speaker,
|
||||
content: row.content,
|
||||
emotionStrength: row.emotionStrength,
|
||||
matchedPanelId: 'panel-1',
|
||||
matchedStoryboardId: 'storyboard-1',
|
||||
matchedPanelIndex: row.matchedPanel.panelIndex,
|
||||
}))
|
||||
txState.deletedWhereClauses = [
|
||||
rows.length === 0
|
||||
? { episodeId: 'episode-1' }
|
||||
: {
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: {
|
||||
notIn: rows.map((row) => row.lineIndex),
|
||||
},
|
||||
},
|
||||
]
|
||||
return {
|
||||
persistedStoryboards: [
|
||||
{
|
||||
storyboardId: 'storyboard-1',
|
||||
clipId: 'clip-1',
|
||||
panels: [{ id: 'panel-1', panelIndex: 1 }],
|
||||
},
|
||||
],
|
||||
voiceLineCount: rows.length,
|
||||
}
|
||||
})
|
||||
|
||||
parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())
|
||||
})
|
||||
|
||||
it('缺少 episodeId -> 显式失败', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleScriptToStoryboardTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('成功路径: 写入 voice line 时包含 matchedPanel 映射后的 panelId', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
storyboardCount: 1,
|
||||
panelCount: 1,
|
||||
voiceLineCount: 1,
|
||||
})
|
||||
|
||||
expect(txState.createdRows).toHaveLength(1)
|
||||
expect(txState.createdRows[0]).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanelId: 'panel-1',
|
||||
matchedStoryboardId: 'storyboard-1',
|
||||
matchedPanelIndex: 1,
|
||||
}))
|
||||
expect(txState.deletedWhereClauses[0]).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: {
|
||||
notIn: [1],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('voice 解析失败后会重试一次再成功', async () => {
|
||||
parseVoiceLinesJsonMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('invalid voice json')
|
||||
})
|
||||
.mockImplementationOnce(() => baseVoiceRows())
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
voiceLineCount: 1,
|
||||
}))
|
||||
expect(chatCompletionMock).toHaveBeenCalledTimes(2)
|
||||
expect(parseVoiceLinesJsonMock).toHaveBeenCalledTimes(2)
|
||||
expect(withInternalLLMStreamCallbacksMock).toHaveBeenCalledTimes(3)
|
||||
const firstChatCall = chatCompletionMock.mock.calls[0] as unknown as [unknown, unknown, unknown, Record<string, unknown>] | undefined
|
||||
expect(firstChatCall?.[3]).toEqual(expect.objectContaining({
|
||||
action: 'voice_analyze',
|
||||
streamStepId: 'voice_analyze',
|
||||
streamStepAttempt: 1,
|
||||
}))
|
||||
const secondChatCall = chatCompletionMock.mock.calls[1] as unknown as [unknown, unknown, unknown, Record<string, unknown>] | undefined
|
||||
expect(secondChatCall?.[3]).toEqual(expect.objectContaining({
|
||||
action: 'voice_analyze',
|
||||
streamStepId: 'voice_analyze',
|
||||
streamStepAttempt: 2,
|
||||
}))
|
||||
expect(reportTaskProgressMock).toHaveBeenCalledWith(
|
||||
job,
|
||||
84,
|
||||
expect.objectContaining({
|
||||
stage: 'script_to_storyboard_step',
|
||||
stepId: 'voice_analyze',
|
||||
stepAttempt: 2,
|
||||
message: '台词分析失败,准备重试 (2/2)',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('空台词数组 -> 成功完成并清空旧台词', async () => {
|
||||
parseVoiceLinesJsonMock.mockReturnValue([])
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
storyboardCount: 1,
|
||||
panelCount: 1,
|
||||
voiceLineCount: 0,
|
||||
})
|
||||
expect(txState.createdRows).toEqual([])
|
||||
expect(txState.deletedWhereClauses[0]).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('phase 级重试: 仅执行原子 phase,不走整图重跑', async () => {
|
||||
parseStoryboardRetryTargetMock.mockReturnValue({
|
||||
stepKey: 'clip_clip-1_phase3_detail',
|
||||
clipId: 'clip-1',
|
||||
phase: 'phase3_detail',
|
||||
})
|
||||
runScriptToStoryboardAtomicRetryMock.mockResolvedValue({
|
||||
clipPanels: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
clipIndex: 1,
|
||||
finalPanels: [
|
||||
{
|
||||
panel_number: 1,
|
||||
description: 'phase3 retry panel',
|
||||
location: 'Office',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
phase1PanelsByClipId: {},
|
||||
phase2CinematographyByClipId: {},
|
||||
phase2ActingByClipId: {},
|
||||
phase3PanelsByClipId: {
|
||||
'clip-1': [
|
||||
{
|
||||
panel_number: 1,
|
||||
description: 'phase3 retry panel',
|
||||
location: 'Office',
|
||||
},
|
||||
],
|
||||
},
|
||||
totalPanelCount: 1,
|
||||
totalStepCount: 6,
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
episodeId: 'episode-1',
|
||||
retryStepKey: 'clip_clip-1_phase3_detail',
|
||||
retryStepAttempt: 2,
|
||||
})
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
storyboardCount: 1,
|
||||
panelCount: 1,
|
||||
voiceLineCount: 0,
|
||||
retryStepKey: 'clip_clip-1_phase3_detail',
|
||||
})
|
||||
expect(runScriptToStoryboardAtomicRetryMock).toHaveBeenCalledTimes(1)
|
||||
expect(runScriptToStoryboardOrchestratorMock).not.toHaveBeenCalled()
|
||||
expect(persistStoryboardOutputsMock).toHaveBeenCalledWith({
|
||||
episodeId: 'episode-1',
|
||||
clipPanels: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
clipIndex: 1,
|
||||
finalPanels: [
|
||||
{
|
||||
panel_number: 1,
|
||||
description: 'phase3 retry panel',
|
||||
location: 'Office',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
voiceLineRows: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Job } from 'bullmq'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const tryUpdateTaskProgressMock = vi.hoisted(() => vi.fn(async () => true))
|
||||
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))
|
||||
const publishTaskStreamEventMock = vi.hoisted(() => vi.fn(async () => ({})))
|
||||
const publishRunEventMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const mapTaskSSEEventToRunEventsMock = vi.hoisted(() =>
|
||||
vi.fn(() => [{
|
||||
runId: 'run-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'step.start',
|
||||
stepKey: 'split_clips',
|
||||
attempt: 1,
|
||||
lane: null,
|
||||
payload: { mirrored: true },
|
||||
}]),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
createScopedLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/service', () => ({
|
||||
rollbackTaskBillingForTask: vi.fn(async () => ({ attempted: false, rolledBack: false, billingInfo: null })),
|
||||
touchTaskHeartbeat: vi.fn(async () => undefined),
|
||||
tryMarkTaskCompleted: vi.fn(async () => true),
|
||||
tryMarkTaskFailed: vi.fn(async () => true),
|
||||
tryMarkTaskProcessing: vi.fn(async () => true),
|
||||
tryUpdateTaskProgress: tryUpdateTaskProgressMock,
|
||||
updateTaskBillingInfo: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: publishTaskEventMock,
|
||||
publishTaskStreamEvent: publishTaskStreamEventMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/progress-message', () => ({
|
||||
buildTaskProgressMessage: vi.fn(() => 'progress-message'),
|
||||
getTaskStageLabel: vi.fn((stage: string) => `label:${stage}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/errors/normalize', () => ({
|
||||
normalizeAnyError: vi.fn((error: Error) => ({
|
||||
code: 'ERROR',
|
||||
message: error.message,
|
||||
retryable: false,
|
||||
provider: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
rollbackTaskBilling: vi.fn(async () => null),
|
||||
settleTaskBilling: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/runtime-usage', () => ({
|
||||
withTextUsageCollection: vi.fn(async (fn: () => Promise<unknown>) => ({
|
||||
result: await fn(),
|
||||
textUsage: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/file-writer', () => ({
|
||||
onProjectNameAvailable: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/run-runtime/task-bridge', () => ({
|
||||
mapTaskSSEEventToRunEvents: mapTaskSSEEventToRunEventsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/run-runtime/publisher', () => ({
|
||||
publishRunEvent: publishRunEventMock,
|
||||
}))
|
||||
|
||||
import { reportTaskProgress, reportTaskStreamChunk, withTaskLifecycle } from '@/lib/workers/shared'
|
||||
|
||||
function buildJob(taskType: TaskJobData['type']): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: taskType,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
userId: 'user-1',
|
||||
payload: {
|
||||
runId: 'run-1',
|
||||
},
|
||||
trace: null,
|
||||
},
|
||||
queueName: 'text',
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shared direct run events', () => {
|
||||
beforeEach(() => {
|
||||
tryUpdateTaskProgressMock.mockReset()
|
||||
tryUpdateTaskProgressMock.mockResolvedValue(true)
|
||||
publishTaskEventMock.mockReset()
|
||||
publishTaskStreamEventMock.mockReset()
|
||||
publishRunEventMock.mockReset()
|
||||
mapTaskSSEEventToRunEventsMock.mockClear()
|
||||
})
|
||||
|
||||
it('publishes run events directly for core analysis progress updates', async () => {
|
||||
await reportTaskProgress(buildJob('story_to_script_run'), 42, {
|
||||
stage: 'story_to_script_step',
|
||||
stepId: 'split_clips',
|
||||
stepTitle: 'Split',
|
||||
})
|
||||
|
||||
expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskType: 'story_to_script_run',
|
||||
type: 'task.progress',
|
||||
}))
|
||||
expect(mapTaskSSEEventToRunEventsMock).toHaveBeenCalledTimes(1)
|
||||
expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
eventType: 'step.start',
|
||||
stepKey: 'split_clips',
|
||||
}))
|
||||
})
|
||||
|
||||
it('publishes run events directly for core analysis stream chunks', async () => {
|
||||
await reportTaskStreamChunk(buildJob('script_to_storyboard_run'), {
|
||||
kind: 'text',
|
||||
delta: 'hello',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
}, {
|
||||
stepId: 'clip_1_phase1',
|
||||
stepTitle: 'Phase 1',
|
||||
})
|
||||
|
||||
expect(publishTaskStreamEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskType: 'script_to_storyboard_run',
|
||||
persist: true,
|
||||
}))
|
||||
expect(mapTaskSSEEventToRunEventsMock).toHaveBeenCalledTimes(1)
|
||||
expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
eventType: 'step.start',
|
||||
stepKey: 'split_clips',
|
||||
}))
|
||||
})
|
||||
|
||||
it('emits run.start directly when the core analysis worker begins execution', async () => {
|
||||
await withTaskLifecycle(buildJob('story_to_script_run'), async () => ({
|
||||
ok: true,
|
||||
}))
|
||||
|
||||
expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
eventType: 'run.start',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_CHARACTER_MODIFY: 'np_character_modify' },
|
||||
buildPrompt: vi.fn(() => 'appearance-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyAppearanceTask } from '@/lib/workers/handlers/shot-ai-prompt-appearance'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-appearance-1',
|
||||
type: TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-appearance behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated appearance description"}')
|
||||
})
|
||||
|
||||
it('missing characterId -> explicit error', async () => {
|
||||
const job = buildJob({
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'new style',
|
||||
})
|
||||
|
||||
await expect(handleModifyAppearanceTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('characterId is required')
|
||||
})
|
||||
|
||||
it('success -> returns modifiedDescription and rawResponse', async () => {
|
||||
const payload = {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'new style',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyAppearanceTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_appearance',
|
||||
prompt: 'appearance-final-prompt',
|
||||
}))
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
modifiedDescription: 'updated appearance description',
|
||||
rawResponse: '{"prompt":"updated appearance description"}',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
requireProjectLocation: vi.fn(),
|
||||
persistLocationDescription: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_LOCATION_MODIFY: 'np_location_modify' },
|
||||
buildPrompt: vi.fn(() => 'location-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyLocationTask } from '@/lib/workers/handlers/shot-ai-prompt-location'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-location-1',
|
||||
type: TASK_TYPE.AI_MODIFY_LOCATION,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionLocation',
|
||||
targetId: 'location-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-location behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated location description","available_slots":["街道左侧靠墙的留白位置"]}')
|
||||
persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })
|
||||
})
|
||||
|
||||
it('missing locationId -> explicit error', async () => {
|
||||
const payload = {
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'new style',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleModifyLocationTask(job, payload)).rejects.toThrow('locationId is required')
|
||||
})
|
||||
|
||||
it('success -> persists modifiedDescription with computed imageIndex', async () => {
|
||||
const payload = {
|
||||
locationId: 'location-1',
|
||||
imageIndex: 2,
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'add fog',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyLocationTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_location',
|
||||
prompt: 'location-final-prompt',
|
||||
}))
|
||||
expect(persistMock.persistLocationDescription).toHaveBeenCalledWith({
|
||||
locationId: 'location-1',
|
||||
imageIndex: 2,
|
||||
modifiedDescription: 'updated location description',
|
||||
availableSlots: ['街道左侧靠墙的留白位置'],
|
||||
})
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
modifiedDescription: 'updated location description',
|
||||
availableSlots: ['街道左侧靠墙的留白位置'],
|
||||
location: { id: 'location-1', images: [] },
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_IMAGE_PROMPT_MODIFY: 'np_image_prompt_modify' },
|
||||
buildPrompt: vi.fn(() => 'shot-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyShotPromptTask } from '@/lib/workers/handlers/shot-ai-prompt-shot'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-prompt-1',
|
||||
type: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-shot behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"image_prompt":"updated image prompt","video_prompt":"updated video prompt"}')
|
||||
})
|
||||
|
||||
it('missing currentPrompt -> explicit error', async () => {
|
||||
const payload = { modifyInstruction: 'new angle' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleModifyShotPromptTask(job, payload)).rejects.toThrow('currentPrompt is required')
|
||||
})
|
||||
|
||||
it('success -> returns modified image/video prompts and passes referencedAssets', async () => {
|
||||
const payload = {
|
||||
currentPrompt: 'old image prompt',
|
||||
currentVideoPrompt: 'old video prompt',
|
||||
modifyInstruction: 'new camera movement',
|
||||
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyShotPromptTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_shot_prompt',
|
||||
prompt: 'shot-final-prompt',
|
||||
}))
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedImagePrompt: 'updated image prompt',
|
||||
modifiedVideoPrompt: 'updated video prompt',
|
||||
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const handlersMock = vi.hoisted(() => ({
|
||||
handleModifyAppearanceTask: vi.fn(),
|
||||
handleModifyLocationTask: vi.fn(),
|
||||
handleModifyShotPromptTask: vi.fn(),
|
||||
handleAnalyzeShotVariantsTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt', () => ({
|
||||
handleModifyAppearanceTask: handlersMock.handleModifyAppearanceTask,
|
||||
handleModifyLocationTask: handlersMock.handleModifyLocationTask,
|
||||
handleModifyShotPromptTask: handlersMock.handleModifyShotPromptTask,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-variants', () => ({
|
||||
handleAnalyzeShotVariantsTask: handlersMock.handleAnalyzeShotVariantsTask,
|
||||
}))
|
||||
|
||||
import { handleShotAITask } from '@/lib/workers/handlers/shot-ai-tasks'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-tasks behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
handlersMock.handleModifyAppearanceTask.mockResolvedValue({ type: 'appearance' })
|
||||
handlersMock.handleModifyLocationTask.mockResolvedValue({ type: 'location' })
|
||||
handlersMock.handleModifyShotPromptTask.mockResolvedValue({ type: 'shot-prompt' })
|
||||
handlersMock.handleAnalyzeShotVariantsTask.mockResolvedValue({ type: 'variants' })
|
||||
})
|
||||
|
||||
it('AI_MODIFY_APPEARANCE -> routes to appearance handler with payload', async () => {
|
||||
const payload = { characterId: 'char-1', appearanceId: 'app-1' }
|
||||
const job = buildJob(TASK_TYPE.AI_MODIFY_APPEARANCE, payload)
|
||||
|
||||
const result = await handleShotAITask(job)
|
||||
|
||||
expect(result).toEqual({ type: 'appearance' })
|
||||
expect(handlersMock.handleModifyAppearanceTask).toHaveBeenCalledWith(job, payload)
|
||||
})
|
||||
|
||||
it('AI_MODIFY_LOCATION / AI_MODIFY_SHOT_PROMPT / ANALYZE_SHOT_VARIANTS route correctly', async () => {
|
||||
const locationPayload = { locationId: 'loc-1' }
|
||||
const locationJob = buildJob(TASK_TYPE.AI_MODIFY_LOCATION, locationPayload)
|
||||
await handleShotAITask(locationJob)
|
||||
expect(handlersMock.handleModifyLocationTask).toHaveBeenCalledWith(locationJob, locationPayload)
|
||||
|
||||
const shotPayload = { currentPrompt: 'old prompt', modifyInstruction: 'new angle' }
|
||||
const shotJob = buildJob(TASK_TYPE.AI_MODIFY_SHOT_PROMPT, shotPayload)
|
||||
await handleShotAITask(shotJob)
|
||||
expect(handlersMock.handleModifyShotPromptTask).toHaveBeenCalledWith(shotJob, shotPayload)
|
||||
|
||||
const variantPayload = { panelId: 'panel-1' }
|
||||
const variantJob = buildJob(TASK_TYPE.ANALYZE_SHOT_VARIANTS, variantPayload)
|
||||
await handleShotAITask(variantJob)
|
||||
expect(handlersMock.handleAnalyzeShotVariantsTask).toHaveBeenCalledWith(variantJob, variantPayload)
|
||||
})
|
||||
|
||||
it('unsupported type -> throws explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {})
|
||||
await expect(handleShotAITask(job)).rejects.toThrow('Unsupported shot AI task type')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletionWithVision: vi.fn(),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const cosMock = vi.hoisted(() => ({
|
||||
getSignedUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const streamCtxMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => {
|
||||
const flush = vi.fn(async () => undefined)
|
||||
return {
|
||||
flush,
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
onStage: vi.fn(),
|
||||
onChunk: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
flush,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/storage', () => cosMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamCtxMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
|
||||
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_ANALYSIS: 'np_agent_shot_variant_analysis' },
|
||||
buildPrompt: vi.fn(() => 'shot-variants-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAnalyzeShotVariantsTask } from '@/lib/workers/handlers/shot-ai-variants'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-variants-1',
|
||||
type: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-variants behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis-1' })
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
panelNumber: 3,
|
||||
imageUrl: 'images/panel-1.png',
|
||||
description: 'panel desc',
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'black coat' }]),
|
||||
})
|
||||
cosMock.getSignedUrl.mockReturnValue('https://signed.example/panel-1.png')
|
||||
llmMock.chatCompletionWithVision.mockResolvedValue({ id: 'vision-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('[{"name":"v1"},{"name":"v2"},{"name":"v3"}]')
|
||||
})
|
||||
|
||||
it('panel not found -> explicit error', async () => {
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)
|
||||
const job = buildJob({ panelId: 'panel-404' })
|
||||
|
||||
await expect(handleAnalyzeShotVariantsTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('Panel not found')
|
||||
})
|
||||
|
||||
it('success -> returns suggestions and signed panel image', async () => {
|
||||
const payload = { panelId: 'panel-1' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleAnalyzeShotVariantsTask(job, payload)
|
||||
|
||||
expect(llmMock.chatCompletionWithVision).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'llm::analysis-1',
|
||||
'shot-variants-prompt',
|
||||
['https://signed.example/panel-1.png'],
|
||||
expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
action: 'analyze_shot_variants',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
suggestions: [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }],
|
||||
panelInfo: expect.objectContaining({
|
||||
panelNumber: 3,
|
||||
imageUrl: 'https://signed.example/panel-1.png',
|
||||
}),
|
||||
}))
|
||||
expect(llmStreamMock.flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suggestions fewer than 3 -> explicit error', async () => {
|
||||
llmMock.getCompletionContent.mockReturnValueOnce('[{"name":"only-one"}]')
|
||||
const payload = { panelId: 'panel-1' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleAnalyzeShotVariantsTask(job, payload)).rejects.toThrow('生成的变体数量不足')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runStoryToScriptOrchestrator } from '@/lib/novel-promotion/story-to-script/orchestrator'
|
||||
|
||||
describe('story-to-script orchestrator retry', () => {
|
||||
it('retries retryable step failure up to 3 attempts', async () => {
|
||||
const actionCalls = new Map<string, number>()
|
||||
const characterMetas: Array<{ stepId: string; stepAttempt?: number }> = []
|
||||
const runStep = vi.fn(async (meta, _prompt, action: string) => {
|
||||
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
|
||||
|
||||
if (action === 'analyze_characters') {
|
||||
characterMetas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
|
||||
const count = actionCalls.get(action) || 0
|
||||
if (count < 3) {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
{
|
||||
start: '甲在门口',
|
||||
end: '乙回答',
|
||||
summary: '片段摘要',
|
||||
location: '地点A',
|
||||
characters: ['甲'],
|
||||
},
|
||||
]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
return { text: JSON.stringify({ scenes: [{ id: 1 }] }), reasoning: '' }
|
||||
})
|
||||
|
||||
const result = await runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
expect(actionCalls.get('analyze_characters')).toBe(3)
|
||||
expect(characterMetas).toEqual([
|
||||
{ stepId: 'analyze_characters', stepAttempt: undefined },
|
||||
{ stepId: 'analyze_characters', stepAttempt: 2 },
|
||||
{ stepId: 'analyze_characters', stepAttempt: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry non-retryable failures', async () => {
|
||||
const actionCalls = new Map<string, number>()
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
|
||||
if (action === 'analyze_characters') {
|
||||
throw new Error('SENSITIVE_CONTENT: blocked')
|
||||
}
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('SENSITIVE_CONTENT')
|
||||
|
||||
expect(actionCalls.get('analyze_characters')).toBe(1)
|
||||
})
|
||||
|
||||
it('does not retry Ark invalid parameter errors even if message contains json', async () => {
|
||||
const actionCalls = new Map<string, number>()
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
|
||||
if (action === 'analyze_characters') {
|
||||
throw new Error(
|
||||
'Ark Responses 调用失败: 400 - {"error":{"code":"InvalidParameter","message":"json: unknown field \\"reasoning_effort\\""}}',
|
||||
)
|
||||
}
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('unknown field')
|
||||
|
||||
expect(actionCalls.get('analyze_characters')).toBe(1)
|
||||
})
|
||||
|
||||
it('parses first balanced JSON block when model appends extra JSON text', async () => {
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
if (action === 'analyze_characters') {
|
||||
return {
|
||||
text: '{"characters":[{"name":"甲","introduction":"人物介绍"}]}\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'analyze_locations') {
|
||||
return {
|
||||
text: '{"locations":[{"name":"地点A"}]}\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return {
|
||||
text: '{"props":[]}\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: '[{"start":"甲在门口","end":"乙回答","summary":"片段摘要","location":"地点A","characters":["甲"]}]\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'screenplay_conversion') {
|
||||
return {
|
||||
text: '{"scenes":[{"scene_number":1,"content":[{"type":"action","text":"甲在门口。"}]}]}\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`)
|
||||
})
|
||||
|
||||
const result = await runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
expect(result.summary.screenplayFailedCount).toBe(0)
|
||||
expect(result.summary.screenplaySuccessCount).toBe(1)
|
||||
expect(result.screenplayResults[0]).toMatchObject({
|
||||
clipId: 'clip_1',
|
||||
success: true,
|
||||
sceneCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces topology: split waits for analyses, screenplay waits for split', async () => {
|
||||
const actionOrder: string[] = []
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
actionOrder.push(action)
|
||||
if (action === 'analyze_characters') {
|
||||
return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
{
|
||||
start: '甲在门口',
|
||||
end: '乙回答',
|
||||
summary: '片段摘要',
|
||||
location: '地点A',
|
||||
characters: ['甲'],
|
||||
},
|
||||
]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'screenplay_conversion') {
|
||||
return {
|
||||
text: JSON.stringify({ scenes: [{ scene_number: 1 }] }),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`)
|
||||
})
|
||||
|
||||
const result = await runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
const analyzeCharactersIndex = actionOrder.indexOf('analyze_characters')
|
||||
const analyzeLocationsIndex = actionOrder.indexOf('analyze_locations')
|
||||
const splitIndex = actionOrder.indexOf('split_clips')
|
||||
const screenplayIndex = actionOrder.indexOf('screenplay_conversion')
|
||||
expect(splitIndex).toBeGreaterThan(Math.max(analyzeCharactersIndex, analyzeLocationsIndex))
|
||||
expect(screenplayIndex).toBeGreaterThan(splitIndex)
|
||||
})
|
||||
|
||||
it('limits screenplay conversion fan-out by configured concurrency', async () => {
|
||||
let activeScreenplay = 0
|
||||
let maxActiveScreenplay = 0
|
||||
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
if (action === 'analyze_characters') {
|
||||
return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
{ start: '甲在门口', end: '乙回应', summary: '片段1', location: '地点A', characters: ['甲'] },
|
||||
{ start: '丙出场', end: '丁离开', summary: '片段2', location: '地点A', characters: ['丙'] },
|
||||
{ start: '戊总结', end: '己收尾', summary: '片段3', location: '地点A', characters: ['戊'] },
|
||||
]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'screenplay_conversion') {
|
||||
activeScreenplay += 1
|
||||
maxActiveScreenplay = Math.max(maxActiveScreenplay, activeScreenplay)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
activeScreenplay -= 1
|
||||
return { text: JSON.stringify({ scenes: [{ scene_number: 1 }] }), reasoning: '' }
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`)
|
||||
})
|
||||
|
||||
const result = await runStoryToScriptOrchestrator({
|
||||
concurrency: 1,
|
||||
content: '甲在门口。乙回应。丙出场。丁离开。戊总结。己收尾。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(3)
|
||||
expect(result.summary.screenplaySuccessCount).toBe(3)
|
||||
expect(maxActiveScreenplay).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
novelPromotionClip: { update: vi.fn(async () => ({})) },
|
||||
locationImage: { createMany: vi.fn(async () => ({ count: 0 })) },
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({ reasoningEffort: 'high' })),
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 2,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
|
||||
const orchestratorMock = vi.hoisted(() => ({
|
||||
runStoryToScriptOrchestrator: vi.fn(),
|
||||
}))
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),
|
||||
persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),
|
||||
persistAnalyzedProps: vi.fn(async () => [{ id: 'prop-new-1' }]),
|
||||
persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),
|
||||
}))
|
||||
const workflowLeaseMock = vi.hoisted(() => ({
|
||||
assertWorkflowRunActive: vi.fn(async () => undefined),
|
||||
withWorkflowRunLease: vi.fn(async (params: { run: () => Promise<unknown> }) => ({
|
||||
claimed: true,
|
||||
result: await params.run(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => ({
|
||||
chatCompletion: vi.fn(),
|
||||
getCompletionParts: vi.fn(() => ({ text: '', reasoning: '' })),
|
||||
getCompletionContent: vi.fn(() => ''),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
|
||||
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/orchestrator', () => orchestratorMock)
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'a',
|
||||
NP_SELECT_LOCATION: 'b',
|
||||
NP_SELECT_PROP: 'c',
|
||||
NP_AGENT_CLIP: 'd',
|
||||
NP_SCREENPLAY_CONVERSION: 'e',
|
||||
},
|
||||
getPromptTemplate: vi.fn(() => 'prompt-template'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({
|
||||
asString: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
parseEffort: vi.fn(() => null),
|
||||
parseTemperature: vi.fn(() => 0.7),
|
||||
persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,
|
||||
persistAnalyzedLocations: helperMock.persistAnalyzedLocations,
|
||||
persistAnalyzedProps: helperMock.persistAnalyzedProps,
|
||||
persistClips: helperMock.persistClips,
|
||||
resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,
|
||||
}))
|
||||
vi.mock('@/lib/run-runtime/workflow-lease', () => workflowLeaseMock)
|
||||
|
||||
import { handleStoryToScriptTask } from '@/lib/workers/handlers/story-to-script'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
const runId = typeof payload.runId === 'string' && payload.runId.trim() ? payload.runId.trim() : 'run-test-story'
|
||||
const payloadMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta)
|
||||
? (payload.meta as Record<string, unknown>)
|
||||
: {}
|
||||
const normalizedPayload: Record<string, unknown> = {
|
||||
...payload,
|
||||
runId,
|
||||
meta: {
|
||||
...payloadMeta,
|
||||
runId,
|
||||
},
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-story-to-script-1',
|
||||
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: normalizedPayload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker story-to-script behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$transaction.mockImplementation(async (fn: (tx: typeof prismaMock) => Promise<unknown>) => await fn(prismaMock))
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town', assetKind: 'location' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'episode text',
|
||||
})
|
||||
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({
|
||||
analyzedCharacters: [{ name: 'New Hero' }],
|
||||
analyzedLocations: [{ name: 'Market' }],
|
||||
analyzedProps: [{ name: 'Knife', summary: 'bronze dagger' }],
|
||||
propsObject: { props: [{ name: 'Knife', summary: 'bronze dagger' }] },
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
|
||||
screenplayResults: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
success: true,
|
||||
screenplay: { scenes: [{ shot: 'close-up' }] },
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 1,
|
||||
screenplayFailedCount: 0,
|
||||
propCount: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleStoryToScriptTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> persists clips and screenplay with concrete fields', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
|
||||
const result = await handleStoryToScriptTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 1,
|
||||
screenplayFailedCount: 0,
|
||||
persistedCharacters: 1,
|
||||
persistedLocations: 1,
|
||||
persistedProps: 1,
|
||||
persistedClips: 1,
|
||||
})
|
||||
|
||||
expect(helperMock.persistClips).toHaveBeenCalledWith(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
|
||||
}))
|
||||
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-row-1' },
|
||||
data: {
|
||||
screenplay: JSON.stringify({ scenes: [{ shot: 'close-up' }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('orchestrator partial failure summary -> throws explicit error', async () => {
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({
|
||||
analyzedCharacters: [],
|
||||
analyzedLocations: [],
|
||||
analyzedProps: [],
|
||||
propsObject: { props: [] },
|
||||
clipList: [],
|
||||
screenplayResults: [
|
||||
{
|
||||
clipId: 'clip-3',
|
||||
success: false,
|
||||
error: 'bad screenplay json',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 0,
|
||||
screenplayFailedCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
|
||||
await expect(handleStoryToScriptTask(job)).rejects.toThrow('STORY_TO_SCRIPT_PARTIAL_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withUserConcurrencyGate } from '@/lib/workers/user-concurrency-gate'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('user concurrency gate', () => {
|
||||
it('serializes same-scope work for the same user when limit is 1', async () => {
|
||||
const firstDone = deferred<void>()
|
||||
const events: string[] = []
|
||||
|
||||
const first = withUserConcurrencyGate({
|
||||
scope: 'image',
|
||||
userId: 'user-1',
|
||||
limit: 1,
|
||||
run: async () => {
|
||||
events.push('first:start')
|
||||
await firstDone.promise
|
||||
events.push('first:end')
|
||||
},
|
||||
})
|
||||
|
||||
const second = withUserConcurrencyGate({
|
||||
scope: 'image',
|
||||
userId: 'user-1',
|
||||
limit: 1,
|
||||
run: async () => {
|
||||
events.push('second:start')
|
||||
events.push('second:end')
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['first:start'])
|
||||
|
||||
firstDone.resolve()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(events).toEqual([
|
||||
'first:start',
|
||||
'first:end',
|
||||
'second:start',
|
||||
'second:end',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
task: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const taskServiceMock = vi.hoisted(() => ({
|
||||
isTaskActive: vi.fn(async () => true),
|
||||
trySetTaskExternalId: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
const asyncPollMock = vi.hoisted(() => ({
|
||||
pollAsyncTask: vi.fn(),
|
||||
}))
|
||||
|
||||
const generatorApiMock = vi.hoisted(() => ({
|
||||
generateImage: vi.fn(),
|
||||
generateVideo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/service', () => taskServiceMock)
|
||||
vi.mock('@/lib/async-poll', () => asyncPollMock)
|
||||
vi.mock('@/lib/generator-api', () => generatorApiMock)
|
||||
vi.mock('@/lib/lipsync', () => ({ generateLipSync: vi.fn() }))
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
getSignedUrl: vi.fn((value: string) => value),
|
||||
toFetchableUrl: vi.fn((value: string) => value),
|
||||
}))
|
||||
vi.mock('@/lib/fonts', () => ({ initializeFonts: vi.fn(), createLabelSVG: vi.fn() }))
|
||||
vi.mock('@/lib/media-process', () => ({ processMediaResult: vi.fn() }))
|
||||
vi.mock('@/lib/config-service', () => ({
|
||||
getProjectModelConfig: vi.fn(),
|
||||
getUserModelConfig: vi.fn(),
|
||||
resolveProjectModelCapabilityGenerationOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
import { resolveImageSourceFromGeneration, resolveVideoSourceFromGeneration } from '@/lib/workers/utils'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: 'VIDEO_PANEL',
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker utils video generation resume', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('continues polling from existing externalId without re-submitting generation', async () => {
|
||||
const externalId = 'OPENAI:VIDEO:b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ:vid_123'
|
||||
prismaMock.task.findUnique.mockResolvedValueOnce({ externalId })
|
||||
asyncPollMock.pollAsyncTask.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
resultUrl: 'https://oa.test/v1/videos/vid_123/content',
|
||||
downloadHeaders: {
|
||||
Authorization: 'Bearer oa-key',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await resolveVideoSourceFromGeneration(buildJob(), {
|
||||
userId: 'user-1',
|
||||
modelId: 'openai-compatible:oa-1::sora-2',
|
||||
imageUrl: 'data:image/png;base64,QQ==',
|
||||
options: {
|
||||
prompt: 'animate this frame',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://oa.test/v1/videos/vid_123/content',
|
||||
downloadHeaders: {
|
||||
Authorization: 'Bearer oa-key',
|
||||
},
|
||||
})
|
||||
expect(asyncPollMock.pollAsyncTask).toHaveBeenCalledWith(externalId, 'user-1')
|
||||
expect(generatorApiMock.generateVideo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents duplicate panel candidates by skipping task externalId resume when requested', async () => {
|
||||
prismaMock.task.findUnique.mockResolvedValueOnce({ externalId: 'FAL:IMAGE:fal-ai/nano-banana-pro:req_1' })
|
||||
generatorApiMock.generateImage.mockResolvedValueOnce({
|
||||
success: true,
|
||||
imageUrl: 'https://fal.test/new-image.png',
|
||||
})
|
||||
|
||||
const result = await resolveImageSourceFromGeneration(buildJob(), {
|
||||
userId: 'user-1',
|
||||
modelId: 'fal::banana',
|
||||
prompt: 'a cinematic portrait',
|
||||
options: {
|
||||
aspectRatio: '16:9',
|
||||
},
|
||||
allowTaskExternalIdResume: false,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://fal.test/new-image.png')
|
||||
expect(prismaMock.task.findUnique).not.toHaveBeenCalled()
|
||||
expect(asyncPollMock.pollAsyncTask).not.toHaveBeenCalled()
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>
|
||||
|
||||
type PanelRow = {
|
||||
id: string
|
||||
videoUrl: string | null
|
||||
imageUrl: string | null
|
||||
videoPrompt: string | null
|
||||
description: string | null
|
||||
firstLastFramePrompt: string | null
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as WorkerProcessor | null,
|
||||
}))
|
||||
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const withTaskLifecycleMock = vi.hoisted(() =>
|
||||
vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),
|
||||
)
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
|
||||
resolveVideoSourceFromGeneration: vi.fn<(...args: unknown[]) => Promise<{ url: string; actualVideoTokens?: number; downloadHeaders?: Record<string, string> }>>(async () => ({ url: 'https://provider.example/video.mp4' })),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
|
||||
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
|
||||
}))
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 5,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
const concurrencyGateMock = vi.hoisted(() => ({
|
||||
withUserConcurrencyGate: vi.fn(async <T>(input: {
|
||||
run: () => Promise<T>
|
||||
}) => await input.run()),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => undefined),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
constructor(name: string) {
|
||||
void name
|
||||
}
|
||||
|
||||
async add() {
|
||||
return { id: 'job-1' }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(name: string, processor: WorkerProcessor) {
|
||||
void name
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
withTaskLifecycle: withTaskLifecycleMock,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
|
||||
}))
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => ({
|
||||
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
|
||||
}))
|
||||
vi.mock('@/lib/model-config-contract', () => ({
|
||||
parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),
|
||||
}))
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/workers/user-concurrency-gate', () => concurrencyGateMock)
|
||||
|
||||
function buildPanel(overrides?: Partial<PanelRow>): PanelRow {
|
||||
return {
|
||||
id: 'panel-1',
|
||||
videoUrl: 'cos/base-video.mp4',
|
||||
imageUrl: 'cos/panel-image.png',
|
||||
videoPrompt: 'panel prompt',
|
||||
description: 'panel description',
|
||||
firstLastFramePrompt: null,
|
||||
duration: 5,
|
||||
...(overrides || {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildJob(params: {
|
||||
type: TaskJobData['type']
|
||||
payload?: Record<string, unknown>
|
||||
targetType?: string
|
||||
targetId?: string
|
||||
}): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: params.type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: params.targetType ?? 'NovelPromotionPanel',
|
||||
targetId: params.targetId ?? 'panel-1',
|
||||
payload: params.payload ?? {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker video processor behavior', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
workerState.processor = null
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue(buildPanel())
|
||||
prismaMock.novelPromotionPanel.findFirst.mockResolvedValue(buildPanel())
|
||||
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
|
||||
id: 'line-1',
|
||||
audioUrl: 'cos/line-1.mp3',
|
||||
audioDuration: 1200,
|
||||
})
|
||||
|
||||
const mod = await import('@/lib/workers/video.worker')
|
||||
mod.createVideoWorker()
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL: 缺少 payload.videoModel 时显式失败', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
payload: {},
|
||||
})
|
||||
|
||||
await expect(processor!(job)).rejects.toThrow('VIDEO_MODEL_REQUIRED: payload.videoModel is required')
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL: 透传异步轮询返回的下载头到 COS 上传', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
utilsMock.resolveVideoSourceFromGeneration.mockResolvedValueOnce({
|
||||
url: 'https://provider.example/video.mp4',
|
||||
downloadHeaders: {
|
||||
Authorization: 'Bearer oa-key',
|
||||
},
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
payload: {
|
||||
videoModel: 'openai-compatible:oa-1::sora-2',
|
||||
generationOptions: {
|
||||
duration: 8,
|
||||
resolution: '720p',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await processor!(job)
|
||||
|
||||
expect(utilsMock.uploadVideoSourceToCos).toHaveBeenCalledWith(
|
||||
'https://provider.example/video.mp4',
|
||||
'panel-video',
|
||||
'panel-1',
|
||||
{
|
||||
Authorization: 'Bearer oa-key',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL: 将 Ark 返回的实际视频 token 用量透传到任务结果', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
utilsMock.resolveVideoSourceFromGeneration.mockResolvedValueOnce({
|
||||
url: 'https://provider.example/video.mp4',
|
||||
actualVideoTokens: 108000,
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
payload: {
|
||||
videoModel: 'ark::doubao-seedance-2-0-260128',
|
||||
generationOptions: {
|
||||
duration: 5,
|
||||
resolution: '720p',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await processor!(job) as { panelId: string; videoUrl: string; actualVideoTokens: number }
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
videoUrl: 'cos/lip-sync/video.mp4',
|
||||
actualVideoTokens: 108000,
|
||||
})
|
||||
})
|
||||
|
||||
it('LIP_SYNC: 缺少 panel 时显式失败', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
payload: { voiceLineId: 'line-1' },
|
||||
targetId: 'panel-missing',
|
||||
})
|
||||
|
||||
await expect(processor!(job)).rejects.toThrow('Lip-sync panel not found')
|
||||
})
|
||||
|
||||
it('LIP_SYNC: 正常路径写回 lipSyncVideoUrl 并清理 lipSyncTaskId', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
payload: {
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncModel: 'fal::lipsync-model',
|
||||
},
|
||||
targetId: 'panel-1',
|
||||
})
|
||||
|
||||
const result = await processor!(job) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveLipSyncVideoSource).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
modelKey: 'fal::lipsync-model',
|
||||
audioDurationMs: 1200,
|
||||
videoDurationMs: 5000,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
lipSyncTaskId: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('未知任务类型: 显式报错', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const unsupportedJob = buildJob({
|
||||
type: TASK_TYPE.AI_CREATE_CHARACTER,
|
||||
})
|
||||
|
||||
await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported video task type')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const txState = vi.hoisted(() => ({
|
||||
createdRows: [] as Array<Record<string, unknown>>,
|
||||
deletedWhereClauses: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => 'voice-line-json'),
|
||||
}))
|
||||
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
parseVoiceLinesJson: vi.fn(),
|
||||
buildStoryboardJson: vi.fn(() => 'storyboard-json'),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/voice-analyze-helpers', () => ({
|
||||
buildStoryboardJson: helperMock.buildStoryboardJson,
|
||||
parseVoiceLinesJson: helperMock.parseVoiceLinesJson,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_VOICE_ANALYSIS: 'np_voice_analysis' },
|
||||
buildPrompt: vi.fn(() => 'voice-analysis-prompt'),
|
||||
}))
|
||||
|
||||
import { handleVoiceAnalyzeTask } from '@/lib/workers/handlers/voice-analyze'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-voice-analyze-1',
|
||||
type: TASK_TYPE.VOICE_ANALYZE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker voice-analyze behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
txState.createdRows = []
|
||||
txState.deletedWhereClauses = []
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1' })
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: '这是可以用于台词分析的文本',
|
||||
storyboards: [
|
||||
{
|
||||
id: 'storyboard-1',
|
||||
clip: { id: 'clip-1' },
|
||||
panels: [{ id: 'panel-1', panelIndex: 0 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
helperMock.parseVoiceLinesJson.mockReturnValue([
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Hero',
|
||||
content: '第一句台词',
|
||||
emotionStrength: 0.7,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
lineIndex: 2,
|
||||
speaker: 'Narrator',
|
||||
content: '第二句旁白',
|
||||
emotionStrength: 0.5,
|
||||
},
|
||||
])
|
||||
|
||||
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
|
||||
novelPromotionVoiceLine: {
|
||||
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
|
||||
create: (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => Promise<{
|
||||
id: string
|
||||
speaker: string
|
||||
matchedStoryboardId: string | null
|
||||
}>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionVoiceLine: {
|
||||
deleteMany: async (args: { where: Record<string, unknown> }) => {
|
||||
txState.deletedWhereClauses.push(args.where)
|
||||
return undefined
|
||||
},
|
||||
create: async (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => {
|
||||
txState.createdRows.push(args.data)
|
||||
const speaker = typeof args.data.speaker === 'string' ? args.data.speaker : 'unknown'
|
||||
const matchedStoryboardId = typeof args.data.matchedStoryboardId === 'string'
|
||||
? args.data.matchedStoryboardId
|
||||
: null
|
||||
return {
|
||||
id: `line-${txState.createdRows.length}`,
|
||||
speaker,
|
||||
matchedStoryboardId,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
return await fn(tx)
|
||||
})
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> persists mapped panelId and speaker stats', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleVoiceAnalyzeTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
count: 2,
|
||||
matchedCount: 1,
|
||||
speakerStats: {
|
||||
Hero: 1,
|
||||
Narrator: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(txState.createdRows[0]).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: 1,
|
||||
speaker: 'Hero',
|
||||
content: '第一句台词',
|
||||
matchedPanelId: 'panel-1',
|
||||
matchedStoryboardId: 'storyboard-1',
|
||||
matchedPanelIndex: 0,
|
||||
}))
|
||||
expect(txState.deletedWhereClauses[0]).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: {
|
||||
notIn: [1, 2],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('empty voice lines -> success with zero rows and clears existing lines', async () => {
|
||||
helperMock.parseVoiceLinesJson.mockReturnValue([])
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleVoiceAnalyzeTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
count: 0,
|
||||
matchedCount: 0,
|
||||
speakerStats: {},
|
||||
})
|
||||
expect(txState.createdRows).toEqual([])
|
||||
expect(txState.deletedWhereClauses[0]).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('line references non-existent storyboard panel -> explicit error', async () => {
|
||||
helperMock.parseVoiceLinesJson.mockImplementation(() => [
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Hero',
|
||||
content: 'bad line',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-404',
|
||||
panelIndex: 0,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('references non-existent panel')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const bailianMock = vi.hoisted(() => ({
|
||||
createVoiceDesign: vi.fn(),
|
||||
validateVoicePrompt: vi.fn(),
|
||||
validatePreviewText: vi.fn(),
|
||||
}))
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
getProviderConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/bailian/voice-design', () => bailianMock)
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
|
||||
import { handleVoiceDesignTask } from '@/lib/workers/handlers/voice-design'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-voice-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'VoiceDesign',
|
||||
targetId: 'voice-design-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker voice-design behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bailianMock.validateVoicePrompt.mockReturnValue({ valid: true })
|
||||
bailianMock.validatePreviewText.mockReturnValue({ valid: true })
|
||||
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'bailian-key' })
|
||||
bailianMock.createVoiceDesign.mockResolvedValue({
|
||||
success: true,
|
||||
voiceId: 'voice-id-1',
|
||||
targetModel: 'bailian-tts',
|
||||
audioBase64: 'base64-audio',
|
||||
sampleRate: 24000,
|
||||
responseFormat: 'mp3',
|
||||
usageCount: 11,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('missing required fields -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.VOICE_DESIGN, { previewText: 'hello' })
|
||||
await expect(handleVoiceDesignTask(job)).rejects.toThrow('voicePrompt is required')
|
||||
})
|
||||
|
||||
it('invalid prompt validation -> explicit error message from validator', async () => {
|
||||
bailianMock.validateVoicePrompt.mockReturnValue({ valid: false, error: 'bad prompt' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.VOICE_DESIGN, {
|
||||
voicePrompt: 'x',
|
||||
previewText: 'hello',
|
||||
})
|
||||
await expect(handleVoiceDesignTask(job)).rejects.toThrow('bad prompt')
|
||||
})
|
||||
|
||||
it('success path -> submits normalized input and returns typed result', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_VOICE_DESIGN, {
|
||||
voicePrompt: ' calm female narrator ',
|
||||
previewText: ' hello world ',
|
||||
preferredName: ' custom_name ',
|
||||
language: 'en',
|
||||
})
|
||||
|
||||
const result = await handleVoiceDesignTask(job)
|
||||
|
||||
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'bailian')
|
||||
expect(bailianMock.createVoiceDesign).toHaveBeenCalledWith({
|
||||
voicePrompt: 'calm female narrator',
|
||||
previewText: 'hello world',
|
||||
preferredName: 'custom_name',
|
||||
language: 'en',
|
||||
}, 'bailian-key')
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
voiceId: 'voice-id-1',
|
||||
taskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseVoiceLinesJson as parseStoryboardVoiceLinesJson } from '@/lib/workers/handlers/script-to-storyboard-helpers'
|
||||
import { parseVoiceLinesJson as parseStandaloneVoiceLinesJson } from '@/lib/workers/handlers/voice-analyze-helpers'
|
||||
|
||||
describe('voice line parse helpers', () => {
|
||||
it('script-to-storyboard parser accepts explicit empty array', () => {
|
||||
expect(parseStoryboardVoiceLinesJson('[]')).toEqual([])
|
||||
})
|
||||
|
||||
it('script-to-storyboard parser rejects non-object array payload', () => {
|
||||
expect(() => parseStoryboardVoiceLinesJson('[1,2]')).toThrow('voice_analyze: invalid payload')
|
||||
})
|
||||
|
||||
it('voice-analyze parser accepts explicit empty array', () => {
|
||||
expect(parseStandaloneVoiceLinesJson('[]')).toEqual([])
|
||||
})
|
||||
|
||||
it('voice-analyze parser rejects non-object array payload', () => {
|
||||
expect(() => parseStandaloneVoiceLinesJson('[1,2]')).toThrow('Invalid voice lines data structure')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as WorkerProcessor | null,
|
||||
}))
|
||||
|
||||
const generateVoiceLineMock = vi.hoisted(() => vi.fn())
|
||||
const handleVoiceDesignTaskMock = vi.hoisted(() => vi.fn())
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const withTaskLifecycleMock = vi.hoisted(() =>
|
||||
vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),
|
||||
)
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
constructor(_name: string) {}
|
||||
|
||||
async add() {
|
||||
return { id: 'job-1' }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: WorkerProcessor) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
queueRedis: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/voice/generate-voice-line', () => ({
|
||||
generateVoiceLine: generateVoiceLineMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
withTaskLifecycle: withTaskLifecycleMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/voice-design', () => ({
|
||||
handleVoiceDesignTask: handleVoiceDesignTaskMock,
|
||||
}))
|
||||
|
||||
function buildJob(params: {
|
||||
type: TaskJobData['type']
|
||||
targetType?: string
|
||||
targetId?: string
|
||||
episodeId?: string | null
|
||||
payload?: Record<string, unknown>
|
||||
}): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: params.type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: params.episodeId !== undefined ? params.episodeId : 'episode-1',
|
||||
targetType: params.targetType ?? 'NovelPromotionVoiceLine',
|
||||
targetId: params.targetId ?? 'line-1',
|
||||
payload: params.payload ?? {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker voice processor behavior', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
workerState.processor = null
|
||||
|
||||
generateVoiceLineMock.mockResolvedValue({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
handleVoiceDesignTaskMock.mockResolvedValue({
|
||||
presetId: 'preset-1',
|
||||
previewAudioUrl: 'cos/preset-1.mp3',
|
||||
})
|
||||
|
||||
const mod = await import('@/lib/workers/voice.worker')
|
||||
mod.createVoiceWorker()
|
||||
})
|
||||
|
||||
it('VOICE_LINE: lineId/episodeId 缺失时显式失败', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const missingLineJob = buildJob({
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetId: '',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
})
|
||||
await expect(processor!(missingLineJob)).rejects.toThrow('VOICE_LINE task missing lineId')
|
||||
|
||||
const missingEpisodeJob = buildJob({
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
episodeId: null,
|
||||
targetId: 'line-1',
|
||||
payload: {},
|
||||
})
|
||||
await expect(processor!(missingEpisodeJob)).rejects.toThrow('VOICE_LINE task missing episodeId')
|
||||
})
|
||||
|
||||
it('VOICE_LINE: 正常生成时把核心参数传给 generateVoiceLine', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const job = buildJob({
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
payload: {
|
||||
lineId: 'line-9',
|
||||
episodeId: 'episode-9',
|
||||
audioModel: 'fal::voice-model',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await processor!(job)
|
||||
expect(result).toEqual({ lineId: 'line-1', audioUrl: 'cos/voice-line-1.mp3' })
|
||||
expect(generateVoiceLineMock).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-9',
|
||||
lineId: 'line-9',
|
||||
userId: 'user-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('VOICE_DESIGN / ASSET_HUB_VOICE_DESIGN: 路由到 voice design handler', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const designJob = buildJob({
|
||||
type: TASK_TYPE.VOICE_DESIGN,
|
||||
targetType: 'NovelPromotionVoiceDesign',
|
||||
targetId: 'voice-design-1',
|
||||
})
|
||||
|
||||
const assetHubJob = buildJob({
|
||||
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
targetType: 'GlobalAssetHubVoiceDesign',
|
||||
targetId: 'asset-hub-voice-design-1',
|
||||
})
|
||||
|
||||
await processor!(designJob)
|
||||
await processor!(assetHubJob)
|
||||
|
||||
expect(handleVoiceDesignTaskMock).toHaveBeenCalledTimes(2)
|
||||
expect(generateVoiceLineMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('未知任务类型: 显式报错', async () => {
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
const unsupportedJob = buildJob({
|
||||
type: TASK_TYPE.AI_CREATE_CHARACTER,
|
||||
targetId: 'character-1',
|
||||
})
|
||||
|
||||
await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported voice task type')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user