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: 'cinematic' })), 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: [], })), })) const outboundMock = vi.hoisted(() => ({ normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']), })) 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( '@/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: vi.fn(() => 'panel-image-prompt'), })) import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler' function buildJob(payload: Record, targetId = 'panel-1'): Job { 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 } 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', videoPrompt: 'dramatic', location: 'Old Town', characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]), 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', options: expect.objectContaining({ referenceImages: ['normalized-ref-1'], aspectRatio: '16:9', }), }), ) 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', 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']), }, }) }) })