import { describe, expect, it } from 'vitest'; import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { buildContentFromPastedText, type ChatComposerDraft, chatComposerDraftToDirectCodexUserItem, type ChatReference, chatReferenceMentionToken, contentPartText, directCodexContentToPromptText, hasMeaningfulDirectCodexContent, normalizeMentionName, resourceDisplayName, resourceLabelResolver, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; describe('DirectProject user Response item', () => { it('保留 Lexical content 的文本与引用交错顺序', () => { const draft: ChatComposerDraft = { content: [ { type: 'input_text', text: '先看 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, { type: 'input_text', text: ' 再看 ' }, { type: 'agc_runtime_region_reference', label: '主画面', runId: 'run-1', versionId: 'version-1', elementTag: 'canvas', elementRole: 'playfield', text: '可试玩区域', width: 320, height: 180, resourceIds: ['asset-hero'], }, ], }; expect( chatComposerDraftToDirectCodexUserItem(draft, 'turn-1:user'), ).toEqual({ type: 'message', role: 'user', id: 'turn-1:user', content: draft.content, }); }); it('资源引用只投影稳定 resourceId,不携带展示字段', () => { const draft: ChatComposerDraft = { content: [ { type: 'input_text', text: '请使用素材' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, ], }; expect( chatComposerDraftToDirectCodexUserItem(draft, 'turn-2:user'), ).toEqual({ type: 'message', role: 'user', id: 'turn-2:user', content: [ { type: 'input_text', text: '请使用素材' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, ], }); }); it('原样保留纯空白 input_text,不替用户改写提示词', () => { const draft: ChatComposerDraft = { content: [ { type: 'input_text', text: '先看' }, { type: 'input_text', text: ' ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, { type: 'input_text', text: '\n' }, ], }; expect( chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, ).toEqual(draft.content); }); it('只有最终 content 全为空白时才判定为空输入', () => { expect( hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]), ).toBe(false); expect(hasMeaningfulDirectCodexContent([])).toBe(false); // 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。 expect( hasMeaningfulDirectCodexContent([ { type: 'input_text', text: ' \n' }, { type: 'input_text', text: '看' }, ]), ).toBe(true); expect( hasMeaningfulDirectCodexContent([ { type: 'agc_resource_reference', resourceId: 'asset-hero' }, ]), ).toBe(true); }); it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { expect( directCodexContentToPromptText( [ { type: 'input_text', text: '用 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, { type: 'input_text', text: ' 做主视觉' }, ], resourceLabelResolver([]), ), ).toBe('用 @asset-hero 做主视觉'); }); }); describe('粘贴文本反解析', () => { const assetEntry = ( id: string, localPath: string, ): GameCreationAppAssetManifestEntry => ({ id, kind: 'character', mediaType: 'image/png', localPath, source: { kind: 'uploaded' }, }); const heroAsset = assetEntry('hero', 'assets/hero.png'); const enemyAsset = assetEntry('enemy', 'assets/enemy.png'); const hero = resourceReferenceFromAsset(heroAsset, 'asset-picker'); const enemy = resourceReferenceFromAsset(enemyAsset, 'asset-picker'); const skill: ChatReference = { type: 'skill', name: 'image-gen' }; it('命中显示名 token 时原位换成引用,其余字符逐字保留', () => { expect(buildContentFromPastedText('看 @hero 这一版', [hero])).toEqual([ { type: 'input_text', text: '看 ' }, { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: ' 这一版' }, ]); expect(buildContentFromPastedText('用 $image-gen 出图', [skill])).toEqual([ { type: 'input_text', text: '用 ' }, { type: 'agc_skill_reference', name: 'image-gen' }, { type: 'input_text', text: ' 出图' }, ]); }); it('不做兼容别名:只认显示名,且 token 前后必须是行首 / 行尾或空白', () => { // 带扩展名的文件名、紧贴中文、全角 `@`、大小写变体都不解析——宁可不成 chip,也不能认错。 expect(buildContentFromPastedText('@hero.png', [hero])).toBeNull(); expect(buildContentFromPastedText('看@hero这一版', [hero])).toBeNull(); expect(buildContentFromPastedText('@hero', [hero])).toBeNull(); expect(buildContentFromPastedText('@HERO', [hero])).toBeNull(); // 行首 / 行尾同样是边界。 expect(buildContentFromPastedText('@hero', [hero])).toEqual([ { type: 'agc_resource_reference', resourceId: 'hero' }, ]); }); it('未命中的 token 逐字保留,混在正文里也只换认出那几条', () => { expect( buildContentFromPastedText('@hero 与 @unknown 都在', [hero]), ).toEqual([ { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: ' 与 @unknown 都在' }, ]); // 一条都没命中时返回 null:调用方据此放行编辑器默认粘贴。 expect(buildContentFromPastedText('纯文本 @unknown', [hero])).toBeNull(); }); it('同名多候选一律按文本保留:宁可不成 chip,也不能认错引用', () => { const sameName = [ resourceReferenceFromAsset( assetEntry('asset-hero-a', 'characters/hero.png'), 'asset-picker', ), resourceReferenceFromAsset( assetEntry('asset-hero-b', 'enemies/hero.png'), 'asset-picker', ), ]; expect(buildContentFromPastedText('@hero', sameName)).toBeNull(); // 其余可判定的 token 照常解析。 expect( buildContentFromPastedText('@hero @enemy', [...sameName, enemy]), ).toEqual([ { type: 'input_text', text: '@hero ' }, { type: 'agc_resource_reference', resourceId: 'enemy' }, ]); }); it('同一 token 出现几次就成几个 chip,换行保留为独立的文本 part', () => { expect(buildContentFromPastedText('@hero\n@hero', [hero])).toEqual([ { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: '\n' }, { type: 'agc_resource_reference', resourceId: 'hero' }, ]); }); it('引用名内部空白折成 `-`:资源显示名 / Skill 名 / 附件名同一条口径', () => { expect(normalizeMentionName('hero v2')).toBe('hero-v2'); expect(normalizeMentionName(' 英雄\u3000参考\t')).toBe('英雄-参考'); expect( resourceDisplayName(assetEntry('hero-v2', 'assets/hero v2.png')), ).toBe('hero-v2'); expect( chatReferenceMentionToken({ type: 'skill', name: 'image gen' }), ).toBe('$image-gen'); expect( chatReferenceMentionToken({ type: 'attachment', name: 'brief v2.md', mediaType: 'text/markdown', size: 1, localPath: 'notes/brief v2.md', status: 'imported', }), ).toBe('@brief-v2.md'); }); it('空白折成 `-` 后 token 自带边界:前缀重叠不再多插一个 chip', () => { const heroV2Asset = assetEntry('hero-v2', 'assets/hero v2.png'); const heroV2 = resourceReferenceFromAsset(heroV2Asset, 'asset-picker'); const displayed = directCodexContentToPromptText( [ { type: 'input_text', text: '看' }, { type: 'agc_resource_reference', resourceId: 'hero-v2' }, { type: 'input_text', text: '这一版' }, ], resourceLabelResolver([heroAsset, heroV2Asset]), ); expect(displayed).toBe('看 @hero-v2 这一版'); expect(buildContentFromPastedText(displayed, [hero, heroV2])).toEqual([ { type: 'input_text', text: '看 ' }, { type: 'agc_resource_reference', resourceId: 'hero-v2' }, { type: 'input_text', text: ' 这一版' }, ]); }); it('整名就是扩展名(`.env` / `.gitignore`)时显示名回退 asset.id,不留空名', () => { // 空名字会退化成只有触发符的裸 `@` token:菜单里是一枚空芯片,粘贴解析还会认领正文里 // 任何一处裸 `@`(见「退化 token 不参与解析」用例)。 expect(resourceDisplayName(assetEntry('dotenv', '.env'))).toBe('dotenv'); expect( resourceDisplayName(assetEntry('gitignore', 'config/.gitignore')), ).toBe('gitignore'); expect( resourceReferenceFromAsset(assetEntry('dotenv', '.env'), 'asset-picker') .label, ).toBe('dotenv'); }); it('退化 token(名字为空只剩触发符)不参与解析,正文里的裸 `@` 逐字保留', () => { // 上游仍然可能送来空名字的引用(显示名兜底只是把常见路径堵上),裸 `@` token 会在正文里 // 匹配到任何一处 `@`,认下来就是错认:这里必须按「宁可不成 chip」跳过。 const nameless: ChatReference = { ...resourceReferenceFromAsset( assetEntry('dotenv', '.env'), 'asset-picker', ), label: '', }; expect(chatReferenceMentionToken(nameless)).toBe('@'); expect( buildContentFromPastedText('@ 单独一个 @ 符号,看 @hero', [ nameless, hero, ]), ).toEqual([ { type: 'input_text', text: '@ 单独一个 @ 符号,看 ' }, { type: 'agc_resource_reference', resourceId: 'hero' }, ]); expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull(); }); it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => { // 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。 const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({ type: 'agc_runtime_region_reference', label, runId: null, versionId: null, elementTag: null, elementRole: null, text: null, width: null, height: null, resourceIds: [], }); expect( contentPartText({ type: 'agc_resource_reference', resourceId: 'hero' }), ).toBe('@hero'); expect( contentPartText({ type: 'agc_skill_reference', name: 'image gen' }), ).toBe('$image-gen'); expect( contentPartText({ type: 'agc_attachment_reference', name: 'brief v2.md', mediaType: 'text/markdown', size: 1, localPath: 'notes/brief v2.md', status: 'imported', }), ).toBe('@brief-v2.md'); expect(contentPartText(runtimeRegionPart('主画面'))).toBe('@主画面'); // 文本 part 没有「文本形态」,它自己就是文本。 expect(contentPartText({ type: 'input_text', text: '先看' })).toBeNull(); }); it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: ' 这一版,再用 ' }, { type: 'agc_skill_reference', name: 'image-gen' }, { type: 'input_text', text: ' 出图' }, ]; const displayed = directCodexContentToPromptText( content, resourceLabelResolver([heroAsset]), ); expect(displayed).toBe('看 @hero 这一版,再用 $image-gen 出图'); expect(buildContentFromPastedText(displayed, [hero, skill])).toEqual( content, ); }); });