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 & 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(); }); });