/* @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 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 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(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?'); expect(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('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('uses gameplay and art style hints for icon specs', () => { renderPanel({ dialog: createSpecDialog({ specType: 'icon', specValues: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿', customPrompt: '', }, }), }); expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( '', ); expect( (screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder, ).toBe('游戏的核心玩法是什么?'); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '', ); expect( (screen.getByLabelText('美术风格') as HTMLInputElement).placeholder, ).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( '玩法设定', ) as HTMLTextAreaElement; const artStyle = screen.getByLabelText('美术风格') as HTMLTextAreaElement; expect(playSetting.hasAttribute('maxlength')).toBe(false); expect(artStyle.hasAttribute('maxlength')).toBe(false); fireEvent.change(playSetting, { target: { value: overLimit } }); expect(Array.from(playSetting.value)).toHaveLength(200); fireEvent.change(playSetting, { target: { value: '🎮'.repeat(201) } }); expect(Array.from(playSetting.value)).toHaveLength(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('玩法设定') as HTMLTextAreaElement).disabled, ).toBe(true); expect( (screen.getByLabelText('美术风格') as HTMLTextAreaElement).disabled, ).toBe(false); 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('玩法设定') as HTMLTextAreaElement).value, ).toBe('优化后的玩法'), ); expect( ( screen.getByRole('button', { name: '提交生成规范', }) as HTMLButtonElement ).disabled, ).toBe(true); await act(async () => artStyleDeferred.resolve('优化后的画风')); await waitFor(() => expect( (screen.getByLabelText('美术风格') as HTMLTextAreaElement).value, ).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('玩法设定') as HTMLTextAreaElement).value, ).toBe('第一次优化'), ); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).toBe('回合制占点'); expect( screen.queryByRole('button', { name: '撤销玩法设定优化' }), ).toBeNull(); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '手动修改玩法' }, }); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).toBe('第二次优化'), ); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).toBe('手动修改玩法'); }); it('leaves text and the existing 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('玩法设定') as HTMLTextAreaElement).value, ).toBe('成功优化'), ); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '失败前文本' }, }); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'), ); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).toBe('失败前文本'); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).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( '自定义规范提示词', ) as HTMLTextAreaElement; expect(customPrompt.placeholder).toBe( '写下你想要的任何内容,陶泥儿帮你形成新的约束', ); fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: '新的规范提示' }, }); 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('生成失败'); }); });