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 删除随面板外移与待发附件列表一起失效的规则
168 lines
6.0 KiB
TypeScript
168 lines
6.0 KiB
TypeScript
// @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(),
|
||
);
|
||
});
|
||
});
|