5e36b61534
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m19s
Project CI / Native shell tests (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m16s
resourceReferences.ts 新增共享 normalizeMentionName:内部空白折 `-`、裁掉首尾,token 层幂等再折一次 素材显示名(resourceDisplayName)、Skill 名(目录读入与 toReference / mentionToken)、附件名(导入映射与 toReference / mentionToken)统一过这一份口径 resourceDisplayName 去掉 `|| asset.id` 兜底:引用名假定非空,不再回退 resourceId 前缀重叠 bug 由此收口:`@hero` 不再命中 `@hero-v2`(后一个字符不是空白),粘贴只剩正确的那一枚芯片 新增用例:素材 / Skill / 附件三类归一化口径、前缀重叠回归、Skill 目录名带空白、附件名带空白;把 normalizeMentionName 变异成恒等函数后新增用例全红 顺带修掉 referenceSourceProviders 用例里重命名遗留的 provider.match 断言与 skill provider 注释里的 match 同步 CONTEXT.md 术语、功能说明、实施计划与决策记录
599 lines
20 KiB
TypeScript
599 lines
20 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('候选只含可提及素材,`fuzzyLookup` 按显示名 / 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.fuzzyLookup?.('')).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.fuzzyLookup?.('')).toHaveLength(8);
|
|
|
|
expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([
|
|
'resource',
|
|
]);
|
|
expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1);
|
|
expect(provider.fuzzyLookup?.('character')).toHaveLength(1);
|
|
expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1);
|
|
expect(provider.fuzzyLookup?.('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.fuzzyLookup?.('') ?? [];
|
|
// 显示 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('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => {
|
|
const assets = [
|
|
heroAsset,
|
|
asset('asset-orphan', 'image', 'image/png', ''),
|
|
...Array.from({ length: 10 }, (_, index) =>
|
|
asset(
|
|
`asset-${index}`,
|
|
'image',
|
|
'image/png',
|
|
`assets/pic-${index}.png`,
|
|
),
|
|
),
|
|
];
|
|
const scoped = createResourceReferenceProvider({ assets });
|
|
|
|
// 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。
|
|
expect(scoped.fuzzyLookup?.('')).toHaveLength(8);
|
|
expect(scoped.lookup?.()).toHaveLength(11);
|
|
expect(scoped.lookup?.().map(chatReferenceMentionToken)).toEqual(
|
|
expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']),
|
|
);
|
|
// 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。
|
|
expect(
|
|
scoped
|
|
.lookup?.()
|
|
.some(
|
|
(item) => 'resourceId' in item && item.resourceId === 'asset-orphan',
|
|
),
|
|
).toBe(false);
|
|
// 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。
|
|
expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.(''));
|
|
});
|
|
|
|
it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => {
|
|
expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
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.fuzzyLookup?.('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.fuzzyLookup).toBeUndefined();
|
|
});
|
|
|
|
it('附件名过同一份空白口径:`toReference` 与 `mentionToken` 都是 `@brief-v2.md`', () => {
|
|
const part = {
|
|
type: 'agc_attachment_reference' as const,
|
|
name: 'brief v2.md',
|
|
mediaType: 'text/markdown',
|
|
size: 128,
|
|
localPath: 'notes/brief v2.md',
|
|
status: 'imported',
|
|
};
|
|
expect(attachmentReferenceProvider.toReference(part)).toMatchObject({
|
|
type: 'attachment',
|
|
name: 'brief-v2.md',
|
|
});
|
|
expect(attachmentReferenceProvider.mentionToken(part)).toBe('@brief-v2.md');
|
|
});
|
|
|
|
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('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', 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();
|
|
|
|
// `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
|
act(() => {
|
|
expect(result.current.fuzzyLookup?.('')).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.fuzzyLookup?.('')).toHaveLength(1);
|
|
});
|
|
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
// 菜单关闭(query 为 `null`)不触发读取。
|
|
act(() => {
|
|
result.current.onMenuQueryChange?.(null);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({
|
|
type: 'skill',
|
|
name: 'agc-test-skill',
|
|
});
|
|
});
|
|
|
|
it('目录里的名字带空白时折成 `-`:候选与 token 是同一份口径', 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 [
|
|
{
|
|
name: 'client skill',
|
|
extensionType: 'skill',
|
|
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.lookup?.()).toHaveLength(2);
|
|
});
|
|
expect(
|
|
result.current
|
|
.lookup?.()
|
|
.map((item) => (item.type === 'skill' ? item.name : null)),
|
|
).toEqual(['agc-test-skill', 'client-skill']);
|
|
expect(
|
|
result.current.mentionToken?.({
|
|
type: 'agc_skill_reference',
|
|
name: 'agc test skill',
|
|
}),
|
|
).toBe('$agc-test-skill');
|
|
});
|
|
|
|
it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', 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());
|
|
|
|
// 冷启动:精确查找只能看到「此刻就绪」的候选,目录没读过就是空数组——粘贴 `$名称` 因此按字面保留。
|
|
act(() => {
|
|
expect(result.current.lookup?.()).toEqual([]);
|
|
});
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
|
|
// 菜单第一次打开才读目录,读回之后精确查找立刻可用。
|
|
act(() => {
|
|
result.current.onMenuQueryChange?.('');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.lookup?.()).toHaveLength(1);
|
|
});
|
|
expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([
|
|
'$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.fuzzyLookup?.('')).toHaveLength(8);
|
|
});
|
|
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
|
|
expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1);
|
|
expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8);
|
|
expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([
|
|
{ type: 'skill', name: 'client-skill' },
|
|
]);
|
|
// 未启用与非 Skill 扩展都不是候选。
|
|
expect(result.current.fuzzyLookup?.('client-off')).toEqual([]);
|
|
expect(result.current.fuzzyLookup?.('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.fuzzyLookup?.('')).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);
|
|
});
|
|
});
|