Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
T
k88936 4abeb3a7f4 通过 Tauri command 提供 Skill 候选
从已校验的 AGC Skill manifest 派生 list_agc_skill_catalog 命令

移除前端内置 Skill 名称硬编码并增加读取命令测试
2026-09-16 19:26:20 +08:00

1006 lines
36 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,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { createRef, StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
chatReferenceListKey,
chatReferenceToContentPart,
currentIterationVersionAssets,
dedupeChatReferences,
directCodexContentToPromptText,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
resourceReferenceTagLibrary,
type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
function asset(
id: string,
kind: string,
mediaType: string,
localPath: string,
): GameCreationAppAssetManifestEntry {
return {
id,
kind,
mediaType,
localPath,
source: { kind: 'uploaded' },
};
}
function iterationVersion(
versionId: string,
resourceIds: string[],
parentVersionId: string | null = null,
): GameIterationVersion {
return {
versionId,
parentVersionId,
projectRevision: 1,
resourceBindings: resourceIds.map((resourceId, index) => ({
slotId: `slot-${index}`,
resourceId,
})),
createdReason: 'initial',
createdAt: 1,
};
}
async function settleComposer() {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */
function draftText(draft: ChatComposerDraft | undefined) {
return directCodexContentToPromptText(draft?.content ?? []);
}
/** 草稿里的资源引用 id,按 content 顺序。 */
function draftResourceIds(draft: ChatComposerDraft | undefined) {
return (draft?.content ?? []).flatMap((part) =>
part.type === 'agc_resource_reference' ? [part.resourceId] : [],
);
}
/**
* 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本:
* DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。
*/
function composerEditor(): {
getEditorState: () => { read: <T>(fn: () => T) => T };
} {
const element = screen.getByLabelText('聊天') as HTMLElement & {
__lexicalEditor?: {
getEditorState: () => { read: <T>(fn: () => T) => T };
};
};
const editor = element.__lexicalEditor;
if (!editor) throw new Error('lexical editor instance not found');
return editor;
}
function editorTextSize() {
return composerEditor()
.getEditorState()
.read(() => $getRoot().getTextContentSize());
}
const assets = [
asset('hero', 'character', 'image/png', 'assets/hero.png'),
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
];
/** 带 manifest 标签的同一批素材:标签筛选用例只换 `tags`,其余口径与 `assets` 一致。 */
const taggedAssets: GameCreationAppAssetManifestEntry[] = [
{ ...assets[0]!, tags: ['角色', '主角'] },
{ ...assets[1]!, tags: ['角色'] },
{ ...assets[2]!, tags: ['背景乐'] },
];
/**
* 面板里的候选筛选条由共享组件渲染,标签行是它内部带 `素材筛选标签` 名字的分组。
* 用这一层 scope 取标签 chip,避免和同名的功能分类按钮(例如「角色与对象」)串。
*/
function pickerTagGroup() {
return within(screen.getByRole('group', { name: '素材筛选标签' }));
}
/** 取某个 aria-label 的引用输入区,把它内部(含 `@` 按钮)的操作局限定在自己身上。 */
function referenceInputByLabel(ariaLabel: string) {
const editor = screen.getByLabelText(ariaLabel);
const root = editor.closest('.resource-reference-input');
if (!root)
throw new Error(`resource reference input not found: ${ariaLabel}`);
return within(root as HTMLElement);
}
async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
await userEvent.click(
referenceInputByLabel(ariaLabel).getByRole('button', {
name: '插入素材引用',
}),
);
await userEvent.click(
within(screen.getByRole('dialog', { name: '选择素材' })).getByRole(
'option',
{ name: optionName },
),
);
await userEvent.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
}
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
test('从 Tauri command 读取内置 Skill catalog', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
}
if (command === 'list_client_extensions') return [];
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
try {
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
} finally {
delete window.__TAURI__;
}
});
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
const base: RuntimeRegionReference = {
type: 'runtime-region',
label: 'Play',
runId: 'run-1',
versionId: 'v1',
elementTag: 'button',
elementRole: 'button',
text: '开始',
width: 120,
height: 40,
resourceIds: ['hero', 'enemy'],
source: 'runtime-picker',
};
// 同一个 run 里两个同文案的元素、或重新选点后绑定变了的同一块区域,都是不同引用:
// 指纹漏字段会让 `dedupeChatReferences` 静默丢掉其中一条。
const distinctVariants: RuntimeRegionReference[] = [
{ ...base, resourceIds: ['hero'] },
{ ...base, versionId: 'v2' },
{ ...base, elementRole: 'link' },
{ ...base, width: 200 },
{ ...base, height: 80 },
];
for (const variant of distinctVariants) {
expect(chatReferenceListKey([variant])).not.toBe(
chatReferenceListKey([base]),
);
expect(dedupeChatReferences([base, variant])).toHaveLength(2);
}
// 绑定素材顺序不影响身份:同一组素材换个顺序还是同一条引用。
expect(
dedupeChatReferences([base, { ...base, resourceIds: ['enemy', 'hero'] }]),
).toHaveLength(1);
});
test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
function Controlled() {
const composerRef = createRef<ResourceReferenceInputHandle>();
return (
<>
<button
type="button"
onClick={() => composerRef.current?.replaceText('润色后的需求')}
>
模拟润色
</button>
<ResourceReferenceInput
ref={composerRef}
initialContent={[
{ type: 'input_text', text: '原始需求' },
chatReferenceToContentPart(reference),
]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</>
);
}
render(<Controlled />);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
await settleComposer();
// 这次替换是对唯一 EditorState 的明确编辑动作,只产生一次派生快照,
// 不会因为 props 回写而重复触发。
expect(onChange).toHaveBeenCalledTimes(1);
const editorText =
document.querySelector('.resource-reference-input-editor')?.textContent ??
'';
expect(editorText).toContain('润色后的需求');
expect(editorText.match(/@hero/gu) ?? []).toHaveLength(1);
expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
function Controlled() {
const [draft, setDraft] = useState<ChatComposerDraft>({
content: [{ type: 'input_text', text: '要一个' }],
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<ResourceReferenceInput
initialContent={draft.content}
onChange={setDraft}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</form>
);
}
render(<Controlled />);
// 候选浮层开着时 Enter 属于浮层(候选菜单用它选引用),输入区的 HIGH 优先级提交
// 必须先让位,否则用户按 Enter 选候选会变成把这一轮直接发出去。
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await screen.findByRole('dialog', { name: '选择素材' });
const composer = screen.getByLabelText('聊天');
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
// 对照:关掉浮层后 Enter 立刻恢复提交语义,说明上面不是编辑器整体坏掉。
await user.click(screen.getByRole('button', { name: '关闭素材选择' }));
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
test('opens the asset picker, supports multi-select, and inserts stable references', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('dialog', { name: '选择素材' })).not.toBeNull();
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('option', { name: /enemy/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const draft = onChange.mock.calls.at(-1)?.[0];
// canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版,
// 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。
expect(draftText(draft)).toBe('@hero@enemy');
expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']);
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('loads image thumbnails through the controlled native preview command', async () => {
const user = userEvent.setup();
const invoke = vi.fn().mockResolvedValue({
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
});
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
render(
<StrictMode>
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</StrictMode>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await waitFor(() => {
expect(
document.querySelector('.resource-reference-picker-list img'),
).not.toBeNull();
});
expect(invoke).toHaveBeenCalledWith(
'read_local_project_image_preview',
expect.objectContaining({
projectPath: 'C:/project',
relativePath: 'assets/hero.png',
}),
);
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__;
});
test('filters candidate references by name, id, kind, and functional category', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
expect(resourceReferenceCategory(hero)).toBe('character');
expect(resourceReferenceCategory(theme)).toBe('audio');
expect(hero.tags).toEqual([]);
});
test('exposes the six functional categories plus all as the single filter source', () => {
expect(RESOURCE_REFERENCE_FILTERS).toEqual([
{ id: 'all', label: '全部' },
{ id: 'ui-interaction', label: 'UI 交互' },
{ id: 'character', label: '角色与对象' },
{ id: 'scene', label: '场景与环境' },
{ id: 'audio', label: '音频' },
{ id: 'document', label: '文档' },
{ id: 'unclassified', label: '待归类' },
]);
});
test('projects explicit category and manifest tags onto the reference', () => {
const hero = resourceReferenceFromAsset(
{
...assets[0]!,
category: 'scene',
tags: ['主舞台', '日夜'],
},
'asset-picker',
);
expect(hero.category).toBe('scene');
expect(hero.tags).toEqual(['主舞台', '日夜']);
expect(resourceReferenceMatchesCategoryFilter(hero, 'scene')).toBe(true);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
false,
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'all')).toBe(true);
});
test('falls back to the kind-derived category when the manifest asset omits it', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const unclassified = resourceReferenceFromAsset(
{
...assets[0]!,
kind: 'image',
},
'asset-picker',
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
true,
);
expect(resourceReferenceCategory(unclassified)).toBe('unclassified');
});
test('resource-card insertion uses the shared structured reference event', () => {
const reference = resourceReferenceFromAsset(assets[0]!, 'resource-card');
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
// 资源 id 允许 `:` 与非 ASCII(画布规范形态是 `local-asset:<id>`
// 落盘文件名派生的 id 还可能是 `persisted-角色草图.png`);
// 空白与路径分隔符不是资源 id,照旧丢掉。
resourceIds: [
'hero',
'local-asset:hero',
'persisted-角色草图.png',
'bad id',
'../secret',
'a/b',
],
sourcePath: '/assets/hero.png?token=secret',
html: '<button>secret</button>',
},
}),
).toEqual({
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero', 'local-asset:hero', 'persisted-角色草图.png'],
sourcePath: '/assets/hero.png',
},
});
expect(
parseLocalGamePreviewInspectMessage({
type: 'unknown',
action: 'selected',
}),
).toBeNull();
});
test('runtime-region references travel through the same structured event', () => {
const reference: ChatReference = {
type: 'runtime-region',
label: '开始按钮',
runId: 'preview-3101',
elementTag: 'button',
text: '开始游戏',
resourceIds: ['hero'],
source: 'runtime-picker',
};
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('removing a chip keeps the remaining text editable', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await screen.findByRole('button', { name: '移除引用 hero' });
await user.click(screen.getByRole('button', { name: '移除引用 hero' }));
await waitFor(() => {
expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0);
});
});
test('counts one inserted reference as a single character in the editor text model', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
// 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格
// 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算,
// 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。
// 提交用的结构化引用另走 `references`,不受这个占位字符影响。
expect(editorTextSize()).toBe(2);
});
test('keeps the whole reference after one delete and drops its data in one step', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
// chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上
// `移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。
const chip = document.querySelector('[data-resource-reference-id="hero"]');
expect(chip).not.toBeNull();
expect(chip?.textContent).toBe('@hero');
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero');
expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']);
// 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。
await user.click(deleteButton);
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).toBeNull();
expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0);
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('');
});
test('exposes the current-version and all-canvas scopes as the only two tabs', () => {
expect(RESOURCE_REFERENCE_SCOPES).toEqual([
{ id: 'current-version', label: '当前版本素材' },
{ id: 'all-canvas', label: '全部画布素材' },
]);
});
test('resolves the current version from activeVersionId or the newest manifest version', () => {
const versions = [
iterationVersion('v1', ['hero']),
iterationVersion('v2', ['theme'], 'v1'),
];
expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1');
expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2');
expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe(
'v2',
);
expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull();
expect(resolveActiveIterationVersion([], null)).toBeNull();
expect(resolveActiveIterationVersion(undefined, null)).toBeNull();
});
test('derives current-version assets from bindings and drops dangling bindings', () => {
const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])];
expect(
currentIterationVersionAssets(assets, versions, 'v1').map(
(entry) => entry.id,
),
).toEqual(['hero']);
expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual(
[],
);
expect(currentIterationVersionAssets(assets, [], null)).toEqual([]);
expect(
currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'),
).toEqual([]);
});
test('keeps an independent search and filter state per picker scope', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
versions={[
iterationVersion('v1', ['hero', 'enemy']),
iterationVersion('v2', ['theme'], 'v1'),
]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
await user.type(screen.getByLabelText('搜索当前版本素材'), 'the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的搜索与筛选,不受当前版本页签影响。
const allCanvasSearch = screen.getByLabelText(
'搜索全部画布素材',
) as HTMLInputElement;
expect(allCanvasSearch.value).toBe('');
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
await user.type(allCanvasSearch, 'hero');
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(
(screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value,
).toBe('the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
});
test('shows an empty state when the active version has no bound resources', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
activeVersionId="v1"
versions={[iterationVersion('v1', ['deleted-asset'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
expect(screen.queryByRole('option')).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
});
test('shows the current-version empty state when the project has no versions', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
versions={[]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
});
test('refreshes chip and candidate display names after a resource rename', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const composerRef = createRef<ResourceReferenceInputHandle>();
const renamedAssets = [
asset('hero', 'character', 'image/png', 'assets/hero-final.png'),
assets[1]!,
assets[2]!,
];
render(
<ResourceReferenceInput
initialContent={[
{ type: 'input_text', text: '用这个角色' },
chatReferenceToContentPart(
resourceReferenceFromAsset(assets[0]!, 'asset-picker'),
),
]}
ref={composerRef}
onChange={onChange}
assets={renamedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(
document.querySelector('.resource-reference-chip-label')?.textContent,
).toBe('hero-final');
});
// 改名只影响显示名:canonical content 仍只记稳定 resourceId。
expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero /u })).toBeNull();
});
test('restores a cross-session draft with the caret at the end of the text', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput
initialContent={[{ type: 'input_text', text: '上一个会话的草稿' }]}
ref={composerRef}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态。
composerRef.current?.replaceText('恢复出来的草稿');
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(
onChange.mock.calls
.at(-1)?.[0]
.content.filter((part) => part.type === 'input_text')
.map((part) => (part.type === 'input_text' ? part.text : ''))
.join(''),
).toBe('恢复出来的草稿');
});
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
const references = taggedAssets.map((entry) =>
resourceReferenceFromAsset(entry, 'asset-picker'),
);
expect(resourceReferenceTagLibrary(references)).toEqual([
{ tag: '角色', assetCount: 2, assetIds: ['enemy', 'hero'] },
{ tag: '背景乐', assetCount: 1, assetIds: ['theme'] },
{ tag: '主角', assetCount: 1, assetIds: ['hero'] },
]);
const hero = references[0]!;
const enemy = references[1]!;
const theme = references[2]!;
// 空选择不过滤;单标签按「素材带这个标签」判。
expect(resourceReferenceMatchesTagSelection(hero, [])).toBe(true);
expect(resourceReferenceMatchesTagSelection(theme, ['角色'])).toBe(false);
// 多标签是 AND:hero 同时有「角色」「主角」才算命中,enemy 只有「角色」。
expect(resourceReferenceMatchesTagSelection(hero, ['角色', '主角'])).toBe(
true,
);
expect(resourceReferenceMatchesTagSelection(enemy, ['角色', '主角'])).toBe(
false,
);
});
test('标签筛选把不符合的候选筛掉,多标签叠加,再与搜索关键字、功能分类叠加', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 versions 时默认落在「全部画布素材」,标签行按这一页签的候选派生。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
// 只勾一个标签:没带这个标签的素材必须消失,不是"排前面"。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
// 第二个标签再收一层:两个标签都命中的只剩 hero。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
expect(
pickerTagGroup()
.getByRole('button', { name: /^主角/u })
.getAttribute('aria-pressed'),
).toBe('true');
// 关键字叠在标签之上:hero 不含 'enemy',两个标签 + 关键字一起就空了。
await user.type(screen.getByLabelText('搜索全部画布素材'), 'enemy');
expect(screen.queryByRole('option')).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
// 功能分类也参与叠加:hero 是 character,切到「音频」后同样为空。
await user.clear(screen.getByLabelText('搜索全部画布素材'));
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option')).toBeNull();
// 取消两个标签,分类仍在生效:只剩音频素材 theme。
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
});
test('两个页签各自持有一份独立的标签库与已选标签', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={taggedAssets}
versions={[iterationVersion('v1', ['hero', 'theme'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本 v1 只绑定 hero / theme,标签计数也只看这两个。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色1',
);
expect(pickerTagGroup().queryByText('主角')).not.toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的已选标签(此时为空)与自己的标签计数。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(
pickerTagGroup()
.getByRole('button', { name: /^角色/u })
.getAttribute('aria-pressed'),
).toBe('false');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
// 切回来仍是自己那份选择与筛后结果。
expect(
pickerTagGroup()
.getByRole('button', { name: /^角色/u })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
});
test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => {
const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<>
<ResourceReferenceInput
onChange={chatOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>
<ResourceReferenceInput
onChange={quickEditOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>
</>,
);
await insertAssetThroughPicker('聊天', /hero/u);
await insertAssetThroughPicker('快速编辑提示词', /hero/u);
const chatDraft = chatOnChange.mock.calls.at(-1)?.[0];
const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0];
// 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同:
// 「快速编辑」不允许出现第二种引用格式。
expect(chatDraft?.content).toEqual([
{ type: 'agc_resource_reference', resourceId: 'hero' },
]);
expect(quickEditDraft).toEqual(chatDraft);
expect(
document.querySelectorAll('[data-resource-reference-id="hero"]'),
).toHaveLength(2);
});
test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => {
render(
<ResourceReferenceInput
initialContent={[{ type: 'input_text', text: '把夜色改成星空' }]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>,
);
expect(screen.queryByRole('button', { name: 'AI 润色' })).toBeNull();
// `@` 入口照旧在:润色收起来不影响引用。
expect(
referenceInputByLabel('快速编辑提示词').getByRole('button', {
name: '插入素材引用',
}),
).not.toBeNull();
});
});