mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-06-10 17:56:47 +00:00
14c5b00f14
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.
1595 lines
58 KiB
JavaScript
1595 lines
58 KiB
JavaScript
'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._lpos(stub 无此字段是正常的)
|
||
});
|
||
|
||
// ─── 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('批量 ops:position + 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 置 null,PrefabInstance 孤儿化', () => {
|
||
const tmp = cloneFixture('bug1a-remove-stub');
|
||
tmpFiles.push(tmp);
|
||
|
||
// stub 节点 id=10(HomeUI 中 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 应置 null(BUG-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 传原始 UUID,set-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';
|
||
|
||
// 同一 batch:add-component 用原始 UUID,set-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 PrefabInfo(instance=null → root PrefabInfo,targetOverrides=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]');
|
||
});
|
||
|