// @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); }); });