54d0fb75ea
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
1105 lines
36 KiB
TypeScript
1105 lines
36 KiB
TypeScript
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',
|
||
]);
|
||
});
|
||
});
|