Files
youle_cocos/cocoscreator_projects/scripts/test/ui-convert.test.mjs
T
joywayerandClaude Opus 5 b6c9af25e5 feat(ui-migration): 中间描述组装
对象 → 节点(坐标/锚点/九宫格/帧引用/flags/legacy), 层 → LayerDoc(bucket/背景/分组)。
manifest 含 ObjectID 全局索引 —— 子系统 C 移植 1773 处 set_self 调用的承重依赖。
真实数据回归护栏: 55 界面 / 991 节点, 一个对象都不得丢。

真实数据里有 10 个对象(如 3067/3068/3075/3076/3087/3088/3089/3145/388/3265)的
FrameIndex 超出其图的帧数(多帧图重导后帧数变少但对象未同步)——不抛错中止、不静默
钳到末帧,而是回退帧1 + 打 flags.frameIndexOutOfRange + 收进 convertAll 返回的
warnings 清单(objectId/layerId/imageFileId/frameIndex/frameCount/reason),真实
数据护栏钉死 warnings.length===10,数量漂移会被人工看到。另补一条背景判定用例:
非首位的满屏对象不应被误判为背景,钉住"层内首个+满屏"而非"只要满屏"的规则。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:29:06 +08:00

182 lines
8.7 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 背景判定只认层内首个:非首位的满屏对象不算背景', () => {
const s = tinySource();
// 追加一个非首位、同样满屏的对象——用来钉住"首个 + 满屏"而非"只要满屏"的判定规则
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, false, '非首位的满屏对象不应被认作背景');
});
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 帧号越界:回退帧 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 { layers, manifest, warnings } = convertAll(loadSource(REAL_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 个——数量漂移应被人工关注');
});