Files
youle_cocos/cocoscreator_projects/scripts/test/ui-convert.test.mjs
T
joywayerandClaude Opus 5 96cbbd8b0a fix(ui-migration): 拉伸解耦背景判定 + objectIndex 内容级校验 + 缺图抛错回归
评审 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>
2026-08-28 01:40:33 +08:00

217 lines
10 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.
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})`,
);
}
});