Files
Genarrative/packages/shared/src/contracts/gameCreationApp.test.ts
T
kdletters 54d0fb75ea 接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
2026-09-20 16:35:04 +08:00

1105 lines
36 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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_CAPABILITIES,
GAME_CREATION_AGENT_RUN_MAX_PASSES,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_ASSET_CATEGORIES,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_ASSET_KINDS,
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAgentRunTrace,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
gameCreationAppAssetCategoryForRawKind,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
type GameCreationAppManifest,
isGameCreationAppAssetAudioKind,
isGameCreationAppAssetVisualKind,
isGameCreationAppUiDesignDocAsset,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
parseGameCreationAppAssetKind,
PROJECT_RESOURCE_CANVAS_SECTIONS,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
describe('AI 游戏创作 App 共享契约', () => {
// 严格解析会给每个非 canonical 原值打一条留痕日志;契约用例大量喂 legacy 值,
// 这里只关心返回值,不把留痕刷进测试输出。
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(67);
expect(commandIds).toContain('project.bootstrap');
expect(commandIds).toContain('project.git_inspect');
expect(commandIds).toContain('project.git_commit');
expect(commandIds).toContain('project.patchset');
expect(commandIds).toContain('command.exec');
expect(commandIds).toContain('command.output_read');
expect(commandIds).toContain('command.start');
expect(commandIds).toContain('command.poll');
expect(commandIds).toContain('command.stdin');
expect(commandIds).toContain('command.terminate');
expect(commandIds).toContain('cocos.editor.execute');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'godot.editor.execute',
)?.permission,
).toBe('confirm');
expect(commandIds).toContain('mcp.call');
expect(commandIds.indexOf('command.exec')).toBe(
commandIds.indexOf('command.run_limited') + 1,
);
expect(commandIds.indexOf('command.output_read')).toBe(
commandIds.indexOf('command.exec') + 1,
);
expect(commandIds.indexOf('command.start')).toBe(
commandIds.indexOf('command.output_read') + 1,
);
expect(commandIds.indexOf('command.poll')).toBe(
commandIds.indexOf('command.start') + 1,
);
expect(commandIds.indexOf('command.stdin')).toBe(
commandIds.indexOf('command.poll') + 1,
);
expect(commandIds.indexOf('command.terminate')).toBe(
commandIds.indexOf('command.stdin') + 1,
);
expect(commandIds.indexOf('project.git_commit')).toBe(
commandIds.indexOf('project.git_inspect') + 1,
);
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'help.show')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.run_limited',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.rename',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.exec',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.output_read',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.start',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.poll',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.stdin',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.terminate',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.git_inspect',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.git_commit',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.patchset',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.verify',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'game.generate_draft',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'game.run_local',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.export_package',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.export_list',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.status',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'task.list')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'agent.trace_read',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'agent.delegate',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'agent.spawn_isolated',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'agent.capabilities',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'agent.audit')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'llm.config_check',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'mcp.call')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'asset.list')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'asset.register',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'image.inspect',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'preview.open',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'preview.status',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'preview.validate',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.read')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.delete')
?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'memory.read')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'conversation.read',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'conversation.write',
)?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'canvas.project_open',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'canvas.project_sync',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'canvas.asset_generate',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'canvas.export_import',
)?.permission,
).toBe('confirm');
});
it('lists the standard agent capabilities expected by the editor app', () => {
const capabilityIds = GAME_CREATION_AGENT_CAPABILITIES.map(
(capability) => capability.id,
);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(41);
expect(capabilityIds).toEqual(
expect.arrayContaining([
'chat',
'project-supervisor',
'persistent-user-input',
'file-upload',
'llm-draft-generation',
'provider-web-search',
'mcp-tools',
'task-decomposition',
'orchestration',
'agent-loop',
'task-graph-agenda',
'evaluator-repair-routing',
'tool-call-budget',
'isolated-subagents',
'repository-startup-context',
'persistent-process-sessions',
'browser-validation',
'visual-inspection',
'persistent-runner',
'multi-agent-collaboration',
'role-level-collaboration',
'repair-loop-carryover',
'short-term-memory',
'long-term-memory',
'conversation-history',
'canvas-project-sync',
'local-preview',
'developer-window',
'command-exec',
'os-workspace-sandbox',
'command-output-read',
]),
);
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'persistent-user-input',
),
).toEqual({
id: 'persistent-user-input',
area: 'agent-runtime',
title: '持久用户澄清请求',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'provider-web-search',
),
).toEqual({
id: 'provider-web-search',
area: 'agent-runtime',
title: 'Provider 原生联网检索',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'command-exec',
),
).toEqual({
id: 'command-exec',
area: 'dev-runtime',
title: '结构化项目命令执行(Linux 工作区沙箱)',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'os-workspace-sandbox',
),
).toEqual({
id: 'os-workspace-sandbox',
area: 'dev-runtime',
title: 'Linux OS 强制工作区沙箱',
platforms: ['linux'],
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'command-output-read',
),
).toEqual({
id: 'command-output-read',
area: 'dev-runtime',
title: '命令输出分页回查',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'visual-inspection',
),
).toEqual({
id: 'visual-inspection',
area: 'local-runtime',
title: '模型视觉检查',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'persistent-process-sessions',
),
).toEqual({
id: 'persistent-process-sessions',
area: 'local-runtime',
title: 'Runner 托管的持久进程会话',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'conversation-history',
)?.title,
).toBe('对话记录上下文');
});
it('keeps at least one concrete limited run command for local verification', () => {
expect(GAME_CREATION_APP_LIMITED_RUN_COMMANDS).toContainEqual({
id: 'game.static_smoke',
title: '静态入口自检',
});
});
it('defines the agent run trace stored in .agent/run.latest.json', () => {
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-1',
commandId: 'game.generate_draft',
status: 'preview-running',
passes: 2,
maxPasses: GAME_CREATION_AGENT_RUN_MAX_PASSES,
toolCallCount: 1,
maxToolCalls: GAME_CREATION_AGENT_TOOL_CALL_MAX,
stopReason: 'preview-running',
goal: '像素动作',
coordination: 'filesystem',
nextStep: 'manual-playtest',
error: null,
updatedAt: 123,
artifacts: [
{
path: '.agent/run.latest.json',
sizeBytes: 100,
checksum: 'fnv1a64:abc',
},
{ path: 'game/index.html', sizeBytes: 200, checksum: 'fnv1a64:def' },
],
steps: [
{
pass: 2,
agent: 'Preview',
phase: 'preview',
taskId: 'preview-playtest',
group: 'code',
role: 'Playtest',
status: 'running',
inputPaths: ['game/index.html'],
outputPaths: ['.agent/manifest.json'],
summary: '本地 HTTP 预览已启动',
toolCalls: [
{
toolId: 'preview.start',
status: 'running',
inputPaths: ['game/index.html'],
outputPaths: ['.agent/manifest.json'],
summary: '127.0.0.1',
},
],
},
],
taskGraph: {
goal: '像素动作',
readyTaskIds: ['preview-playtest'],
activeTaskIds: ['preview-playtest'],
carriedTaskIds: ['preview-readiness'],
repairFocus: [],
repairRoutes: [
{
issue: 'preview smoke failed',
taskIds: ['preview-playtest'],
reason: 'code-runtime',
},
],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [
{
pass: 2,
mode: 'repair',
summary: '第 2 轮只返工预览试玩',
activeTaskIds: ['preview-playtest'],
carriedTaskIds: ['preview-readiness'],
dependencyWaves: [['preview-playtest']],
repairFocus: ['preview smoke failed'],
repairRoutes: [
{
issue: 'preview smoke failed',
taskIds: ['preview-playtest'],
reason: 'code-runtime',
},
],
},
],
};
expect(trace.schemaVersion).toBe('game-creator-agent-run.v1');
expect(trace.maxPasses).toBe(3);
expect(trace.stopReason).toBe('preview-running');
expect(trace.artifacts[0]?.checksum).toBe('fnv1a64:abc');
expect(trace.taskGraph.activeTaskIds).toEqual(['preview-playtest']);
expect(trace.taskGraph.repairRoutes[0]?.taskIds).toEqual([
'preview-playtest',
]);
expect(trace.passPlans[0]?.dependencyWaves).toEqual([['preview-playtest']]);
expect(trace.steps[0]?.taskId).toBe('preview-playtest');
expect(trace.steps[0]?.phase).toBe('preview');
expect(trace.steps[0]?.toolCalls[0]?.toolId).toBe('preview.start');
});
it('creates the local manifest shell', () => {
const manifest = createGameCreationAppManifest('project-1', '像素动作原型');
expect(manifest.schemaVersion).toBe(
GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
);
expect(manifest.projectId).toBe('project-1');
expect(manifest.name).toBe('像素动作原型');
expect(manifest.godotProjectRoot).toBeUndefined();
expect(manifest.assets).toEqual([]);
expect(manifest.tasks.map((task) => task.id)).toEqual([
'design-director',
'art-director',
'design-foundation',
'balance-director',
'balance-seed',
'art-asset-plan',
'art-polish',
'audio-director',
'audio-asset-plan',
'code-director',
'code-prototype',
'quality-review',
'preview-readiness',
'preview-playtest',
'publish-strategy',
'publish-package',
]);
for (const directorId of [
'design-director',
'art-director',
'code-director',
]) {
expect(
manifest.tasks.find((task) => task.id === directorId)?.dependencies,
).toEqual([]);
}
expect(
manifest.tasks.find((task) => task.id === 'design-foundation'),
).toEqual({
id: 'design-foundation',
title: '确定玩法规格与界面原型',
group: 'design',
role: 'Gameplay',
status: 'pending',
dependencies: ['design-director', 'art-director'],
artifacts: [
'memory/project.md',
'game/game_design.md',
'assets/ui-prototype.png',
],
acceptanceCriteria: [
'核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图',
],
});
expect(manifest.tasks.find((task) => task.id === 'art-asset-plan')).toEqual(
{
id: 'art-asset-plan',
title: '生成首版美术素材',
group: 'art',
role: 'Asset',
status: 'pending',
dependencies: ['art-director', 'design-foundation'],
artifacts: ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
acceptanceCriteria: [
'角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
],
},
);
expect(
manifest.tasks.find((task) => task.id === 'code-prototype')?.dependencies,
).toEqual([
'code-director',
'balance-seed',
'art-polish',
'audio-asset-plan',
]);
});
it('keeps the workspace-relative Godot project root in manifest JSON', () => {
const manifest: GameCreationAppManifest = {
...createGameCreationAppManifest('project-1', 'Godot 项目'),
godotProjectRoot: 'game-source',
};
expect(JSON.parse(JSON.stringify(manifest))).toMatchObject({
projectId: 'project-1',
godotProjectRoot: 'game-source',
});
});
it('selects ready tasks from dependency status', () => {
const manifest = createGameCreationAppManifest('project-1', '像素动作原型');
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['design-director', 'art-director', 'code-director']);
for (const directorId of [
'design-director',
'art-director',
'code-director',
]) {
manifest.tasks.find((task) => task.id === directorId)!.status =
'completed';
}
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['design-foundation']);
manifest.tasks.find((task) => task.id === 'design-foundation')!.status =
'completed';
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['balance-director', 'art-asset-plan', 'audio-director']);
for (const taskId of [
'balance-director',
'art-asset-plan',
'audio-director',
]) {
manifest.tasks.find((task) => task.id === taskId)!.status = 'completed';
}
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['balance-seed', 'art-polish', 'audio-asset-plan']);
manifest.tasks.find((task) => task.id === 'balance-seed')!.status =
'completed';
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['art-polish', 'audio-asset-plan']);
for (const taskId of ['art-polish', 'audio-asset-plan']) {
manifest.tasks.find((task) => task.id === taskId)!.status = 'completed';
}
expect(
selectGameCreationAppReadyTasks(manifest).map((task) => task.id),
).toEqual(['code-prototype']);
});
it('keeps the formal seed task graph acyclic with known dependencies', () => {
const tasks = createGameCreationAppSeedTasks();
const remaining = new Map(
tasks.map((task) => [task.id, new Set(task.dependencies)]),
);
const knownIds = new Set(remaining.keys());
expect(
tasks
.flatMap((task) => task.dependencies)
.every((id) => knownIds.has(id)),
).toBe(true);
let visited = 0;
while (remaining.size > 0) {
const readyIds = Array.from(remaining)
.filter(([, dependencies]) => dependencies.size === 0)
.map(([id]) => id);
expect(readyIds.length).toBeGreaterThan(0);
for (const id of readyIds) {
remaining.delete(id);
visited += 1;
}
for (const dependencies of remaining.values()) {
for (const id of readyIds) {
dependencies.delete(id);
}
}
}
expect(visited).toBe(tasks.length);
});
it('keeps canvas asset source fields camelCase', () => {
const manifest: GameCreationAppManifest = {
...createGameCreationAppManifest('project-1', '像素动作原型'),
preview: {
status: 'running',
url: 'http://127.0.0.1:3001/',
port: 3001,
},
commandRuns: [
{
commandId: 'game.static_smoke',
status: 'completed',
output: 'ok',
logPath: '.agent/logs/command.log',
updatedAt: 123,
},
],
versions: [
{
versionId: 'version-1',
parentVersionId: null,
projectRevision: 7,
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
createdReason: 'initial',
createdAt: 456,
},
],
assets: [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image',
localPath: 'assets/images/player.png',
source: {
kind: 'canvas',
canvasProjectId: 'canvas-project-1',
resourceId: 'resource-1',
assetObjectId: 'asset-object-1',
taskId: 'task-1',
prompt: '像素风主角',
model: 'image-model',
},
},
],
};
expect(JSON.parse(JSON.stringify(manifest)).assets[0].source).toMatchObject(
{
kind: 'canvas',
canvasProjectId: 'canvas-project-1',
resourceId: 'resource-1',
assetObjectId: 'asset-object-1',
},
);
expect(JSON.parse(JSON.stringify(manifest))).toMatchObject({
preview: {
status: 'running',
url: 'http://127.0.0.1:3001/',
port: 3001,
},
commandRuns: [
{
commandId: 'game.static_smoke',
status: 'completed',
logPath: '.agent/logs/command.log',
},
],
versions: [
{
versionId: 'version-1',
parentVersionId: null,
projectRevision: 7,
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
createdReason: 'initial',
createdAt: 456,
},
],
});
});
it('parses kind strictly and closes unknown values over unknown', () => {
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('character-animation');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('font');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown');
// 口径是严格接受:只认精确的 canonical 值。
for (const raw of ['future-kind', 'UI', 'FONT', ' image ']) {
expect(parseGameCreationAppAssetKind(raw, 'test.kind-boundary')).toBe(
'unknown',
);
expect(
gameCreationAppAssetCategoryForRawKind(raw, 'test.kind-category'),
).toBe('unclassified');
}
expect(parseGameCreationAppAssetKind('font', 'test.kind-boundary')).toBe(
'font',
);
expect(
parseGameCreationAppAssetKind('character', 'test.kind-boundary'),
).toBe('character');
expect(parseGameCreationAppAssetKind('unknown', 'test.kind-boundary')).toBe(
'unknown',
);
/**
* `Object.prototype` 上的键不再是特例:kind 查表用 `Set`,不存在原型命中,
* 与普通未知串一样收口成 `unknown`(派生 `unclassified`)。
*/
for (const prototypeKey of [
'constructor',
'__proto__',
'toString',
'valueOf',
'hasOwnProperty',
]) {
expect(parseGameCreationAppAssetKind(prototypeKey, 'test.legacy')).toBe(
'unknown',
);
expect(
gameCreationAppAssetCategoryForRawKind(prototypeKey, 'test.legacy'),
).toBe('unclassified');
}
});
it('shares exhaustive audio and visual kind families', () => {
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetAudioKind),
).toEqual(['audio', 'sound-effect', 'background-music']);
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetVisualKind),
).toEqual([
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'ui-design',
]);
});
it('识别 UI 编辑器文档资产只认 kind + mediaType 的唯一定义', () => {
expect(
isGameCreationAppUiDesignDocAsset({
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(true);
// 只满足一半不算文档:kind 对但 mediaType 不符、mediaType 对但 kind 不是文档。
expect(
isGameCreationAppUiDesignDocAsset({
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: 'text/plain',
}),
).toBe(false);
expect(
isGameCreationAppUiDesignDocAsset({
kind: 'ui-design',
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(false);
// 非 canonical kind 一律严格收口,不被当作文档(判据只做等值匹配)。
for (const raw of [
'UI',
'future-kind',
'ui-design-doc ',
'UI-DESIGN-DOC',
]) {
expect(
isGameCreationAppUiDesignDocAsset({
kind: raw,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(false);
}
});
it('maps every asset kind to a functional category', () => {
expect(GAME_CREATION_APP_ASSET_CATEGORIES).toHaveLength(6);
expect(Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND)).toHaveLength(
GAME_CREATION_APP_ASSET_KINDS.length,
);
for (const kind of GAME_CREATION_APP_ASSET_KINDS) {
expect(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind]).toBeDefined();
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind],
);
}
expect(gameCreationAppAssetCategoryForKind('icon')).toBe('ui-interaction');
expect(gameCreationAppAssetCategoryForKind('ui-design')).toBe(
'ui-interaction',
);
expect(gameCreationAppAssetCategoryForKind('character-animation')).toBe(
'character',
);
expect(gameCreationAppAssetCategoryForKind('scene')).toBe('scene');
expect(gameCreationAppAssetCategoryForKind('sound-effect')).toBe('audio');
expect(gameCreationAppAssetCategoryForKind('spec')).toBe('document');
for (const kind of [
'image',
'video',
'code',
'publication-material',
] as const) {
expect(gameCreationAppAssetCategoryForKind(kind)).toBe('unclassified');
}
// `font` 是 canonical 成员(现役写入侧直接写 `font`),分类是 `document`。
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
expect(
gameCreationAppAssetCategoryForRawKind('unknown-kind', 'test.unknown'),
).toBe('unclassified');
expect(gameCreationAppAssetCategoryForKind('unknown')).toBe('unclassified');
});
it('resolves asset category and tags with legacy and forward compatibility', () => {
const legacy: GameCreationAppAssetManifestEntry = {
id: 'asset-1',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
};
expect(gameCreationAppAssetCategory(legacy)).toBe('character');
expect(gameCreationAppAssetTags(legacy)).toEqual([]);
// 有意行为(2026-09-10 分类自愈批次):落盘 `unclassified` 而 kind 能派生出明确的
// 非 `unclassified` 分类时,读取侧采用派生值。这条规则用于自愈历史上被系统误写成
// `unclassified` 的存量由 canonical kind 的读显示口径决定,无需迁移脚本且永久生效。
// 已知盲区:用户手动把可明确分类的资产设为「待归类」时该手动值会被覆盖,属有意接受。
expect(
gameCreationAppAssetCategory({ ...legacy, category: 'unclassified' }),
).toBe('character');
expect(gameCreationAppAssetCategory({ ...legacy, category: 'audio' })).toBe(
'audio',
);
expect(
gameCreationAppAssetCategory({
...legacy,
category: 'future-category' as never,
}),
).toBe('character');
expect(
gameCreationAppAssetCategory({
kind: 'unknown-kind',
category: undefined,
}),
).toBe('unclassified');
expect(normalizeGameCreationAppAssetCategory(' audio ')).toBe('audio');
expect(normalizeGameCreationAppAssetCategory('future-category')).toBeNull();
expect(normalizeGameCreationAppAssetCategory(undefined)).toBeNull();
expect(
gameCreationAppAssetTags({
tags: ['主角', '战斗'],
}),
).toEqual(['主角', '战斗']);
expect(gameCreationAppAssetTags({ tags: undefined })).toEqual([]);
});
it('normalizes custom asset tags by trimming, dropping empty and deduping', () => {
expect(
normalizeGameCreationAppAssetTags([
' 主角 ',
'',
'主角',
' ',
'战斗',
'战斗',
]),
).toEqual(['主角', '战斗']);
expect(normalizeGameCreationAppAssetTags([])).toEqual([]);
});
/**
* 分类自愈规则的三条边界(2026-09-10 批次)。
*
* 规则:落盘 `category === 'unclassified'` 且该资产 `kind` 能派生出明确的非
* `unclassified` 分类时采用派生值;其余情况信任落盘值。目的是自愈历史上被系统误写成
* `unclassified` 的存量(真机出现过 UI 资产落盘 `unclassified` 而 kind 已是
* `ui-design` 的情况),不写迁移脚本且永久生效。
*
* 认不出的 kind 收口成 `unknown`,派生结果同样是 `unclassified`,规则不触发。
*/
it('自愈规则覆盖落盘 unclassified:这是显式接受的盲区', () => {
// 落盘 unclassified + kind 可明确分类 → 派生值覆盖落盘值。
// 盲区:用户手动把可明确分类的资产设为「待归类」时同样会被覆盖,属有意接受的
// 最小覆盖窗口;若将来产品需要「显式待归类」,应改为在 manifest 区分
// 「未设置」与「显式 unclassified」,而不是取消这条规则。
expect(
gameCreationAppAssetCategory({
kind: 'character',
category: 'unclassified',
}),
).toBe('character');
expect(
gameCreationAppAssetCategory({
kind: 'ui-design',
category: 'unclassified',
}),
).toBe('ui-interaction');
expect(
gameCreationAppAssetCategory({
kind: 'icon',
category: 'unclassified',
}),
).toBe('ui-interaction');
// unknown kind 不在自愈窗口内:它没有可派生的明确分类。
expect(
gameCreationAppAssetCategory({
kind: 'future-kind',
category: 'unclassified',
}),
).toBe('unclassified');
});
it('派生结果本身就是 unclassified 的 kind 不受自愈规则影响,保持落盘值', () => {
// image / video / code / publication-material 的 canonical 分类就是 unclassified,
// 派生结果等于落盘值,规则不触发,必须继续信任落盘值。
for (const kind of ['image', 'video', 'code', 'publication-material']) {
expect(
gameCreationAppAssetCategory({ kind, category: 'unclassified' }),
).toBe('unclassified');
}
// 无法识别的 kind 落到 image 兜底,派生结果同样是 unclassified。
expect(
gameCreationAppAssetCategory({
kind: 'unknown-kind',
category: 'unclassified',
}),
).toBe('unclassified');
});
it('落盘为明确分类时信任落盘值,不被 kind 派生结果覆盖', () => {
expect(
gameCreationAppAssetCategory({ kind: 'image', category: 'scene' }),
).toBe('scene');
expect(
gameCreationAppAssetCategory({ kind: 'character', category: 'audio' }),
).toBe('audio');
expect(
gameCreationAppAssetCategory({
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
category: 'document',
}),
).toBe('document');
});
/**
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
*
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
* `gameCreationAppAssetPersistedCategory` 只做兜底、不套自愈(写回必须原样)。
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
*/
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
const uiAsset = { kind: 'ui-design', category: 'unclassified' as const };
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
// 字段缺失时两个口径都按 kind 派生(与 Rust 反序列化的缺字段兜底一致)。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: undefined,
}),
).toBe('character');
// 非法值这一支只服务内存对象:Rust 读侧对未知 category 失败关闭,
// 比本客户端新的值根本读不出 manifest,不会以「派生值」的形式出现在 UI 里。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: 'future-category' as never,
}),
).toBe('character');
// 落盘值合法且非 unclassified 时两个口径一致。
expect(
gameCreationAppAssetPersistedCategory({
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
category: 'audio',
}),
).toBe('audio');
});
/**
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐——
* 分区顺序不是 ts-rs 生成物,只能靠这条用例跨语言钉住;两侧各写一份就一定会分叉。
*/
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
const rustSource = readFileSync(
resolve(
process.cwd(),
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
),
'utf8',
);
const start = rustSource.indexOf(
'pub const PROJECT_RESOURCE_CANVAS_SECTIONS',
);
expect(start).toBeGreaterThan(-1);
const body = rustSource.slice(
start,
rustSource.indexOf('];', start) + '];'.length,
);
const rustSections = Array.from(
body.matchAll(/ProjectResourceCanvasSection::(\w+)/gu),
).map((match) =>
// Rust 变体名 → kebab-case 线上值(与 `#[serde(rename_all = "kebab-case")]` 同口径)。
match[1]!.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(),
);
expect(rustSections).toEqual([...PROJECT_RESOURCE_CANVAS_SECTIONS]);
// 顺带钉住「资产分类轴 + 末尾独立 version」这条分区口径本身没有被改掉。
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toEqual([
...GAME_CREATION_APP_ASSET_CATEGORIES,
'version',
]);
});
});