Bug fixes (verified against Cocos Creator 3.8.8): - diagnostics: shell:true for .cmd/.bat on Windows (T125/T126) - prefabs: duplicatePrefab target resolves under assets/ (T420) - cocos-project: add preview.open candidate for 3.8.8 (T444) - assets-advanced: detect directory assets in inspectAssetDependencies (T110) - scene: improve component-not-found errors with compilation hint (T429) Documentation improvements: - tool-registry: add enum and injected vars to execute_javascript, path format examples, asset ref limitation note - resources: add cocos://mcp/execute-context resource with variables, patterns, pitfalls New tools (core 37->38, full 101->110): - scene-management: create_scene, query_scene_state (core), copy_paste_node, rename_node, reparent_node - scripts: create_script with component/plain templates - prefabs: create_prefab - assets-advanced: batch_asset_ops, find_unused_assets
360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const { createSceneManagementTools, buildSceneContent } = require('../lib/tools/scene-management');
|
|
|
|
function makeTools(overrides = {}) {
|
|
const createSchema = (properties, required) => {
|
|
const schema = { type: 'object', properties };
|
|
if (required && required.length) {
|
|
schema.required = required;
|
|
}
|
|
return schema;
|
|
};
|
|
const calls = [];
|
|
const sceneBridge = {
|
|
call: async (method, args) => {
|
|
calls.push({ method, args });
|
|
return { ok: true, method, args };
|
|
},
|
|
};
|
|
const tools = createSceneManagementTools({ createSchema, sceneBridge, ...overrides });
|
|
const byName = {};
|
|
for (const tool of tools) {
|
|
byName[tool.name] = tool;
|
|
}
|
|
return { tools, byName, calls, sceneBridge, createSchema };
|
|
}
|
|
|
|
function mockEditor(requestImpl) {
|
|
global.Editor = {
|
|
Message: {
|
|
request: requestImpl,
|
|
},
|
|
};
|
|
}
|
|
|
|
function unmockEditor() {
|
|
delete global.Editor;
|
|
}
|
|
|
|
test('create_scene requires sceneName and savePath', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.create_scene.handler({ savePath: 'db://assets/x.scene' }), /sceneName is required/);
|
|
await assert.rejects(() => byName.create_scene.handler({ sceneName: 'X' }), /savePath is required/);
|
|
});
|
|
|
|
test('create_scene rejects non-.scene savePath', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(
|
|
() => byName.create_scene.handler({ sceneName: 'X', savePath: 'db://assets/x.prefab' }),
|
|
/must end with \.scene/
|
|
);
|
|
});
|
|
|
|
test('buildSceneContent reads template and replaces scene name', () => {
|
|
const content = buildSceneContent('MyNewScene');
|
|
const json = JSON.parse(content);
|
|
assert.equal(json[0]._name, 'MyNewScene');
|
|
assert.equal(json[1]._name, 'MyNewScene');
|
|
assert.equal(json[0].__type__, 'cc.SceneAsset');
|
|
assert.equal(json[1].__type__, 'cc.Scene');
|
|
assert.ok(Array.isArray(json[1]._children), 'Scene should have _children array');
|
|
assert.equal(json[1]._children.length, 0, 'Template scene should have no children');
|
|
const globalTypes = json.slice(2).map((o) => o.__type__);
|
|
assert.ok(globalTypes.includes('cc.SceneGlobals'));
|
|
assert.ok(globalTypes.includes('cc.AmbientInfo'));
|
|
assert.ok(globalTypes.includes('cc.ShadowsInfo'));
|
|
assert.ok(globalTypes.includes('cc.SkyboxInfo'));
|
|
assert.ok(globalTypes.includes('cc.FogInfo'));
|
|
assert.ok(globalTypes.includes('cc.OctreeInfo'));
|
|
assert.ok(globalTypes.includes('cc.SkinInfo'));
|
|
assert.ok(globalTypes.includes('cc.LightProbeInfo'));
|
|
assert.ok(globalTypes.includes('cc.PostSettingsInfo'));
|
|
});
|
|
|
|
test('buildSceneContent throws on missing template file', () => {
|
|
assert.throws(
|
|
() => buildSceneContent('Test', '/nonexistent/path/template.scene'),
|
|
/Failed to read scene template/
|
|
);
|
|
});
|
|
|
|
test('create_scene creates asset with template content and returns result', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { uuid: 'scene-uuid-1', url: 'db://assets/scenes/NewScene.scene' };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.create_scene.handler({
|
|
sceneName: 'NewScene',
|
|
savePath: 'db://assets/scenes/NewScene.scene',
|
|
open: false,
|
|
});
|
|
assert.equal(result.created, true);
|
|
assert.equal(result.sceneName, 'NewScene');
|
|
assert.equal(result.uuid, 'scene-uuid-1');
|
|
assert.equal(result.opened, false);
|
|
assert.equal(requests.length, 1);
|
|
assert.equal(requests[0].method, 'create-asset');
|
|
assert.equal(requests[0].args[0], 'db://assets/scenes/NewScene.scene');
|
|
const content = requests[0].args[1];
|
|
assert.ok(typeof content === 'string' && content.length > 0, 'content should be non-empty string');
|
|
const json = JSON.parse(content);
|
|
assert.equal(json[0]._name, 'NewScene');
|
|
assert.equal(json[1]._name, 'NewScene');
|
|
assert.equal(json[0].__type__, 'cc.SceneAsset');
|
|
assert.equal(json[1].__type__, 'cc.Scene');
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('create_scene with overwrite passes option to create-asset', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { uuid: 'scene-uuid-2', url: 'db://assets/scenes/OverwriteScene.scene' };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
await byName.create_scene.handler({
|
|
sceneName: 'OverwriteScene',
|
|
savePath: 'db://assets/scenes/OverwriteScene.scene',
|
|
open: false,
|
|
overwrite: true,
|
|
});
|
|
assert.equal(requests[0].args.length, 3, 'should have 3 args: url, content, option');
|
|
assert.deepEqual(requests[0].args[2], { overwrite: true });
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('query_scene_state is_dirty calls query-dirty', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method) => {
|
|
requests.push({ channel, method });
|
|
return true;
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.query_scene_state.handler({ action: 'is_dirty' });
|
|
assert.equal(result.action, 'is_dirty');
|
|
assert.equal(result.result, true);
|
|
assert.equal(requests[0].method, 'query-dirty');
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('query_scene_state is_ready calls query-is-ready', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method) => {
|
|
requests.push({ channel, method });
|
|
return true;
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.query_scene_state.handler({ action: 'is_ready' });
|
|
assert.equal(result.action, 'is_ready');
|
|
assert.equal(requests[0].method, 'query-is-ready');
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('query_scene_state soft_reload calls soft-reload', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method) => {
|
|
requests.push({ channel, method });
|
|
return null;
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.query_scene_state.handler({ action: 'soft_reload' });
|
|
assert.equal(result.action, 'soft_reload');
|
|
assert.equal(requests[0].method, 'soft-reload');
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('query_scene_state rejects unknown action', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.query_scene_state.handler({ action: 'unknown' }), /Unknown action/);
|
|
});
|
|
|
|
test('copy_paste_node copy requires uuids', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.copy_paste_node.handler({ action: 'copy' }), /uuids is required/);
|
|
});
|
|
|
|
test('copy_paste_node copy calls copy-node', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { ok: true };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.copy_paste_node.handler({
|
|
action: 'copy',
|
|
uuids: ['uuid-1', 'uuid-2'],
|
|
});
|
|
assert.equal(result.action, 'copy');
|
|
assert.deepEqual(result.uuids, ['uuid-1', 'uuid-2']);
|
|
assert.equal(requests[0].method, 'copy-node');
|
|
assert.deepEqual(requests[0].args[0], ['uuid-1', 'uuid-2']);
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('copy_paste_node cut calls cut-node', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { ok: true };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.copy_paste_node.handler({
|
|
action: 'cut',
|
|
uuids: ['uuid-1'],
|
|
});
|
|
assert.equal(result.action, 'cut');
|
|
assert.equal(requests[0].method, 'cut-node');
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('copy_paste_node paste requires target', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.copy_paste_node.handler({ action: 'paste' }), /target is required/);
|
|
});
|
|
|
|
test('copy_paste_node paste calls paste-node with target and keepWorldTransform', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { ok: true };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.copy_paste_node.handler({
|
|
action: 'paste',
|
|
target: 'parent-uuid',
|
|
keepWorldTransform: true,
|
|
});
|
|
assert.equal(result.action, 'paste');
|
|
assert.equal(result.target, 'parent-uuid');
|
|
assert.equal(result.keepWorldTransform, true);
|
|
assert.equal(requests[0].method, 'paste-node');
|
|
assert.deepEqual(requests[0].args[0], { target: 'parent-uuid', keepWorldTransform: true });
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('copy_paste_node paste with uuids passes them in options', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return { ok: true };
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
await byName.copy_paste_node.handler({
|
|
action: 'paste',
|
|
target: 'parent-uuid',
|
|
uuids: ['copied-uuid-1'],
|
|
});
|
|
assert.equal(requests[0].method, 'paste-node');
|
|
assert.deepEqual(requests[0].args[0], {
|
|
target: 'parent-uuid',
|
|
keepWorldTransform: false,
|
|
uuids: ['copied-uuid-1'],
|
|
});
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('copy_paste_node duplicate calls duplicate-node', async () => {
|
|
const requests = [];
|
|
mockEditor(async (channel, method, ...args) => {
|
|
requests.push({ channel, method, args });
|
|
return ['new-uuid-1'];
|
|
});
|
|
try {
|
|
const { byName } = makeTools();
|
|
const result = await byName.copy_paste_node.handler({
|
|
action: 'duplicate',
|
|
uuids: ['uuid-1'],
|
|
});
|
|
assert.equal(result.action, 'duplicate');
|
|
assert.deepEqual(result.uuids, ['uuid-1']);
|
|
assert.equal(requests[0].method, 'duplicate-node');
|
|
assert.deepEqual(requests[0].args[0], ['uuid-1']);
|
|
} finally {
|
|
unmockEditor();
|
|
}
|
|
});
|
|
|
|
test('copy_paste_node duplicate requires uuids', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.copy_paste_node.handler({ action: 'duplicate' }), /uuids is required/);
|
|
});
|
|
|
|
test('copy_paste_node rejects unknown action', async () => {
|
|
const { byName } = makeTools();
|
|
await assert.rejects(() => byName.copy_paste_node.handler({ action: 'invalid' }), /Unknown action/);
|
|
});
|
|
|
|
test('rename_node calls sceneBridge with renameNode', async () => {
|
|
const { byName, calls } = makeTools();
|
|
await byName.rename_node.handler({ path: 'Canvas/TestNode', newName: 'RenamedNode' });
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].method, 'renameNode');
|
|
assert.equal(calls[0].args.path, 'Canvas/TestNode');
|
|
assert.equal(calls[0].args.newName, 'RenamedNode');
|
|
});
|
|
|
|
test('reparent_node calls sceneBridge with reparentNode', async () => {
|
|
const { byName, calls } = makeTools();
|
|
await byName.reparent_node.handler({
|
|
path: 'Canvas/TestNode',
|
|
targetPath: 'Canvas/OtherParent',
|
|
siblingIndex: 2,
|
|
keepWorldTransform: true,
|
|
});
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].method, 'reparentNode');
|
|
assert.equal(calls[0].args.path, 'Canvas/TestNode');
|
|
assert.equal(calls[0].args.targetPath, 'Canvas/OtherParent');
|
|
assert.equal(calls[0].args.siblingIndex, 2);
|
|
assert.equal(calls[0].args.keepWorldTransform, true);
|
|
});
|
|
|
|
test('all 5 tools have correct profiles', () => {
|
|
const { byName } = makeTools();
|
|
assert.equal(byName.create_scene.profile, 'full');
|
|
assert.equal(byName.query_scene_state.profile, 'core');
|
|
assert.equal(byName.copy_paste_node.profile, 'full');
|
|
assert.equal(byName.rename_node.profile, 'full');
|
|
assert.equal(byName.reparent_node.profile, 'full');
|
|
});
|
|
|
|
test('all 5 tools have inputSchema with type object', () => {
|
|
const { byName } = makeTools();
|
|
for (const name of Object.keys(byName)) {
|
|
assert.equal(byName[name].inputSchema.type, 'object', `${name} should have object schema`);
|
|
assert.ok(byName[name].inputSchema.properties, `${name} should have properties`);
|
|
}
|
|
});
|