Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
T
k88936 f138f648e7 修复:粘贴插入解析不出引用时落下文本形态,不再什么都不插
resourceReferences.ts 抽出 contentPartToken:part → 正文 token 的逐字投影,不补白、不裁剪(补白只在 joinMentionText 拼整段文本时加);directCodexContentToPromptText 与新导出 contentPartText 都走这一份
contentPartText 就是单个非文本 part 的文本形态:资源落 @resourceId、Skill 落 $名称、附件落 @附件名、运行区域落 @标签,文本 part 返回 null
$insertContentAtSelection 的兜底链改成 provider 的 mentionToken 之外再接 contentPartText,解析不出引用也一定有一段正常文本落到光标处,删掉「两条都答不出来就什么都不插」这条违背「粘贴内容逐字保留」的分支
新增单测覆盖四类 part 的文本形态与文本 part 返回 null;新增输入区集成用例(provider 的 toReference 与 mentionToken 都答不出来时粘贴仍按 @resourceId 落下),去掉兜底即变红
同步 AGC 聊天素材引用功能说明的粘贴解析小节,并在实施计划补记本轮评审的收口
2026-09-23 13:06:43 +08:00

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
});
});