/* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; import { type ComponentProps, createRef, type Dispatch, type SetStateAction, useState, } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPresetModel'; import type { BackgroundMusicGenerationDialogState } from './ImageCanvasBackgroundMusicPromptModel'; import type { CanvasGenerationDialogState, GenerateDialogState, } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; import { resolveImageGenerationErrorMessage } from './ImageCanvasGenerationModel'; import { buildImageGenerationSubmissionPlan } from './ImageCanvasGenerationSubmissionModel'; import type { BackgroundMusicPromptAssistComposerController, BackgroundMusicPromptAssistDialogState, } from './useImageCanvasBackgroundMusicPromptAssist'; 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, updateCanvasGenerationDialogById: vi.fn(), 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 SceneValidationHarness({ initialDialog, }: { initialDialog: GenerateDialogState; }) { const [dialog, setDialog] = useState( initialDialog, ); if (!dialog) { return null; } const props = createComposerProps(dialog, { setGenerateDialog: setDialog, onSubmitImageGeneration: (submittedDialog) => { try { buildImageGenerationSubmissionPlan({ dialog: submittedDialog, layers: [], nextGeneratedIndex: 1, }); } catch (error) { setDialog({ ...submittedDialog, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), }); } }, }); return ; } function renderComposer( generateDialog: GenerateDialogState, overrides: Partial< ComponentProps > = {}, ) { const props = createComposerProps(generateDialog, overrides); return render(); } type AssistStateOverrides = Partial; function createBackgroundMusicDialog( overrides: Partial = {}, ): BackgroundMusicGenerationDialogState { return { id: 'dialog-bgm', mode: 'audio-background-music', prompt: '', status: 'idle', composerOpen: true, ...overrides, }; } function createBackgroundMusicPromptAssistStub( assistState: AssistStateOverrides = {}, overrides: Partial = {}, ): BackgroundMusicPromptAssistComposerController { return { getDialogState: (dialogId: string) => ({ dialogId, status: 'idle' as const, operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, errorMessage: null, ...assistState, }), completePrompt: vi.fn(async () => ({ started: true, applied: true, reason: 'applied' as const, errorMessage: null, })), simplifyPrompt: vi.fn(async () => ({ started: true, applied: true, reason: 'applied' as const, errorMessage: null, })), preparePreset: vi.fn(() => ''), undoPrompt: vi.fn(() => true), ...overrides, }; } function renderBackgroundMusicComposer({ dialog = createBackgroundMusicDialog(), promptAssist = createBackgroundMusicPromptAssistStub(), onSubmit = vi.fn(), updateCanvasGenerationDialogById = vi.fn(), }: { dialog?: BackgroundMusicGenerationDialogState; promptAssist?: BackgroundMusicPromptAssistComposerController; onSubmit?: ReturnType; updateCanvasGenerationDialogById?: ReturnType; } = {}) { const view = renderComposer(dialog, { backgroundMusicPromptAssist: promptAssist, updateCanvasGenerationDialogById, onSubmitImageGeneration: onSubmit, }); return { ...view, dialog, promptAssist, onSubmit, updateCanvasGenerationDialogById, }; } function getBackgroundMusicPanel() { return screen.getByRole('dialog', { name: '生成游戏背景音乐' }); } function getBackgroundMusicPromptTextarea() { return within(getBackgroundMusicPanel()).getByRole('textbox', { name: 'gpt_description_prompt', }) as HTMLTextAreaElement; } function getBackgroundMusicActionButton(name: string) { return within(getBackgroundMusicPanel()).getByRole('button', { name, }) as HTMLButtonElement; } function applyBackgroundMusicDialogUpdate( updateSpy: ReturnType, dialog: BackgroundMusicGenerationDialogState, ) { const [dialogId, updater] = updateSpy.mock.calls[ updateSpy.mock.calls.length - 1 ] as [ string, ( current: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null, ]; return { dialogId, nextDialog: updater(dialog) }; } describe('ImageCanvasGenerationComposerView', () => { it.each([ { status: 'pending-confirmation' as const, message: '结果尚未确认,系统不会自动重复提交。', }, { status: 'failed' as const, message: '完美像素请求尚未发出,请重试同一操作。', }, ])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => { const dialogId = `dialog-perfect-pixel-${status}`; const onRetryPerfectPixelOperation = vi.fn(); const onSubmitImageGeneration = vi.fn(); renderComposer( { id: dialogId, mode: 'generate', prompt: '不应重新提交的普通提示词', status, composerOpen: true, imageModel: 'gpt-image-2', ...(status === 'failed' ? { errorMessage: message } : {}), perfectPixelOperation: { version: 1, kind: 'perfect-pixel', operationId: dialogId, taskId: `pixel-art-snap-${dialogId}`, request: { sourceImageSrc: 'ref:project-resource:resource-source', projectId: 'project-1', sourceResourceId: 'resource-source', assetKind: 'character', assetLabel: '角色 · 完美像素', canvasCompletion: { dialogId, title: '角色 · 完美像素', placeholder: { x: 100, y: 120, width: 320, height: 320, originalWidth: 640, originalHeight: 640, }, }, }, submittedAt: 1_700_000_000_000, reconcileUntil: 1_700_000_120_000, }, }, { onRetryPerfectPixelOperation, onSubmitImageGeneration, }, ); const panel = screen.getByRole('dialog', { name: '完美像素操作' }); expect(within(panel).getByText(message)).toBeTruthy(); expect(within(panel).queryByRole('textbox')).toBeNull(); expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull(); expect(within(panel).queryByText('gpt-image-2')).toBeNull(); expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull(); const retryButton = within(panel).getByRole('button', { name: '重试同一完美像素操作', }); expect(within(panel).getAllByRole('button')).toEqual([retryButton]); fireEvent.click(retryButton); expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce(); expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId); expect(onSubmitImageGeneration).not.toHaveBeenCalled(); }); it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => { const onRetryPerfectPixelOperation = vi.fn(); const onSubmitImageGeneration = vi.fn(); renderComposer( { id: 'dialog-perfect-pixel-invalid', mode: 'generate', prompt: '不得重新生成', status: 'failed', composerOpen: true, imageModel: 'gpt-image-2', perfectPixelOperationInvalid: true, errorMessage: '完美像素操作快照身份不匹配。', }, { onRetryPerfectPixelOperation, onSubmitImageGeneration, }, ); const panel = screen.getByRole('dialog', { name: '完美像素操作' }); expect(within(panel).getByRole('alert').textContent).toBe( '完美像素操作快照身份不匹配。', ); expect(within(panel).queryByRole('textbox')).toBeNull(); expect(within(panel).queryByRole('button')).toBeNull(); expect(within(panel).queryByText('不得重新生成')).toBeNull(); expect(within(panel).queryByText('gpt-image-2')).toBeNull(); fireEvent.submit(panel); expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled(); expect(onSubmitImageGeneration).not.toHaveBeenCalled(); }); it('让快速编辑显示提示词、尺寸和模型选择', () => { renderComposer({ mode: 'quick-edit', prompt: '', status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', generationReferences: [], imageModel: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', }); const panel = screen.getByRole('dialog', { name: '快速编辑图片' }); expect(panel.className).toContain( 'image-canvas-editor__generation-composer', ); expect(panel.className).toContain( 'image-canvas-editor__generation-composer--image', ); expect(panel.className).not.toContain( 'image-canvas-editor__quick-edit-panel', ); expect( within(panel).getByRole('textbox', { name: '快速编辑提示词' }), ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: '添加参考图' }), ).toBeNull(); expect( within(panel).getByRole('button', { name: '快速编辑图片尺寸 1:1·1K', }), ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '快速编辑图片模型 gpt-image-2', }), ).toBeTruthy(); expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy(); expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); }); it('显示游戏场景专用输入和画风控件', () => { renderComposer({ mode: 'scene', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], sceneStylePreset: 'anime', sceneCustomStyle: '', imageModel: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', }); const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); const sceneContent = within(panel).getByRole('textbox', { name: '画面内容', }); expect(sceneContent.className).toContain('auto-grow-text-area'); expect(sceneContent.className).not.toContain('platform-text-field'); expect( within(panel).getByRole('button', { name: '画风预设 日系动画' }), ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }), ).toBeTruthy(); expect(within(panel).queryByText('像素艺术')).toBeNull(); }); it('触屏点击可以打开并关闭当前画风预览', () => { renderComposer({ mode: 'scene', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], sceneStylePreset: 'anime', sceneCustomStyle: '', imageModel: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', }); fireEvent.click(screen.getByRole('button', { name: '画风预设 日系动画' })); const previewButton = screen.getByRole('button', { name: '预览日系动画画风', }); fireEvent.pointerDown(previewButton, { pointerType: 'touch' }); fireEvent.click(previewButton); fireEvent.blur(previewButton); expect(previewButton.getAttribute('aria-expanded')).toBe('true'); expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy(); fireEvent.pointerDown(previewButton, { pointerType: 'touch' }); fireEvent.click(previewButton); expect(previewButton.getAttribute('aria-expanded')).toBe('false'); expect(screen.queryByAltText('日系动画固定画风预览')).toBeNull(); fireEvent.focus(previewButton); expect(previewButton.getAttribute('aria-expanded')).toBe('true'); expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy(); }); it('让场景自定义画风使用可自增长的多行字段', () => { renderComposer({ mode: 'scene', prompt: '', status: 'idle', composerOpen: true, generationReferences: [], sceneStylePreset: 'custom', sceneCustomStyle: '', imageModel: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', }); const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); const customStyle = within(panel).getByRole('textbox', { name: '自定义画风', }); expect(customStyle.tagName).toBe('TEXTAREA'); expect(customStyle.className).toContain('auto-grow-text-area'); expect(customStyle.className).toContain( 'image-canvas-editor__generation-prompt', ); expect(customStyle.className).not.toContain('platform-text-field'); }); it('提交空场景内容时在对应字段附近呈现关联错误', () => { render( , ); const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); const sceneContent = within(panel).getByRole('textbox', { name: '画面内容', }); fireEvent.click(within(panel).getByRole('button', { name: '生成' })); const error = within(panel).getByRole('alert'); expect(error.textContent).toBe('请填写画面内容'); expect(error.className).toContain( 'image-canvas-editor__scene-field-error', ); expect(sceneContent.getAttribute('aria-invalid')).toBe('true'); expect(sceneContent.getAttribute('aria-describedby')).toBe(error.id); expect( panel.querySelector('.image-canvas-editor__generate-status'), ).toBeNull(); fireEvent.change(sceneContent, { target: { value: '雨夜中的海边车站' } }); expect(within(panel).queryByRole('alert')).toBeNull(); expect(sceneContent.getAttribute('aria-invalid')).toBeNull(); expect(sceneContent.getAttribute('aria-describedby')).toBeNull(); }); it('提交空自定义画风时在自定义字段附近呈现关联错误', () => { render( , ); const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); const customStyle = within(panel).getByRole('textbox', { name: '自定义画风', }); fireEvent.click(within(panel).getByRole('button', { name: '生成' })); const error = within(panel).getByRole('alert'); expect(error.textContent).toBe('请填写自定义画风'); expect(error.className).toContain( 'image-canvas-editor__scene-field-error', ); expect(customStyle.getAttribute('aria-invalid')).toBe('true'); expect(customStyle.getAttribute('aria-describedby')).toBe(error.id); expect( panel.querySelector('.image-canvas-editor__generate-status'), ).toBeNull(); fireEvent.change(customStyle, { target: { value: '90 年代复古像素风' } }); expect(within(panel).queryByRole('alert')).toBeNull(); expect(customStyle.getAttribute('aria-invalid')).toBeNull(); expect(customStyle.getAttribute('aria-describedby')).toBeNull(); }); 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('生成3泥点'); }, ); 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: '生成视频' }); const prompt = within(panel).getByRole('textbox', { name: '视频描述' }); expect(prompt.className).toContain('auto-grow-text-area'); expect(prompt.className).not.toContain('platform-text-field'); 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: '视频参数选项', }); expect( paramsPanel .closest('.image-canvas-editor__portal-theme') ?.classList.contains('platform-theme--light'), ).toBe(true); const ratioChoice = within(paramsPanel).getByRole('button', { name: '比例 16:9', }); expect(ratioChoice.className).toContain( 'image-canvas-editor__option-popover-choice--ratio', ); expect( ratioChoice .querySelector('.image-canvas-editor__ratio-wireframe') ?.getAttribute('data-ratio'), ).toBe('16:9'); expect( within(paramsPanel) .getByRole('button', { name: '比例 21:9' }) .querySelector('.image-canvas-editor__ratio-wireframe') ?.getAttribute('data-ratio'), ).toBe('21:9'); fireEvent.change( within(paramsPanel).getByRole('slider', { name: '视频时长' }), { target: { value: '5' }, }, ); fireEvent.click( within(paramsPanel).getByRole('button', { name: '清晰度 720p' }), ); fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' })); 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( within(paramsPanel) .getByRole('button', { name: '清晰度 1080p' }) .hasAttribute('disabled'), ).toBe(true); 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', ); const prompt = within(panel).getByRole('textbox', { name: 'prompt' }); expect(prompt.className).toContain('auto-grow-text-area'); expect(prompt.className).not.toContain('platform-text-field'); 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).queryByRole('button', { name: 'AI 补全' })).toBeNull(); expect( within(panel).queryByRole('button', { name: '一键简化' }), ).toBeNull(); expect( within(panel).queryByRole('button', { name: '一键优化' }), ).toBeNull(); expect(within(panel).queryByRole('button', { name: '展开' })).toBeNull(); expect( within(panel).getByRole('button', { name: '生成游戏音效' }).textContent, ).toBe('生成5泥点'); fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), ); const optionPanel = screen.getByRole('menu', { name: '音效时长选项' }); const durationSlider = within(optionPanel).getByRole('slider', { name: '音效时长', }) as HTMLInputElement; expect(durationSlider.min).toBe('2'); expect(durationSlider.max).toBe('10'); expect(durationSlider.step).toBe('1'); expect(durationSlider.value).toBe('5'); expect( within(optionPanel).queryByRole('button', { name: '时长 2秒' }), ).toBeNull(); expect(within(optionPanel).queryByText(/BPM/u)).toBeNull(); fireEvent.change(durationSlider, { target: { value: '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( { id: 'dialog-background-music', mode: 'audio-background-music', prompt: '', status: 'idle', composerOpen: true, }, { backgroundMusicPromptAssist: createBackgroundMusicPromptAssistStub(), }, ); 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(); // 助手动作与模型、生成同处底部一行:左侧动作,右侧「Suno + 生成」。 expect(footer?.children[0]?.className).toContain( 'image-canvas-editor__background-music-prompt-actions', ); expect(footer?.children[1]?.className).toContain( 'image-canvas-editor__option-popover-anchor--model', ); expect(footer?.children[2]?.className).toContain( 'image-canvas-editor__generation-submit', ); // 字数计数和「预设」同排,收起预设时也必须还在。 const presetsHeader = panel.querySelector( '.image-canvas-editor__background-music-presets-header', ); expect( presetsHeader?.querySelector( '.image-canvas-editor__background-music-prompt-count', )?.textContent, ).toBe('0 / 200'); 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('Vidu')).toBeNull(); expect( within(panel).queryByRole('button', { name: /音效时长/u }), ).toBeNull(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); expect( within(panel).getByRole('button', { name: '生成游戏背景音乐' }) .textContent, ).toBe('生成12泥点'); }); describe('共享音频 composer 的背景音乐行为', () => { beforeEach(() => { vi.stubGlobal( 'requestAnimationFrame', vi.fn(() => 1), ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); }); it('按 canonical preview 计数但不改写输入框原文', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: ' 森林冒险背景音乐 ', }), }); const textarea = getBackgroundMusicPromptTextarea(); expect(textarea.value).toBe(' 森林冒险背景音乐 '); expect( within(getBackgroundMusicPanel()).getByText('8 / 200'), ).toBeTruthy(); expect(textarea.getAttribute('aria-describedby')).toBe( within(getBackgroundMusicPanel()).getByText('8 / 200').id, ); expect(textarea.getAttribute('aria-invalid')).toBeNull(); expect(textarea.hasAttribute('maxlength')).toBe(false); }); it.each([ ['', false, false, false], [' ', false, false, false], ['森', false, false, true], ['森林', true, false, true], ['森'.repeat(200), true, false, true], ['森'.repeat(201), false, true, false], ['森'.repeat(2000), false, true, false], ['森'.repeat(2001), false, false, false], ])( '按 canonical code point 解析第 %# 组动作资格', (prompt, canComplete, canSimplify, canGenerate) => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt }), }); expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe( !canComplete, ); expect(getBackgroundMusicActionButton('一键简化').disabled).toBe( !canSimplify, ); expect( getBackgroundMusicActionButton('生成游戏背景音乐').disabled, ).toBe(!canGenerate); }, ); it('保留超出接口上限的原文并标记计数无效', () => { const prompt = '森'.repeat(2001); renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt }), }); const textarea = getBackgroundMusicPromptTextarea(); expect(textarea.value).toBe(prompt); expect(textarea.value).toHaveLength(2001); expect(textarea.getAttribute('aria-invalid')).toBe('true'); expect( within(getBackgroundMusicPanel()).getByText('2001 / 200'), ).toBeTruthy(); }); it('把助手动作路由到当前 dialog ID', () => { const promptAssist = createBackgroundMusicPromptAssistStub(); renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ id: 'dialog-second', prompt: '森林冒险', }), promptAssist, }); fireEvent.click(getBackgroundMusicActionButton('AI 补全')); expect(promptAssist.completePrompt).toHaveBeenCalledWith('dialog-second'); expect(promptAssist.simplifyPrompt).not.toHaveBeenCalled(); }); it('把预设追加到 preparePreset 返回的 canonical prompt', () => { const updateCanvasGenerationDialogById = vi.fn(); const promptAssist = createBackgroundMusicPromptAssistStub( {}, { preparePreset: vi.fn(() => '阳光动物园') }, ); const dialog = createBackgroundMusicDialog({ prompt: ' 阳光动物园 ' }); renderBackgroundMusicComposer({ dialog, promptAssist, updateCanvasGenerationDialogById, }); fireEvent.click(getBackgroundMusicActionButton('展开')); const firstPreset = BACKGROUND_MUSIC_PROMPT_PRESETS[0]; fireEvent.click(getBackgroundMusicActionButton(firstPreset.label)); expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm'); const { dialogId, nextDialog } = applyBackgroundMusicDialogUpdate( updateCanvasGenerationDialogById, dialog, ); expect(dialogId).toBe('dialog-bgm'); expect(nextDialog?.prompt).toBe(`阳光动物园。${firstPreset.prompt}`); }); it('preparePreset 拒绝当前 dialog 时不写回', () => { const updateCanvasGenerationDialogById = vi.fn(); const promptAssist = createBackgroundMusicPromptAssistStub( {}, { preparePreset: vi.fn(() => null) }, ); renderBackgroundMusicComposer({ promptAssist, updateCanvasGenerationDialogById, }); fireEvent.click(getBackgroundMusicActionButton('展开')); fireEvent.click( getBackgroundMusicActionButton( BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, ), ); expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm'); expect(updateCanvasGenerationDialogById).not.toHaveBeenCalled(); }); it('同时展示助手错误和已有生成错误并保持顺序', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险', status: 'failed', errorMessage: '生成失败', }), promptAssist: createBackgroundMusicPromptAssistStub({ errorMessage: 'AI 暂时不可用', }), }); const alerts = within(getBackgroundMusicPanel()).getAllByRole('alert'); expect(alerts.map((alert) => alert.textContent)).toEqual([ 'AI 暂时不可用', '生成失败', ]); }); it('保留固定 Suno 模型和动态泥点价格', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), }); const modelButton = getBackgroundMusicActionButton('背景音乐模型 Suno'); expect(modelButton.disabled).toBe(true); expect( getBackgroundMusicActionButton('生成游戏背景音乐').textContent, ).toBe('生成12泥点'); }); it('仅在允许生成时提交当前 dialog', () => { const onSubmit = vi.fn(); const dialog = createBackgroundMusicDialog({ prompt: '森林冒险' }); renderBackgroundMusicComposer({ dialog, onSubmit }); fireEvent.submit(getBackgroundMusicPanel()); expect(onSubmit).toHaveBeenCalledWith(dialog); onSubmit.mockClear(); const blocked = renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: ' ' }), onSubmit, }); fireEvent.submit( within(blocked.container).getByRole('dialog', { name: '生成游戏背景音乐', }), ); expect(onSubmit).not.toHaveBeenCalled(); }); it.each([ ['completing' as const], ['simplifying' as const], ['submitting' as const], ])('%s 状态锁定面板中的全部控件', (status) => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), promptAssist: createBackgroundMusicPromptAssistStub({ status, operationId: 'operation-1', temporaryPromptSnapshot: '森林冒险', }), }); const panel = getBackgroundMusicPanel(); expect(panel.getAttribute('aria-busy')).toBe('true'); expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true); expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true); expect(getBackgroundMusicActionButton('一键简化').disabled).toBe(true); expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); expect(getBackgroundMusicActionButton('展开').disabled).toBe(true); expect(getBackgroundMusicActionButton('生成游戏背景音乐').disabled).toBe( true, ); }); it('已有生成占位运行时锁定面板', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险', status: 'generating', }), }); expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true'); expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true); expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true); expect(getBackgroundMusicActionButton('展开').disabled).toBe(true); expect( getBackgroundMusicActionButton('生成游戏背景音乐').textContent, ).toBe('生成中'); }); describe('撤销按钮矩阵', () => { it('没有任何快照时保持隐藏', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), }); expect( within(getBackgroundMusicPanel()).queryByRole('button', { name: '撤销', }), ).toBeNull(); }); it.each([['completing' as const], ['simplifying' as const]])( '%s 仅持有临时快照时展示禁用的撤销', (status) => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), promptAssist: createBackgroundMusicPromptAssistStub({ status, operationId: 'operation-1', undoPromptSnapshot: null, temporaryPromptSnapshot: '森林冒险', }), }); expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); }, ); it('助手成功后及手动编辑后允许撤销', () => { const promptAssist = createBackgroundMusicPromptAssistStub({ undoPromptSnapshot: '森林冒险', }); renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '完整的森林冒险背景音乐,手动改过', }), promptAssist, }); const undoButton = getBackgroundMusicActionButton('撤销'); expect(undoButton.disabled).toBe(false); fireEvent.click(undoButton); expect(promptAssist.undoPrompt).toHaveBeenCalledWith('dialog-bgm'); }); it('助手失败清空两份快照后重新隐藏撤销', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), promptAssist: createBackgroundMusicPromptAssistStub({ undoPromptSnapshot: null, temporaryPromptSnapshot: null, errorMessage: 'AI 暂时不可用', }), }); expect( within(getBackgroundMusicPanel()).queryByRole('button', { name: '撤销', }), ).toBeNull(); }); it('有快照且正在提交时保持可见但禁用', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), promptAssist: createBackgroundMusicPromptAssistStub({ status: 'submitting', operationId: 'operation-1', undoPromptSnapshot: '森林冒险', }), }); expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); }); it('没有快照且正在提交时保持隐藏', () => { renderBackgroundMusicComposer({ dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), promptAssist: createBackgroundMusicPromptAssistStub({ status: 'submitting', operationId: 'operation-1', undoPromptSnapshot: null, }), }); expect( within(getBackgroundMusicPanel()).queryByRole('button', { name: '撤销', }), ).toBeNull(); }); }); }); describe('共享音频 composer 的 mode 与 dialog 隔离', () => { beforeEach(() => { vi.stubGlobal( 'requestAnimationFrame', vi.fn(() => 1), ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); }); it.each([ { label: '缺少稳定 dialog ID', dialog: { mode: 'audio-background-music' as const, prompt: '森林冒险', status: 'idle' as const, composerOpen: true, }, overrides: { backgroundMusicPromptAssist: createBackgroundMusicPromptAssistStub(), }, }, { label: '缺少助手 controller', dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), overrides: {}, }, ])('$label 的 BGM 失败关闭', ({ dialog, overrides }) => { renderComposer(dialog, overrides); expect( screen.queryByRole('dialog', { name: '生成游戏背景音乐' }), ).toBeNull(); }); it('缺少稳定 ID 和 BGM controller 不影响 SFX 正常渲染及默认时长', () => { renderComposer({ mode: 'audio-sound-effect', prompt: '', status: 'idle', composerOpen: true, soundModel: 'audio1.0', }); const panel = screen.getByRole('dialog', { name: '生成游戏音效' }); expect( within(panel).getByRole('button', { name: '音效时长 5秒' }), ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: 'AI 补全' }), ).toBeNull(); }); it('切换 BGM dialog ID 时不继承预设展开状态', () => { const promptAssist = createBackgroundMusicPromptAssistStub(); const firstDialog = createBackgroundMusicDialog({ id: 'dialog-bgm-a', prompt: '森林冒险', }); const view = renderBackgroundMusicComposer({ dialog: firstDialog, promptAssist, }); fireEvent.click(getBackgroundMusicActionButton('展开')); expect(getBackgroundMusicActionButton('收起')).toBeTruthy(); expect( getBackgroundMusicActionButton( BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, ), ).toBeTruthy(); const secondDialog = createBackgroundMusicDialog({ id: 'dialog-bgm-b', prompt: '海边追逐', }); view.rerender( , ); expect(getBackgroundMusicActionButton('展开')).toBeTruthy(); expect( within(getBackgroundMusicPanel()).queryByRole('button', { name: BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, }), ).toBeNull(); }); it('SFX 与 BGM 切换时不泄漏菜单或 BGM 助手锁', () => { const promptAssist = createBackgroundMusicPromptAssistStub({ status: 'submitting', operationId: 'operation-1', }); const soundEffectDialog: GenerateDialogState = { mode: 'audio-sound-effect', prompt: '开门声', status: 'idle', composerOpen: true, soundModel: 'audio1.0', soundDurationSeconds: 5, }; const view = renderComposer(soundEffectDialog, { backgroundMusicPromptAssist: promptAssist, }); fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' })); expect(screen.getByRole('menu', { name: '音效时长选项' })).toBeTruthy(); const backgroundMusicDialog = createBackgroundMusicDialog({ id: 'dialog-bgm-locked', prompt: '森林冒险', }); view.rerender( , ); expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true'); view.rerender( , ); const soundEffectPanel = screen.getByRole('dialog', { name: '生成游戏音效', }); expect( ( within(soundEffectPanel).getByRole('textbox', { name: 'prompt', }) as HTMLTextAreaElement ).disabled, ).toBe(false); expect( ( within(soundEffectPanel).getByRole('button', { name: '生成游戏音效', }) as HTMLButtonElement ).disabled, ).toBe(false); expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); }); }); 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(); }); });