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 修订、实施计划、术语表、功能说明与决策记录
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
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,
|
||
);
|
||
});
|
||
});
|