Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts
T
suzmii 7c5bcc4fb0 内部版本登记后立即宣告清单失效,版本卡不再需要重开项目才出现
- 根因:版本追加只写盘、不发「清单已变」事件,而前端被当作「回合结束刷新」的那次调用实际发生在入队时(useDirectProjectChatController.ts:588-592 紧跟 startTurn 的 finally,await 的只是 enqueue_direct_codex_turn),于是 agent 追加版本后无人重读清单,新卡必须先重开项目才出现(旁证:resource-layouts/dependency.json 里 74 分钟缺该版本,直到重开项目才补齐)
- 修复:版本真正追加成功时才 emit_game_creator_manifest_invalidated(root, "direct-codex.version")(与素材工具桥同口径),订阅侧立刻重读清单
- 版本卡显示顺序:orderIndex(内部版本序数)+ compareResourceOrder,修正初始版本被排到最后(4 版读作 2/3/4/1 → 现为 1..N 单调);不带 orderIndex 的资源排序与坐标逐值不变
- 最新版本卡角标:`最新 · 发布标签 v{N}`(只给 versions 最后一条,N = versions.length = 发布面板取的标签)
- 发布面板文案:「本次发布标签 = 最新内部版本 v{N};agent 再改进一次后,下一次发布将是 v{N+1}」,并保留「平台线上号是平台侧事实、与本标签互不推导」
- 验证:vitest apps/ai-game-creator-shell/tests 1935 passed / 0 failed;cargo check(带编辑器 feature)通过;encoding / typecheck 通过;真机未验(环境重新拉起前无法核验,待补)
2026-10-04 15:13:31 +08:00

586 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'code',
mediaType: 'text/html',
localPath: 'game/index.html',
source: { kind: 'generated' },
},
{
id: 'registered-bgm',
// `bgm` 已不在 canonical kind 词表里,运行时会收口成 `unknown`(落「待归类」);
// 这里直接写真实 union 成员,保持投影结果不变。
kind: 'unknown',
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',
// 真实词表用 `unknown` 表示无法解析的二进制产物,替换已退役的 `binary`。
kind: 'unknown',
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');
// 只有最新内部版本(数组最后一条)带发布标签:它等于 `versions.length`,
// 也就是发布面板「项目版本」取的同一个数;角标只长在这一张卡上。
expect(versions[0]?.version?.publicationTag).toBeUndefined();
expect(versions[1]?.version?.publicationTag).toBe(2);
});
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();
});
});