import { describe, expect, it } from 'vitest'; import type { CanvasLayer, CharacterAnimationPanelState, GenerateDialogState, } from './ImageCanvasEditorTypes'; import { appendCharacterReference, appendGenerationReference, assignCharacterSpecReference, assignIconSpecReference, assignUiDesignSpecReference, closeGenerateComposerDialog, createAudioRedrawGenerationDialogDraft, createBackgroundMusicGenerationDialogDraft, createCharacterAnimationGenerationDialogDraft, createCharacterAnimationPanelDraft, createCharacterGenerationDialogDraft, createEditDialogDraft, createGenerateDialogDraft, createIconGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditPanelDraft, createRedrawPanelDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, createVideoGenerationDialogDraft, formatIconDescriptionsText, hideGeneratedLayerComposerAfterBlur, parseIconDescriptionsText, updateCharacterAnimationDurationPanel, updateIconDescriptionsTextInDialog, updateSpecFormDialogValue, } from './ImageCanvasGenerationDialogModel'; import { IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'data:image/png;base64,source', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', ...overrides, }; } describe('ImageCanvasGenerationDialogModel', () => { it('creates centered drafts for image and spec generation dialogs', () => { const canvasSize = { width: 1000, height: 800 }; const viewport = { x: 100, y: 40, scale: 2 }; expect(createGenerateDialogDraft({ canvasSize, viewport })).toMatchObject({ mode: 'generate', status: 'idle', composerOpen: true, placeholder: { x: -312, y: -332, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 1024, }, }); expect( createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }), ).toMatchObject({ mode: 'spec', specType: 'icon', specValues: { playSetting: '休闲小游戏', artStyle: '清爽卡通', }, placeholder: { x: -824, y: -396, width: 2048, height: 1152, originalWidth: 2048, originalHeight: 1152, }, }); }); it('creates publication material drafts with the workflow output size', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect( createPublicationGenerationDialogDraft({ canvasSize, viewport, workflowId: 'publication-promo-poster', }), ).toMatchObject({ mode: 'publication', publicationWorkflowId: 'publication-promo-poster', placeholder: { x: -160, y: 0, width: 1280, height: 720, originalWidth: 1280, originalHeight: 720, }, }); }); it('creates character and icon generation drafts with model dimensions', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect( createCharacterGenerationDialogDraft({ canvasSize, viewport, imageModel: 'gpt-image-2', }), ).toMatchObject({ mode: 'character', imageModel: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', characterSpecReference: null, characterReferences: [], placeholder: { x: -32, y: -152, width: 1024, height: 1024, }, }); expect( createIconGenerationDialogDraft({ canvasSize, viewport, imageModel: 'unknown-model', }), ).toMatchObject({ mode: 'icon', imageModel: 'unknown-model', aspectRatio: '1:1', imageSize: '1K', prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮', iconSpecReference: null, iconDescriptions: [ '返回按钮', '设置按钮', '下一关按钮', '提示按钮', '原图按钮', '冻结按钮', ], placeholder: { x: -32, y: -152, width: 1024, height: 1024, }, }); }); it('creates video and UI design drafts restored from the original generation entry work', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect(createVideoGenerationDialogDraft({ canvasSize, viewport })) .toMatchObject({ mode: 'video', status: 'idle', composerOpen: true, generationReferences: [], videoModel: 'seedance2.0-fast', videoAspectRatio: '16:9', videoResolution: '480p', videoDurationSeconds: 4, videoMode: 'std', videoSound: 'on', videoWebSearchEnabled: true, placeholder: { x: 53, y: 120, width: 854, height: 480, originalWidth: 854, originalHeight: 480, }, }); expect( createUiDesignGenerationDialogDraft({ canvasSize, viewport, imageModel: 'gpt-image-2', }), ).toMatchObject({ mode: 'ui-design', imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '1K', uiDesignSpecReference: null, placeholder: { x: -32, y: 72, width: 1024, height: 576, }, }); }); it('creates audio generation drafts for sound effects and background music', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport })) .toMatchObject({ mode: 'audio-sound-effect', prompt: '短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。', status: 'idle', composerOpen: true, soundModel: 'audio1.0', soundDurationSeconds: 5, placeholder: { x: 270, y: 300, width: 420, height: 120, originalWidth: 420, originalHeight: 120, }, }); expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport })) .toMatchObject({ mode: 'audio-background-music', prompt: '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。', status: 'idle', composerOpen: true, makeInstrumental: true, placeholder: { x: 270, y: 300, width: 420, height: 120, }, }); }); it('creates audio remodel drafts from generated audio layers without references', () => { const soundLayer = createLayer({ id: 'layer-sfx', title: '游戏音效', mediaType: 'audio', assetKind: 'sound-effect', prompt: '【系统内置】完整音效提示词:金币收集音,自动混音,内部参数。', generationInputs: { fields: [ { title: 'prompt', value: '金币收集音' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '7秒' }, ], references: [], }, }); const musicLayer = createLayer({ id: 'layer-music', title: '游戏背景音乐', mediaType: 'audio', assetKind: 'background-music', prompt: '', generationInputs: { fields: [{ title: 'gpt_description_prompt', value: '地牢循环音乐' }], references: [], }, }); expect(createAudioRedrawGenerationDialogDraft(soundLayer)).toMatchObject({ mode: 'audio-sound-effect', sourceLayerId: 'layer-sfx', prompt: '金币收集音', soundModel: 'audio1.0', soundDurationSeconds: 7, composerOpen: true, placeholder: { x: 472, y: 140, width: 420, height: 120, }, }); expect(createAudioRedrawGenerationDialogDraft(musicLayer)).toMatchObject({ mode: 'audio-background-music', sourceLayerId: 'layer-music', prompt: '地牢循环音乐', makeInstrumental: true, composerOpen: true, placeholder: { x: 472, y: 140, width: 420, height: 120, }, }); expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull(); }); it('keeps audio remodel prompts blank when no user input snapshot exists', () => { expect( createAudioRedrawGenerationDialogDraft( createLayer({ mediaType: 'audio', assetKind: 'sound-effect', prompt: '【系统内置】完整音效提示词', generationInputs: { fields: [], references: [] }, }), ), ).toMatchObject({ mode: 'audio-sound-effect', prompt: '', }); expect( createAudioRedrawGenerationDialogDraft( createLayer({ mediaType: 'audio', assetKind: 'background-music', title: '游戏背景音乐', prompt: '【系统内置】完整背景音乐提示词', generationInputs: { fields: [{ title: 'duration', value: '10秒' }], references: [], }, }), ), ).toMatchObject({ mode: 'audio-background-music', prompt: '', }); }); it('creates edit, quick-edit, and character animation panel drafts', () => { const sourceLayer = createLayer({ prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。', model: 'gpt-image-2', assetKind: 'character', generationInputs: { fields: [{ title: '生成提示词', value: '原图提示' }], references: [], }, }); expect(createEditDialogDraft(sourceLayer)).toEqual({ mode: 'edit', prompt: '原图提示', status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', }); expect(createQuickEditPanelDraft(sourceLayer)).toEqual({ mode: 'quick-edit', sourceLayerId: 'layer-source', prompt: '', size: '1024x768', aspectRatio: '4:3', imageSize: '1K', model: 'gpt-image-2', quickEditReferences: [], status: 'idle', }); expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示'); expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({ sourceLayerId: 'layer-source', promptText: '', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, status: 'idle', }); expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull(); }); it('restores remodel prompts from user-visible generation input snapshots only', () => { const cases: Array<{ fields: NonNullable['fields']; expectedPrompt: string; }> = [ { fields: [{ title: '视频描述', value: '用户的视频描述' }], expectedPrompt: '用户的视频描述', }, { fields: [{ title: '角色设定', value: '用户的角色设定' }], expectedPrompt: '用户的角色设定', }, { fields: [{ title: '用户输入', value: '用户的 UI 需求' }], expectedPrompt: '用户的 UI 需求', }, { fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], expectedPrompt: '返回按钮\n设置按钮', }, { fields: [{ title: '自定义规范提示词', value: '用户自定义规范' }], expectedPrompt: '用户自定义规范', }, { fields: [ { title: '玩法设定', value: '平台跳跃' }, { title: '美术风格', value: '像素风' }, ], expectedPrompt: '玩法设定:平台跳跃\n美术风格:像素风', }, { fields: [ { title: '游戏名', value: '星海远征' }, { title: '游戏分类', value: '冒险' }, { title: '一句话描述游戏', value: '驾驶飞船探索星云' }, ], expectedPrompt: '游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云', }, { fields: [{ title: '动作描述', value: '待机动作' }], expectedPrompt: '待机动作', }, ]; for (const testCase of cases) { expect( createRedrawPanelDraft( createLayer({ prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。', generationInputs: { fields: testCase.fields, references: [], }, }), ).prompt, ).toBe(testCase.expectedPrompt); } expect( createRedrawPanelDraft( createLayer({ prompt: '【系统内置】固定素材提取提示词', generationInputs: { fields: [ { title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet', }, { title: 'model', value: 'gpt-image-2' }, ], references: [], }, }), ).prompt, ).toBe(''); }); it('normalizes legacy nanobanana model aliases in quick edit drafts', () => { expect( createQuickEditPanelDraft( createLayer({ model: 'nano-banana', }), ).model, ).toBe(IMAGE_MODEL_NANOBANANA2); expect( createQuickEditPanelDraft( createLayer({ model: 'nanobanana2', }), ).model, ).toBe(IMAGE_MODEL_NANOBANANA2); }); it('creates character animation generation dialog drafts for character layers', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; const characterLayer = createLayer({ id: 'layer-character', assetKind: 'character', }); expect( createCharacterAnimationGenerationDialogDraft({ canvasSize, viewport, layer: characterLayer, }), ).toMatchObject({ mode: 'character-animation', sourceLayerId: 'layer-character', status: 'idle', composerOpen: true, characterAnimationResolution: '480p', characterAnimationRatio: 'same', characterAnimationFrameCount: 32, characterAnimationDurationSeconds: 4, placeholder: { x: 270, y: 150, width: 420, height: 420, originalWidth: 1024, originalHeight: 1024, }, }); expect( createCharacterAnimationGenerationDialogDraft({ canvasSize, viewport, layer: createLayer(), }), ).toBeNull(); }); it('resets failed character and icon dialogs when picking references', () => { const characterDialog: GenerateDialogState = { mode: 'character', prompt: '', status: 'failed', errorMessage: '请选择角色规范', characterReferences: [], }; const sourceLayer = createLayer({ title: '参考图' }); expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual( expect.objectContaining({ status: 'idle', errorMessage: undefined, composerOpen: true, characterSpecReference: expect.objectContaining({ id: 'canvas-layer-source', label: '参考图', src: 'data:image/png;base64,source', resourceId: 'resource-source', }), }), ); expect( appendCharacterReference(characterDialog, sourceLayer), ).toMatchObject({ status: 'idle', errorMessage: undefined, characterReferences: [{ label: '参考图' }], }); const iconDialog: GenerateDialogState = { mode: 'icon', prompt: '', status: 'failed', errorMessage: '请选择图标规范', }; expect( assignIconSpecReference( iconDialog, createLayer({ assetKind: 'icon-spec' }), ), ).toMatchObject({ status: 'idle', errorMessage: undefined, iconSpecReference: { id: 'canvas-layer-source' }, }); expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog); expect( assignIconSpecReference( iconDialog, createLayer({ title: '旧UI素材规范', assetKind: 'spec', generationInputs: { fields: [ { title: '玩法设定', value: '生成一张完整游戏UI规范汇总设定展板', }, ], references: [], }, }), ), ).toMatchObject({ status: 'idle', errorMessage: undefined, iconSpecReference: { label: '旧UI素材规范' }, }); }); it('routes picked canvas references to spec, image, video, and UI design fields', () => { const sourceLayer = createLayer({ title: '参考图' }); const videoLayer = createLayer({ id: 'layer-video', title: '动作参考', src: 'https://signed.example.com/reference.mp4', mediaType: 'video', objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4', assetObjectId: 'asset-object-video', }); expect( appendGenerationReference( { mode: 'spec', prompt: '', status: 'failed', errorMessage: '失败', }, sourceLayer, ), ).toMatchObject({ status: 'idle', errorMessage: undefined, specReference: { label: '参考图' }, }); expect( appendGenerationReference( { mode: 'video', prompt: '', status: 'idle', generationReferences: [], }, sourceLayer, ), ).toMatchObject({ generationReferences: [{ label: '参考图' }], }); expect( appendGenerationReference( { mode: 'video', prompt: '', status: 'idle', videoModel: 'seedance2.0-fast', generationReferences: [], }, videoLayer, ), ).toMatchObject({ generationReferences: [ { label: '动作参考', mediaType: 'video', objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4', assetObjectId: 'asset-object-video', }, ], }); const imageDialog: GenerateDialogState = { mode: 'generate', prompt: '', status: 'idle', generationReferences: [], }; expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog); const specDialog: GenerateDialogState = { mode: 'spec', prompt: '', status: 'idle', }; expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog); const uiDialog: GenerateDialogState = { mode: 'ui-design', prompt: '', status: 'failed', errorMessage: '失败', }; expect(assignUiDesignSpecReference(uiDialog, sourceLayer)).toBe(uiDialog); expect( assignUiDesignSpecReference( uiDialog, createLayer({ title: '图标规范', assetKind: 'icon-spec' }), ), ).toMatchObject({ status: 'idle', errorMessage: undefined, uiDesignSpecReference: { label: '图标规范' }, }); }); it('limits Seedance canvas-picked references by media type', () => { const videoDialog: GenerateDialogState = { mode: 'video', prompt: '', status: 'idle', videoModel: 'seedance2.0', generationReferences: Array.from({ length: 3 }, (_, index) => ({ id: `video-${index}`, label: `视频${index + 1}`, src: `generated-character-drafts/video-${index}.mp4`, mediaType: 'video', })), }; expect( appendGenerationReference( videoDialog, createLayer({ id: 'video-extra', title: '额外视频', mediaType: 'video', src: 'generated-character-drafts/video-extra.mp4', }), ), ).toMatchObject({ generationReferences: [ { label: '视频1' }, { label: '视频2' }, { label: '视频3' }, ], }); expect( appendGenerationReference( videoDialog, createLayer({ id: 'audio-first', title: '旁白', mediaType: 'audio', src: 'generated-character-drafts/audio.mp3', }), ), ).toMatchObject({ generationReferences: [ { label: '视频1' }, { label: '视频2' }, { label: '视频3' }, { label: '旁白', mediaType: 'audio' }, ], }); }); it('updates failed spec and icon dialog fields back to idle state', () => { const specDialog: GenerateDialogState = { mode: 'spec', prompt: '', status: 'failed', errorMessage: '生成失败', specType: 'custom', specValues: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '', customPrompt: '', }, }; expect( updateSpecFormDialogValue(specDialog, 'customPrompt', '新规范'), ).toEqual( expect.objectContaining({ status: 'idle', errorMessage: undefined, specValues: expect.objectContaining({ customPrompt: '新规范', bodyRatio: '3', }), }), ); const iconDialog: GenerateDialogState = { mode: 'icon', prompt: '', status: 'failed', errorMessage: '描述错误', iconDescriptions: ['旧描述'], }; expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual( expect.objectContaining({ status: 'idle', errorMessage: undefined, prompt: '剑\n盾、药水', iconDescriptions: ['剑', '盾', '药水'], }), ); expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([ '剑', '盾', '药水', '宝箱', '钥匙', ]); expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾'); }); it('updates character animation duration and composer visibility', () => { const failedPanel: CharacterAnimationPanelState = { sourceLayerId: 'layer-character', promptText: '', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, status: 'failed', errorMessage: '失败', }; expect(updateCharacterAnimationDurationPanel(failedPanel, '48')).toEqual({ ...failedPanel, frameCount: 48, durationSeconds: 6, status: 'idle', errorMessage: undefined, }); expect(updateCharacterAnimationDurationPanel(failedPanel, '999')).toBe( failedPanel, ); const generateDialog: GenerateDialogState = { mode: 'generate', prompt: '', status: 'idle', composerOpen: true, }; expect(hideGeneratedLayerComposerAfterBlur(generateDialog)).toEqual({ ...generateDialog, composerOpen: false, }); expect(closeGenerateComposerDialog(generateDialog)).toEqual({ ...generateDialog, composerOpen: false, }); }); });