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; const autoSizeText = spirit.text !== undefined && spirit.autoSize === 1; return JSON.stringify([ spirit.name, round(spirit.x + spirit.width / 2 - 640), round(360 - (spirit.y + spirit.height / 2)), autoSizeText ? null : isFullScreen ? 1600 : spirit.width, autoSizeText ? null : isFullScreen ? 720 : spirit.height, [3112,3114,3117].includes(spirit.id) ? false : 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`); const autoSizeText = nodeComponent(prefab,node,'cc.Label')?._overflow === 0; return JSON.stringify([ node._name, round(node._lpos.x), round(node._lpos.y), autoSizeText ? null : uiTransformRef._contentSize.width, autoSizeText ? null : uiTransformRef._contentSize.height, node._active, ]); } // Approved replacements: EditBox input; dynamic phrase rows; expression grid; // history row template; scrollable announcements and their dynamic cost label. // All other source art stays. Spirit3117 retains the user's authored font size 16. const dynamicSourceIds = new Set([ 204, 206, 207, 208, 209, 210, 211, 212, ...Array.from({length:20},(_,index)=>528+index), 467, 696, 697, 163, 164, 165, 480, 3109, 3110, 3117, ]); test('Layer418 static source art preserves its layout and sprite 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 staticSpirits = sourceSpirits.filter(spirit=>!dynamicSourceIds.has(spirit.id)); const staticNames = new Set(staticSpirits.map(spirit=>spirit.name)); const spiritNodes = nodes.filter(node=>staticNames.has(node._name)); assert.equal(spiritNodes.length,staticSpirits.length); assert.deepEqual(root._children.map((ref) => prefab[ref.__id__]._name), ['group-30']); assert.deepEqual( group._children.map((ref) => prefab[ref.__id__]._name).filter(name=>staticNames.has(name)), [...staticSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name), ); assert.deepEqual( spiritNodes.map((node) => prefabSignature(prefab, node)).sort(), staticSpirits.map(sourceSignature).sort(), ); for (const spirit of staticSpirits) { 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) => !dynamicSourceIds.has(spirit.id)&&objectList[spirit.id]?.Property.ObjectType === 4); assert.ok(labels.length>0); 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, ); } }); test('Layer418 dynamic panels own clipped scroll content, layouts and reusable rows',()=>{ const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8')); const nodes=prefab.filter(entry=>entry.__type__==='cc.Node'); const group=nodes.find(node=>node._name==='group-30'); const child=(node,name)=>{ const matches=node._children.map(ref=>prefab[ref.__id__]).filter(item=>item._name===name); assert.equal(matches.length,1,`${node._name}/${name} must be unique`); return matches[0]; }; const input=child(group,'聊天输入框'),edit=nodeComponent(prefab,input,'cc.EditBox'); assert.ok(edit,'input must be a real EditBox'); for(const field of ['_textLabel','_placeholderLabel']){ const label=prefab[edit[field]?.__id__]; assert.equal(label?.__type__,'cc.Label',`${field} must resolve to a Label`); assert.equal(prefab[prefab[label.node.__id__]._parent.__id__],input); } const cost=child(group,'Spirit3117'),costLabel=nodeComponent(prefab,cost,'cc.Label'); assert.equal(cost._active,false,'announcement cost is hidden until policy selection'); assert.equal(costLabel._fontSize,16,'preserve authored cost-label font size'); assert.equal(costLabel._overflow,1,'cost uses a bounded line'); assert.equal(costLabel._string,'','cost is populated from remote policy'); assert.equal(nodeComponent(prefab,cost,'cc.UITransform')._contentSize.width,520); for(const name of ['Phrases','History','Expressions','Announcements']){ const panel=child(group,name),viewport=child(panel,'Viewport'),content=child(viewport,'Content'); assert.equal(panel._active,name==='Phrases',`${name} initial tab state`); const scroll=nodeComponent(prefab,panel,'cc.ScrollView'); assert.ok(scroll,`${name} ScrollView`); assert.equal(prefab[scroll._content.__id__],content,`${name} content reference`); assert.equal(scroll.horizontal,false);assert.equal(scroll.vertical,true); assert.ok(nodeComponent(prefab,viewport,'cc.Mask'),`${name} clipping mask`); const layout=nodeComponent(prefab,content,'cc.Layout'); assert.ok(layout,`${name} Layout`); assert.equal(layout._resizeMode,1,`${name} grows content container`); assert.equal(layout._layoutType,name==='Expressions'?3:2,`${name} layout mode`); assert.equal(nodeComponent(prefab,content,'cc.UITransform')._anchorPoint.y,1,`${name} content top anchor`); if(name==='Phrases'){ const row=child(content,'ChatPhraseItem'); assert.ok(nodeComponent(prefab,child(row,'Text'),'cc.Label')); assert.ok(nodeComponent(prefab,child(row,'Background'),'cc.Sprite')._spriteFrame?.__uuid__); }else if(name==='History'){ const row=child(content,'ChatHistoryItem'); for(const label of ['Spirit164','Spirit3110','Spirit480'])assert.ok(nodeComponent(prefab,child(row,label),'cc.Label')); for(const sprite of ['Spirit163','Spirit165','Spirit697'])assert.ok(nodeComponent(prefab,child(row,sprite),'cc.Sprite')._spriteFrame?.__uuid__); }else if(name==='Expressions'){ assert.equal(content._children.length,20); for(let index=1;index<=20;index++)assert.ok(nodeComponent(prefab,child(content,`Expression${index}`),'cc.Sprite')._spriteFrame?.__uuid__); }else assert.equal(content._children.length,0,'announcements are populated from state'); } }); test('Layer418 serialized node, component and prefab references remain intact',()=>{ const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8')); const visit=value=>{ if(!value||typeof value!=='object')return; if(Object.prototype.hasOwnProperty.call(value,'__id__')){ assert.ok(Number.isInteger(value.__id__)&&value.__id__>=0&&value.__id__