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 & Pick, ): 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); }); });