Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
T
k88936 3f9c698a67 修复:粘贴解析跳过只有触发符的退化 token,不再错认正文里的裸 @
buildContentFromPastedText 组装候选时跳过 token 长度小于等于 1 的条目(引用名为空时 token 就是裸 `@` / `$`):边界规则会让它在正文里匹配到任何一处触发符,认下来等于把无关文字错认成一条引用
函数注释的保守规则从两条补成三条,写明空名字按「宁可不成 chip」的同一口径跳过
新增用例:空名字引用的 token 是 `@` 时不认领正文里的裸 `@`、其余 token 照常解析;去掉该跳过分支后用例变红
同步 AGC 聊天素材引用功能说明的粘贴解析小节
2026-09-23 11:54:09 +08:00

296 lines
11 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,
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('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 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,
);
});
});