Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx
T
k88936 7e009a4c94 引用输入区改为宿主注入引用来源,素材选择器与输入区分离
- 新增 features/project-workspace/reference-source(types + 资源/Skill/附件/运行画面区域四个 provider 工厂),输入区按 providers 顺序取第一个非空回答,不再判断引用种类
- ResourceReferenceInput 删除 assets / versions / activeVersionId / skills / showTriggerButton / openPicker,改为 providers / inputActions / submitSuppressed / containerRef 四个通用接缝
- 新增 ResourceReferencePicker 与 ResourceReferencePickerAction:面板自己拿数据(素材、版本、缩略图预览 invoke)并自管筛选态,由宿主渲染,确认后只经 insertReferences 交回输入区
- 四个宿主改为按需注入 provider 并各自渲染选择器:DirectProject 注入资源+Skill+附件+运行区域,策划输入盒 / 画布生成面板 / 资源卡快速编辑只注入资源+两个静默 provider,非 DirectProject 宿主不再出现 $ Skill 候选
- 附件并入 ChatReference,编辑器收敛为单一 ResourceReferenceNode(删除 AttachmentReferenceNode),附件 chip 的 DOM 契约(data-attachment-reference / data-attachment-status / title / 移除按钮 aria-label)逐字保留
- 附件改为导入成功即以芯片进入正文、失败与异常一律不插入;控制器删除待发附件状态与 removeAttachment,ComposerPendingAttachments 与其提交时拼接一并删除,单次上限按草稿中的附件芯片数计算,导入进行中禁止发送
- directCodexContentToPromptText 的引用 token 前后各留一个空白(不重复),显示名反查改由调用方注入 resourceLabelResolver(assets),并补 TODO:后续改为按组件渲染引用
- 队列 chip 文案、快速编辑出站提示词、润色回填的引用恢复统一走 joinMentionText 的同一份 token 口径
- 新增 tests/referenceSourceProviders.test.ts 规则矩阵单测(触发符、候选过滤与截断、toReference、refresh、mentionToken、Skill 目录懒加载与失败重试),既有输入区/聊天用例迁移为注入 provider
- styles.css 删除随面板外移与待发附件列表一起失效的规则
2026-09-22 16:01:54 +08:00

168 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
variant="floating"
assets={assets}
projectPath="/tmp/project"
onSubmit={onSubmit}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
variant="floating"
assets={assets}
projectPath="/tmp/project"
onSubmit={onSubmit}
onClose={() => 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(),
);
});
});