/* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; import { type ComponentProps, createRef, type Dispatch, type SetStateAction, useState, } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; function mockStateSetter() { return vi.fn() as unknown as Dispatch>; } function createComposerProps( generateDialog: GenerateDialogState, overrides: Partial< ComponentProps > = {}, ): ComponentProps { return { specToolWrapRef: createRef(), characterSpecButtonRef: createRef(), characterReferenceButtonRef: createRef(), generationReferenceButtonRef: createRef(), iconSpecButtonRef: createRef(), publicationReferenceButtonRef: createRef(), isSpecMenuOpen: false, isCharacterSpecMenuOpen: false, isCharacterReferenceMenuOpen: false, isGenerationReferenceMenuOpen: false, isIconSpecMenuOpen: false, isUiDesignSpecMenuOpen: false, isPublicationReferenceMenuOpen: false, isPickingCharacterSpecFromCanvas: false, isPickingCharacterReferenceFromCanvas: false, isPickingGenerationReferenceFromCanvas: false, isPickingQuickEditReferenceFromCanvas: false, isPickingIconSpecFromCanvas: false, isPickingUiDesignSpecFromCanvas: false, isPickingPublicationReferenceFromCanvas: false, generateDialog, generationComposerStyle: { left: 320, top: 240 }, iconComposerStyle: { left: 320, top: 240, width: '32rem' }, quickEditPanel: null, quickEditSourceLayer: null, quickEditPanelStyle: null, cropExpandPanel: null, cropExpandSourceLayer: null, cropExpandPanelStyle: null, characterAnimationPanel: null, characterAnimationSourceLayer: null, characterAnimationPanelStyle: null, characterAnimationPrice: 40, setGenerateDialog: mockStateSetter(), setQuickEditPanel: mockStateSetter< ComponentProps< typeof ImageCanvasGenerationComposerView >['quickEditPanel'] >(), setCropExpandPanel: mockStateSetter< ComponentProps< typeof ImageCanvasGenerationComposerView >['cropExpandPanel'] >(), setCharacterAnimationPanel: mockStateSetter< ComponentProps< typeof ImageCanvasGenerationComposerView >['characterAnimationPanel'] >(), setIsCharacterSpecMenuOpen: mockStateSetter(), setIsCharacterReferenceMenuOpen: mockStateSetter(), setIsGenerationReferenceMenuOpen: mockStateSetter(), setIsPickingQuickEditReferenceFromCanvas: mockStateSetter(), setIsIconSpecMenuOpen: mockStateSetter(), setIsUiDesignSpecMenuOpen: mockStateSetter(), setIsPublicationReferenceMenuOpen: mockStateSetter(), setIsPickingCharacterSpecFromCanvas: mockStateSetter(), setIsPickingCharacterReferenceFromCanvas: mockStateSetter(), setIsPickingGenerationReferenceFromCanvas: mockStateSetter(), setIsPickingIconSpecFromCanvas: mockStateSetter(), setIsPickingUiDesignSpecFromCanvas: mockStateSetter(), setIsPickingPublicationReferenceFromCanvas: mockStateSetter(), onOpenSpecDialog: vi.fn(), onRequestUpload: vi.fn(), onSubmitImageGeneration: vi.fn(), onSubmitIconSpritesheetGeneration: vi.fn(), onSubmitQuickEdit: vi.fn(), onSubmitCropExpand: vi.fn(), onSubmitCharacterAnimation: vi.fn(), onUpdateSpecFormValue: vi.fn(), onUpdateIconDescriptionText: vi.fn(), onUpdateCharacterAnimationDuration: vi.fn(), onRememberImageModel: vi.fn(), ...overrides, }; } function renderComposer( generateDialog: GenerateDialogState, overrides: Partial< ComponentProps > = {}, ) { const props = createComposerProps(generateDialog, overrides); return render(); } describe('ImageCanvasGenerationComposerView', () => { it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { const setGenerateDialog = vi.fn(); renderComposer({ mode: 'ui-design', prompt: '', status: 'idle', composerOpen: true, uiDesignSpecReference: null, imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '1K', }, { isUiDesignSpecMenuOpen: true, setGenerateDialog: setGenerateDialog as unknown as Dispatch< SetStateAction >, }); const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); expect(panel.className).toContain( 'image-canvas-editor__generation-composer', ); expect(panel.className).toContain( 'image-canvas-editor__generation-composer--image', ); expect( within(panel).getByRole('button', { name: 'UI设计图标规范' }), ).toBeTruthy(); expect( within(panel).getByRole('textbox', { name: 'UI设计要求' }).className, ).toContain('image-canvas-editor__generation-prompt'); expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), { target: { value: '主界面和结算弹窗' }, }); expect(setGenerateDialog).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect( within(menu).getByRole('menuitem', { name: '新建图标规范' }), ).toBeTruthy(); }); it('让生成规范图片面板复用生成类面板 shell 和底部按钮结构', () => { renderComposer({ mode: 'spec', prompt: '', status: 'idle', composerOpen: true, specType: 'character', specValues: { playSetting: '战棋类RPG玩法', artStyle: '像素风', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, specReference: null, }); const panel = screen.getByRole('dialog', { name: '生成规范' }); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); expect(panel.className).toContain( 'image-canvas-editor__generation-composer', ); expect(panel.className).toContain( 'image-canvas-editor__generation-composer--image', ); expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy(); expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '提交生成规范' }).className, ).toContain('image-canvas-editor__generation-submit'); }); it('让图标规范生成面板也保留首行参考区', () => { renderComposer({ mode: 'spec', prompt: '', status: 'idle', composerOpen: true, specType: 'icon', specValues: { playSetting: '休闲小游戏', artStyle: '清爽卡通', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, specReference: null, }); const panel = screen.getByRole('dialog', { name: '生成规范' }); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy(); }); it.each([ ['publication-cover-image', '游戏首图', '720 x 540'], ['publication-detail-gallery', '详情五图', '720 x 1280'], ['publication-promo-poster', '运营海报', '1280 x 720'], ] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => { renderComposer({ mode: 'publication', prompt: '', status: 'idle', composerOpen: true, publicationWorkflowId: workflowId, publicationGameInfo: { gameName: '', gameCategories: '', gameDescription: '', }, publicationReferences: [], imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '1K', }); const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); expect(panel.className).toContain('image-canvas-editor__character-composer'); expect(panel.className).toContain('image-canvas-editor__publication-composer'); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy(); expect( within(panel).getByRole('textbox', { name: `${label}游戏名` }), ).toBeTruthy(); expect( within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), ).toBeTruthy(); expect(panel.textContent).toContain(sizeLabel); expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe( '生成20泥点', ); }); it('恢复宣发素材生成卡片的字段和参考图', () => { renderComposer({ mode: 'publication', prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', status: 'idle', composerOpen: true, publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '马戏团午夜惊魂', gameCategories: '非对称对抗', gameDescription: '找到钥匙,开门逃离马戏团', }, publicationReferences: [ { id: 'publication-reference-1', label: '首图参考', src: '/generated-character-drafts/editor/reference.png', }, ], imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', }); const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' }); expect( (within(panel).getByRole('textbox', { name: '运营海报游戏名', }) as HTMLInputElement).value, ).toBe('马戏团午夜惊魂'); expect( (within(panel).getByRole('textbox', { name: '运营海报游戏分类', }) as HTMLInputElement).value, ).toBe('非对称对抗'); expect( (within(panel).getByRole('textbox', { name: '运营海报一句话描述游戏', }) as HTMLTextAreaElement).value, ).toBe('找到钥匙,开门逃离马戏团'); expect(within(panel).getByLabelText('首图参考')).toBeTruthy(); expect( within(panel).getByRole('button', { name: '删除首图参考' }), ).toBeTruthy(); }); it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); const setIsGenerationReferenceMenuOpen = vi.fn(); renderComposer( { mode: 'generate', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], }, { isGenerationReferenceMenuOpen: true, onRequestUpload, setIsGenerationReferenceMenuOpen, }, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect( within(menu).getByRole('menuitem', { name: '从画布中选择' }), ).toBeTruthy(); expect( within(menu).getByRole('menuitem', { name: '上传图片' }), ).toBeTruthy(); }); it('生成视频参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); const setIsGenerationReferenceMenuOpen = vi.fn(); renderComposer( { mode: 'video', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], videoModel: 'seedance2.0-fast', videoAspectRatio: '16:9', videoDurationSeconds: 4, videoResolution: '480p', videoMode: 'std', videoSound: 'off', }, { isGenerationReferenceMenuOpen: true, onRequestUpload, setIsGenerationReferenceMenuOpen, }, ); const panel = screen.getByRole('dialog', { name: '生成视频' }); fireEvent.click( within(panel).getByRole('button', { name: '添加视频参考素材' }), ); expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考素材来源' }); expect( within(menu).getByRole('menuitem', { name: '从画布中选择' }), ).toBeTruthy(); expect( within(menu).getByRole('menuitem', { name: '上传图片' }), ).toBeTruthy(); expect( within(menu).getByRole('menuitem', { name: '上传视频' }), ).toBeTruthy(); expect( within(menu).getByRole('menuitem', { name: '上传音频' }), ).toBeTruthy(); }); it('点击生成视频父级面板任意区域会收起参考素材菜单', () => { function VideoReferenceMenuHarness() { const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = useState(true); const dialog: GenerateDialogState = { mode: 'video', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], videoModel: 'seedance2.0-fast', videoAspectRatio: '16:9', videoDurationSeconds: 4, videoResolution: '480p', videoMode: 'std', videoSound: 'off', }; return ( ); } render(); expect(screen.getByRole('menu', { name: '参考素材来源' })).toBeTruthy(); fireEvent.click(screen.getByRole('textbox', { name: '视频描述' })); expect(screen.queryByRole('menu', { name: '参考素材来源' })).toBeNull(); }); it('生成视频面板用子面板修改参数和模型,不展示 Veo,并在按钮内显示泥点文案', () => { function VideoHarness() { const [dialog, setDialog] = useState({ mode: 'video', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], videoModel: 'seedance2.0-fast', videoAspectRatio: '16:9', videoDurationSeconds: 4, videoResolution: '480p', videoMode: 'std', videoSound: 'off', placeholder: { x: 53, y: 120, width: 854, height: 480, originalWidth: 854, originalHeight: 480, }, }); return ( <> >} /> {dialog.videoModel} {dialog.videoDurationSeconds} {dialog.videoResolution} {dialog.placeholder ? [ dialog.placeholder.x, dialog.placeholder.y, dialog.placeholder.width, dialog.placeholder.height, dialog.placeholder.originalWidth, dialog.placeholder.originalHeight, ].join(':') : '-'} ); } render(); const panel = screen.getByRole('dialog', { name: '生成视频' }); expect( within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }), ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe( '生成40泥点', ); fireEvent.click( within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }), ); const paramsPanel = screen.getByRole('menu', { name: '视频参数选项', }); fireEvent.click(within(paramsPanel).getByRole('button', { name: '时长 5秒' })); fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' })); expect( screen.getByRole('menu', { name: '视频参数选项' }), ).toBeTruthy(); expect(screen.getByLabelText('当前视频时长').textContent).toBe('5'); expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p'); expect(screen.getByLabelText('当前视频占位').textContent).toBe( '-160:0:1280:720:1280:720', ); expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy(); expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe( '生成100泥点', ); fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' })); const modelPanel = screen.getByRole('menu', { name: '视频模型选项' }); expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull(); fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' })); expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0'); expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy(); expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe( '生成150泥点', ); }); it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => { const onSubmitImageGeneration = vi.fn(); function AudioHarness() { const [dialog, setDialog] = useState({ mode: 'audio-sound-effect', prompt: '', status: 'idle', composerOpen: true, soundModel: 'audio1.0', soundDurationSeconds: 5, }); return ( <> > } /> {dialog.soundModel} {dialog.soundDurationSeconds ?? 'null'} ); } render(); const panel = screen.getByRole('dialog', { name: '生成游戏音效' }); const footer = panel.querySelector( '.image-canvas-editor__generation-composer-footer', ); expect(footer).toBeTruthy(); expect(footer?.children[0]?.className).toContain( 'image-canvas-editor__option-popover-anchor--dimensions', ); expect(footer?.children[1]?.className).toContain( 'image-canvas-editor__option-popover-anchor--model', ); expect(footer?.children[2]?.className).toContain( 'image-canvas-editor__generation-submit', ); expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy(); expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0'); const soundModelButton = within(panel).getByRole('button', { name: '音效模型 Vidu', }); expect((soundModelButton as HTMLButtonElement).disabled).toBe(true); expect(within(panel).getByText('Vidu')).toBeTruthy(); expect(within(panel).queryByText(/Suno/u)).toBeNull(); expect( within(panel).getByRole('button', { name: '音效时长 5秒' }), ).toBeTruthy(); expect(within(panel).queryByText('单次')).toBeNull(); expect(within(panel).queryByText('循环')).toBeNull(); expect(within(panel).queryByText(/BPM/u)).toBeNull(); expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe( '生成10泥点', ); fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), ); const optionPanel = screen.getByRole('menu', { name: '音效时长选项' }); expect( within(optionPanel).getByRole('button', { name: '时长 2秒' }), ).toBeTruthy(); expect( within(optionPanel).getByRole('button', { name: '时长 10秒' }), ).toBeTruthy(); expect(within(optionPanel).queryByText(/BPM/u)).toBeNull(); fireEvent.click(within(optionPanel).getByRole('button', { name: '时长 8秒' })); expect(screen.getByLabelText('当前音效时长').textContent).toBe('8'); expect( screen.getByRole('button', { name: '音效时长 8秒' }), ).toBeTruthy(); fireEvent.submit(panel); expect(onSubmitImageGeneration).toHaveBeenCalledWith( expect.objectContaining({ mode: 'audio-sound-effect', soundDurationSeconds: 8, }), ); }); it('生成游戏背景音乐面板只展示 gpt_description_prompt 并固定隐藏 instrumental 开关', () => { renderComposer({ mode: 'audio-background-music', prompt: '', status: 'idle', composerOpen: true, }); const panel = screen.getByRole('dialog', { name: '生成游戏背景音乐' }); expect( within(panel).getByRole('textbox', { name: 'gpt_description_prompt' }), ).toBeTruthy(); const footer = panel.querySelector( '.image-canvas-editor__generation-composer-footer', ); expect(footer).toBeTruthy(); expect(footer?.children[0]?.className).toContain( 'image-canvas-editor__option-popover-anchor--model', ); expect(footer?.children[1]?.className).toContain( 'image-canvas-editor__generation-submit', ); const backgroundMusicModelButton = within(panel).getByRole('button', { name: '背景音乐模型 Suno', }); expect((backgroundMusicModelButton as HTMLButtonElement).disabled).toBe( true, ); expect(within(panel).getByText('Suno')).toBeTruthy(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe( '生成5泥点', ); }); it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); const setIsGenerationReferenceMenuOpen = vi.fn(); renderComposer( { mode: 'spec', prompt: '', status: 'idle', composerOpen: true, specType: 'character', specValues: { playSetting: '战棋类RPG玩法', artStyle: '像素风', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, specReference: null, }, { isGenerationReferenceMenuOpen: true, onRequestUpload, setIsGenerationReferenceMenuOpen, }, ); const panel = screen.getByRole('dialog', { name: '生成规范' }); fireEvent.click(within(panel).getByRole('button', { name: '参考图' })); expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy(); expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy(); }); });