d5fb34209a
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- ReferenceProvider 新增可选 onMenuQueryChange(query):输入区在 useEffect 里回调(菜单关闭时收到 null),作为懒加载的唯一入口 - ProviderMentionMenu 不再让 provider 在渲染期产生副作用:match 只负责过滤已就绪的数据,契约里写明它必须是纯函数 - useSkillReferenceProvider 把目录读取从 match 搬进 onMenuQueryChange,第一次敲出 $ 才读;失败仍放开重试(下一次菜单查询变化时重试)并保留 console.warn - referenceSourceProviders 用例改为经菜单回调驱动,并显式断言 match 不触发任何 Tauri invoke、菜单关闭(null)也不触发 - 同步 ADR、决策记录与功能说明里 provider 契约的那段描述
475 lines
16 KiB
TypeScript
475 lines
16 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();
|
|
|
|
// `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
|
act(() => {
|
|
expect(result.current.match?.('')).toEqual([]);
|
|
});
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
|
|
// 懒加载只由菜单回调触发(菜单打开 → 输入区在 effect 里给非 null 的 query)。
|
|
act(() => {
|
|
result.current.onMenuQueryChange?.('');
|
|
});
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.match?.('')).toHaveLength(1);
|
|
});
|
|
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
// 菜单关闭(query 为 `null`)不触发读取。
|
|
act(() => {
|
|
result.current.onMenuQueryChange?.(null);
|
|
});
|
|
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.onMenuQueryChange?.('');
|
|
});
|
|
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.onMenuQueryChange?.('');
|
|
});
|
|
// 一次触发读两份目录:内置 Skill 与客户端扩展。
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
act(() => {
|
|
result.current.onMenuQueryChange?.('a');
|
|
});
|
|
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);
|
|
});
|
|
});
|