/* @vitest-environment jsdom */ import { act, cleanup, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import { createRef, useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getPlainTextEditorHost, setPlainTextEditorValue, } from '../common/AutoGrowTextArea.test-utils'; import type { GenerateDialogState, SpecFormValues, UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; const iconSpecClientMocks = vi.hoisted(() => ({ refineGamePlay: vi.fn(), refineArtStyle: vi.fn(), })); vi.mock('../../services/image-editor/editorProjectClient', () => ({ EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH: 200, refineEditorIconSpecPlaySetting: iconSpecClientMocks.refineGamePlay, refineEditorIconSpecArtStyle: iconSpecClientMocks.refineArtStyle, })); afterEach(() => { cleanup(); vi.clearAllMocks(); }); function createSpecDialog( patch: Partial = {}, ): GenerateDialogState { return { mode: 'spec', prompt: '', status: 'idle', specType: 'character', specValues: { playSetting: 'RPG玩法', artStyle: '像素风', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, ...patch, }; } function renderPanel({ dialog, onUpdateSpecFormValue = vi.fn(), onRequestUpload = vi.fn(), onSubmit = vi.fn(), }: { dialog: GenerateDialogState; onUpdateSpecFormValue?: (key: keyof SpecFormValues, value: string) => void; onRequestUpload?: (target: UploadTarget) => void; onSubmit?: (dialog: GenerateDialogState) => void; }) { render( , ); } function UiDesignHarness({ initialDialog, onRequestUpload = vi.fn(), }: { initialDialog: GenerateDialogState; onRequestUpload?: (target: UploadTarget) => void; }) { const [dialog, setDialog] = useState( initialDialog, ); const referenceButtonRef = createRef(); return dialog ? ( node} buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} onUpdateSpecFormValue={vi.fn()} onRequestUpload={onRequestUpload} onSubmit={vi.fn()} /> ) : null; } function CharacterSpecReferenceMenuHarness({ onRequestUpload = vi.fn(), }: { onRequestUpload?: (target: UploadTarget) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const referenceButtonRef = createRef(); return ( node} buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} onUpdateSpecFormValue={vi.fn()} onRequestUpload={onRequestUpload} onSubmit={vi.fn()} /> ); } function IconSpecHarness({ playSetting = '回合制占点', artStyle = '低多边形', onSubmit = vi.fn(), }: { playSetting?: string; artStyle?: string; onSubmit?: (dialog: GenerateDialogState) => void; }) { const [dialog, setDialog] = useState( createSpecDialog({ specType: 'icon', specValues: { playSetting, artStyle, bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, }), ); return ( setDialog((current) => ({ ...current, specValues: { ...current.specValues!, [key]: value, }, })) } onRequestUpload={vi.fn()} onSubmit={onSubmit} /> ); } function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } describe('ImageCanvasSpecGenerationPanelView', () => { it('keeps the reference row above fields for spec panels and shows mud point text', () => { renderPanel({ dialog: createSpecDialog() }); const panel = screen.getByRole('dialog', { name: '生成规范' }); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); const submitButton = screen.getByRole('button', { name: '提交生成规范' }); expect(submitButton.textContent).toBe('生成5泥点'); const dimensionsButton = screen.getByRole('button', { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; const modelButton = screen.getByRole('button', { name: '生成图片模型 gpt-image-2', }) as HTMLButtonElement; expect(dimensionsButton.disabled).toBe(true); expect(modelButton.disabled).toBe(true); expect(dimensionsButton.className).toContain( 'image-canvas-editor__option-cluster--dimensions', ); expect(modelButton.className).toContain( 'image-canvas-editor__option-cluster--model', ); fireEvent.click(dimensionsButton); fireEvent.click(modelButton); expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull(); expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); }); it('renders UI design as a single borderless prompt below the first reference row', () => { render( , ); const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); const prompt = screen.getByRole('textbox', { name: 'UI设计要求' }); expect( panel.firstElementChild?.className.includes( 'image-canvas-editor__reference-strip', ), ).toBe(true); expect(panel.textContent).not.toContain('UI设计要求'); expect( getPlainTextEditorHost(prompt).querySelector( '.auto-grow-text-area__placeholder', )?.textContent, ).toBe('你希望这个 UI 长什么样?'); expect(getPlainTextEditorHost(prompt).className).toContain( 'auto-grow-text-area', ); expect(prompt.className).not.toContain('platform-text-field'); expect(getPlainTextEditorHost(prompt).className).toContain( 'image-canvas-editor__generation-prompt', ); expect(prompt.className).not.toContain( 'image-canvas-editor__generation-prompt--borderless', ); const submitButton = screen.getByRole('button', { name: '生成UI设计图' }); const modelButton = screen.getByRole('button', { name: '生成图片模型 gpt-image-2', }) as HTMLButtonElement; expect(submitButton.textContent).toBe('生成3泥点'); expect(modelButton.disabled).toBe(true); expect(modelButton.closest('div')?.className).toContain( 'image-canvas-editor__readonly-generation-option', ); fireEvent.click(modelButton); expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); expect(screen.queryByText('nanobanana2')).toBeNull(); }); it('uploads and removes UI design generation references', () => { const requestUpload = vi.fn(); render( , ); expect(screen.getByLabelText('界面风格参考')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '上传UI设计参考图' })); expect(requestUpload).toHaveBeenCalledWith('generation-reference'); fireEvent.click(screen.getByRole('button', { name: '删除界面风格参考' })); expect(screen.queryByLabelText('界面风格参考')).toBeNull(); }); it('removes only the selected character spec reference and keeps its action visible', () => { render( , ); expect(screen.getAllByRole('button', { name: /删除参考/ })).toHaveLength(3); fireEvent.click(screen.getByRole('button', { name: '删除参考二' })); expect(screen.queryByLabelText('参考二')).toBeNull(); expect(screen.getByLabelText('参考一')).toBeTruthy(); expect(screen.getByLabelText('参考三')).toBeTruthy(); }); it('shows and removes a legacy non-icon spec reference', () => { render( , ); expect(screen.getByLabelText('历史角色参考')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '删除历史角色参考' })); expect(screen.queryByLabelText('历史角色参考')).toBeNull(); expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy(); }); it('hides a stale legacy spec reference when canonical references exist', () => { render( , ); expect(screen.getByLabelText('当前角色参考')).toBeTruthy(); expect(screen.queryByLabelText('过期角色参考')).toBeNull(); }); it('renders character spec fields and forwards updates', () => { const updateSpecFormValue = vi.fn(); renderPanel({ dialog: createSpecDialog(), onUpdateSpecFormValue: updateSpecFormValue, }); expect( (screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder, ).toBe('这是什么类型的游戏?'); expect( (screen.getByLabelText('美术风格') as HTMLInputElement).placeholder, ).toBe('游戏的画风是怎样的?'); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '战棋玩法' }, }); fireEvent.change(screen.getByLabelText('美术风格'), { target: { value: '水彩' }, }); fireEvent.change(screen.getByLabelText('头身比'), { target: { value: '5' }, }); fireEvent.change(screen.getByLabelText('角色视角'), { target: { value: '左向三分之二侧身站姿' }, }); expect(updateSpecFormValue).toHaveBeenCalledWith('playSetting', '战棋玩法'); expect(updateSpecFormValue).toHaveBeenCalledWith('artStyle', '水彩'); expect(updateSpecFormValue).toHaveBeenCalledWith('bodyRatio', '5'); expect(updateSpecFormValue).toHaveBeenCalledWith( 'characterView', '左向三分之二侧身站姿', ); }); it('keeps the character spec reference source menu open after clicking the add slot', () => { render(); const addButton = screen.getByRole('button', { name: '参考图' }); fireEvent.click(addButton); expect(screen.getByRole('menu', { name: '参考图来源' })).toBeTruthy(); fireEvent.click(addButton); expect(screen.queryByRole('menu', { name: '参考图来源' })).toBeNull(); }); it('uses gameplay and art style hints for icon specs', () => { renderPanel({ dialog: createSpecDialog({ specType: 'icon', specValues: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, }), }); const playSetting = screen.getByLabelText('玩法设定'); const artStyle = screen.getByLabelText('美术风格'); expect(playSetting.textContent).toBe(''); expect( getPlainTextEditorHost(playSetting).querySelector( '.auto-grow-text-area__placeholder', )?.textContent, ).toBe('游戏的核心玩法是什么?'); expect(artStyle.textContent).toBe(''); expect( getPlainTextEditorHost(artStyle).querySelector( '.auto-grow-text-area__placeholder', )?.textContent, ).toBe('游戏的画风是怎样的?'); expect( ( screen.getByRole('button', { name: '一键优化玩法设定', }) as HTMLButtonElement ).disabled, ).toBe(true); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(true); }); it('enforces the icon spec prompt limit in the fields and submit state', () => { const overLimit = '玩'.repeat(201); const onSubmit = vi.fn(); render(); const playSetting = screen.getByLabelText('玩法设定'); const artStyle = screen.getByLabelText('美术风格'); expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe('5/200'); expect(screen.getByLabelText('美术风格字符计数').textContent).toBe('4/200'); expect(screen.getByLabelText('玩法设定字符计数').className).toContain( 'ml-auto', ); expect(playSetting.hasAttribute('maxlength')).toBe(false); expect(artStyle.hasAttribute('maxlength')).toBe(false); setPlainTextEditorValue(playSetting, overLimit); expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200); setPlainTextEditorValue(playSetting, '🎮'.repeat(201)); expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200); expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe( '200/200', ); fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); expect(onSubmit).toHaveBeenCalledTimes(1); cleanup(); renderPanel({ dialog: createSpecDialog({ specType: 'icon', specValues: { playSetting: overLimit, artStyle: '低多边形', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, }), onSubmit, }); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(true); expect( ( screen.getByRole('button', { name: '一键优化玩法设定', }) as HTMLButtonElement ).disabled, ).toBe(true); }); it('optimizes each icon spec field independently and disables generation until both finish', async () => { const playSettingDeferred = createDeferred(); const artStyleDeferred = createDeferred(); iconSpecClientMocks.refineGamePlay.mockReturnValueOnce( playSettingDeferred.promise, ); iconSpecClientMocks.refineArtStyle.mockReturnValueOnce( artStyleDeferred.promise, ); render(); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); expect( screen.getByLabelText('玩法设定').getAttribute('aria-disabled'), ).toBe('true'); expect( screen.getByLabelText('美术风格').getAttribute('aria-disabled'), ).toBeNull(); expect( screen.getByRole('button', { name: '一键优化玩法设定' }).textContent, ).toBe('正在优化'); fireEvent.click(screen.getByRole('button', { name: '一键优化美术风格' })); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(true); await act(async () => playSettingDeferred.resolve('优化后的玩法')); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe( '优化后的玩法', ), ); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(true); await act(async () => artStyleDeferred.resolve('优化后的画风')); await waitFor(() => expect(screen.getByLabelText('美术风格').textContent).toBe( '优化后的画风', ), ); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(false); }); it('ignores an optimization result after the active dialog changes', async () => { const deferred = createDeferred(); const updateSpecFormValue = vi.fn(); iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); rerender( , ); await act(async () => deferred.resolve('不应写入新对话框')); expect(updateSpecFormValue).not.toHaveBeenCalled(); expect(screen.queryByRole('alert')).toBeNull(); }); it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => { iconSpecClientMocks.refineGamePlay .mockResolvedValueOnce('第一次优化') .mockResolvedValueOnce('第二次优化'); render(); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe('第一次优化'), ); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点'); expect( screen.queryByRole('button', { name: '撤销玩法设定优化' }), ).toBeNull(); setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法'); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe('第二次优化'), ); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect(screen.getByLabelText('玩法设定').textContent).toBe('手动修改玩法'); }); it('restores the exact pre-optimization value including surrounding whitespace', async () => { iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法'); render(); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe( '优化后的玩法', ), ); expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith( '回合制占点', ); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect(screen.getByLabelText('玩法设定').textContent).toBe( ' 回合制占点 ', ); }); it('clears the field undo snapshot after a manual edit', async () => { iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化'); render(); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'), ); expect( screen.getByRole('button', { name: '撤销玩法设定优化' }), ).toBeTruthy(); setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法'); expect( screen.queryByRole('button', { name: '撤销玩法设定优化' }), ).toBeNull(); }); it('leaves text and the existing valid undo snapshot unchanged when optimization fails', async () => { iconSpecClientMocks.refineGamePlay .mockResolvedValueOnce('成功优化') .mockRejectedValueOnce(new Error('服务暂不可用')); render(); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'), ); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'), ); expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点'); }); it('renders custom prompt and hides reference upload for icon specs', () => { const updateSpecFormValue = vi.fn(); renderPanel({ dialog: createSpecDialog({ specType: 'custom', specValues: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '', customPrompt: '自定义提示', }, }), onUpdateSpecFormValue: updateSpecFormValue, }); const customPrompt = screen.getByLabelText('自定义规范提示词'); expect(customPrompt.textContent).toBe('自定义提示'); expect(getPlainTextEditorHost(customPrompt).className).toContain( 'auto-grow-text-area', ); expect(customPrompt.className).not.toContain('platform-text-field'); expect(getPlainTextEditorHost(customPrompt).className).toContain( 'image-canvas-editor__generation-prompt', ); setPlainTextEditorValue(customPrompt, '新的规范提示'); expect(updateSpecFormValue).toHaveBeenCalledWith( 'customPrompt', '新的规范提示', ); cleanup(); renderPanel({ dialog: createSpecDialog({ specType: 'icon' }) }); expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy(); }); it('requests reference upload and submits while idle', () => { const requestUpload = vi.fn(); const submitSpec = vi.fn(); const dialog = createSpecDialog(); renderPanel({ dialog, onRequestUpload: requestUpload, onSubmit: submitSpec, }); fireEvent.click(screen.getByRole('button', { name: '参考图' })); fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); expect(requestUpload).toHaveBeenCalledWith('spec-reference'); expect(submitSpec).toHaveBeenCalledWith(dialog); }); it('renders compact spec submit cost with mud point text', () => { renderPanel({ dialog: createSpecDialog() }); const submitButton = screen.getByRole('button', { name: '提交生成规范' }); expect(submitButton.textContent).toBe('生成5泥点'); }); it('disables controls while generating and renders failure state', () => { const submitSpec = vi.fn(); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); expect( (screen.getByLabelText('玩法设定') as HTMLInputElement).disabled, ).toBe(true); expect(submitSpec).not.toHaveBeenCalled(); rerender( , ); expect(screen.getByRole('alert').textContent).toContain('生成失败'); }); });