fix: Windows spawn, prefab path, preview API, asset deps, error messages; add execute-context resource, tool descriptions, scene/script/asset tools
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
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { findUnusedAssets, collectUuidReferences } = require('../lib/tools/assets-advanced');
|
||||
|
||||
function createMockAssetsDir() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-unused-test-'));
|
||||
const assetsDir = path.join(tmpDir, 'assets');
|
||||
fs.mkdirSync(assetsDir, { recursive: true });
|
||||
return { tmpDir, assetsDir };
|
||||
}
|
||||
|
||||
function writeFile(dir, relPath, content) {
|
||||
const fullPath = path.join(dir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
}
|
||||
|
||||
test('findUnusedAssets returns assets not referenced by any serialized file', async () => {
|
||||
const { tmpDir, assetsDir } = createMockAssetsDir();
|
||||
try {
|
||||
const usedUuid = 'aaa11111-bbbb-cccc-dddd-eeeeeeee1111';
|
||||
writeFile(assetsDir, 'textures/used.png', 'fake-png');
|
||||
writeFile(assetsDir, 'textures/used.png.meta', JSON.stringify({ uuid: usedUuid }));
|
||||
|
||||
const unusedUuid = 'aaa22222-bbbb-cccc-dddd-eeeeeeee2222';
|
||||
writeFile(assetsDir, 'textures/unused.png', 'fake-png');
|
||||
writeFile(assetsDir, 'textures/unused.png.meta', JSON.stringify({ uuid: unusedUuid }));
|
||||
|
||||
writeFile(assetsDir, 'scenes/game.scene', JSON.stringify({
|
||||
__type__: 'cc.SceneAsset',
|
||||
scene: { __uuid__: usedUuid },
|
||||
}));
|
||||
|
||||
const mockListAssets = async () => [
|
||||
{ uuid: usedUuid, url: 'db://assets/textures/used.png', type: 'cc.Texture2D' },
|
||||
{ uuid: unusedUuid, url: 'db://assets/textures/unused.png', type: 'cc.Texture2D' },
|
||||
];
|
||||
|
||||
const result = await findUnusedAssets(tmpDir, {}, mockListAssets);
|
||||
assert.equal(result.totalScanned, 2);
|
||||
assert.equal(result.unusedCount, 1);
|
||||
assert.equal(result.unused[0].uuid, unusedUuid);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findUnusedAssets skips .ts and .scene files from unused list', async () => {
|
||||
const { tmpDir, assetsDir } = createMockAssetsDir();
|
||||
try {
|
||||
const scriptUuid = 'bbb11111-bbbb-cccc-dddd-eeeeeeee3333';
|
||||
const sceneUuid = 'bbb22222-bbbb-cccc-dddd-eeeeeeee4444';
|
||||
|
||||
writeFile(assetsDir, 'scripts/MyScript.ts', 'export class MyScript {}');
|
||||
writeFile(assetsDir, 'scripts/MyScript.ts.meta', JSON.stringify({ uuid: scriptUuid }));
|
||||
writeFile(assetsDir, 'scenes/level.scene', '{}');
|
||||
writeFile(assetsDir, 'scenes/level.scene.meta', JSON.stringify({ uuid: sceneUuid }));
|
||||
|
||||
const mockListAssets = async () => [
|
||||
{ uuid: scriptUuid, url: 'db://assets/scripts/MyScript.ts', type: 'cc.ScriptAsset' },
|
||||
{ uuid: sceneUuid, url: 'db://assets/scenes/level.scene', type: 'cc.SceneAsset' },
|
||||
];
|
||||
|
||||
const result = await findUnusedAssets(tmpDir, {}, mockListAssets);
|
||||
assert.equal(result.totalScanned, 2);
|
||||
assert.equal(result.unusedCount, 0);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findUnusedAssets respects excludeDirectories', async () => {
|
||||
const { tmpDir, assetsDir } = createMockAssetsDir();
|
||||
try {
|
||||
const referencedUuid = 'ccc11111-bbbb-cccc-dddd-eeeeeeee5555';
|
||||
const unreferencedUuid = 'ccc22222-bbbb-cccc-dddd-eeeeeeee6666';
|
||||
|
||||
writeFile(assetsDir, 'textures/a.png', 'fake');
|
||||
writeFile(assetsDir, 'textures/a.png.meta', JSON.stringify({ uuid: referencedUuid }));
|
||||
writeFile(assetsDir, 'internal/b.png', 'fake');
|
||||
writeFile(assetsDir, 'internal/b.png.meta', JSON.stringify({ uuid: unreferencedUuid }));
|
||||
|
||||
writeFile(assetsDir, 'internal/hidden.scene', JSON.stringify({
|
||||
__type__: 'cc.SceneAsset',
|
||||
tex: { __uuid__: referencedUuid },
|
||||
}));
|
||||
|
||||
const mockListAssets = async () => [
|
||||
{ uuid: referencedUuid, url: 'db://assets/textures/a.png', type: 'cc.Texture2D' },
|
||||
{ uuid: unreferencedUuid, url: 'db://assets/internal/b.png', type: 'cc.Texture2D' },
|
||||
];
|
||||
|
||||
const result = await findUnusedAssets(tmpDir, {
|
||||
excludeDirectories: ['internal'],
|
||||
}, mockListAssets);
|
||||
|
||||
assert.equal(result.totalScanned, 2);
|
||||
assert.equal(result.unusedCount, 2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findUnusedAssets respects limit option', async () => {
|
||||
const { tmpDir, assetsDir } = createMockAssetsDir();
|
||||
try {
|
||||
const assets = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const uuid = `ddd${i}1111-bbbb-cccc-dddd-eeeeeeee777${i}`;
|
||||
writeFile(assetsDir, `textures/tex${i}.png`, 'fake');
|
||||
writeFile(assetsDir, `textures/tex${i}.png.meta`, JSON.stringify({ uuid }));
|
||||
assets.push({ uuid, url: `db://assets/textures/tex${i}.png`, type: 'cc.Texture2D' });
|
||||
}
|
||||
|
||||
const mockListAssets = async () => assets;
|
||||
|
||||
const result = await findUnusedAssets(tmpDir, { limit: 3 }, mockListAssets);
|
||||
assert.equal(result.totalScanned, 5);
|
||||
assert.equal(result.unusedCount, 5);
|
||||
assert.equal(result.unused.length, 3);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collectUuidReferences is re-exported and works', () => {
|
||||
const refs = collectUuidReferences(JSON.stringify({
|
||||
__uuid__: 'abc12345-def6-7890-abcd-ef1234567890',
|
||||
}));
|
||||
assert.ok(refs.some((r) => r.uuid === 'abc12345-def6-7890-abcd-ef1234567890'));
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
'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`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const {
|
||||
createScriptTools,
|
||||
generateComponentTemplate,
|
||||
generatePlainTemplate,
|
||||
} = require('../lib/tools/scripts');
|
||||
|
||||
test('generateComponentTemplate produces valid component with start and update', () => {
|
||||
const code = generateComponentTemplate('PlayerController');
|
||||
assert.ok(code.includes("import { _decorator, Component } from 'cc'"));
|
||||
assert.ok(code.includes("@ccclass('PlayerController')"));
|
||||
assert.ok(code.includes('export class PlayerController extends Component'));
|
||||
assert.ok(code.includes('start()'));
|
||||
assert.ok(code.includes('update(deltaTime: number)'));
|
||||
});
|
||||
|
||||
test('generateComponentTemplate includes primitive @property', () => {
|
||||
const code = generateComponentTemplate('Enemy', [
|
||||
{ name: 'speed', type: 'Number', default: 5 },
|
||||
{ name: 'label', type: 'String', default: "'Enemy'" },
|
||||
]);
|
||||
assert.ok(code.includes('@property'));
|
||||
assert.ok(code.includes('speed: number = 5'));
|
||||
assert.ok(code.includes("label: string = 'Enemy'"));
|
||||
assert.ok(!code.includes('Label'));
|
||||
});
|
||||
|
||||
test('generateComponentTemplate includes CC type @property', () => {
|
||||
const code = generateComponentTemplate('Weapon', [
|
||||
{ name: 'target', type: 'Label' },
|
||||
{ name: 'sprite', type: 'Sprite' },
|
||||
]);
|
||||
assert.ok(code.includes('@property(Label)'));
|
||||
assert.ok(code.includes('target: Label | null = null'));
|
||||
assert.ok(code.includes('@property(Sprite)'));
|
||||
assert.ok(code.includes('sprite: Sprite | null = null'));
|
||||
assert.ok(code.includes('import { _decorator, Component, Label, Sprite }'));
|
||||
});
|
||||
|
||||
test('generateComponentTemplate uses defaults when property default is omitted', () => {
|
||||
const code = generateComponentTemplate('Item', [
|
||||
{ name: 'count', type: 'Number' },
|
||||
{ name: 'flag', type: 'Boolean' },
|
||||
]);
|
||||
assert.ok(code.includes('count: number = 0'));
|
||||
assert.ok(code.includes('flag: boolean = false'));
|
||||
});
|
||||
|
||||
test('generateComponentTemplate with no properties has placeholder comment', () => {
|
||||
const code = generateComponentTemplate('Empty');
|
||||
assert.ok(code.includes('// Add properties here'));
|
||||
});
|
||||
|
||||
test('generatePlainTemplate produces minimal class', () => {
|
||||
const code = generatePlainTemplate('Utils');
|
||||
assert.ok(code.includes('export class Utils'));
|
||||
assert.ok(!code.includes('@ccclass'));
|
||||
assert.ok(!code.includes('@property'));
|
||||
});
|
||||
|
||||
test('create_script writes file and returns metadata', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-script-test-'));
|
||||
try {
|
||||
const createSchema = (properties, required) => {
|
||||
const schema = { type: 'object', properties };
|
||||
if (required && required.length) {
|
||||
schema.required = required;
|
||||
}
|
||||
return schema;
|
||||
};
|
||||
const getRuntimeContext = () => ({ projectPath: tmpDir });
|
||||
const tools = createScriptTools({ createSchema, getRuntimeContext });
|
||||
const tool = tools[0];
|
||||
|
||||
const result = await tool.handler({
|
||||
scriptName: 'TestComp',
|
||||
savePath: 'assets/scripts/TestComp.ts',
|
||||
template: 'component',
|
||||
properties: [{ name: 'speed', type: 'Number', default: 10 }],
|
||||
});
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.scriptName, 'TestComp');
|
||||
assert.equal(result.template, 'component');
|
||||
assert.equal(result.className, 'TestComp');
|
||||
|
||||
const written = fs.readFileSync(path.join(tmpDir, 'assets', 'scripts', 'TestComp.ts'), 'utf8');
|
||||
assert.ok(written.includes('export class TestComp extends Component'));
|
||||
assert.ok(written.includes('speed: number = 10'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('create_script rejects non-.ts savePath', async () => {
|
||||
const createSchema = () => ({ type: 'object', properties: {} });
|
||||
const getRuntimeContext = () => ({ projectPath: '/tmp' });
|
||||
const tools = createScriptTools({ createSchema, getRuntimeContext });
|
||||
await assert.rejects(
|
||||
() => tools[0].handler({ scriptName: 'X', savePath: 'assets/scripts/X.js' }),
|
||||
/must end with \.ts/
|
||||
);
|
||||
});
|
||||
|
||||
test('create_script requires scriptName and savePath', async () => {
|
||||
const createSchema = () => ({ type: 'object', properties: {} });
|
||||
const getRuntimeContext = () => ({ projectPath: '/tmp' });
|
||||
const tools = createScriptTools({ createSchema, getRuntimeContext });
|
||||
await assert.rejects(() => tools[0].handler({ savePath: 'X.ts' }), /scriptName is required/);
|
||||
await assert.rejects(() => tools[0].handler({ scriptName: 'X' }), /savePath is required/);
|
||||
});
|
||||
|
||||
test('create_script plain template writes minimal class', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-script-test-'));
|
||||
try {
|
||||
const createSchema = () => ({ type: 'object', properties: {} });
|
||||
const getRuntimeContext = () => ({ projectPath: tmpDir });
|
||||
const tools = createScriptTools({ createSchema, getRuntimeContext });
|
||||
|
||||
const result = await tools[0].handler({
|
||||
scriptName: 'Helper',
|
||||
savePath: 'assets/scripts/Helper.ts',
|
||||
template: 'plain',
|
||||
});
|
||||
|
||||
assert.equal(result.template, 'plain');
|
||||
const written = fs.readFileSync(path.join(tmpDir, 'assets', 'scripts', 'Helper.ts'), 'utf8');
|
||||
assert.ok(written.includes('export class Helper'));
|
||||
assert.ok(!written.includes('@ccclass'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -21,7 +21,7 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
|
||||
|
||||
test('core profile exposes the documented focused tool set', () => {
|
||||
const tools = createRegistry('core').listTools();
|
||||
assert.equal(tools.length, 37);
|
||||
assert.equal(tools.length, 38);
|
||||
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
|
||||
@@ -31,12 +31,13 @@ test('core profile exposes the documented focused tool set', () => {
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'query_scene_state'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
|
||||
});
|
||||
|
||||
test('full profile exposes all built-in tools', () => {
|
||||
const tools = createRegistry('full').listTools();
|
||||
assert.equal(tools.length, 101);
|
||||
assert.equal(tools.length, 110);
|
||||
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
|
||||
|
||||
Reference in New Issue
Block a user