Files
Genarrative/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts
T
suzmii 1e17d2c852
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口
润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化
共享聊天输入区显示润色提示,并让提示随用户手改提示词失效
快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉
恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目
账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用
派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘
名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮
浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉
恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式
空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码
补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离
同步 decision-log 与画布验收待办的进展说明
2026-09-20 17:36:02 +08:00

507 lines
18 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 { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
canonicalProjectedResourceMediaType,
defaultCharacterAnimationResourceName,
defaultDerivedResourceName,
DERIVED_RESOURCE_NAME_SUFFIXES,
resolveDerivedResourceNameCheck,
resolveProjectCharacterAnimationCapability,
resolveProjectResourceEditCapability,
resolveResourceEditNameCheck,
resolveResourceEditRequestIdentity,
RESOURCE_EDIT_NAME_INVALID_NOTICE,
RESOURCE_EDIT_NAME_MAX_LENGTH,
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();
});
});
/**
* Rust 源码路径按**本文件位置**解析,不按仓库根 cwd 解析:这个用例是跨语言对表,
* 从别的工作目录单跑这一个文件时 `ENOENT` 会把「两边分叉」误报成一次环境问题。
*/
const RUST_RESOURCE_EDITOR_PATH = fileURLToPath(
new URL('../src-tauri/src/project/resource_editor.rs', import.meta.url),
);
/**
* Rust `normalize_resource_edit_name` 的函数体原文。
*
* 派生资源名的门禁在 Rust 侧,前端这份只是镜像;一旦两边数字或文案分叉,用户就会先看到
* 一句「提交前拦下」的话、再被后端换成另一句话,所以这里直接拿源码对表。
*/
function rustResourceEditNameNormalizer() {
const source = readFileSync(RUST_RESOURCE_EDITOR_PATH, 'utf8');
const start = source.indexOf('fn normalize_resource_edit_name(');
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf('\n}\n', start));
expect(body.length).toBeGreaterThan(0);
return body;
}
/** 只换 label 的 Agent 回执资源:AGC 侧 label 直接取任务标题,是最容易触发名称门禁的一类。 */
function labelResource(label: string) {
return resource({
id: 'agent-result:design:message-1',
category: 'document',
path: '专业 Agent 文本回执',
subtype: 'agent-result',
producerTaskId: null,
content: '回执',
label,
});
}
/** 默认名函数与 kind 的对应关系,供「默认名 == 检查名」的断言复用。 */
function defaultDerivedNameFor(
target: ProjectResource,
kind: 'edit' | 'character-animation',
) {
return kind === 'edit'
? defaultDerivedResourceName(target)
: defaultCharacterAnimationResourceName(target);
}
describe('派生资源名称:提交前镜像 Rust 的 1..=120 与禁止控制字符', () => {
/**
* Rust 是权威:`value.trim()` → 非空 → `chars().count() > 120` → `char::is_control`
* 任一不满足就 `Err`,并且**不截断**(没有 `truncate` / `chars().take(...)`)。
* 前端镜像必须逐条对齐,包括「宁可按原话拒绝、也不截成可能重名的名字」这条口径。
*/
it('上限与提示文案逐字取自 Rust 的 normalize_resource_edit_name', () => {
const body = rustResourceEditNameNormalizer();
expect(body).toMatch(/\bvalue\.trim\(\)/u);
expect(body).toMatch(/value\.is_empty\(\)/u);
expect(body).toMatch(/char::is_control/u);
expect(body).toMatch(/return Err\(/u);
expect(body).not.toMatch(/truncate|\.take\(/u);
const limit = /chars\(\)\.count\(\)\s*>\s*(\d+)/u.exec(body);
expect(limit?.[1]).toBe(String(RESOURCE_EDIT_NAME_MAX_LENGTH));
const notice = /"([^"]*派生资源名称[^"]*)"/u.exec(body);
expect(notice?.[1]).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
});
it('恰好 120 码点放行,多一个字就按 Rust 原话拦住且不截断', () => {
const exactlyLimit = '字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
expect(resolveResourceEditNameCheck(exactlyLimit)).toEqual({
name: exactlyLimit,
error: null,
});
const overLimit = `${exactlyLimit}多`;
const overCheck = resolveResourceEditNameCheck(overLimit);
expect(overCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
// 提示里报的长度上限必须是拦下它的那个数字,且原样返回(不返回截断名)。
expect(RESOURCE_EDIT_NAME_INVALID_NOTICE).toContain(
`1..=${RESOURCE_EDIT_NAME_MAX_LENGTH}`,
);
expect(overCheck.name).toBe(overLimit);
});
it('先 trim 再判空:首尾空白不算违规,只有空白则拦下', () => {
expect(resolveResourceEditNameCheck(' 小猫咪-编辑版 ')).toEqual({
name: '小猫咪-编辑版',
error: null,
});
expect(resolveResourceEditNameCheck('').error).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
expect(resolveResourceEditNameCheck(' ').error).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
expect(resolveResourceEditNameCheck('\n\t').error).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
});
it('拒绝 Cc 控制字符(含内嵌换行 / 制表符),但放行 Rust 也放行的 Cf 格式符', () => {
for (const control of ['\u0000', '\u0007', '\u001f', '\u007f', '\u009f']) {
expect(
resolveResourceEditNameCheck(`角色${control}形象-编辑版`).error,
`U+${control.codePointAt(0)!.toString(16)}`,
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
}
// 任务标题里的换行是真实成因:label 直接取任务标题,内嵌换行原样传下来。
for (const embedded of ['\n', '\r', '\t']) {
expect(
resolveResourceEditNameCheck(`生成角色${embedded}正面形象-编辑版`)
.error,
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
}
// 首尾换行被 trim 掉,Rust 同样放行——不能在这里多收窄一条客户端自有规则。
expect(
resolveResourceEditNameCheck('\n角色形象-编辑版\n').error,
).toBeNull();
expect(
resolveResourceEditNameCheck('角色形象\u200b-编辑版').error,
).toBeNull();
});
it('按码点计长(chars().count() 口径),不按 UTF-16 长度误判', () => {
const emojiLimit = '😀'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
// UTF-16 长度是 240,码点数是 120:Rust 接受,前端不许拦。
expect(emojiLimit.length).toBe(RESOURCE_EDIT_NAME_MAX_LENGTH * 2);
expect(resolveResourceEditNameCheck(emojiLimit).error).toBeNull();
expect(
resolveResourceEditNameCheck(
`${'字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH - 5)}${'😀'.repeat(5)}`,
).error,
).toBeNull();
expect(resolveResourceEditNameCheck(`${emojiLimit}😀`).error).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
});
it('编辑版与角色动画共用同一张后缀表,默认名与检查名逐个相等', () => {
expect(DERIVED_RESOURCE_NAME_SUFFIXES).toEqual({
edit: '-编辑版',
'character-animation': '-角色动画',
});
const hero = resource({
id: 'asset:hero',
category: 'unclassified',
path: 'assets/hero.png',
label: 'hero.png',
mediaType: 'image/png',
manifestAssetId: 'hero',
});
expect(resolveDerivedResourceNameCheck(hero, 'edit')).toEqual({
name: 'hero-编辑版',
error: null,
});
expect(
resolveDerivedResourceNameCheck(hero, 'character-animation'),
).toEqual({ name: 'hero-角色动画', error: null });
// 默认名与检查名是同一份拼接结果,调用方不必各自拼一遍。
expect(defaultDerivedResourceName(hero)).toBe('hero-编辑版');
expect(defaultCharacterAnimationResourceName(hero)).toBe('hero-角色动画');
// 去扩展名 + 空基名兜底的口径不变。
expect(defaultDerivedResourceName(labelResource('.png'))).toBe(
'资源-编辑版',
);
expect(defaultDerivedResourceName(labelResource('theme.mp3'))).toBe(
'theme-编辑版',
);
});
/**
* 两个后缀都要处理:快速编辑的「-编辑版」和角色动画的「-角色动画」是同一个源名的两种
* 派生。后缀只多 4~5 个字,所以超限的源名在两条路径上都必须被拦下,而不是只拦一条。
*/
it('超长源名在编辑版与角色动画两条路径上都被拦下,不截成可能重名的名字', () => {
const base = '生成角色正面半身战斗姿态设定稿'.repeat(8);
const first = labelResource(`${base}A.png`);
const second = labelResource(`${base}B.png`);
for (const kind of ['edit', 'character-animation'] as const) {
const firstCheck = resolveDerivedResourceNameCheck(first, kind);
const secondCheck = resolveDerivedResourceNameCheck(second, kind);
expect(firstCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
expect(secondCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
// 不截断:检查结果仍是原样的长名(否则两个同前缀的长名会被截成同一个可用名字)。
expect([...firstCheck.name].length).toBeGreaterThan(
RESOURCE_EDIT_NAME_MAX_LENGTH,
);
expect(firstCheck.name).not.toBe(secondCheck.name);
expect(firstCheck.name).toBe(defaultDerivedNameFor(first, kind));
}
});
/**
* 真实成因:`agent-result` 资源的 label 直接取任务标题(`resourceProjectionModel`),
* 用户写的长标题或带换行的标题会原样进入派生名。
*/
it('任务标题型源名(超长 / 带换行)在派生前就给出同一句中文提示', () => {
const longTitle = `${'把主角改成夜行侠并加上战斗姿态'.repeat(9)}.png`;
expect(
resolveDerivedResourceNameCheck(labelResource(longTitle), 'edit').error,
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
const multilineTitle = '生成角色\n正面形象.png';
expect(
resolveDerivedResourceNameCheck(
labelResource(multilineTitle),
'character-animation',
).error,
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
});
});