// @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, ChatReference, ResourceReference, 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(); 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('`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 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); }); 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); }); });