Files
cc-3-8-x-mcp/cli/test/api.test.js
T
furao 14c5b00f14 Initial public release: cc-3-8-x-mcp
Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI.

Components:
- Editor extension: in-process MCP server exposing scene / asset-db /
  preview / local / editor-process-control tools
- stdio router: aggregates multiple editor instances on one machine,
  with shortName dedup
- offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper
  around the Cocos CLI build

Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
2026-06-06 11:33:19 +08:00

1595 lines
58 KiB
JavaScript
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.
'use strict';
// ============================================================
// T8 api.test.js
// 测试链路:editPrefab() → 内存执行所有 ops → 落盘 → re-parse → 断言
// fixture: cli/test/fixtures/HomeUI.prefab(只读副本)
// 所有写操作走 tmp 文件,断言原 fixture + assets 原文件未变
// ============================================================
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { editPrefab } = require('../src/editor/index.js');
const { parsePrefab } = require('../src/parse.js');
const { listOverrides } = require('../src/overrides.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab');
// 项目根(含 assets/ + package.json),传给 editPrefab options.projectRoot
// 让 UuidResolver 在 /tmp/ 临时文件场景下也能定位 assets/ 目录
const PROJECT_ROOT = path.resolve(__dirname, '../../../../');
// 每个测试独立 tmp 文件
function makeTmp(tag) {
return path.join(os.tmpdir(), `HomeUI-api-${tag}-${Date.now()}.prefab`);
}
// 把 fixture 复制到 tmp,返回 tmp 路径(让 editPrefab 可以写回)
function cloneFixture(tag) {
const tmp = makeTmp(tag);
fs.copyFileSync(FIXTURE_PATH, tmp);
return tmp;
}
function md5(content) {
return crypto.createHash('md5').update(content).digest('hex');
}
// 记录生成的 tmp 路径,after 统一清理
const tmpFiles = [];
after(() => {
for (const f of tmpFiles) {
try { fs.unlinkSync(f); } catch (_) {}
}
});
// ─── fixture 存在性 ───────────────────────────────────────────
test('fixture 文件存在', () => {
assert.ok(fs.existsSync(FIXTURE_PATH), `fixture 不存在: ${FIXTURE_PATH}`);
});
// ─── set-position 普通节点 ────────────────────────────────────
test('set-position: 普通节点直改 _lpos', () => {
const tmp = cloneFixture('pos-normal');
tmpFiles.push(tmp);
// touchArea 是普通非 stub 节点,原始 _lpos.x = 0
const result = editPrefab(tmp, [
{ op: 'set-position', node: 'touchArea', x: 111, y: 222, z: 5 },
]);
assert.equal(result.changed, true);
assert.equal(result.opsApplied, 1);
assert.ok(Array.isArray(result.nodesAffected) && result.nodesAffected.length === 1);
// 验证写回结果
const reparsed = parsePrefab(tmp);
const node = reparsed.findNodeByName('touchArea');
assert.ok(node, 'touchArea 应存在');
assert.equal(node._lpos.x, 111, '_lpos.x 应为 111');
assert.equal(node._lpos.y, 222, '_lpos.y 应为 222');
assert.equal(node._lpos.z, 5, '_lpos.z 应为 5');
});
test('set-position: 按 {id: N} 定位普通节点', () => {
const tmp = cloneFixture('pos-byid');
tmpFiles.push(tmp);
// touchArea 在 fixture 里是 cc.Node,找其 __id__
const p0 = parsePrefab(FIXTURE_PATH);
const ta = p0.findNodeByName('touchArea');
let taId = -1;
for (let i = 0; i < p0.elements.length; i++) {
if (p0.elements[i] === ta) { taId = i; break; }
}
assert.ok(taId >= 0, 'touchArea id 应能找到');
const result = editPrefab(tmp, [
{ op: 'set-position', node: { id: taId }, x: 500, y: 600 },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const node = reparsed.findNodeByName('touchArea');
assert.equal(node._lpos.x, 500);
assert.equal(node._lpos.y, 600);
assert.equal(node._lpos.z, 0, 'z 默认为 0');
});
// ─── set-position stub 节点(走 override)────────────────────
test('set-position: stub 节点走 override 而非直改字段', () => {
const tmp = cloneFixture('pos-stub');
tmpFiles.push(tmp);
// stub 节点 id=10,已有 _lpos override {x:-272, y:53}
const STUB_ID = 10;
const result = editPrefab(tmp, [
{ op: 'set-position', node: { id: STUB_ID }, x: 99, y: -88, z: 0 },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const overrides = listOverrides(reparsed, STUB_ID);
const lposOverride = overrides.find((o) => o.propertyPath[0] === '_lpos');
assert.ok(lposOverride, 'stub 节点应有 _lpos override');
assert.equal(lposOverride.value.x, 99, 'override _lpos.x 应为 99');
assert.equal(lposOverride.value.y, -88, 'override _lpos.y 应为 -88');
// stub 节点本身的 _lpos 字段不应有 x=99(直接修改无效,这里验证 override 是主路径)
// 不验证 stub._lposstub 无此字段是正常的)
});
// ─── set-label-text ───────────────────────────────────────────
test('set-label-text: 普通节点修改 cc.Label._string', () => {
const tmp = cloneFixture('label');
tmpFiles.push(tmp);
// n7 节点上有 cc.Label,原始 text='开始'
const result = editPrefab(tmp, [
{ op: 'set-label-text', node: 'n7', text: 'Hello World' },
]);
assert.equal(result.opsApplied, 1);
assert.ok(result.nodesAffected.includes('n7'));
const reparsed = parsePrefab(tmp);
const labels = reparsed.findNodesByType('cc.Label');
const label = labels.find((l) => reparsed.elements[l.node.__id__]._name === 'n7');
assert.ok(label, 'n7 的 Label 应存在');
assert.equal(label._string, 'Hello World', 'Label 文字应已更新');
});
// ─── set-label-text stub 节点(T21:真正写 override,不再抛 unsupported)──
// ─── set-sprite-frame ─────────────────────────────────────────
test('set-sprite-frame: 普通节点修改 cc.Sprite._spriteFrame uuid', () => {
const tmp = cloneFixture('sprite');
tmpFiles.push(tmp);
const newUuid = 'aaaabbbb-cccc-dddd-eeee-000011112222@f9941';
const result = editPrefab(tmp, [
{ op: 'set-sprite-frame', node: 'n5', uuid: newUuid },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const sprites = reparsed.findNodesByType('cc.Sprite');
const sprite = sprites.find((s) => reparsed.elements[s.node.__id__]._name === 'n5');
assert.ok(sprite, 'n5 的 Sprite 应存在');
assert.equal(sprite._spriteFrame.__uuid__, newUuid, 'spriteFrame uuid 应已更新');
assert.equal(sprite._spriteFrame.__expectedType__, 'cc.SpriteFrame');
});
// ─── set-active ───────────────────────────────────────────────
test('set-active: 普通节点设置 _active = false', () => {
const tmp = cloneFixture('active');
tmpFiles.push(tmp);
const result = editPrefab(tmp, [
{ op: 'set-active', node: 'touchArea', active: false },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const node = reparsed.findNodeByName('touchArea');
assert.equal(node._active, false, '_active 应为 false');
});
test('set-active: stub 节点走 override', () => {
const tmp = cloneFixture('active-stub');
tmpFiles.push(tmp);
const STUB_ID = 10;
const result = editPrefab(tmp, [
{ op: 'set-active', node: { id: STUB_ID }, active: false },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const overrides = listOverrides(reparsed, STUB_ID);
const activeOverride = overrides.find((o) => o.propertyPath[0] === '_active');
assert.ok(activeOverride, 'stub 节点应有 _active override');
assert.equal(activeOverride.value, false, 'override _active 应为 false');
});
// ─── 批量混合 ops ─────────────────────────────────────────────
test('批量 opsposition + label-text + active 混合', () => {
const tmp = cloneFixture('batch');
tmpFiles.push(tmp);
const result = editPrefab(tmp, [
{ op: 'set-position', node: 'touchArea', x: 10, y: 20 },
{ op: 'set-label-text', node: 'n7', text: 'Batch Test' },
{ op: 'set-active', node: 'touchArea', active: false },
]);
assert.equal(result.opsApplied, 3);
// touchArea 被两个 op 命中,nodesAffected 去重后应有 2 个不同节点
assert.ok(result.nodesAffected.length >= 1, 'nodesAffected 至少 1 项');
const reparsed = parsePrefab(tmp);
const touchArea = reparsed.findNodeByName('touchArea');
assert.equal(touchArea._lpos.x, 10);
assert.equal(touchArea._active, false);
const labels = reparsed.findNodesByType('cc.Label');
const label = labels.find((l) => reparsed.elements[l.node.__id__]._name === 'n7');
assert.equal(label._string, 'Batch Test');
});
// ─── 失败回滚(不落盘)───────────────────────────────────────
test('失败回滚:其中一个 op 找不到节点,不落盘', () => {
const tmp = cloneFixture('rollback');
tmpFiles.push(tmp);
const originalContent = fs.readFileSync(tmp, 'utf8');
assert.throws(() => {
editPrefab(tmp, [
// 第 1 个 op 合法
{ op: 'set-position', node: 'touchArea', x: 999, y: 999 },
// 第 2 个 op 找不到节点 → 抛错
{ op: 'set-position', node: '__nonexistent_node__', x: 1, y: 2 },
]);
}, /找不到节点/);
// 文件内容应与修改前完全一致(未落盘)
const currentContent = fs.readFileSync(tmp, 'utf8');
assert.equal(
md5(currentContent),
md5(originalContent),
'发生错误时不应写回文件(原子性保证)'
);
});
test('失败回滚:unsupported op 类型,不落盘', () => {
const tmp = cloneFixture('rollback-unsupported');
tmpFiles.push(tmp);
const originalContent = fs.readFileSync(tmp, 'utf8');
assert.throws(() => {
editPrefab(tmp, [
{ op: 'unsupported-op-xyz', node: 'touchArea' },
]);
}, /不支持的 op 类型/);
const currentContent = fs.readFileSync(tmp, 'utf8');
assert.equal(md5(currentContent), md5(originalContent), '不支持的 op 不应落盘');
});
// ─── add-node:普通父节点 ─────────────────────────────────────
test('add-node: 普通节点父 → 新节点存在、parent._children 包含、__id__ 连续', () => {
const tmp = cloneFixture('add-normal');
tmpFiles.push(tmp);
// 在 touchArea(id=2) 下新增子节点
const result = editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'newChild', lpos: [10, 20, 0] } },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
// 新节点应能按名查到
const newNode = reparsed.findNodeByName('newChild');
assert.ok(newNode, '新节点 newChild 应存在');
assert.equal(newNode._name, 'newChild');
assert.equal(newNode._lpos.x, 10);
assert.equal(newNode._lpos.y, 20);
// touchArea 的 _children 应包含新节点
const parent = reparsed.findNodeByName('touchArea');
assert.ok(Array.isArray(parent._children) && parent._children.length > 0, 'touchArea._children 应非空');
// 新节点的 __id__ 应在 elements 数组内且是末尾之一
const newNodeId = reparsed.elements.indexOf(newNode);
assert.ok(newNodeId > 0, '新节点 __id__ 应 > 0');
const hasRef = parent._children.some((r) => r.__id__ === newNodeId);
assert.ok(hasRef, 'touchArea._children 应包含新节点 __id__');
// 新节点有 cc.PrefabInfo
const prefabInfo = reparsed.elements[newNode._prefab.__id__];
assert.ok(prefabInfo && prefabInfo.__type__ === 'cc.PrefabInfo', '新节点应有 cc.PrefabInfo');
assert.ok(typeof prefabInfo.fileId === 'string' && prefabInfo.fileId.length > 0, 'fileId 应非空');
assert.equal(prefabInfo.instance, null, '普通节点 instance 应为 null');
});
test('add-node: fileId 幂等 — 相同输入生成相同 fileId', () => {
const tmp1 = cloneFixture('add-fid-1');
const tmp2 = cloneFixture('add-fid-2');
tmpFiles.push(tmp1, tmp2);
const op = { op: 'add-node', parent: 'touchArea', node: { name: 'stableNode' } };
editPrefab(tmp1, [op]);
editPrefab(tmp2, [op]);
const p1 = parsePrefab(tmp1);
const p2 = parsePrefab(tmp2);
const n1 = p1.findNodeByName('stableNode');
const n2 = p2.findNodeByName('stableNode');
assert.ok(n1 && n2, '两次 add-node 都应生成 stableNode');
const fi1 = p1.elements[n1._prefab.__id__].fileId;
const fi2 = p2.elements[n2._prefab.__id__].fileId;
assert.equal(fi1, fi2, '相同种子应生成相同 fileId(幂等)');
});
test('add-node: 带 UITransform 组件 → 组件存在且 CompPrefabInfo 存在', () => {
const tmp = cloneFixture('add-uit');
tmpFiles.push(tmp);
editPrefab(tmp, [
{
op: 'add-node',
parent: 'touchArea',
node: { name: 'uitNode', components: ['UITransform'], width: 200, height: 80 },
},
]);
const reparsed = parsePrefab(tmp);
const newNode = reparsed.findNodeByName('uitNode');
assert.ok(newNode, 'uitNode 应存在');
assert.ok(Array.isArray(newNode._components) && newNode._components.length > 0, '应有组件');
// 找到 UITransform 组件
const uitComp = reparsed.elements[newNode._components[0].__id__];
assert.ok(uitComp, 'UITransform 组件应存在');
assert.equal(uitComp.__type__, 'cc.UITransform', '组件类型应是 cc.UITransform');
assert.equal(uitComp._contentSize.width, 200, '宽度应为 200');
assert.equal(uitComp._contentSize.height, 80, '高度应为 80');
// CompPrefabInfo 应存在
const cpi = reparsed.elements[uitComp.__prefab.__id__];
assert.ok(cpi && cpi.__type__ === 'cc.CompPrefabInfo', 'UITransform 应有 CompPrefabInfo');
assert.ok(typeof cpi.fileId === 'string' && cpi.fileId.length > 0, 'CompPrefabInfo.fileId 应非空');
});
// ─── add-node:stub 父节点 ────────────────────────────────────
test('add-node: stub 父 → 走 mountedChildren 路径', () => {
const tmp = cloneFixture('add-stub');
tmpFiles.push(tmp);
const STUB_ID = 10;
editPrefab(tmp, [
{ op: 'add-node', parent: { id: STUB_ID }, node: { name: 'mountedChild' } },
]);
const reparsed = parsePrefab(tmp);
// 新节点应能按名查到(findNodeByName 只走 _children,但 mountedChildren 不在其中)
// 直接在 elements 里找
const mountedNode = reparsed.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'mountedChild'
);
assert.ok(mountedNode, 'mountedChild 节点应在 elements 中');
// PrefabInstance.mountedChildren 应包含新节点
const stubNode = reparsed.elements[STUB_ID];
const stubPrefabInfo = reparsed.elements[stubNode._prefab.__id__];
const prefabInstance = reparsed.elements[stubPrefabInfo.instance.__id__];
assert.ok(Array.isArray(prefabInstance.mountedChildren), 'mountedChildren 应是数组');
const newNodeId = reparsed.elements.indexOf(mountedNode);
const hasRef = prefabInstance.mountedChildren.some((r) => r.__id__ === newNodeId);
assert.ok(hasRef, 'mountedChildren 应包含新节点 __id__');
// 新节点 _parent 应指向 stub 节点
assert.equal(mountedNode._parent.__id__, STUB_ID, '新节点 _parent 应指向 stub');
});
// ─── remove-node ──────────────────────────────────────────────
test('remove-node: 普通节点 → 父 _children 不再含引用', () => {
const tmp = cloneFixture('remove-normal');
tmpFiles.push(tmp);
// 先 add-node 创建一个可删除的节点
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'toDelete' } },
]);
// 记录当前元素数,找 toDelete 的 id
const p1 = parsePrefab(tmp);
const toDeleteNode = p1.findNodeByName('toDelete');
assert.ok(toDeleteNode, 'toDelete 应存在');
const toDeleteId = p1.elements.indexOf(toDeleteNode);
// 再 remove-node
const result = editPrefab(tmp, [
{ op: 'remove-node', target: 'toDelete' },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
const parent = reparsed.findNodeByName('touchArea');
const stillReferenced = Array.isArray(parent._children) &&
parent._children.some((r) => r.__id__ === toDeleteId);
assert.ok(!stillReferenced, 'touchArea._children 不应再引用已删节点');
// 节点元素本身仍在数组(orphan)
const orphan = reparsed.elements[toDeleteId];
assert.ok(orphan && orphan.__type__ === 'cc.Node', '孤儿节点元素仍在 elements 中');
assert.equal(orphan._parent, null, '孤儿节点 _parent 应为 null');
});
test('remove-node: stub 子节点(mountedChildren 内) → mountedChildren 移除', () => {
const tmp = cloneFixture('remove-stub-child');
tmpFiles.push(tmp);
const STUB_ID = 10;
// 先 add-node 到 stub,再 remove-node
editPrefab(tmp, [
{ op: 'add-node', parent: { id: STUB_ID }, node: { name: 'stubChild' } },
]);
// 找到新节点 id
const p1 = parsePrefab(tmp);
const stubChildEl = p1.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'stubChild'
);
assert.ok(stubChildEl, 'stubChild 应存在');
const stubChildId = p1.elements.indexOf(stubChildEl);
// remove-node
editPrefab(tmp, [
{ op: 'remove-node', target: { id: stubChildId } },
]);
const reparsed = parsePrefab(tmp);
const stubNode = reparsed.elements[STUB_ID];
const stubPrefabInfo = reparsed.elements[stubNode._prefab.__id__];
const prefabInstance = reparsed.elements[stubPrefabInfo.instance.__id__];
const stillInMounted = Array.isArray(prefabInstance.mountedChildren) &&
prefabInstance.mountedChildren.some((r) => r.__id__ === stubChildId);
assert.ok(!stillInMounted, 'mountedChildren 应不再包含已删节点');
});
test('remove-node: 失败回滚 — target 不存在时文件不变', () => {
const tmp = cloneFixture('remove-rollback');
tmpFiles.push(tmp);
const originalMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.throws(() => {
editPrefab(tmp, [
{ op: 'remove-node', target: '__nonexistent_target__' },
]);
}, /找不到节点/);
const currentMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.equal(currentMd5, originalMd5, 'remove-node 失败时不应落盘');
});
test('remove-node: 删嵌套 stub → 清根 targetOverrides 中指向它的悬空条目,保留无关条目', () => {
// 最小 prefab:根(1) 挂一个嵌套 stub(2),根 PrefabInfo 有两条 targetOverride——
// _toStub 指向被删 stub(2)(应清),_keep target=null(应留)。
// 复现 HomeUI fixture 缺失的场景:外层脚本对嵌套实例内部的引用 override。
const tmp = path.join(os.tmpdir(), `nested-ov-${crypto.randomBytes(4).toString('hex')}.prefab`);
tmpFiles.push(tmp);
const data = [
{ __type__: 'cc.Prefab', data: { __id__: 1 } },
{ __type__: 'cc.Node', _name: 'Root', _parent: null, _children: [{ __id__: 2 }], _components: [], _prefab: { __id__: 5 } },
{ __type__: 'cc.Node', _name: null, _parent: { __id__: 1 }, _children: [], _components: [], _prefab: { __id__: 3 } },
{ __type__: 'cc.PrefabInfo', root: { __id__: 2 }, asset: { __uuid__: 'dummyuuid-0000-0000-0000-000000000001' }, fileId: 'stubFileId0000000000aa', instance: { __id__: 4 }, targetOverrides: null },
{ __type__: 'cc.PrefabInstance', root: { __id__: 2 }, asset: { __uuid__: 'dummyuuid-0000-0000-0000-000000000001' }, fileId: 'instFileId0000000000bb', mountedChildren: [], mountedComponents: [], propertyOverrides: [], targetOverrides: null },
{ __type__: 'cc.PrefabInfo', root: { __id__: 1 }, asset: { __id__: 0 }, fileId: 'rootFileId0000000000cc', instance: null, targetOverrides: [{ __id__: 6 }, { __id__: 7 }], nestedPrefabInstanceRoots: [{ __id__: 2 }] },
{ __type__: 'cc.TargetOverrideInfo', source: { __id__: 1 }, sourceInfo: null, propertyPath: ['_toStub'], target: { __id__: 2 }, targetInfo: { __id__: 8 } },
{ __type__: 'cc.TargetOverrideInfo', source: { __id__: 1 }, sourceInfo: null, propertyPath: ['_keep'], target: null, targetInfo: { __id__: 9 } },
{ __type__: 'cc.TargetInfo', localID: ['stubLocalId'] },
{ __type__: 'cc.TargetInfo', localID: ['keepLocalId'] },
];
fs.writeFileSync(tmp, JSON.stringify(data));
editPrefab(tmp, [{ op: 'remove-node', target: { id: 2 } }]);
const reparsed = parsePrefab(tmp);
const rootPi = reparsed.elements.find(
(e) => e && e.__type__ === 'cc.PrefabInfo' && e.root && e.root.__id__ === 1
);
const remaining = (rootPi.targetOverrides || []).map(
(r) => reparsed.elements[r.__id__].propertyPath[0]
);
assert.deepEqual(remaining, ['_keep'], '指向被删 stub 的 _toStub override 应被清,_keep 应保留');
assert.equal(
(rootPi.nestedPrefabInstanceRoots || []).length,
0,
'nestedPrefabInstanceRoots 应清空孤儿 stub'
);
});
// ─── clone-node ───────────────────────────────────────────────
test('clone-node: 整棵子树复制、所有 _parent 正确、新 fileId 不与原冲突', () => {
const tmp = cloneFixture('clone-normal');
tmpFiles.push(tmp);
// 先 add-node 创建有子节点的树
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'srcRoot', lpos: [0, 0, 0] } },
]);
// 克隆 srcRoot 到同父
const result = editPrefab(tmp, [
{ op: 'clone-node', source: 'srcRoot', parent: 'touchArea', name: 'clonedRoot' },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
// 原节点和克隆节点都应存在
const src = reparsed.findNodeByName('srcRoot');
const cloned = reparsed.findNodeByName('clonedRoot');
assert.ok(src, 'srcRoot 应存在');
assert.ok(cloned, 'clonedRoot 应存在');
// 两者 __id__ 不同
const srcId = reparsed.elements.indexOf(src);
const clonedId = reparsed.elements.indexOf(cloned);
assert.notEqual(srcId, clonedId, '克隆节点应有不同 __id__');
// 克隆节点 _parent 应指向 touchArea
const touchArea = reparsed.findNodeByName('touchArea');
const touchAreaId = reparsed.elements.indexOf(touchArea);
assert.equal(cloned._parent.__id__, touchAreaId, '克隆节点 _parent 应指向 touchArea');
// touchArea._children 应包含克隆节点
const hasCloned = touchArea._children.some((r) => r.__id__ === clonedId);
assert.ok(hasCloned, 'touchArea._children 应包含克隆节点');
// fileId 不冲突
const srcFileId = reparsed.elements[src._prefab.__id__].fileId;
const clonedFileId = reparsed.elements[cloned._prefab.__id__].fileId;
assert.notEqual(srcFileId, clonedFileId, '克隆节点 fileId 应与原节点不同');
});
test('clone-node: 带子节点的子树 → 所有子节点 _parent 正确', () => {
const tmp = cloneFixture('clone-subtree');
tmpFiles.push(tmp);
// 先建一个带子节点的树
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'parent2' } },
]);
editPrefab(tmp, [
{ op: 'add-node', parent: 'parent2', node: { name: 'child2' } },
]);
// 克隆 parent2(含 child2
editPrefab(tmp, [
{ op: 'clone-node', source: 'parent2', parent: 'touchArea', name: 'clonedParent2' },
]);
const reparsed = parsePrefab(tmp);
const clonedParent = reparsed.findNodeByName('clonedParent2');
assert.ok(clonedParent, 'clonedParent2 应存在');
// 克隆节点应有 _children(子树被克隆)
assert.ok(
Array.isArray(clonedParent._children) && clonedParent._children.length > 0,
'克隆后的父节点应有子节点'
);
// 子节点的 _parent 应指向克隆节点
const clonedParentId = reparsed.elements.indexOf(clonedParent);
for (const childRef of clonedParent._children) {
const childNode = reparsed.elements[childRef.__id__];
assert.ok(childNode, '子节点应存在');
assert.equal(
childNode._parent.__id__,
clonedParentId,
`子节点 _parent 应指向克隆节点(${clonedParentId})`
);
}
});
test('clone-node: 失败回滚 — source 不存在时文件不变', () => {
const tmp = cloneFixture('clone-rollback');
tmpFiles.push(tmp);
const originalMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.throws(() => {
editPrefab(tmp, [
{ op: 'clone-node', source: '__nonexistent_src__', parent: 'touchArea', name: 'cloned' },
]);
}, /找不到节点/);
const currentMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.equal(currentMd5, originalMd5, 'clone-node 失败时不应落盘');
});
test('clone-node: 失败回滚 — parent 不存在时文件不变', () => {
const tmp = cloneFixture('clone-rollback-parent');
tmpFiles.push(tmp);
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'srcForRollback' } },
]);
const contentAfterAdd = fs.readFileSync(tmp, 'utf8');
const md5AfterAdd = md5(contentAfterAdd);
assert.throws(() => {
editPrefab(tmp, [
{ op: 'clone-node', source: 'srcForRollback', parent: '__bad_parent__', name: 'cloned' },
]);
}, /找不到节点/);
const currentMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.equal(currentMd5, md5AfterAdd, 'clone-node parent 不存在时不应落盘');
});
// ─── T21 新增:stub 节点 set-label-text 错误路径 ─────────────
test('set-sprite-frame: stub 节点 + UUID 对应 prefab 不存在时抛错', () => {
const tmp = cloneFixture('sprite-stub-bad-uuid');
tmpFiles.push(tmp);
// 先修改 fixture 的某个 stub 节点的嵌套 prefab asset.__uuid__ 为不存在的 uuid
const data = JSON.parse(fs.readFileSync(tmp, 'utf8'));
const STUB_ID = 10;
const stubNode = data[STUB_ID];
const pi = data[stubNode._prefab.__id__];
// 保存原 uuid,设置为不存在的 uuid
pi.asset = { __uuid__: '00000000-0000-0000-0000-000000000000', __expectedType__: 'cc.Prefab' };
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
assert.throws(
() => {
editPrefab(
tmp,
[{ op: 'set-sprite-frame', node: { id: STUB_ID }, uuid: 'xxxx@f9941' }],
{ projectRoot: PROJECT_ROOT }
);
},
(err) => {
assert.ok(err instanceof Error, '应抛 Error');
assert.ok(
err.message.includes('00000000-0000-0000-0000-000000000000'),
`错误消息应包含不存在的 UUID,实际: ${err.message}`
);
return true;
},
'UUID 找不到对应 prefab 时应抛错'
);
});
// ─── T21 新增:uuid-resolver 单测 ────────────────────────────
test('uuid-resolver: 不存在的 uuid 抛明确错误', () => {
const { resolveUuidToPath } = require('../src/uuid-resolver.js');
assert.throws(
() => {
resolveUuidToPath('00000000-0000-0000-0000-deadbeefcafe', PROJECT_ROOT);
},
(err) => {
assert.ok(err instanceof Error, '应抛 Error');
assert.ok(
err.message.includes('00000000-0000-0000-0000-deadbeefcafe'),
`错误消息应包含不存在的 uuid,实际: ${err.message}`
);
return true;
}
);
});
// ─── T20 BUG-1: remove-node 递归清理子树 ─────────────────────
test('BUG-1 remove-node: 删除有多层子节点的父节点 → 所有后代 _parent 都断开', () => {
const tmp = cloneFixture('bug1-remove-subtree');
tmpFiles.push(tmp);
// 建立 grandParent → child1 → grandChild 三层结构
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'gpNode' } },
]);
editPrefab(tmp, [
{ op: 'add-node', parent: 'gpNode', node: { name: 'childNode1' } },
]);
editPrefab(tmp, [
{ op: 'add-node', parent: 'childNode1', node: { name: 'grandChildNode' } },
]);
// 验证结构建立完整
const p0 = parsePrefab(tmp);
assert.ok(p0.findNodeByName('gpNode'), 'gpNode 应存在');
assert.ok(p0.findNodeByName('childNode1'), 'childNode1 应存在');
assert.ok(p0.findNodeByName('grandChildNode'), 'grandChildNode 应存在');
// 记录各节点 id
const gpEl = p0.findNodeByName('gpNode');
const c1El = p0.findNodeByName('childNode1');
const gcEl = p0.findNodeByName('grandChildNode');
const gpId = p0.elements.indexOf(gpEl);
const c1Id = p0.elements.indexOf(c1El);
const gcId = p0.elements.indexOf(gcEl);
// 删除 gpNode(整棵子树)
const result = editPrefab(tmp, [
{ op: 'remove-node', target: 'gpNode' },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
// 父节点 touchArea 不再包含 gpNode 引用
const touchArea = reparsed.findNodeByName('touchArea');
const stillRef = Array.isArray(touchArea._children) &&
touchArea._children.some((r) => r.__id__ === gpId);
assert.ok(!stillRef, 'touchArea._children 不应再含 gpNode 引用');
// 所有后代节点 _parent 都应为 null
assert.equal(reparsed.elements[gpId]._parent, null, 'gpNode._parent 应为 null');
assert.equal(reparsed.elements[c1Id]._parent, null, 'childNode1._parent 应为 null');
assert.equal(reparsed.elements[gcId]._parent, null, 'grandChildNode._parent 应为 null');
// 元素仍在数组中(__id__ 稳定)
assert.ok(reparsed.elements[gpId].__type__ === 'cc.Node', 'gpNode 仍在 elements');
assert.ok(reparsed.elements[c1Id].__type__ === 'cc.Node', 'childNode1 仍在 elements');
assert.ok(reparsed.elements[gcId].__type__ === 'cc.Node', 'grandChildNode 仍在 elements');
});
// ─── T20 BUG-2: add-node fileId 冲突检测 ─────────────────────
test('BUG-2 add-node: 同父同名两次 → fileId 不同(deterministic', () => {
const tmp = cloneFixture('bug2-dup-add');
tmpFiles.push(tmp);
// 第一次 add
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'dupNode' } },
]);
// 第二次 add 同名(同父同名)
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'dupNode' } },
]);
const reparsed = parsePrefab(tmp);
// 找所有名为 dupNode 的节点
const dupNodes = reparsed.elements.filter(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'dupNode'
);
assert.equal(dupNodes.length, 2, '应有两个名为 dupNode 的节点');
// 两个节点的 fileId 不同
const fi0 = reparsed.elements[dupNodes[0]._prefab.__id__].fileId;
const fi1 = reparsed.elements[dupNodes[1]._prefab.__id__].fileId;
assert.notEqual(fi0, fi1, '两个 dupNode 的 fileId 应不同');
});
test('BUG-2 add-node: 同一系列调用多次结果 fileId 稳定(deterministic', () => {
const tmp1 = cloneFixture('bug2-det-1');
const tmp2 = cloneFixture('bug2-det-2');
tmpFiles.push(tmp1, tmp2);
// 两个 tmp 执行完全相同的操作序列
function runOps(f) {
editPrefab(f, [{ op: 'add-node', parent: 'touchArea', node: { name: 'dupNode' } }]);
editPrefab(f, [{ op: 'add-node', parent: 'touchArea', node: { name: 'dupNode' } }]);
}
runOps(tmp1);
runOps(tmp2);
const p1 = parsePrefab(tmp1);
const p2 = parsePrefab(tmp2);
const nodes1 = p1.elements.filter((el) => el && el.__type__ === 'cc.Node' && el._name === 'dupNode');
const nodes2 = p2.elements.filter((el) => el && el.__type__ === 'cc.Node' && el._name === 'dupNode');
assert.equal(nodes1.length, 2, 'tmp1 应有 2 个 dupNode');
assert.equal(nodes2.length, 2, 'tmp2 应有 2 个 dupNode');
const fi1 = nodes1.map((n) => p1.elements[n._prefab.__id__].fileId).sort();
const fi2 = nodes2.map((n) => p2.elements[n._prefab.__id__].fileId).sort();
assert.deepEqual(fi1, fi2, '相同操作序列应产生相同 fileId 集合(deterministic');
});
// ─── T20 BUG-3: clone-node fileId 冲突检测 ───────────────────
test('BUG-3 clone-node: 同 source 同 name clone 两次 → 两棵克隆子树 fileId 互不重复', () => {
const tmp = cloneFixture('bug3-dup-clone');
tmpFiles.push(tmp);
// 先建一个带子节点的树作为 source
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'cloneSrc' } },
]);
editPrefab(tmp, [
{ op: 'add-node', parent: 'cloneSrc', node: { name: 'cloneSrcChild' } },
]);
// 第一次 clone
editPrefab(tmp, [
{ op: 'clone-node', source: 'cloneSrc', parent: 'touchArea', name: 'clonedOnce' },
]);
// 第二次 clone 同 source 同目标 name(会产生新 name,但 source fileId 相同)
editPrefab(tmp, [
{ op: 'clone-node', source: 'cloneSrc', parent: 'touchArea', name: 'clonedTwice' },
]);
const reparsed = parsePrefab(tmp);
// 找两棵克隆子树的根节点
const once = reparsed.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'clonedOnce'
);
const twice = reparsed.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'clonedTwice'
);
assert.ok(once, 'clonedOnce 应存在');
assert.ok(twice, 'clonedTwice 应存在');
// 两棵克隆根节点 fileId 不同
const fiOnce = reparsed.elements[once._prefab.__id__].fileId;
const fiTwice = reparsed.elements[twice._prefab.__id__].fileId;
assert.notEqual(fiOnce, fiTwice, '两次克隆根节点的 fileId 应不同');
// 收集两棵克隆子树各自的 fileId(通过遍历子树节点)
function collectSubtreeFileIds(elements, nodeEl) {
const ids = new Set();
function walk(el) {
if (!el || el.__type__ !== 'cc.Node') return;
if (el._prefab && typeof el._prefab.__id__ === 'number') {
const pi = elements[el._prefab.__id__];
if (pi && typeof pi.fileId === 'string') ids.add(pi.fileId);
}
if (Array.isArray(el._components)) {
for (const ref of el._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (!comp) continue;
if (comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
const cpi = elements[comp.__prefab.__id__];
if (cpi && typeof cpi.fileId === 'string') ids.add(cpi.fileId);
}
}
}
if (Array.isArray(el._children)) {
for (const childRef of el._children) {
if (typeof childRef.__id__ === 'number') {
walk(elements[childRef.__id__]);
}
}
}
}
walk(nodeEl);
return ids;
}
const idsOnce = collectSubtreeFileIds(reparsed.elements, once);
const idsTwice = collectSubtreeFileIds(reparsed.elements, twice);
// 两棵子树的 fileId 集合互不重叠
for (const fid of idsOnce) {
assert.ok(
!idsTwice.has(fid),
`fileId "${fid}" 同时出现在两棵克隆子树中(应互不重复)`
);
}
assert.ok(idsOnce.size > 0, 'clonedOnce 子树应有 fileId');
assert.ok(idsTwice.size > 0, 'clonedTwice 子树应有 fileId');
});
// ─── T20 BUG-4: add-node 未知组件类型报错 ────────────────────
test('BUG-4 add-node: 未知组件类型抛错且文件不变', () => {
const tmp = cloneFixture('bug4-unknown-comp');
tmpFiles.push(tmp);
const originalMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.throws(
() => {
editPrefab(tmp, [
{
op: 'add-node',
parent: 'touchArea',
node: { name: 'badComp', components: ['cc.Unknown'] },
},
]);
},
(err) => {
assert.ok(err instanceof Error, '应抛 Error');
assert.ok(
err.message.includes('unknown component type') || err.message.includes('cc.Unknown'),
`错误消息应提及 unknown component type 或 cc.Unknown,实际: ${err.message}`
);
return true;
},
'传入未知组件类型应抛错'
);
// 文件不应被修改
const currentMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.equal(currentMd5, originalMd5, '抛错后文件不应落盘');
});
test('BUG-4 add-node: components 传字符串未知类型也抛错', () => {
const tmp = cloneFixture('bug4-str-unknown-comp');
tmpFiles.push(tmp);
const originalMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.throws(
() => {
editPrefab(tmp, [
{
op: 'add-node',
parent: 'touchArea',
node: { name: 'badComp2', components: ['cc.UnknownComp'] },
},
]);
},
(err) => {
assert.ok(err instanceof Error, '应抛 Error');
assert.ok(
err.message.includes('unknown component type') || err.message.includes('cc.UnknownComp'),
`错误消息应提及 unknown component type,实际: ${err.message}`
);
return true;
},
'传入字符串未知组件类型也应抛错'
);
const currentMd5 = md5(fs.readFileSync(tmp, 'utf8'));
assert.equal(currentMd5, originalMd5, '抛错后文件不应落盘');
});
// ─── T20 BUG-1a: remove-node stub 节点 → PrefabInstance 被断开 ──
test('BUG-1a remove-node: 删除 stub 节点 → PrefabInfo.instance 置 nullPrefabInstance 孤儿化', () => {
const tmp = cloneFixture('bug1a-remove-stub');
tmpFiles.push(tmp);
// stub 节点 id=10HomeUI 中 TaskEntryBtn 嵌套 prefab 实例)
const STUB_ID = 10;
// 获取删除前 PrefabInstance 的 __id__
const p0 = parsePrefab(tmp);
const stubNode0 = p0.elements[STUB_ID];
const pi0 = p0.elements[stubNode0._prefab.__id__];
assert.ok(pi0.instance && typeof pi0.instance.__id__ === 'number', '删除前 instance 应有效');
const piInstId = pi0.instance.__id__;
const piId = stubNode0._prefab.__id__;
// 先找 stub 父节点,获取 stub 在父 _children 里
const parentId = stubNode0._parent.__id__;
// remove-node
const result = editPrefab(tmp, [
{ op: 'remove-node', target: { id: STUB_ID } },
]);
assert.equal(result.opsApplied, 1);
const reparsed = parsePrefab(tmp);
// PrefabInfo._parent 应为 null
const pi = reparsed.elements[piId];
assert.equal(pi._parent, null, 'PrefabInfo._parent 应置 null');
// PrefabInfo.instance 应被置 null(断开指向)
assert.equal(pi.instance, null, 'PrefabInfo.instance 应置 nullBUG-1a 修复)');
// PrefabInstance 元素本身仍在(__id__ 稳定),但其 propertyOverrides 应被清空
const prefabInst = reparsed.elements[piInstId];
assert.ok(prefabInst && prefabInst.__type__ === 'cc.PrefabInstance', 'PrefabInstance 仍在 elements');
assert.ok(
Array.isArray(prefabInst.propertyOverrides) && prefabInst.propertyOverrides.length === 0,
'PrefabInstance.propertyOverrides 应被清空(BUG-1a 修复)'
);
// stub 节点本身的 _parent 也应为 null
assert.equal(reparsed.elements[STUB_ID]._parent, null, 'stub 节点 _parent 应为 null');
// 父节点 _children 不再含 stub 引用
const parentNode = reparsed.elements[parentId];
const stillRef = Array.isArray(parentNode._children) &&
parentNode._children.some((r) => r.__id__ === STUB_ID);
assert.ok(!stillRef, '父节点 _children 不应再含 stub 引用');
});
test('BUG-1a remove-node: stub 节点内有 mountedChildren → mountedChildren 节点也被递归断开', () => {
const tmp = cloneFixture('bug1a-remove-stub-mc');
tmpFiles.push(tmp);
// 先 add-node 到 stub(id=10) 的 mountedChildren,再删除 stub 节点本身
const STUB_ID = 10;
editPrefab(tmp, [
{ op: 'add-node', parent: { id: STUB_ID }, node: { name: 'mountedKid' } },
]);
// 找 mountedKid 的 id
const p1 = parsePrefab(tmp);
const kidEl = p1.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'mountedKid'
);
assert.ok(kidEl, 'mountedKid 应在 add-node 后存在');
const kidId = p1.elements.indexOf(kidEl);
// 验证 mountedChildren 确实包含 kidId
const stubNode1 = p1.elements[STUB_ID];
const pi1 = p1.elements[stubNode1._prefab.__id__];
const inst1 = p1.elements[pi1.instance.__id__];
assert.ok(
Array.isArray(inst1.mountedChildren) && inst1.mountedChildren.some((r) => r.__id__ === kidId),
'mountedChildren 应包含 kidId'
);
// 删除 stub 节点(含 mountedKid
editPrefab(tmp, [
{ op: 'remove-node', target: { id: STUB_ID } },
]);
const reparsed = parsePrefab(tmp);
// mountedKid 仍在 elements__id__ 稳定),但 _parent 应为 null
const kidOrphan = reparsed.elements[kidId];
assert.ok(kidOrphan && kidOrphan.__type__ === 'cc.Node', 'mountedKid 仍在 elements');
assert.equal(kidOrphan._parent, null, 'mountedKid._parent 应为 null(递归断开)');
// PrefabInstance.mountedChildren 应被清空
const piId = stubNode1._prefab.__id__;
const piInstId = pi1.instance.__id__;
const prefabInst = reparsed.elements[piInstId];
assert.ok(
Array.isArray(prefabInst.mountedChildren) && prefabInst.mountedChildren.length === 0,
'PrefabInstance.mountedChildren 应被清空(BUG-1a 修复)'
);
});
// ─── T20 BUG-2a: _collectExistingFileIds 收集 cc.PrefabInstance.fileId ──
test('BUG-2a add-node: 生成的 fileId 不与现有 PrefabInstance.fileId 冲突', () => {
const tmp = cloneFixture('bug2a-fileId-no-clash');
tmpFiles.push(tmp);
// 先收集现有所有 PrefabInstance.fileId
const p0 = parsePrefab(tmp);
const existingPiFileIds = new Set(
p0.elements
.filter((el) => el && el.__type__ === 'cc.PrefabInstance' && typeof el.fileId === 'string')
.map((el) => el.fileId)
);
assert.ok(existingPiFileIds.size > 0, 'HomeUI 应有 PrefabInstance.fileId');
// add-node 生成新节点
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'noPiClashNode' } },
]);
const reparsed = parsePrefab(tmp);
const newNode = reparsed.findNodeByName('noPiClashNode');
assert.ok(newNode, 'noPiClashNode 应存在');
const newPrefabInfo = reparsed.elements[newNode._prefab.__id__];
const newFileId = newPrefabInfo.fileId;
assert.ok(
!existingPiFileIds.has(newFileId),
`add-node 生成的 fileId "${newFileId}" 不应与现有 PrefabInstance.fileId 冲突(BUG-2a 修复)`
);
});
test('BUG-2a clone-node: 生成的 fileId 不与现有 PrefabInstance.fileId 冲突', () => {
const tmp = cloneFixture('bug2a-clone-fileId-no-clash');
tmpFiles.push(tmp);
// 先 add-node 创建可克隆源
editPrefab(tmp, [
{ op: 'add-node', parent: 'touchArea', node: { name: 'cloneSrc2a' } },
]);
// 收集现有 PrefabInstance.fileId(克隆前)
const p0 = parsePrefab(tmp);
const existingPiFileIds = new Set(
p0.elements
.filter((el) => el && el.__type__ === 'cc.PrefabInstance' && typeof el.fileId === 'string')
.map((el) => el.fileId)
);
// clone-node
editPrefab(tmp, [
{ op: 'clone-node', source: 'cloneSrc2a', parent: 'touchArea', name: 'clonedNode2a' },
]);
const reparsed = parsePrefab(tmp);
const clonedNode = reparsed.elements.find(
(el) => el && el.__type__ === 'cc.Node' && el._name === 'clonedNode2a'
);
assert.ok(clonedNode, 'clonedNode2a 应存在');
const clonedFileId = reparsed.elements[clonedNode._prefab.__id__].fileId;
assert.ok(
!existingPiFileIds.has(clonedFileId),
`clone-node 生成的 fileId "${clonedFileId}" 不应与现有 PrefabInstance.fileId 冲突(BUG-2a 修复)`
);
});
// ─── add-component ───────────────────────────────────────────
test('add-component: 普通节点挂自定义 ccclass 追加组件 + CompPrefabInfo', () => {
const tmp = cloneFixture('addcomp-basic');
tmpFiles.push(tmp);
const before = parsePrefab(tmp);
const beforeLen = before.elements.length;
const result = editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
]);
assert.equal(result.opsApplied, 1);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
assert.ok(node);
// _components 追加了一项
const newCompRef = node._components[node._components.length - 1];
assert.ok(typeof newCompRef.__id__ === 'number');
const comp = after.elements[newCompRef.__id__];
assert.equal(comp.__type__, 'TaskBtn');
assert.equal(comp.node.__id__, after.elements.findIndex((e) => e === node));
assert.equal(comp._enabled, true);
assert.ok(comp.__prefab && typeof comp.__prefab.__id__ === 'number');
const cpi = after.elements[comp.__prefab.__id__];
assert.equal(cpi.__type__, 'cc.CompPrefabInfo');
assert.ok(typeof cpi.fileId === 'string' && cpi.fileId.length > 0);
// elements 追加了 2 个(组件 + CompPrefabInfo
assert.equal(after.elements.length, beforeLen + 2);
});
test('add-component: 同类型重复挂抛错,不落盘', () => {
const tmp = cloneFixture('addcomp-dup');
tmpFiles.push(tmp);
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
]);
const before = fs.readFileSync(tmp, 'utf8');
assert.throws(
() =>
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
]),
/已挂/
);
// 未落盘:文件内容不变
assert.equal(fs.readFileSync(tmp, 'utf8'), before);
});
test('add-component: props 被浅合并到组件对象上', () => {
const tmp = cloneFixture('addcomp-props');
tmpFiles.push(tmp);
editPrefab(tmp, [
{
op: 'add-component',
node: 'btnMerge',
componentType: 'TaskBtn',
props: { _foo: 42, _bar: null },
},
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.equal(comp._foo, 42);
assert.equal(comp._bar, null);
});
test('add-component: fileId 与现有 PrefabInfo / CompPrefabInfo 不冲突', () => {
const tmp = cloneFixture('addcomp-fileid');
tmpFiles.push(tmp);
const before = parsePrefab(tmp);
const existing = new Set();
for (const el of before.elements) {
if (
el &&
(el.__type__ === 'cc.PrefabInfo' ||
el.__type__ === 'cc.CompPrefabInfo' ||
el.__type__ === 'cc.PrefabInstance') &&
typeof el.fileId === 'string'
) {
existing.add(el.fileId);
}
}
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
const cpi = after.elements[comp.__prefab.__id__];
assert.ok(!existing.has(cpi.fileId), `新组件 fileId "${cpi.fileId}" 不应与现有冲突`);
});
// ─── remove-component ────────────────────────────────────────
test('remove-component: 普通节点移除组件引用 + 保持其他 __id__ 稳定', () => {
const tmp = cloneFixture('removecomp-basic');
tmpFiles.push(tmp);
// 先挂上 TaskBtn 拿到一份可移除的组件
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
]);
const before = parsePrefab(tmp);
const beforeNode = before.findNodeByName('btnMerge');
const beforeCompRef = beforeNode._components[beforeNode._components.length - 1];
const beforeCompId = beforeCompRef.__id__;
const beforeCompCount = beforeNode._components.length;
const beforeElementsLen = before.elements.length;
const result = editPrefab(tmp, [
{ op: 'remove-component', node: 'btnMerge', componentType: 'TaskBtn' },
]);
assert.equal(result.opsApplied, 1);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
// _components 引用减少一项
assert.equal(node._components.length, beforeCompCount - 1);
for (const ref of node._components) {
assert.notEqual(ref.__id__, beforeCompId, '_components 不应再引用被移除的组件');
}
// 组件元素本身作为 orphan 保留:elements 长度不变、原槽位仍是 TaskBtn
assert.equal(after.elements.length, beforeElementsLen);
assert.equal(after.elements[beforeCompId].__type__, 'TaskBtn');
});
test('remove-component: 节点上找不到对应组件抛错,不落盘', () => {
const tmp = cloneFixture('removecomp-missing');
tmpFiles.push(tmp);
const before = fs.readFileSync(tmp, 'utf8');
assert.throws(
() =>
editPrefab(tmp, [
{ op: 'remove-component', node: 'btnMerge', componentType: 'cc.Animation' },
]),
/找不到 cc\.Animation 组件/
);
assert.equal(fs.readFileSync(tmp, 'utf8'), before);
});
test('remove-component: schema 校验缺 componentType 抛错', () => {
const tmp = cloneFixture('removecomp-schema');
tmpFiles.push(tmp);
assert.throws(
() => editPrefab(tmp, [{ op: 'remove-component', node: 'btnMerge' }]),
/缺必填字段 "componentType"/
);
});
// ─── set-component-ref ───────────────────────────────────────
test('set-component-ref: 字段指向另一节点 (cc.Node)', () => {
const tmp = cloneFixture('setref-node');
tmpFiles.push(tmp);
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_target',
refNode: 'HomeUI',
},
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.ok(comp._target && typeof comp._target.__id__ === 'number');
const refNode = after.elements[comp._target.__id__];
assert.equal(refNode.__type__, 'cc.Node');
assert.equal(refNode._name, 'HomeUI');
});
test('set-component-ref: 字段指向另一节点上的组件 (cc.UITransform)', () => {
const tmp = cloneFixture('setref-comp');
tmpFiles.push(tmp);
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_uitRef',
refNode: 'btnMerge',
refType: 'cc.UITransform',
},
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.ok(comp._uitRef && typeof comp._uitRef.__id__ === 'number');
const refComp = after.elements[comp._uitRef.__id__];
assert.equal(refComp.__type__, 'cc.UITransform');
});
test('set-component-ref: 目标组件未挂时抛错', () => {
const tmp = cloneFixture('setref-missing-comp');
tmpFiles.push(tmp);
assert.throws(
() =>
editPrefab(tmp, [
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_x',
refNode: 'HomeUI',
},
]),
/未挂 "TaskBtn"/
);
});
test('set-component-ref: refNode 不存在该类型组件时抛错', () => {
const tmp = cloneFixture('setref-missing-ref');
tmpFiles.push(tmp);
assert.throws(
() =>
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_x',
refNode: 'HomeUI',
refType: 'cc.Sprite',
},
]),
/未挂 "cc.Sprite"/
);
});
// ─── FIX-1: add-component 传原始 UUIDset-component-ref 传 @ccclass 名可互查 ──
// Bug: add-component 以原始 UUID 存储 __type__set-component-ref 以压缩 classId 查找 → 不匹配
// Fix: normalizeComponentType 把原始 UUID 也压缩为 classId,两者统一
test('FIX-1 set-component-ref: add-component 传原始 UUID + 同 batch set-component-ref 传 @ccclass 名(有 projectRoot', () => {
const tmp = cloneFixture('fix1-uuid-add-name-ref');
tmpFiles.push(tmp);
// TaskBtn 的原始 UUID(来自 TaskBtn.ts.meta
const TASK_BTN_UUID = '5f4d2de3-a7dc-4d47-a41f-f27a12549b20';
// 同一 batchadd-component 用原始 UUIDset-component-ref 用 @ccclass 名
const result = editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: TASK_BTN_UUID },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn', // @ccclass 名,与上面的 UUID 对应同一类型
property: '_target',
refNode: 'HomeUI',
},
], { projectRoot: PROJECT_ROOT });
assert.equal(result.opsApplied, 2);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
// 组件 __type__ 应是压缩 classId(不是原始 UUID,也不是 @ccclass 字符串)
const { compressUuid } = require('../src/id.js');
assert.equal(comp.__type__, compressUuid(TASK_BTN_UUID), '组件 __type__ 应已压缩为 classId');
// _target 字段应指向 HomeUI 节点
assert.ok(comp._target && typeof comp._target.__id__ === 'number', '_target 应已赋值');
const refNode = after.elements[comp._target.__id__];
assert.equal(refNode._name, 'HomeUI', '_target 应指向 HomeUI');
});
test('FIX-1 set-component-ref: add-component 传原始 UUID + 分批 set-component-ref 传 @ccclass 名(有 projectRoot', () => {
const tmp = cloneFixture('fix1-uuid-add-name-ref-split');
tmpFiles.push(tmp);
const TASK_BTN_UUID = '5f4d2de3-a7dc-4d47-a41f-f27a12549b20';
// 第一批:add-component 传原始 UUID
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: TASK_BTN_UUID },
], { projectRoot: PROJECT_ROOT });
// 第二批:set-component-ref 传 @ccclass 名
const result = editPrefab(tmp, [
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_target',
refNode: 'HomeUI',
},
], { projectRoot: PROJECT_ROOT });
assert.equal(result.opsApplied, 1);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.ok(comp._target && typeof comp._target.__id__ === 'number', '_target 应已赋值');
const refNode2 = after.elements[comp._target.__id__];
assert.equal(refNode2._name, 'HomeUI', '_target 应指向 HomeUI');
});
// ─── FIX-2: set-component-ref 数组字段(_items.0 / _items[1])────────────────
// Bug: property 只支持单字段名,多次调用同字段名被幂等检查覆盖
// Fix: parsePropertyPath 拆出数组路径;addRootTargetOverride 按完整 path 幂等;
// 普通节点走 setByPropertyPath 多级赋值
test('FIX-2 set-component-ref: 普通节点数组字段 "_items.0" 和 "_items[1]" 独立赋值', () => {
const tmp = cloneFixture('fix2-array-ref-normal');
tmpFiles.push(tmp);
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_items.0', // . 分隔写法
refNode: 'HomeUI',
},
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_items[1]', // [] 写法等价
refNode: 'touchArea',
},
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.ok(Array.isArray(comp._items), '_items 应是数组');
assert.ok(comp._items[0] && typeof comp._items[0].__id__ === 'number', '_items[0] 应有 __id__');
assert.ok(comp._items[1] && typeof comp._items[1].__id__ === 'number', '_items[1] 应有 __id__');
const r0 = after.elements[comp._items[0].__id__];
const r1 = after.elements[comp._items[1].__id__];
assert.equal(r0._name, 'HomeUI', '_items[0] 应指向 HomeUI');
assert.equal(r1._name, 'touchArea', '_items[1] 应指向 touchArea');
});
test('FIX-2 set-component-ref: 普通节点数组字段 — 分批调用不互相覆盖', () => {
const tmp = cloneFixture('fix2-array-ref-split');
tmpFiles.push(tmp);
// 第一批
editPrefab(tmp, [
{ op: 'add-component', node: 'btnMerge', componentType: 'TaskBtn' },
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_items.0',
refNode: 'HomeUI',
},
]);
// 第二批
editPrefab(tmp, [
{
op: 'set-component-ref',
node: 'btnMerge',
componentType: 'TaskBtn',
property: '_items.1',
refNode: 'touchArea',
},
]);
const after = parsePrefab(tmp);
const node = after.findNodeByName('btnMerge');
const compRef = node._components[node._components.length - 1];
const comp = after.elements[compRef.__id__];
assert.ok(Array.isArray(comp._items), '_items 应是数组');
const r0 = after.elements[comp._items[0].__id__];
const r1 = after.elements[comp._items[1].__id__];
assert.equal(r0._name, 'HomeUI', '分批调用后 _items[0] 仍指向 HomeUI');
assert.equal(r1._name, 'touchArea', '分批调用后 _items[1] 指向 touchArea');
});
test('FIX-2 addRootTargetOverride: 数组 propertyPath 各索引独立不被幂等覆盖', () => {
// Unit test: 直接调 addRootTargetOverride,构造最小 prefabData
const { addRootTargetOverride } = require('../src/editor/nested.js');
const elements = [];
// [0] root PrefabInfoinstance=null → root PrefabInfotargetOverrides=null
elements.push({ __type__: 'cc.PrefabInfo', instance: null, targetOverrides: null, root: { __id__: 1 }, asset: null });
// [1] root node_prefab 指向 [0]
elements.push({ __type__: 'cc.Node', _name: 'Root', _prefab: { __id__: 0 } });
const rootId = 1;
// [2] source comp
const sourceCompId = 2;
elements.push({ __type__: 'SomeComp' });
// [3] target stub(任意 id
const stubId = 3;
elements.push({ __type__: 'cc.Node', _name: null });
const prefabData = { elements, rootId };
// 调 3 次,索引 0/1/2
addRootTargetOverride(prefabData, rootId, sourceCompId, ['_items', 0], stubId, ['fid-A']);
addRootTargetOverride(prefabData, rootId, sourceCompId, ['_items', 1], stubId, ['fid-B']);
addRootTargetOverride(prefabData, rootId, sourceCompId, ['_items', 2], stubId, ['fid-C']);
// 第 4 次:_items[0] 重复调用,幂等 → 不应再增加
addRootTargetOverride(prefabData, rootId, sourceCompId, ['_items', 0], stubId, ['fid-A']);
const rootPI = elements[0];
assert.ok(Array.isArray(rootPI.targetOverrides), 'targetOverrides 应已初始化为数组');
assert.equal(rootPI.targetOverrides.length, 3,
'应有 3 条 override_items[0/1/2]),幂等的第 4 次不增加');
const ovs = rootPI.targetOverrides.map(r => elements[r.__id__]);
const paths = ovs.map(ov => JSON.stringify(ov.propertyPath));
assert.ok(paths.includes(JSON.stringify(['_items', 0])), '应含 ["_items", 0]');
assert.ok(paths.includes(JSON.stringify(['_items', 1])), '应含 ["_items", 1]');
assert.ok(paths.includes(JSON.stringify(['_items', 2])), '应含 ["_items", 2]');
});