Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
T
k88936 6950e08506
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m55s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
功能:引用输入区支持粘贴解析 @显示名 / $名称
新增 buildContentFromPastedText:粘贴文本按显示口径反解析,同名多候选与未命中 token 一律按文本保留
ReferenceProvider 增加可选 candidates 与 onPasteText:候选枚举保持纯函数,Skill 目录冷启动时补读一次
资源与 Skill provider 落地新缝,静默 provider(附件 / 运行画面区域)不参与粘贴解析
输入区在 CRITICAL 优先级接管 PASTE_COMMAND,仅解析出引用时插入并打 PASTE_TAG,一次 Ctrl+Z 整体回退
补充粘贴解析规则矩阵、provider 候选枚举与补读、输入区粘贴集成用例
同步 ADR 修订、实施计划、术语表、功能说明与决策记录
2026-09-22 19:06:19 +08:00

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