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) => 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, episodeId: string | null = 'episode-1'): Job { 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 } 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') }) })