// @vitest-environment jsdom import { cleanup, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { directCodexContentToPromptText, resourceLabelResolver, } from '../src/features/project-workspace/resourceReferences'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; afterEach(cleanup); const imageAction: ResourceCanvasAssetToolAction = { id: 'generate-image', route: 'asset', label: '生成图片', assetKind: 'image', audioKind: null, assetName: 'AI 生成图片', promptPlaceholder: '今天想生成什么画面?', adjustableDimensions: true, aspectRatio: '1:1', imageSize: '1K', requiresIconSpecReference: false, writesIconSpecReference: false, }; function asset( id: string, label: string, mediaType = 'image/png', ): GameCreationAppAssetManifestEntry { return { id, kind: 'image', mediaType, localPath: `assets/${label}.png`, source: { kind: 'canvas' }, }; } /** 取面板里的引用输入区(含 `@` 触发器按钮),把操作局限在它自己身上。 */ function referenceInputScope(ariaLabel: string) { const root = screen .getByLabelText(ariaLabel) .closest('.resource-reference-input'); if (!root) { throw new Error(`资源引用输入区不存在:${ariaLabel}`); } return within(root as HTMLElement); } describe('生成浮层里的引用选择(真实组件,不做 props mock)', () => { test('浮层不是模态,真实点击候选就能选中并随提交带走引用', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')]; render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); /* 浮层形态不是模态:没有全屏遮罩、没有 aria-modal。`ThemedModal` 的焦点陷阱正是 P1 的成因—— 引用选择器 portal 到 body,被陷阱挡在外面时候选项点了不生效(真实浏览器复现过)。 */ expect(panel.getAttribute('aria-modal')).toBeNull(); expect(document.querySelector('[aria-modal="true"]')).toBeNull(); expect(document.querySelector('.fixed.inset-0')).toBeNull(); await typeGenerationPrompt(panel, '画一只猫'); await user.click( referenceInputScope('生成提示词').getByRole('button', { name: '插入素材引用', }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); await user.click(within(picker).getByRole('option', { name: /素材-a/ })); expect(within(picker).getByText('已选择 1 个')).not.toBeNull(); const insert = within(picker).getByRole('button', { name: '插入引用' }); expect((insert as HTMLButtonElement).disabled).toBe(false); await user.click(insert); // 选中的引用进了面板:计数可见、提交时随载荷带走。 await waitFor(() => expect(within(panel).getByText('参考图 1/5')).not.toBeNull(), ); await user.click(within(panel).getByRole('button', { name: '生成图片' })); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0] as { content: DirectCodexUserContentPart[]; }; // 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。 expect( directCodexContentToPromptText( submitted.content, resourceLabelResolver(assets), ), ).toContain('画一只猫'); expect( submitted.content.some( (part) => part.type === 'agc_resource_reference' && part.resourceId === 'asset-a', ), ).toBe(true); }); test('搜索能收窄候选,键盘也能完成选择', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')]; render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); await typeGenerationPrompt(panel, '画一只猫'); await user.click( referenceInputScope('生成提示词').getByRole('button', { name: '插入素材引用', }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); expect(within(picker).getAllByRole('option')).toHaveLength(2); // 搜索收窄:只剩「素材-b」这一条候选。 await user.clear(screen.getByLabelText('搜索全部画布素材')); await user.type(screen.getByLabelText('搜索全部画布素材'), '素材-b'); await waitFor(() => expect(within(picker).getAllByRole('option')).toHaveLength(1), ); // 键盘路径:Tab 进候选列表,Enter 选中(不依赖鼠标点击)。 const option = within(picker).getByRole('option', { name: /素材-b/ }); option.focus(); await user.keyboard('{Enter}'); await waitFor(() => expect(within(picker).getByText('已选择 1 个')).not.toBeNull(), ); }); });