import { describe, expect, it } from 'vitest'; import { createGameCreationAppManifest, PROJECT_RESOURCE_CANVAS_SECTIONS, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { projectedResourceKind, projectResourceAssetCategory, projectResourceCanvasCategory, projectResourcesFromReadModels, projectResourceTypeLabel, } from '../src/view/project-development/resourceProjectionModel'; describe('项目资源投影', () => { it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => { const manifest = createGameCreationAppManifest( 'resource-projection', '资源投影测试', ); const designTask = manifest.tasks.find( (task) => task.id === 'design-foundation', ); const audioTask = manifest.tasks.find( (task) => task.id === 'audio-asset-plan', ); if (!designTask || !audioTask) { throw new Error('缺少资源投影测试任务'); } designTask.status = 'completed'; designTask.artifacts = [ 'memory/game-design.md', 'assets/ui-preview.svg', 'build/game.bundle', ]; audioTask.status = 'completed'; audioTask.artifacts = ['audio/unregistered-bgm.wav']; manifest.assets = [ { id: 'game-entry', kind: 'game-entry', mediaType: 'text/html', localPath: 'game/index.html', source: { kind: 'generated' }, }, { id: 'registered-bgm', kind: 'bgm', mediaType: 'audio/wav', localPath: 'audio/registered-bgm.wav', source: { kind: 'generated' }, }, { id: 'character-animation', kind: 'character-animation', mediaType: 'application/json', localPath: 'assets/character-animation.json', source: { kind: 'generated' }, }, { id: 'unknown-binary', kind: 'binary', mediaType: 'application/octet-stream', localPath: 'build/game.bundle', source: { kind: 'generated' }, }, ]; manifest.versions = [ { versionId: 'version-1', parentVersionId: null, projectRevision: 7, resourceBindings: [ { slotId: 'background-music', resourceId: 'registered-bgm' }, ], createdReason: 'initial', createdAt: 1, }, ]; const resources = projectResourcesFromReadModels( manifest, [ { fileName: 'rules.yaml', mediaType: 'application/yaml', localPath: 'imports/rules.yaml', status: 'imported', }, { fileName: 'voice.ogg', mediaType: 'audio/ogg', localPath: 'imports/voice.ogg', status: 'imported', }, { fileName: 'archive.zip', mediaType: 'application/zip', localPath: 'imports/archive.zip', status: 'imported', }, ], [ { agentId: 'design-foundation', runId: 'final-run', label: '玩法策划 Agent', title: '玩法文档回执', content: '回执正文', updatedAt: 1, }, ], ); expect(resources.map(({ id, category }) => ({ id, category }))).toEqual( expect.arrayContaining([ // 非 manifest 资源没有功能分类事实源,统一落在「待归类」。 { id: 'task:design-foundation:memory/game-design.md', category: 'unclassified', }, // kind `game-entry` 不在 canonical 目录里,按 canonical 兜底 `image` 归「待归类」。 { id: 'asset:game-entry', category: 'unclassified' }, { id: 'task:design-foundation:assets/ui-preview.svg', category: 'unclassified', }, { id: 'asset:registered-bgm', category: 'unclassified' }, { id: 'asset:character-animation', category: 'character' }, { id: 'attachment:imports/rules.yaml', category: 'unclassified' }, { id: 'attachment:imports/voice.ogg', category: 'unclassified' }, { id: 'agent-result:design-foundation:final-run', category: 'document', }, { id: 'task:audio-asset-plan:audio/unregistered-bgm.wav', category: 'unclassified', }, { id: 'version:version-1', category: 'version' }, ]), ); expect( resources.some(({ id }) => [ 'task:design-foundation:build/game.bundle', 'asset:unknown-binary', 'attachment:imports/archive.zip', ].includes(id), ), ).toBe(false); expect( resources.filter(({ category }) => category === 'version'), ).toHaveLength(1); }); it('显示名称变化不会改变正式资源身份', () => { const manifest = createGameCreationAppManifest( 'stable-resource-id', '稳定资源身份测试', ); manifest.versions = [ { versionId: 'stable-version', parentVersionId: null, projectRevision: 1, resourceBindings: [], createdReason: 'initial', createdAt: 1, }, ]; const first = projectResourcesFromReadModels( manifest, [], [ { agentId: 'design-foundation', runId: 'stable-run', label: '策划 Agent', title: '旧标题', content: '正文', updatedAt: 1, }, ], ); const second = projectResourcesFromReadModels( manifest, [], [ { agentId: 'design-foundation', runId: 'stable-run', label: '策划 Agent', title: '新标题', content: '正文', updatedAt: 2, }, ], ); expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id)); }); it('按 manifest 资产投影功能分类与标签,非资产资源归待归类', () => { const manifest = createGameCreationAppManifest( 'asset-classification-projection', '资源分类投影测试', ); manifest.assets = [ { id: 'classified-scene', kind: 'scene', mediaType: 'image/png', localPath: 'assets/scene.png', source: { kind: 'generated' }, category: 'ui-interaction', tags: ['主界面', '入口'], }, { id: 'legacy-character', kind: 'character', mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'generated' }, }, ]; const resources = projectResourcesFromReadModels( manifest, [ { fileName: 'rules.yaml', mediaType: 'application/yaml', localPath: 'imports/rules.yaml', status: 'imported', }, ], [], ); const classified = resources.find( (resource) => resource.id === 'asset:classified-scene', ); const legacy = resources.find( (resource) => resource.id === 'asset:legacy-character', ); const attachment = resources.find( (resource) => resource.id === 'attachment:imports/rules.yaml', ); expect(classified?.assetCategory).toBe('ui-interaction'); expect(classified?.assetTags).toEqual(['主界面', '入口']); expect(legacy?.assetCategory).toBe('character'); expect(legacy?.assetTags).toEqual([]); expect(projectResourceAssetCategory(classified!)).toBe('ui-interaction'); expect(projectResourceAssetCategory(attachment!)).toBe('unclassified'); }); it('只从 manifest 投影版本并保留直接父子关系', () => { const manifest = createGameCreationAppManifest( 'version-projection', '版本投影测试', ); manifest.versions = [ { versionId: 'version-root', parentVersionId: null, projectRevision: 2, resourceBindings: [], createdReason: 'initial', createdAt: 100, }, { versionId: 'version-child', parentVersionId: 'version-root', projectRevision: 3, resourceBindings: [], createdReason: 'agent-revision', createdAt: 200, }, ]; const versions = projectResourcesFromReadModels(manifest, [], []).filter( (resource) => resource.category === 'version', ); expect(versions.map((version) => version.label)).toEqual([ '版本 1', '版本 2', ]); expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']); expect(versions[1]?.version?.parentVersionId).toBe('version-root'); }); it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => { expect( projectResourceTypeLabel({ subtype: 'project-version', path: 'versions/v1', mediaType: '正式项目版本', }), ).toBe('项目版本'); expect( projectResourceTypeLabel({ subtype: 'agent-result', path: '专业 Agent 文本回执', mediaType: 'Agent 历史文本回执', }), ).toBe('Agent 回执'); expect( projectResourceTypeLabel({ subtype: 'task-artifact', path: 'memory/plan.md', mediaType: '项目文档', }), ).toBe('文档'); expect( projectResourceTypeLabel({ subtype: 'task-artifact', path: 'audio/theme.wav', mediaType: '音乐音效产物', }), ).toBe('音频'); expect( projectResourceTypeLabel({ subtype: 'task-artifact', path: 'assets/hero.svg', mediaType: '美术产物', }), ).toBe('SVG'); // 任务产物准入时扩展名已经判定过类型;无法判定的产物不会进入画布, // 这里仍给出稳定的兜底文案而不是按旧栏目硬猜类型。 expect( projectResourceTypeLabel({ subtype: 'task-artifact', path: 'assets/unknown', mediaType: '美术产物', }), ).toBe('任务产物'); expect( projectResourceTypeLabel({ subtype: 'character', path: 'assets/hero.svg', mediaType: 'image/svg+xml', }), ).toBe('SVG'); expect( projectResourceTypeLabel({ subtype: 'video', path: 'assets/intro.mp4', mediaType: 'video/mp4', }), ).toBe('视频'); expect( projectResourceTypeLabel({ subtype: 'character', path: 'assets/hero.png', mediaType: 'image/png', }), ).toBe('图片'); expect( projectResourceTypeLabel({ subtype: 'attachment', path: 'docs/rules.yaml', mediaType: 'application/yaml', }), ).toBe('文档'); expect( projectResourceTypeLabel({ subtype: 'attachment', path: 'data/blob', mediaType: 'application/octet-stream', }), ).toBe('未知'); expect( projectResourceTypeLabel({ subtype: 'source', path: 'game/main.ts', mediaType: 'text/typescript', }), ).toBe('游戏代码'); expect( projectResourceTypeLabel({ subtype: 'unknown', path: 'assets/blob', mediaType: 'application/octet-stream', }), ).toBe('未知'); }); }); describe('资源画布分区投影', () => { it('manifest 资产按功能分类分区,项目版本独立成栏,其余非资产资源归待归类', () => { const manifest = createGameCreationAppManifest( 'canvas-partition-projection', '画布分区投影测试', ); manifest.assets = [ { id: 'ui-prototype', kind: 'ui-design', mediaType: 'image/png', localPath: 'assets/ui.png', source: { kind: 'generated' }, }, { id: 'hero-animation', kind: 'character-animation', mediaType: 'application/json', localPath: 'assets/hero.json', source: { kind: 'generated' }, }, { id: 'forest', kind: 'scene', mediaType: 'image/png', localPath: 'assets/forest.png', source: { kind: 'generated' }, }, { id: 'theme', kind: 'background-music', mediaType: 'audio/mpeg', localPath: 'audio/theme.mp3', source: { kind: 'generated' }, }, { id: 'gdd', kind: 'spec', mediaType: 'text/markdown', localPath: 'memory/gdd.md', source: { kind: 'generated' }, }, { id: 'raw-illustration', kind: 'image', mediaType: 'image/png', localPath: 'assets/raw.png', source: { kind: 'generated' }, }, ]; manifest.versions = [ { versionId: 'version-1', parentVersionId: null, projectRevision: 1, resourceBindings: [], createdReason: 'initial', createdAt: 1, }, ]; const designTask = manifest.tasks.find( (task) => task.id === 'design-foundation', ); if (!designTask) { throw new Error('缺少资源分区测试任务'); } designTask.status = 'completed'; designTask.artifacts = ['memory/game-design.md']; const resources = projectResourcesFromReadModels( manifest, [ { fileName: 'rules.yaml', mediaType: 'application/yaml', localPath: 'imports/rules.yaml', status: 'imported', }, ], [ { agentId: 'design-foundation', runId: 'final-run', label: '玩法策划 Agent', title: '玩法文档回执', content: '回执正文', updatedAt: 1, }, ], ); const categoryById = new Map( resources.map((resource) => [ resource.id, projectResourceCanvasCategory(resource), ]), ); expect(categoryById.get('asset:ui-prototype')).toBe('ui-interaction'); expect(categoryById.get('asset:hero-animation')).toBe('character'); expect(categoryById.get('asset:forest')).toBe('scene'); expect(categoryById.get('asset:theme')).toBe('audio'); expect(categoryById.get('asset:gdd')).toBe('document'); expect(categoryById.get('asset:raw-illustration')).toBe('unclassified'); expect(categoryById.get('version:version-1')).toBe('version'); expect(categoryById.get('attachment:imports/rules.yaml')).toBe( 'unclassified', ); expect( categoryById.get('task:design-foundation:memory/game-design.md'), ).toBe('unclassified'); expect(categoryById.get('agent-result:design-foundation:final-run')).toBe( 'document', ); for (const category of categoryById.values()) { expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain(category); } }); it('只登记游戏代码的项目:代码资产落在待归类栏目而不是被分区轴排除', () => { const manifest = createGameCreationAppManifest( 'code-only-project', '只登记游戏代码的项目', ); manifest.assets = [ { id: 'game-entry', kind: 'code', mediaType: 'text/html', localPath: 'game/index.html', source: { kind: 'generated' }, }, ]; const codeTask = manifest.tasks.find( (task) => task.id === 'code-prototype', ); if (!codeTask) { throw new Error('缺少游戏代码测试任务'); } codeTask.status = 'completed'; codeTask.artifacts = ['game/main.js']; const resources = projectResourcesFromReadModels(manifest, [], []); const categories = resources.map((resource) => projectResourceCanvasCategory(resource), ); expect(resources.map((resource) => resource.id)).toEqual( expect.arrayContaining([ 'asset:game-entry', 'task:code-prototype:game/main.js', ]), ); // 旧四栏目口径把游戏代码排除在可见栏目之外,只登记代码的项目因此四栏全空。 expect(new Set(categories)).toEqual(new Set(['unclassified'])); expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain('unclassified'); }); it('资源类型判定只决定准入与显示类型,无法识别的二进制产物不进画布', () => { expect(projectedResourceKind({ path: 'game/main.js', mediaType: '' })).toBe( 'code', ); expect( projectedResourceKind({ path: 'assets/ui.svg', mediaType: 'image/svg+xml', }), ).toBe('art'); expect( projectedResourceKind({ path: 'audio/theme.wav', mediaType: '' }), ).toBe('audio'); expect( projectedResourceKind({ path: 'memory/gdd.md', mediaType: '' }), ).toBe('document'); expect( projectedResourceKind({ path: 'build/game.bundle', mediaType: 'application/octet-stream', }), ).toBeNull(); expect( projectedResourceKind({ path: 'imports/archive.zip', mediaType: 'application/zip', }), ).toBeNull(); }); });