feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getUserModels: vi.fn(async () => ({
characterModel: 'model-character-1',
locationModel: 'model-location-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(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),
parseJsonStringArray: vi.fn(() => [] as string[]),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/workers/utils', () => utilsMock)
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,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
parseJsonStringArray: sharedMock.parseJsonStringArray,
}
})
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - image queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
})
it('image tasks are enqueued into image queue with jobId=taskId', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-image-1',
type: TASK_TYPE.ASSET_HUB_IMAGE,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'global-character-1',
payload: { type: 'character', id: 'global-character-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.ASSET_HUB_IMAGE,
options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),
}))
})
it('modify asset image task also routes to image queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-image-2',
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
})
it('queued image job payload can be consumed by worker handler and persist image fields', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')
prismaMock.globalCharacter.findFirst.mockResolvedValue({
id: 'global-character-1',
name: 'Hero',
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'base',
description: '黑发,风衣',
descriptions: null,
},
],
})
await addTaskJob({
taskId: 'task-image-chain-worker-1',
type: TASK_TYPE.ASSET_HUB_IMAGE,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'global-character-1',
payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)
const result = await handleAssetHubImageTask(toJob(queued!))
expect(result).toEqual({
type: 'character',
appearanceId: 'appearance-1',
imageCount: 3,
})
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
where: { id: 'appearance-1' },
data: {
imageUrls: JSON.stringify(['cos/global-character-generated.png', 'cos/global-character-generated.png', 'cos/global-character-generated.png']),
imageUrl: 'cos/global-character-generated.png',
selectedIndex: null,
},
})
})
})

View File

@@ -0,0 +1,185 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const prismaMock = vi.hoisted(() => ({
project: {
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
},
novelPromotionProject: {
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
},
}))
const llmMock = 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 configMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/config-service', () => configMock)
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
buildPrompt: vi.fn(() => 'episode-split-prompt'),
}))
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 }
},
}),
}))
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - text queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
})
it('text tasks are enqueued into text queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-text-1',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: { episodeId: 'episode-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
options: expect.objectContaining({ jobId: 'task-text-1', priority: 0 }),
}))
})
it('explicit priority is preserved for text queue jobs', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-text-2',
type: TASK_TYPE.REFERENCE_TO_CHARACTER,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionProject',
targetId: 'project-1',
payload: { referenceImageUrl: 'https://example.com/ref.png' },
userId: 'user-1',
}, { priority: 7 })
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))
})
it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')
const content = [
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'START_MARKER',
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于链路测试验证。',
'END_MARKER',
'后置内容用于确保边界外还有文本,并继续补足长度。',
].join('')
await addTaskJob({
taskId: 'task-text-chain-worker-1',
type: TASK_TYPE.EPISODE_SPLIT_LLM,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionProject',
targetId: 'project-1',
payload: { content },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)
const result = await handleEpisodeSplitTask(toJob(queued!))
expect(result.success).toBe(true)
expect(result.episodes).toHaveLength(1)
expect(result.episodes[0]?.title).toBe('第一集')
expect(result.episodes[0]?.content).toContain('START_MARKER')
expect(result.episodes[0]?.content).toContain('END_MARKER')
})
})

View File

@@ -0,0 +1,203 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const workerState = vi.hoisted(() => ({
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => 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(async () => '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 {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
withTaskLifecycle: workerMock.withTaskLifecycle,
}))
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 toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - video queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
workerState.processor = null
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
videoUrl: 'cos/base-video.mp4',
})
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
id: 'line-1',
audioUrl: 'cos/line-1.mp3',
})
})
it('VIDEO_PANEL is enqueued into video queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-video-1',
type: TASK_TYPE.VIDEO_PANEL,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { videoModel: 'fal::video-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.VIDEO_PANEL,
options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),
}))
})
it('LIP_SYNC is enqueued into video queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-video-2',
type: TASK_TYPE.LIP_SYNC,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)
})
it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { createVideoWorker } = await import('@/lib/workers/video.worker')
createVideoWorker()
const processor = workerState.processor
expect(processor).toBeTruthy()
await addTaskJob({
taskId: 'task-video-chain-worker-1',
type: TASK_TYPE.LIP_SYNC,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)
const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
expect(result).toEqual({
panelId: 'panel-1',
voiceLineId: 'line-1',
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
})
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
lipSyncTaskId: null,
},
})
})
})

View File

@@ -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 AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const workerState = vi.hoisted(() => ({
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
}))
const voiceMock = vi.hoisted(() => ({
generateVoiceLine: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
}))
const voiceDesignMock = vi.hoisted(() => ({
handleVoiceDesignTask: vi.fn(),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/voice/generate-voice-line', () => ({
generateVoiceLine: voiceMock.generateVoiceLine,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
withTaskLifecycle: workerMock.withTaskLifecycle,
}))
vi.mock('@/lib/workers/handlers/voice-design', () => ({
handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,
}))
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - voice queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
workerState.processor = null
voiceMock.generateVoiceLine.mockResolvedValue({
lineId: 'line-1',
audioUrl: 'cos/voice-line-1.mp3',
})
voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({
presetId: 'voice-design-1',
previewAudioUrl: 'cos/preview-1.mp3',
})
})
it('VOICE_LINE is enqueued into voice queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-voice-1',
type: TASK_TYPE.VOICE_LINE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
payload: { lineId: 'line-1', episodeId: 'episode-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.VOICE_LINE,
options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),
}))
})
it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-voice-2',
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalAssetHubVoiceDesign',
targetId: 'voice-design-1',
payload: { voicePrompt: 'female calm narrator' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)
})
it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { createVoiceWorker } = await import('@/lib/workers/voice.worker')
createVoiceWorker()
const processor = workerState.processor
expect(processor).toBeTruthy()
await addTaskJob({
taskId: 'task-voice-chain-worker-1',
type: TASK_TYPE.VOICE_LINE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
payload: {
lineId: 'line-1',
episodeId: 'episode-1',
audioModel: 'fal::voice-model',
},
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)
const result = await processor!(toJob(queued!))
expect(result).toEqual({
lineId: 'line-1',
audioUrl: 'cos/voice-line-1.mp3',
})
expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({
projectId: 'project-1',
episodeId: 'episode-1',
lineId: 'line-1',
userId: 'user-1',
audioModel: 'fal::voice-model',
})
})
})