评审 Important 修复: 1. spec §2.4: flags.stretch/拉伸widget 适用于全部满屏对象(52个),不只是层内首个 的 backgroundNodeId(40个,判定规则不变)。此前实现把两者绑死,导致12个非首位 满屏遮罩(如"创建房间遮罩")套用三区锚点几何,进 Cocos 后会错位。 2. 真实数据护栏补内容级校验: 遍历 manifest.objectIndex 全部991条,核对每条 layerId 与源数据 BelongLayerID 一致——杀掉"全部条目指向同一层"这类只查条数 查不出的变异,这是子系统 C 定位 1773 处 set_self 的承重字段。 3. 补"引用不存在的图显式抛错"的回归用例——此前只有代码审查保护,真实数据恰好 不触发,没有测试防线;错误信息须可定位对象ID与图ID。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
10 KiB
JavaScript
217 lines
10 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { join } from 'node:path';
|
||
import { convertLayer, convertAll } from '../ui-migration/convert.mjs';
|
||
import { loadSource } from '../ui-migration/gameabc-source.mjs';
|
||
import { imageBuckets } from '../ui-migration/buckets.mjs';
|
||
import { ROOT } from '../lib/paths.mjs';
|
||
|
||
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
|
||
|
||
function tinySource() {
|
||
const objects = new Map([
|
||
[1, { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720,
|
||
BelongLayerID: 2, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
|
||
[2, { ObjectID: 2, ObjectType: 2, ObjectName: '按钮', Left: 320, Top: 200, Width: 150, Height: 70,
|
||
BelongLayerID: 2, IndexOfLayer: 2, ImageFileID: 2, FrameStyle: 0, FrameIndex: 5,
|
||
L9: 12, T9: 8, R9: 12, B9: 8, GroupID: 5, VoiceFileID: 0, TimerInterval: 0,
|
||
events: ['mousedown'] }],
|
||
[3, { ObjectID: 3, ObjectType: 4, ObjectName: '昵称文字', Left: 100, Top: 50, Width: 76, Height: 25,
|
||
BelongLayerID: 2, IndexOfLayer: 3, ImageFileID: 0, FrameStyle: 0, FrameIndex: 0,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
|
||
]);
|
||
return {
|
||
objects,
|
||
layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1, 2, 3] }],
|
||
groups: [{ groupId: 5, objectIds: [1, 2, 3] }],
|
||
images: new Map([
|
||
[1, { id: 1, bmp: '00001.png', w: 1, h: 1, frame_all: 1, w1: 1280, h1: 720, w_all: 1280, h_all: 720 }],
|
||
[2, { id: 2, bmp: '00014.png', w: 3, h: 4, frame_all: 12, w1: 150, h1: 70, w_all: 450, h_all: 280 }],
|
||
]),
|
||
};
|
||
}
|
||
|
||
test('convertLayer 产出层元信息与 bucket', () => {
|
||
const s = tinySource();
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
assert.equal(doc.layerId, 2);
|
||
assert.equal(doc.name, 'Login_Layer');
|
||
assert.equal(doc.bucket, 'login');
|
||
assert.equal(doc.nodes.length, 3);
|
||
});
|
||
|
||
test('convertLayer 识别背景:层内首个 + 满屏 → stretch', () => {
|
||
const s = tinySource();
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
assert.equal(doc.backgroundNodeId, 1);
|
||
const bg = doc.nodes.find((n) => n.id === 1);
|
||
assert.equal(bg.flags.stretch, true);
|
||
assert.equal(bg.widget.horizontal, 'stretch');
|
||
});
|
||
|
||
test('convertLayer 背景判定只认层内首个,但拉伸适用于所有满屏对象(spec §2.4)', () => {
|
||
const s = tinySource();
|
||
// 追加一个非首位、同样满屏的对象(如"创建房间遮罩"那类)——
|
||
// backgroundNodeId 仍应只认层内首个,但 flags.stretch/widget 对所有满屏对象一视同仁
|
||
s.objects.set(4, {
|
||
ObjectID: 4, ObjectType: 2, ObjectName: '第二张满屏遮罩', Left: 0, Top: 0, Width: 1280, Height: 720,
|
||
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
|
||
});
|
||
s.layers[0].objectIds.push(4);
|
||
s.groups[0].objectIds.push(4);
|
||
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
assert.equal(doc.backgroundNodeId, 1, '背景仍应是层内首个对象');
|
||
const second = doc.nodes.find((n) => n.id === 4);
|
||
assert.equal(second.flags.stretch, true, '满屏对象都应拉伸,不论是否层内首个');
|
||
assert.equal(second.widget.horizontal, 'stretch');
|
||
});
|
||
|
||
test('convertLayer 非背景对象用三区锚点与转换坐标', () => {
|
||
const s = tinySource();
|
||
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
|
||
assert.deepEqual(btn.position, { x: -245, y: 125 });
|
||
assert.deepEqual(btn.widget, { horizontal: 'center', horizontalCenter: -245 });
|
||
});
|
||
|
||
test('convertLayer 九宫格非零 → sliced 并带 slice 值', () => {
|
||
const s = tinySource();
|
||
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
|
||
assert.equal(btn.sprite.type, 'sliced');
|
||
assert.deepEqual(btn.sprite.slice, { left: 12, right: 12, top: 8, bottom: 8 });
|
||
});
|
||
|
||
test('convertLayer 多帧图节点带 frame 与 frameSet', () => {
|
||
const s = tinySource();
|
||
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
|
||
assert.equal(btn.sprite.frame, 'atlas-login/00014_05.png');
|
||
assert.deepEqual(btn.sprite.frameSet, { prefix: 'atlas-login/00014', count: 12, pad: 2 });
|
||
});
|
||
|
||
test('convertLayer Type4 转 label 且不带 sprite/样式字段', () => {
|
||
const s = tinySource();
|
||
const txt = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 3);
|
||
assert.equal(txt.kind, 'label');
|
||
assert.equal(txt.sprite, undefined);
|
||
assert.deepEqual(txt.size, { width: 76, height: 25 });
|
||
});
|
||
|
||
test('convertLayer 保留 legacy 承重字段', () => {
|
||
const s = tinySource();
|
||
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
|
||
assert.equal(btn.legacy.objectId, 2);
|
||
assert.equal(btn.legacy.imageFileId, 2);
|
||
assert.equal(btn.legacy.frameIndex, 5);
|
||
assert.equal(btn.legacy.groupId, 5);
|
||
assert.equal(btn.legacy.left, 320);
|
||
assert.equal(btn.legacy.top, 200);
|
||
assert.deepEqual(btn.legacy.events, ['mousedown']);
|
||
});
|
||
|
||
test('convertLayer 对象引用不存在的图时显式抛错(不兜底),消息可定位对象与图', () => {
|
||
const s = tinySource();
|
||
s.objects.set(4, {
|
||
ObjectID: 4, ObjectType: 2, ObjectName: '缺图对象', Left: 10, Top: 10, Width: 50, Height: 50,
|
||
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 999, FrameStyle: 0, FrameIndex: 0,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
|
||
});
|
||
s.layers[0].objectIds.push(4);
|
||
s.groups[0].objectIds.push(4);
|
||
|
||
assert.throws(
|
||
() => convertLayer(s, s.layers[0], imageBuckets(s)),
|
||
(err) => {
|
||
assert.ok(err.message.includes('对象 4'), `错误信息应包含对象 ID: ${err.message}`);
|
||
assert.ok(err.message.includes('999'), `错误信息应包含图 ID: ${err.message}`);
|
||
return true;
|
||
},
|
||
);
|
||
});
|
||
|
||
test('convertLayer 帧号越界:回退帧 1、标记 flags.frameIndexOutOfRange、收集 warning', () => {
|
||
const s = tinySource();
|
||
// 追加一张 frame_all=2 的图,对象却写着 FrameIndex=3——复现真实数据里 9/40 那类越界
|
||
s.images.set(3, { id: 3, bmp: '00099.png', w: 2, h: 1, frame_all: 2, w1: 50, h1: 50, w_all: 100, h_all: 50 });
|
||
s.objects.set(4, {
|
||
ObjectID: 4, ObjectType: 2, ObjectName: '越界帧对象', Left: 500, Top: 300, Width: 50, Height: 50,
|
||
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 3,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
|
||
});
|
||
s.layers[0].objectIds.push(4);
|
||
s.groups[0].objectIds.push(4);
|
||
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
const n = doc.nodes.find((node) => node.id === 4);
|
||
assert.equal(n.flags.frameIndexOutOfRange, true, '越界应打标记');
|
||
assert.equal(n.sprite.frame, 'atlas-login/00099_1.png', '越界应回退为帧 1,不得抛错也不得跳过节点');
|
||
|
||
assert.equal(doc.warnings.length, 1);
|
||
const w = doc.warnings[0];
|
||
assert.equal(w.objectId, 4);
|
||
assert.equal(w.layerId, 2);
|
||
assert.equal(w.imageFileId, 3);
|
||
assert.equal(w.frameIndex, 3);
|
||
assert.equal(w.frameCount, 2);
|
||
assert.equal(typeof w.reason, 'string');
|
||
assert.ok(w.reason.length > 0, 'reason 必须可读,能定位到具体对象/图');
|
||
});
|
||
|
||
test('convertLayer 单帧图上的残留 FrameIndex 不产生 warning(frameCount<=1 本就与 FrameIndex 无关)', () => {
|
||
const s = tinySource();
|
||
// 单帧图(frame_all=1)却写着 FrameIndex=8——真实数据里 30/40 这一类,属无害残留
|
||
s.images.set(3, { id: 3, bmp: '00088.png', w: 1, h: 1, frame_all: 1, w1: 50, h1: 50, w_all: 50, h_all: 50 });
|
||
s.objects.set(4, {
|
||
ObjectID: 4, ObjectType: 2, ObjectName: '单帧残留对象', Left: 500, Top: 300, Width: 50, Height: 50,
|
||
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 8,
|
||
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
|
||
});
|
||
s.layers[0].objectIds.push(4);
|
||
s.groups[0].objectIds.push(4);
|
||
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
const n = doc.nodes.find((node) => node.id === 4);
|
||
assert.equal(n.flags.frameIndexOutOfRange, false);
|
||
assert.equal(n.sprite.frame, 'atlas-login/00088.png');
|
||
assert.equal(doc.warnings.length, 0);
|
||
});
|
||
|
||
test('convertLayer 输出 groups 供父节点分组', () => {
|
||
const s = tinySource();
|
||
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
|
||
assert.deepEqual(doc.groups, [{ groupId: 5, nodeIds: [1, 2, 3] }]);
|
||
});
|
||
|
||
test('convertAll 的 manifest 含 ObjectID 全局索引(C 的承重依赖)', () => {
|
||
const s = tinySource();
|
||
const { manifest, warnings } = convertAll(s);
|
||
assert.equal(manifest.designWidth, 1280);
|
||
assert.deepEqual(manifest.objectIndex['2'], { layerId: 2, nodeId: 2 });
|
||
assert.equal(manifest.layers.length, 1);
|
||
assert.equal(manifest.layers[0].nodeCount, 3);
|
||
assert.deepEqual(warnings, [], 'tinySource 本身没有越界帧,不应产生 warning');
|
||
});
|
||
|
||
test('convertAll 在真实数据上覆盖全部对象与界面(回归护栏)', () => {
|
||
const source = loadSource(REAL_SOURCE);
|
||
const { layers, manifest, warnings } = convertAll(source);
|
||
assert.equal(layers.length, 55, '界面数');
|
||
const total = layers.reduce((s, l) => s + l.nodes.length, 0);
|
||
assert.equal(total, 991, '节点总数必须等于对象总数——不得丢对象');
|
||
assert.equal(Object.keys(manifest.objectIndex).length, 991, 'ObjectID 索引必须完整');
|
||
assert.equal(warnings.length, 10, '已知的帧号越界对象必须恰为 10 个——数量漂移应被人工关注');
|
||
|
||
// 内容级校验(不只是条数):objectIndex 每条的 layerId 必须等于该对象真实所属的层
|
||
// (source 里的 BelongLayerID)。全量遍历能杀掉"全部条目被写成同一个 layerId"
|
||
// 这类变异——55 个不同层里任何一层的对象被错误映射,都会在此处失败。
|
||
for (const [idStr, entry] of Object.entries(manifest.objectIndex)) {
|
||
const obj = source.objects.get(Number(idStr));
|
||
assert.equal(
|
||
entry.layerId,
|
||
obj.BelongLayerID,
|
||
`ObjectID ${idStr} 的 objectIndex.layerId(${entry.layerId}) 应等于其 BelongLayerID(${obj.BelongLayerID})`,
|
||
);
|
||
}
|
||
});
|