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, createLayerGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditGenerationDialogDraft, createQuickEditPanelDraft, createRedrawPanelDraft, createSameSourceGenerationDialogDraft, createSceneGenerationDialogDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, createVideoGenerationDialogDraft, hideGeneratedLayerComposerAfterBlur, updateCharacterAnimationDurationPanel, updateIconDescriptionsTextInDialog, updateSpecFormDialogValue, } from './ImageCanvasGenerationDialogModel'; import { IMAGE_MODEL_GPT_IMAGE_2, 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, style: 'none', placeholder: { x: -312, y: -332, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 1024, }, }); expect( createSpecDialogDraft({ canvasSize, viewport, specType: 'character' }), ).toMatchObject({ mode: 'spec', specType: 'character', specValues: { playSetting: '', artStyle: '', bodyRatio: '3', }, }); 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 scene generation dialogs with product defaults', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect( createSceneGenerationDialogDraft({ canvasSize, viewport }), ).toMatchObject({ mode: 'scene', prompt: '', status: 'idle', sceneStylePreset: 'anime', sceneCustomStyle: '', generationReferences: [], imageModel: IMAGE_MODEL_NANOBANANA2, aspectRatio: '16:9', imageSize: '1K', placeholder: { originalWidth: 1024, originalHeight: 576, }, }); }); it('creates character and icon generation drafts with the selected 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', style: 'none', 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', style: 'none', imageModel: 'unknown-model', aspectRatio: '1:1', imageSize: '1K', prompt: '', iconSpecReference: null, iconDescriptions: [], placeholder: { x: -32, y: -152, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 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: 'eleven_text_to_sound_v2', soundDurationMode: 'auto', soundDurationSeconds: 5, soundLoop: false, 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: '时长', 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: 'eleven_text_to_sound_v2', soundDurationMode: 'manual', soundDurationSeconds: 7, soundLoop: false, 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: '时长', value: '10秒' }], references: [], }, }), ), ).toMatchObject({ mode: 'audio-background-music', prompt: '', }); }); it('restores authoritative SFX V2 prompt, duration mode, and loop on redraw', () => { const draft = createAudioRedrawGenerationDialogDraft( createLayer({ id: 'layer-sfx-v2', mediaType: 'audio', assetKind: 'sound-effect', prompt: '不应覆盖权威用户描述', generationInputs: { fields: [ { title: '用户描述', value: '金币落地' }, { title: '实际英文提示词', value: 'A bright coin landing chime' }, { title: '时长', value: '7.42秒' }, { title: 'Loop', value: '开启' }, ], references: [], soundEffect: { schemaVersion: 2, userPrompt: '金币落地', actualPrompt: 'A bright coin landing chime', model: 'eleven_text_to_sound_v2', durationMode: 'auto', requestedDurationSeconds: null, actualDurationSeconds: 7.42, loop: true, }, }, }), ); expect(draft).toMatchObject({ mode: 'audio-sound-effect', sourceLayerId: 'layer-sfx-v2', prompt: '金币落地', soundModel: 'eleven_text_to_sound_v2', soundDurationMode: 'auto', soundDurationSeconds: 5, soundLoop: true, }); }); 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({ id: 'edit-dialog:layer-source', mode: 'edit', prompt: '原图提示', status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', imageModel: 'gpt-image-2', aspectRatio: '4:3', imageSize: '1K', }); 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('preserves the resolved asset label in quick-edit dialog snapshots', () => { expect( createQuickEditGenerationDialogDraft({ sourceLayer: createLayer(), prompt: '让视频下雨', assetLabel: '雨天版本', }), ).toMatchObject({ mode: 'quick-edit', prompt: '让视频下雨', assetLabel: '雨天版本', }); }); 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('creates same-source generator drafts for generated image layers', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; const characterDraft = createSameSourceGenerationDialogDraft({ sourceLayer: createLayer({ assetKind: 'character', generationInputs: { fields: [ { title: '角色设定', value: '红发骑士' }, { title: '抠图背景色', value: '暖浅桃色 #FFD6C2' }, { title: '抠图模型', value: '动漫风格 anime-seg' }, ], references: [], }, }), canvasSize, viewport, mode: 'redraw', }); expect(characterDraft).toMatchObject({ mode: 'character', prompt: '红发骑士', sourceLayerId: 'layer-source', placeholder: { x: 472, y: 140, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, }, }); const publicationDraft = createSameSourceGenerationDialogDraft({ sourceLayer: createLayer({ assetKind: 'publication-material', originalWidth: 720, originalHeight: 1280, generationInputs: { fields: [ { title: '游戏名', value: '星海远征' }, { title: '游戏分类', value: '冒险' }, { title: '一句话描述游戏', value: '驾驶飞船探索星云' }, ], references: [], }, }), canvasSize, viewport, mode: 'redraw', }); expect(publicationDraft).toMatchObject({ mode: 'publication', publicationWorkflowId: 'publication-detail-gallery', publicationGameInfo: { gameName: '星海远征', gameCategories: '冒险', gameDescription: '驾驶飞船探索星云', }, }); expect( createSameSourceGenerationDialogDraft({ sourceLayer: createLayer(), canvasSize, viewport, mode: 'redraw', }), ).toBeNull(); }); it('restores the plain image generator when a source generation dialog exists', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect( createSameSourceGenerationDialogDraft({ sourceLayer: createLayer({ prompt: '【系统内置】普通提示词', generationInputs: { fields: [{ title: '生成提示词', value: '普通图' }], references: [], }, }), canvasSize, viewport, sourceDialog: { id: 'generation-dialog-1', mode: 'generate', prompt: '普通图', status: 'idle', generatedLayerId: 'layer-source', imageModel: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', style: 'pixelArt', }, mode: 'redraw', }), ).toMatchObject({ mode: 'generate', prompt: '普通图', sourceLayerId: 'layer-source', imageModel: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', style: 'none', placeholder: { x: 472, y: 140, width: 1365, height: 2048, originalWidth: 1365, originalHeight: 2048, }, }); }); it.each([ ['character', 'character'], ['icon', 'icon-spritesheet'], ['ui-design', 'ui-design'], ] as const)( 'restores %s remodel placeholders to the selected 2K dimensions', (mode, assetKind) => { expect( createSameSourceGenerationDialogDraft({ sourceLayer: createLayer({ assetKind }), canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, sourceDialog: { id: `generation-dialog-${mode}`, mode, prompt: '来源生成器', status: 'idle', generatedLayerId: 'layer-source', imageModel: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '2:3', imageSize: '2K', }, mode: 'redraw', }), ).toMatchObject({ mode, imageModel: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '2:3', imageSize: '2K', placeholder: { x: 472, y: 140, width: 1365, height: 2048, originalWidth: 1365, originalHeight: 2048, }, }); }, ); it('restores a plain generated layer from its own metadata', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; expect( createLayerGenerationDialogDraft({ sourceLayer: createLayer({ sourceType: 'generated', prompt: '图层提示词', model: 'gpt-image-2', generationInputs: { fields: [{ title: '生成提示词', value: '元数据提示词' }], references: [], }, }), canvasSize, viewport, }), ).toMatchObject({ mode: 'generate', prompt: '元数据提示词', generatedLayerId: 'layer-source', imageModel: 'gpt-image-2', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, }, }); }); it('restores a tagged uploaded layer from its current asset kind', () => { expect( createLayerGenerationDialogDraft({ sourceLayer: createLayer({ sourceType: 'uploaded', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '黑衣剑士' }], references: [], }, }), canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, }), ).toMatchObject({ mode: 'character', prompt: '黑衣剑士', generatedLayerId: 'layer-source', }); }); it('preserves 2K dimensions when reopening a persisted character layer', () => { expect( createLayerGenerationDialogDraft({ sourceLayer: createLayer({ sourceType: 'generated', assetKind: 'character', model: IMAGE_MODEL_NANOBANANA2, originalWidth: 2048, originalHeight: 2048, generationInputs: { fields: [{ title: '角色设定', value: '香水师角色' }], references: [], }, }), canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, }), ).toMatchObject({ mode: 'character', prompt: '香水师角色', imageModel: IMAGE_MODEL_NANOBANANA2, aspectRatio: '1:1', imageSize: '2K', generatedLayerId: 'layer-source', }); }); it('restores video and audio generators from media layer metadata', () => { const context = { canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, }; expect( createLayerGenerationDialogDraft({ ...context, sourceLayer: createLayer({ title: '技能演示', mediaType: 'video', assetKind: 'video', sourceType: 'generated', generationInputs: { fields: [{ title: '视频描述', value: '火球飞行动画' }], references: [], }, }), }), ).toMatchObject({ mode: 'video', prompt: '火球飞行动画', generatedLayerId: 'layer-source', }); expect( createLayerGenerationDialogDraft({ ...context, sourceLayer: createLayer({ title: '金币音效', mediaType: 'audio', assetKind: 'sound-effect', sourceType: 'generated', generationInputs: { fields: [{ title: 'prompt', value: '金币弹出声' }], references: [], }, }), }), ).toMatchObject({ mode: 'audio-sound-effect', prompt: '金币弹出声', generatedLayerId: 'layer-source', }); expect( createLayerGenerationDialogDraft({ ...context, sourceLayer: createLayer({ title: '背景音乐', mediaType: 'audio', assetKind: 'background-music', sourceType: 'generated', generationInputs: { fields: [ { title: 'gpt_description_prompt', value: '轻松森林音乐' }, ], references: [], }, }), }), ).toMatchObject({ mode: 'audio-background-music', prompt: '轻松森林音乐', generatedLayerId: 'layer-source', }); }); it('inherits and normalizes quick edit model parameters from the source', () => { expect( createQuickEditPanelDraft( createLayer({ model: 'nano-banana', }), ), ).toMatchObject({ model: IMAGE_MODEL_NANOBANANA2, aspectRatio: '4:3', imageSize: '1K', }); expect( createQuickEditPanelDraft(createLayer({ model: 'nanobanana2' }), { imageModel: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '16:9', imageSize: '2K', }), ).toMatchObject({ model: IMAGE_MODEL_GPT_IMAGE_2, aspectRatio: '16:9', imageSize: '2K', }); expect( createQuickEditPanelDraft( createLayer({ originalWidth: 512, originalHeight: 512 }), { imageModel: IMAGE_MODEL_GPT_IMAGE_2, imageSize: '0.5K' }, ).imageSize, ).toBe('1K'); expect( createQuickEditPanelDraft(createLayer({ model: 'legacy-image-model' })), ).toMatchObject({ model: 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: '参考图' }); const characterSpecLayer = createLayer({ title: '角色规范', assetKind: 'spec', }); expect( assignCharacterSpecReference(characterDialog, characterSpecLayer), ).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(assignCharacterSpecReference(characterDialog, sourceLayer)).toBe( characterDialog, ); expect( assignCharacterSpecReference( characterDialog, createLayer({ title: '角色形象', assetKind: 'character' }), ), ).toBe(characterDialog); 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 sceneDialog: GenerateDialogState = { mode: 'scene', prompt: '', status: 'idle', generationReferences: [], }; const sceneWithReference = appendGenerationReference( sceneDialog, sourceLayer, ); expect(sceneWithReference).toMatchObject({ generationReferences: [{ label: '参考图' }], }); expect( appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })), ).toMatchObject({ generationReferences: [{ label: '参考图' }, { label: '源图' }], }); 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('caps image references before a sixth reference can enter submission state', () => { const existingReferences = Array.from({ length: 5 }, (_, index) => ({ id: `reference-${index}`, label: `参考图${index + 1}`, src: `generated-character-drafts/editor/reference-${index}.png`, })); const imageDialog: GenerateDialogState = { mode: 'generate', prompt: '', status: 'idle', generationReferences: existingReferences, }; expect( appendGenerationReference( imageDialog, createLayer({ id: 'reference-6', title: '参考图6' }), ), ).toMatchObject({ generationReferences: existingReferences, }); const characterDialog: GenerateDialogState = { mode: 'character', prompt: '', status: 'idle', characterSpecReference: existingReferences[0], characterReferences: existingReferences.slice(1), }; expect( appendCharacterReference( characterDialog, createLayer({ id: 'character-reference-6', title: '角色参考图5' }), ), ).toMatchObject({ characterReferences: existingReferences.slice(1), }); const iconDialog: GenerateDialogState = { mode: 'icon', prompt: '', status: 'idle', imageModel: IMAGE_MODEL_GPT_IMAGE_2, generationReferences: existingReferences.slice(0, 4), }; expect( appendGenerationReference( iconDialog, createLayer({ id: 'icon-reference-5', title: '图标参考图5' }), ), ).toMatchObject({ generationReferences: existingReferences.slice(0, 4), }); }); it('reserves the primary-image slot before character and UI specs are selected', () => { const references = Array.from({ length: 5 }, (_, index) => ({ id: `reference-${index}`, label: `参考图${index + 1}`, src: `/reference-${index}.png`, })); const characterDialog: GenerateDialogState = { mode: 'character', prompt: '', status: 'idle', characterReferences: references.slice(0, 4), }; const uiDialog: GenerateDialogState = { mode: 'ui-design', prompt: '', status: 'idle', generationReferences: references.slice(0, 4), }; expect( appendCharacterReference( characterDialog, createLayer({ id: 'character-reference-5' }), ), ).toMatchObject({ characterReferences: references.slice(0, 4) }); expect( appendGenerationReference( uiDialog, createLayer({ id: 'ui-reference-5' }), ), ).toMatchObject({ generationReferences: references.slice(0, 4) }); }); 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: [], }), ); }); 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, }); }); });