Files
Genarrative/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts
T
k88936 fbfc7e5e62 修复:候选菜单的 key 改用引用身份,同名素材不再撞 key
- 导出 resourceReferences 的 chatReferenceKey(只认稳定身份、不含显示名的身份键),并加注释说明它为什么不能省
- ReferenceMentionOption 由 super(chatReferenceMentionToken(...)) 改为 super(chatReferenceKey(...)):characters/hero.png 与 enemies/hero.png 都展开成 @hero,拿显示 token 当 key 会让 React 撞 key、复用错 DOM,键盘高亮与选中落到另一条候选上
- referenceSourceProviders 用例补一批同名素材:断言两条候选的显示 token 相同、身份键不同
2026-09-22 17:06:08 +08:00

463 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
GameCreationAppAssetKind,
GameCreationAppAssetManifestEntry,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { attachmentReferenceProvider } from '../src/features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider';
import {
type AttachmentReference,
type ChatReference,
chatReferenceKey,
chatReferenceMentionToken,
type ResourceReference,
type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
function asset(
id: string,
kind: GameCreationAppAssetKind,
mediaType: string,
localPath: string,
): GameCreationAppAssetManifestEntry {
return {
id,
kind,
mediaType,
localPath,
source: { kind: 'uploaded' },
};
}
const heroAsset = asset(
'asset-hero',
'character',
'image/png',
'assets/hero-idle.png',
);
const runtimeRegionPart: DirectCodexUserContentPart = {
type: 'agc_runtime_region_reference',
label: '主画面',
runId: 'run-1',
versionId: 'version-1',
elementTag: 'canvas',
elementRole: 'playfield',
text: '可试玩区域',
width: 320,
height: 180,
resourceIds: ['asset-hero'],
};
const attachmentPart: DirectCodexUserContentPart = {
type: 'agc_attachment_reference',
name: 'brief.md',
mediaType: 'text/markdown',
size: 128,
localPath: 'notes/brief.md',
status: 'imported',
};
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.__TAURI__ = undefined;
});
describe('资源引用 provider', () => {
const provider = createResourceReferenceProvider({ assets: [heroAsset] });
it('触发符是 `@`,且清单为空时先不自称就绪', () => {
expect(provider.trigger).toBe('@');
expect(createResourceReferenceProvider({ assets: [] }).isReady?.()).toBe(
false,
);
// 清单到位后才允许把草稿落进编辑器,否则引用会被当成已删除丢掉。
expect(provider.isReady?.()).toBe(true);
});
it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => {
const assets = [
heroAsset,
asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'),
// 没有 localPath 与 `.agent/` 下的产物都不是可提及素材。
asset('asset-orphan', 'image', 'image/png', ''),
asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'),
];
const scoped = createResourceReferenceProvider({ assets });
expect(scoped.match?.('')).toHaveLength(2);
const many = Array.from({ length: 10 }, (_, index) =>
asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`),
);
const limited = createResourceReferenceProvider({ assets: many });
expect(limited.match?.('')).toHaveLength(8);
expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([
'resource',
]);
expect(provider.match?.('HERO-IDLE')).toHaveLength(1);
expect(provider.match?.('character')).toHaveLength(1);
expect(provider.match?.(' hero ')).toHaveLength(1);
expect(provider.match?.('missing')).toEqual([]);
});
it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => {
const sameName = createResourceReferenceProvider({
assets: [
asset('asset-hero-a', 'character', 'image/png', 'characters/hero.png'),
asset('asset-hero-b', 'character', 'image/png', 'enemies/hero.png'),
],
});
const candidates = sameName.match?.('') ?? [];
// 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。
expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([
'@hero',
'@hero',
]);
expect(new Set(candidates.map((item) => chatReferenceKey(item))).size).toBe(
2,
);
});
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
null,
);
expect(provider.toReference(attachmentPart)).toBe(null);
expect(
provider.toReference({
type: 'agc_resource_reference',
resourceId: 'asset-hero',
}),
).toMatchObject({ type: 'resource', label: 'hero-idle' });
expect(
provider.toReference({
type: 'agc_resource_reference',
resourceId: 'asset-gone',
}),
).toBe(null);
});
it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => {
const reference = provider.match?.('hero')?.[0] as ResourceReference;
expect(provider.refresh(reference)).toBe(reference);
const renamed = createResourceReferenceProvider({
assets: [
asset('asset-hero', 'character', 'image/png', 'assets/hero-v2.png'),
],
});
const refreshed = renamed.refresh(reference);
expect(refreshed?.type === 'resource' ? refreshed.label : null).toBe(
'hero-v2',
);
const deleted = createResourceReferenceProvider({
assets: [asset('asset-other', 'icon', 'image/png', 'assets/btn.png')],
});
expect(deleted.refresh(reference)).toBe(reference);
const skill: ChatReference = { type: 'skill', name: 'agc-test-skill' };
expect(provider.refresh(skill)).toBe(null);
});
it('`mentionToken` 展开为 `@显示名`,清单缺失时退回引用 id', () => {
expect(
provider.mentionToken({
type: 'agc_resource_reference',
resourceId: 'asset-hero',
}),
).toBe('@hero-idle');
expect(
provider.mentionToken({
type: 'agc_resource_reference',
resourceId: 'asset-gone',
}),
).toBe('@asset-gone');
expect(provider.mentionToken({ type: 'input_text', text: '看' })).toBe(
null,
);
});
});
describe('附件 provider', () => {
it('静默:没有触发符也没有候选,注入它不会加出任何入口', () => {
expect(attachmentReferenceProvider.trigger).toBe(null);
expect(attachmentReferenceProvider.match).toBeUndefined();
});
it('`toReference` 逐字搬运附件字段,并只认附件 part', () => {
expect(attachmentReferenceProvider.toReference(attachmentPart)).toEqual({
type: 'attachment',
name: 'brief.md',
mediaType: 'text/markdown',
size: 128,
localPath: 'notes/brief.md',
status: 'imported',
});
expect(
attachmentReferenceProvider.toReference({
type: 'agc_resource_reference',
resourceId: 'asset-hero',
}),
).toBe(null);
expect(
attachmentReferenceProvider.toReference({ type: 'input_text', text: '' }),
).toBe(null);
});
it('`refresh` 对附件恒等,`mentionToken` 是 `@附件名`', () => {
const reference = attachmentReferenceProvider.toReference(
attachmentPart,
) as AttachmentReference;
expect(attachmentReferenceProvider.refresh(reference)).toBe(reference);
expect(
attachmentReferenceProvider.refresh({ type: 'skill', name: 'x' }),
).toBe(null);
expect(attachmentReferenceProvider.mentionToken(attachmentPart)).toBe(
'@brief.md',
);
expect(
attachmentReferenceProvider.mentionToken({
type: 'input_text',
text: '',
}),
).toBe(null);
});
});
describe('运行画面区域 provider', () => {
it('静默且只认运行画面区域 part,可选字段缺失时收成 undefined', () => {
expect(runtimeRegionReferenceProvider.trigger).toBe(null);
expect(runtimeRegionReferenceProvider.match).toBeUndefined();
expect(
runtimeRegionReferenceProvider.toReference(runtimeRegionPart),
).toEqual({
type: 'runtime-region',
label: '主画面',
runId: 'run-1',
versionId: 'version-1',
elementTag: 'canvas',
elementRole: 'playfield',
text: '可试玩区域',
width: 320,
height: 180,
resourceIds: ['asset-hero'],
source: 'runtime-picker',
});
expect(
runtimeRegionReferenceProvider.toReference({
type: 'agc_runtime_region_reference',
label: '主画面',
runId: null,
versionId: null,
elementTag: null,
elementRole: null,
text: null,
width: null,
height: null,
resourceIds: [],
}),
).toEqual({
type: 'runtime-region',
label: '主画面',
runId: undefined,
versionId: undefined,
elementTag: undefined,
elementRole: undefined,
text: undefined,
width: undefined,
height: undefined,
resourceIds: [],
source: 'runtime-picker',
});
expect(attachmentReferenceProvider.toReference(runtimeRegionPart)).toBe(
null,
);
});
it('`refresh` 对运行区域恒等,`mentionToken` 是 `@标签`', () => {
const reference = runtimeRegionReferenceProvider.toReference(
runtimeRegionPart,
) as RuntimeRegionReference;
expect(runtimeRegionReferenceProvider.refresh(reference)).toBe(reference);
expect(
runtimeRegionReferenceProvider.refresh({ type: 'skill', name: 'x' }),
).toBe(null);
expect(runtimeRegionReferenceProvider.mentionToken(runtimeRegionPart)).toBe(
'@主画面',
);
expect(
runtimeRegionReferenceProvider.mentionToken({
type: 'input_text',
text: '@主画面',
}),
).toBe(null);
});
});
describe('Skill provider', () => {
it('触发符是 `$`,挂载时不发查询,第一次 `match` 才读应用级目录', 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 } };
const { result } = renderHook(() => useSkillReferenceProvider());
expect(result.current.trigger).toBe('$');
expect(invoke).not.toHaveBeenCalled();
act(() => {
result.current.match?.('');
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(1);
});
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
expect(invoke).toHaveBeenCalledTimes(2);
expect(result.current.match?.('测试')?.[0]).toMatchObject({
type: 'skill',
name: 'agc-test-skill',
});
});
it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return Array.from({ length: 9 }, (_, index) => ({
name: `builtin-${index}`,
description: '内置',
}));
}
if (command === 'list_client_extensions') {
return [
{
name: 'builtin-0',
extensionType: 'skill',
enabled: true,
status: 'enabled',
},
{
name: 'client-skill',
extensionType: 'skill',
enabled: true,
status: 'enabled',
},
// 未启用 / 非 Skill 的扩展不进候选。
{
name: 'client-off',
extensionType: 'skill',
enabled: false,
status: 'enabled',
},
{
name: 'client-plugin',
extensionType: 'plugin',
enabled: true,
status: 'enabled',
},
];
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
const { result } = renderHook(() => useSkillReferenceProvider());
act(() => {
result.current.match?.('');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(8);
});
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
expect(result.current.match?.('builtin-0')).toHaveLength(1);
expect(result.current.match?.('builtin-')).toHaveLength(8);
expect(result.current.match?.('client-skill')).toMatchObject([
{ type: 'skill', name: 'client-skill' },
]);
// 未启用与非 Skill 扩展都不是候选。
expect(result.current.match?.('client-off')).toEqual([]);
expect(result.current.match?.('client-plugin')).toEqual([]);
});
it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const invoke = vi
.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill' }];
}
if (command === 'list_client_extensions') return [];
throw new Error(`unexpected invoke ${command}`);
})
.mockRejectedValueOnce(new Error('transient'));
window.__TAURI__ = { core: { invoke: invoke as never } };
const { result } = renderHook(() => useSkillReferenceProvider());
act(() => {
result.current.match?.('');
});
// 一次 `match` 读两份目录:内置 Skill 与客户端扩展。
await waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(2);
});
act(() => {
result.current.match?.('');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(1);
});
expect(invoke).toHaveBeenCalledTimes(4);
// 读取失败不能静默:控制台要留下可排障的一条。
expect(warn).toHaveBeenCalledWith(
'[skill-reference] Skill 目录读取失败,下次触发重试',
expect.any(Error),
);
});
it('`toReference` / `refresh` / `mentionToken` 只认 Skill', async () => {
const invoke = vi.fn(async () => []);
window.__TAURI__ = { core: { invoke: invoke as never } };
const { result } = renderHook(() => useSkillReferenceProvider());
expect(
result.current.toReference({ type: 'agc_skill_reference', name: 'demo' }),
).toEqual({ type: 'skill', name: 'demo' });
expect(
result.current.toReference({
type: 'agc_resource_reference',
resourceId: 'asset-hero',
}),
).toBe(null);
const reference: ChatReference = { type: 'skill', name: 'demo' };
expect(result.current.refresh(reference)).toBe(reference);
expect(result.current.refresh({ type: 'resource' } as ChatReference)).toBe(
null,
);
expect(
result.current.mentionToken({
type: 'agc_skill_reference',
name: 'demo',
}),
).toBe('$demo');
expect(
result.current.mentionToken({ type: 'input_text', text: '$demo' }),
).toBe(null);
});
});