Files
Genarrative/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts
T
suzmii d44e1ae8ea 生成素材弹窗接入提示词润色,并让请求身份绑住它铸造时的那句提示词
- 新增 ResourcePromptPolishSlot:素材侧两处入口共用的「AI 润色 / 恢复原文 / 润色中…」动作,取数走 AGC 侧 usePromptPolish,聊天框同款失败态(失败保留原文、可重试文案)
- 场景约束走 polish_local_project_prompt 的 context:「这是素材生成提示词(音效)」+「只润色这一句、不要扩写成需求/方案/验收标准、不要新增事实」(本批不动后端提示词文件),文案落在 resourceCanvasPromptPolishModel
- resourceEditModel 新增 truncateResourceEditPrompt:超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填,并在状态行给出「已按长度上限截断」,不静默丢内容;后端上限未改
- resourceEditModel 新增 resolveResourceEditRequestIdentity:请求身份绑定到铸造时的 prompt(Rust request_fingerprint 含 prompt,改了提示词还复用同一 operationId 会被判「已绑定到不同资源编辑请求」);提示词没变时仍复用,失败重试照旧命中同一账本
- 生成素材弹窗用注入位渲染润色动作(共享 composer 只提供插槽,不上 Tauri 调用),提交时按同一口径解析身份;原先只在这一处使用的 createResourceCanvasGenerationRequest 随之下线
- 样式落在 resourceCanvasChrome.css(resource-prompt-polish 一族),弹窗与 composer 共用一行
- 测试:模型层补截断边界与身份复用/重铸;弹窗层补润色回填与上下文、超限截断提示、失败保留原文、失败后提示词与润色一起锁定且重试复用同一请求
- 变异验证:截断改成恒等 → 模型用例与「超限截断」用例双双红灯(实测字段长度 206 ≠ 140);身份去掉重铸 → 模型用例红灯
2026-09-11 16:58:49 +08:00

283 lines
8.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
canonicalProjectedResourceMediaType,
resolveProjectCharacterAnimationCapability,
resolveProjectResourceEditCapability,
resolveResourceEditRequestIdentity,
RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE,
resourceEditPromptMaxLength,
truncateResourceEditPrompt,
} from '../src/view/project-development/resourceEditModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(
input: Partial<ProjectResource> &
Pick<ProjectResource, 'id' | 'category' | 'path'>,
): ProjectResource {
return {
subtype: 'task-artifact',
label: input.path,
mediaType: '',
sourceLabel: '测试来源',
taskTitle: null,
manifestAssetId: null,
producerTaskId: 'task-1',
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...input,
};
}
describe('全类型资源编辑能力模型', () => {
it('把现役资源稳定分流到图片派生、媒体派生、文本派生和版本分支', () => {
const cases: Array<{
resource: ProjectResource;
route: 'derive';
editKind?: string;
}> = [
{
resource: resource({
id: 'asset:png',
category: 'unclassified',
path: 'assets/hero.png',
mediaType: 'image/png',
manifestAssetId: 'png',
}),
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
id: 'task:art:hero.webp',
category: 'unclassified',
path: 'hero.webp',
mediaType: '美术产物',
}),
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
id: 'asset:gif',
category: 'unclassified',
path: 'assets/hero.gif',
mediaType: 'image/gif',
manifestAssetId: 'gif',
}),
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
id: 'asset:svg',
category: 'unclassified',
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
manifestAssetId: 'svg',
}),
route: 'derive',
editKind: 'svg',
},
{
resource: resource({
id: 'task:art:intro.mp4',
category: 'unclassified',
path: 'assets/intro.mp4',
mediaType: '美术产物',
}),
route: 'derive',
editKind: 'video',
},
{
resource: resource({
id: 'asset:bgm',
category: 'audio',
path: 'audio/theme.ogg',
subtype: 'background-music',
mediaType: 'audio/ogg',
manifestAssetId: 'bgm',
}),
route: 'derive',
editKind: 'background-music',
},
{
resource: resource({
id: 'asset:sfx',
category: 'audio',
path: 'audio/hit.wav',
subtype: 'sound-effect',
mediaType: 'audio/wav',
manifestAssetId: 'sfx',
}),
route: 'derive',
editKind: 'sound-effect',
},
{
resource: resource({
id: 'task:code:main.ts',
category: 'document',
path: 'src/main.ts',
mediaType: '项目文档',
}),
route: 'derive',
editKind: 'text',
},
{
resource: resource({
id: 'agent-result:design:message-1',
category: 'document',
path: '专业 Agent 文本回执',
subtype: 'agent-result',
producerTaskId: null,
content: '回执',
}),
route: 'derive',
editKind: 'agent-result',
},
{
resource: resource({
id: 'version:v1',
category: 'version',
path: '项目版本 · v1',
subtype: 'project-version',
producerTaskId: null,
version: {
versionId: 'v1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
label: '版本 1',
childVersionIds: [],
},
}),
route: 'derive',
editKind: 'version',
},
];
for (const expected of cases) {
const capability = resolveProjectResourceEditCapability(
expected.resource,
);
expect(capability.route, expected.resource.id).toBe(expected.route);
expect(capability.editKind, expected.resource.id).toBe(expected.editKind);
}
});
it('用路径补齐任务产物缺失的标准媒体类型', () => {
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:art:clip.mov',
category: 'unclassified',
path: 'assets/clip.mov',
mediaType: '美术产物',
}),
),
).toBe('video/quicktime');
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:audio:effect.wav',
category: 'audio',
path: 'audio/effect.wav',
mediaType: '美术产物',
}),
),
).toBe('audio/wav');
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:code:main.ts',
category: 'document',
path: 'src/main.ts',
mediaType: '项目文档',
}),
),
).toBe('text/typescript');
});
it('按现役媒体接口限制编辑提示词长度', () => {
expect(resourceEditPromptMaxLength('background-music')).toBe(140);
expect(resourceEditPromptMaxLength('sound-effect')).toBe(1_900);
expect(resourceEditPromptMaxLength('video')).toBe(4_000);
expect(resourceEditPromptMaxLength('character-animation')).toBe(4_000);
expect(resourceEditPromptMaxLength('text')).toBe(32_000);
});
it('超上限的润色结果按类型截断并给出替代文案,边界上不动内容', () => {
const exactlyLimit = '音'.repeat(140);
expect(
truncateResourceEditPrompt(exactlyLimit, 'background-music'),
).toEqual({
text: exactlyLimit,
notice: null,
});
const overLimit = `${exactlyLimit}多出来的一个字`;
expect(truncateResourceEditPrompt(overLimit, 'background-music')).toEqual({
text: exactlyLimit,
notice: RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE,
});
expect(RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE).toBe('已按长度上限截断');
// 上限按类型走,不是一刀切。
expect(
truncateResourceEditPrompt('字'.repeat(200), 'sound-effect').notice,
).toBeNull();
expect(
truncateResourceEditPrompt('字'.repeat(1_901), 'sound-effect').text
.length,
).toBe(1_900);
expect(
truncateResourceEditPrompt('字'.repeat(32_001), 'text').text.length,
).toBe(32_000);
});
it('请求身份只在提示词没变时复用', () => {
const first = resolveResourceEditRequestIdentity(null, '原始提示词');
expect(first.prompt).toBe('原始提示词');
expect(first.operationId).toBeTruthy();
expect(first.idempotencyKey).toBeTruthy();
// 同句重试:同一份身份,命中同一 operation 账本。
expect(resolveResourceEditRequestIdentity(first, '原始提示词')).toBe(first);
// 提示词变了(改了、或润色回填过):必须重铸,否则 Rust 的
// request_fingerprint(含 prompt)会把重试判成「已绑定到不同资源编辑请求」。
const second = resolveResourceEditRequestIdentity(first, '润色后的提示词');
expect(second).not.toBe(first);
expect(second.operationId).not.toBe(first.operationId);
expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
expect(second.prompt).toBe('润色后的提示词');
});
it('图片资源可进入角色动画派生入口', () => {
const capability = resolveProjectCharacterAnimationCapability(
resource({
id: 'asset:hero',
category: 'unclassified',
path: 'assets/hero.png',
mediaType: 'image/png',
manifestAssetId: 'hero',
}),
);
expect(capability?.editKind).toBe('character-animation');
expect(capability?.generationMode).toBe('derive');
expect(
resolveProjectCharacterAnimationCapability(
resource({
id: 'asset:bgm',
category: 'audio',
path: 'audio/theme.mp3',
mediaType: 'audio/mpeg',
manifestAssetId: 'bgm',
}),
),
).toBeNull();
});
});