Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts
T
suzmii c1ffb2386b 资源功能分类与自定义标签:筛选口径收敛为 category 并接入写入 UI
- 删除 resourceReferences.ts 中 mediaType 优先的 7 桶筛选口径与 resourceReferenceFilterKind,改由 resourceReferenceCategory / resourceReferenceMatchesCategoryFilter 以 manifest 资产 category 为唯一权威筛选口径
- RESOURCE_REFERENCE_FILTERS 改为 6 类 + 全部(全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类),并新增 resourceReferenceCategoryLabel 让中文名只定义一处
- ResourceReference 新增 category / tags,由 resourceReferenceFromAsset 经 gameCreationAppAssetCategory / gameCreationAppAssetTags 从 manifest 资产投影;资源画布 ProjectResource 同步新增 assetCategory / assetTags 投影与 projectResourceAssetCategory 待归类兜底
- 新增 Tauri 命令 update_local_project_resource_classification 与 UpdateLocalProjectResourceClassificationInput/Result,实现落在 project/manifest.rs 的 update_manifest_asset_classification_at,复用既有 mutate_manifest_at manifest 写锁与原子写入
- 命令持项目写锁后按 expectedProjectId / expectedProjectRevision 做 CAS,冲突分别返回 project-identity-conflict 与 project-revision-conflict;category 非 6 个合法值即报错且不回退到 kind 派生;tags 走 normalize_game_creation_app_asset_tags;只改目标条目的 category / tags 并在成功后推进一次项目 revision
- 新增 Rust 定向测试 project/manifest/classification_tests.rs:更新成功、非法分类被拒、tags 归一化、未引用资产更新不影响其它字段、revision/CAS 与既有写入一致、输入拒绝未知字段
- 前端新增 ResourceClassificationPanel(ThemedModal + PlatformSegmentedTabs + PlatformTextField),资源详情面板以「分类与标签」按钮打开独立面板,保存后重读 manifest 并经 onManifestChange 反映到筛选与投影
- @ 面板与资源画布共用 packages/shared 的 PlatformSegmentedTabs 渲染同一组功能分类标签;画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存,复用既有 sortMode 状态形状
- 更新 resourceReferenceInput.test.tsx 与 projectResourceProjectionModel.test.ts 覆盖新口径,新增 resourceClassificationPanel.test.tsx 覆盖写入命令的输入归一化与失败不回退
- 同步技术方案与 decision-log:记录按 mediaType 分类到按 category 分类的语义变化(图片 / 视频桶消失,image / video / code / publication-material 归待归类)与新写入命令契约
2026-09-10 10:25:22 +08:00

403 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
projectResourceAssetCategory,
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([
{
id: 'task:design-foundation:memory/game-design.md',
category: 'document',
},
{ id: 'asset:game-entry', category: 'code' },
{
id: 'task:design-foundation:assets/ui-preview.svg',
category: 'art',
},
{ id: 'asset:registered-bgm', category: 'audio' },
{ id: 'asset:character-animation', category: 'art' },
{ id: 'attachment:imports/rules.yaml', category: 'document' },
{ id: 'attachment:imports/voice.ogg', category: 'audio' },
{
id: 'agent-result:design-foundation:final-run',
category: 'document',
},
{
id: 'task:audio-asset-plan:audio/unregistered-bgm.wav',
category: 'audio',
},
{ 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({
category: 'version',
subtype: 'project-version',
path: 'versions/v1',
mediaType: '正式项目版本',
}),
).toBe('项目版本');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'agent-result',
path: '专业 Agent 文本回执',
mediaType: 'Agent 历史文本回执',
}),
).toBe('Agent 回执');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'task-artifact',
path: 'memory/plan.md',
mediaType: '项目文档',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'audio',
subtype: 'task-artifact',
path: 'audio/theme.wav',
mediaType: '音乐音效产物',
}),
).toBe('音频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/hero.svg',
mediaType: '美术产物',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/unknown',
mediaType: '美术产物',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.svg',
mediaType: 'image/svg+xml',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'video',
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
}),
).toBe('视频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.png',
mediaType: 'image/png',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'attachment',
path: 'docs/rules.yaml',
mediaType: 'application/yaml',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'code',
subtype: 'source',
path: 'game/main.ts',
mediaType: 'text/typescript',
}),
).toBe('游戏代码');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'unknown',
path: 'assets/blob',
mediaType: 'application/octet-stream',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'unsupported' as never,
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('未知');
});
});