import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '..', '..', '..'); const sourcePath = path.join(repoRoot, 'projects', 'Game_Surface_3', 'save', 'Layer00418.xml'); const objectDataPath = path.join( repoRoot, 'projects', 'Game_Surface_3', 'output', 'gameabc_Object.json', ); const imageDataPath = path.join( repoRoot, 'projects', 'Game_Surface_3', 'output', 'gameabc_Image.json', ); const prefabPath = path.join( repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'prefabs', 'widgets', 'Layer418_ChatPannel.prefab', ); const atlasRoomPath = path.join( repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-room', ); const atlasPaths = [ atlasRoomPath, path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-common'), path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-hall'), path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-login'), path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-unassigned'), ]; function property(block, name) { const match = block.match(new RegExp(``)); assert.ok(match, `source property ${name} should exist`); return match[1]; } function optionalProperty(block, name) { return block.match(new RegExp(``))?.[1]; } function parseSourceSpirits(xml) { return [...xml.matchAll(/([\s\S]*?)<\/Spirit>/g)].map((match) => { const block = match[1]; return { id: Number(property(block, 'ID')), name: property(block, 'Name'), x: Number(property(block, 'X')), y: Number(property(block, 'Y')), width: Number(property(block, 'Width')), height: Number(property(block, 'Height')), index: Number(property(block, 'IndexOfLayer')), visible: property(block, 'IsVisible') === '1', text: optionalProperty(block, 'Text'), fontSize: Number(optionalProperty(block, 'FontSize')), fontColorAlpha: Number(optionalProperty(block, 'FontColorA')), autoSize: Number(optionalProperty(block, 'AutoSize')), wordWrap: Number(optionalProperty(block, 'WordWrap')), }; }); } function nodeComponent(prefab, node, type) { return node._components.map((ref) => prefab[ref.__id__]).find((component) => component.__type__ === type); } function expectedSpriteFrameUuid(imageFileId, frameIndex, imageFiles) { const image = imageFiles[imageFileId]; assert.equal(image.id, imageFileId, `ImageFileList[${imageFileId}] must be direct indexed`); const frameAll = Number(image.frame_all); const frameNumber = frameAll === 1 ? 1 : ((Number(frameIndex) - 1) % frameAll) + 1; const baseName = String(imageFileId).padStart(5, '0'); const frameSuffix = frameAll > 1 ? `_${String(frameNumber).padStart(String(frameAll).length, '0')}` : ''; const fileName = `${baseName}${frameSuffix}.png.meta`; const metaPath = atlasPaths.map((atlasPath) => path.join(atlasPath, fileName)).find(fs.existsSync); assert.ok(metaPath, `${fileName} should exist in the Layer418 atlas search order`); const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); const spriteFrame = Object.values(meta.subMetas).find((entry) => entry.importer === 'sprite-frame'); assert.ok(spriteFrame, `${fileName} should expose a SpriteFrame subasset`); return spriteFrame.uuid; } function round(value) { return Math.round(value * 1000) / 1000; } function sourceSignature(spirit) { const isFullScreen = spirit.width === 1280 && spirit.height === 720; return JSON.stringify([ spirit.name, round(spirit.x + spirit.width / 2 - 640), round(360 - (spirit.y + spirit.height / 2)), isFullScreen ? 1600 : spirit.width, isFullScreen ? 720 : spirit.height, spirit.visible, ]); } function prefabSignature(prefab, node) { const uiTransformRef = node._components .map((ref) => prefab[ref.__id__]) .find((component) => component.__type__ === 'cc.UITransform'); assert.ok(uiTransformRef, `${node._name} should have UITransform`); return JSON.stringify([ node._name, round(node._lpos.x), round(node._lpos.y), uiTransformRef._contentSize.width, uiTransformRef._contentSize.height, node._active, ]); } test('Layer418 prefab preserves every source spirit and its layout contract', () => { const sourceSpirits = parseSourceSpirits(fs.readFileSync(sourcePath, 'utf8')); const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList; const imageFileList = JSON.parse(fs.readFileSync(imageDataPath, 'utf8')).ImageFileList; const prefab = JSON.parse(fs.readFileSync(prefabPath, 'utf8')); const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node'); const root = nodes.find((node) => node._name === 'Layer418_ChatPannel'); const group = nodes.find((node) => node._name === 'group-30'); const spiritNodes = nodes.filter( (node) => node._name !== 'Layer418_ChatPannel' && node._name !== 'group-30', ); assert.equal(sourceSpirits.length, 54); assert.equal(nodes.length, 56); assert.equal(spiritNodes.length, 54); assert.deepEqual(root._children.map((ref) => prefab[ref.__id__]._name), ['group-30']); assert.deepEqual( group._children.map((ref) => prefab[ref.__id__]._name), [...sourceSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name), ); assert.deepEqual( spiritNodes.map((node) => prefabSignature(prefab, node)).sort(), sourceSpirits.map(sourceSignature).sort(), ); const types = prefab.reduce((counts, entry) => { counts[entry.__type__] = (counts[entry.__type__] ?? 0) + 1; return counts; }, {}); assert.equal(types['cc.Sprite'], 48); assert.equal(types['cc.Label'], 6); for (const spirit of sourceSpirits) { const sourceObject = objectList[spirit.id]?.Property; assert.equal(sourceObject?.ObjectID, spirit.id, `ObjectList[${spirit.id}] must be direct indexed`); assert.equal(sourceObject.ObjectName, spirit.name); const node = spiritNodes.find((candidate) => candidate._name === spirit.name); assert.ok(node, `${spirit.name} should exist`); if (sourceObject.ObjectType === 2) { const sprite = nodeComponent(prefab, node, 'cc.Sprite'); assert.ok(sprite, `${spirit.name} should be a Sprite`); assert.equal( sprite._spriteFrame?.__uuid__, expectedSpriteFrameUuid(sourceObject.ImageFileID, sourceObject.FrameIndex, imageFileList), `${spirit.name} SpriteFrame`, ); const sliced = [sourceObject.L9, sourceObject.T9, sourceObject.R9, sourceObject.B9].some(Number); assert.equal(sprite._type, sliced ? 1 : 0, `${spirit.name} Sprite.Type`); assert.equal(sprite._sizeMode, 0, `${spirit.name} Sprite.SizeMode`); } else if (sourceObject.ObjectType === 4) { assert.ok(nodeComponent(prefab, node, 'cc.Label'), `${spirit.name} should be a Label`); } else { assert.fail(`${spirit.name} has unsupported ObjectType ${sourceObject.ObjectType}`); } } }); test('Layer418 labels preserve text, color, wrapping, and AutoSize semantics', () => { const sourceSpirits = parseSourceSpirits(fs.readFileSync(sourcePath, 'utf8')); const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList; const prefab = JSON.parse(fs.readFileSync(prefabPath, 'utf8')); const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node'); const labels = sourceSpirits.filter((spirit) => objectList[spirit.id]?.Property.ObjectType === 4); assert.equal(labels.length, 6); for (const spirit of labels) { const sourceObject = objectList[spirit.id].Property; const node = nodes.find((candidate) => candidate._name === spirit.name); const label = nodeComponent(prefab, node, 'cc.Label'); assert.equal(label._string, spirit.text, `${spirit.name} text`); assert.equal(label._fontSize, spirit.fontSize, `${spirit.name} font size`); assert.deepEqual( [label._color.r, label._color.g, label._color.b, label._color.a], [sourceObject.FontColorR, sourceObject.FontColorG, sourceObject.FontColorB, spirit.fontColorAlpha], `${spirit.name} font color`, ); assert.equal(label._enableWrapText, spirit.wordWrap === 1, `${spirit.name} word wrap`); assert.equal(label._overflow, spirit.autoSize === 1 ? 0 : 1, `${spirit.name} AutoSize`); } }); test('Layer418 sliced sprite frames preserve their source border contract', () => { const expectedBorders = new Map([ ['00084.png.meta', [20, 20, 20, 20]], ['00342.png.meta', [13, 13, 13, 13]], ]); for (const [fileName, expected] of expectedBorders) { const meta = JSON.parse(fs.readFileSync(path.join(atlasRoomPath, fileName), 'utf8')); const userData = meta.subMetas.f9941.userData; assert.deepEqual( [userData.borderLeft, userData.borderTop, userData.borderRight, userData.borderBottom], expected, fileName, ); } });