2f8753f597
- 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合 - 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史 - RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER - reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留 - 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本 - 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类 - 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目 - 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区 - index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏 - index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2) - index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片 - 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染 - 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例 - 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互) - 同步 PRD 与共享记忆决策记录的栏目口径描述
579 lines
16 KiB
TypeScript
579 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|