const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),ts=require('typescript'); const names=['home','matching','stage1','stage2','stage3']; const assetDir=name=>name==='home'?`assets/cloud_rise_home`:`assets/cloud_rise/${name}`; const read=name=>JSON.parse(fs.readFileSync(`${assetDir(name)}/prefab/CloudRise.prefab`)); function fields(a){const p=a.find(o=>o.nodeKeys),out={};for(const[type,plural]of [['node','nodes'],['component','components'],['frame','frames']])p[type+'Keys'].forEach((key,i)=>out[key]=p[plural][i]);return out;} function walk(value,fn){if(!value||typeof value!=='object')return;fn(value);Object.values(value).forEach(v=>walk(v,fn));} test('startup owns the shared font at a higher priority than cloudRise without a duplicate atlas',()=>{ const original=JSON.parse(fs.readFileSync('assets/common/font.plist.meta')); const scene=JSON.parse(fs.readFileSync('assets/StartScene/LoadScene.fire')); const startup=scene.find(o=>o.commonFont); assert.equal(startup.commonFont.__uuid__,original.uuid); assert(scene[startup.node.__id__]._components.some(r=>scene[r.__id__]===startup)); assert.equal(JSON.parse(fs.readFileSync('settings/builder.json')).startScene,JSON.parse(fs.readFileSync('assets/StartScene/LoadScene.fire.meta')).uuid); assert(!JSON.parse(fs.readFileSync('assets/StartScene.meta')).isBundle); assert.match(fs.readFileSync('assets/Script/Load.ts','utf8'),/@property\(cc.SpriteAtlas\)\s+commonFont:\s*cc.SpriteAtlas/); const home=JSON.parse(fs.readFileSync('assets/cloud_rise_home.meta')); assert.equal(home.priority,6);assert(home.priority<9); // Cocos 2.4 built-in start-scene priority. const digits=Array.from({length:10},(_,i)=>original.subMetas[i===9?'scoin_09.png':`scoin_${i}.png`].uuid); for(const name of names){ const refs=new Set();walk(read(name),o=>{if(o.__uuid__)refs.add(o.__uuid__);}); for(const digit of digits)assert(refs.has(digit),`${name} must share common/font digits`); if(name!=='home')assert(JSON.parse(fs.readFileSync(`${assetDir(name)}.meta`)).priority{ for(const name of names){const m=JSON.parse(fs.readFileSync(`${assetDir(name)}.meta`));assert(m.isBundle);assert.equal(m.bundleName,name==='home'?'cloud_rise_home':name);assert.equal(m.compressionType.wechatgame,name==='home'?'default':'merge_all_json');assert.equal(!!m.isRemoteBundle.wechatgame,name!=='home');} assert(!fs.existsSync('assets/cloud_rise/prefab/CloudRise.prefab')); const group=JSON.parse(fs.readFileSync('assets/cloud_rise.meta')); assert.equal(group.isBundle,false,'the grouping folder cannot be a parent Asset Bundle'); assert.equal(group.compressionType.wechatgame,undefined); }); test('all split prefabs have valid references and no detached nodes or legacy result pages',()=>{ for(const name of names){const a=read(name);walk(a,o=>{if('__id__'in o)assert(a[o.__id__],`${name}: ${o.__id__}`);}); const visited=new Set();function visit(id){if(visited.has(id))return;visited.add(id);const n=a[id];(n._children||[]).forEach(r=>visit(r.__id__));}visit(a[0].data.__id__); a.forEach((o,i)=>{if(o.__type__==='cc.Node'){assert(visited.has(i),`${name}: detached ${o._name}`);assert(!['Result','Pool','Rules','ReturnOverview','TotalReward','Solo'].includes(o._name),`${name}: legacy ${o._name}`);}}); const binding=a.find(o=>o.nodeKeys);if(binding)for(const k of ['nodes','components','frames'])assert(binding[k].every(Boolean),`${name}: missing ${k}`); for(const r of a.filter(o=>o.digits&&o.content&&o.coin)){assert.equal(r.digits.length,10);assert.equal(a[r.content.__id__]._children.length,5);} } }); test('every authored node has PrefabInfo and its prefab root is safe for the Cocos compiled serializer',()=>{ for(const name of names){ const a=read(name),root=a[0].data.__id__; const uuid=JSON.parse(fs.readFileSync(`${assetDir(name)}/prefab/CloudRise.prefab.meta`)).uuid; for(const node of a.filter(o=>o.__type__==='cc.Node')){ assert(node._prefab,`${name}/${node._name}: missing PrefabInfo`); const info=a[node._prefab.__id__];assert.equal(info.__type__,'cc.PrefabInfo'); assert.equal(info.root.__id__,root); // Creator may serialize this as a local reference to the owning Prefab after saving. if(info.asset.__id__!==undefined){assert.equal(info.asset.__id__,0);assert.equal(a[0].__type__,'cc.Prefab');} else assert.equal(info.asset.__uuid__,uuid); assert.equal(typeof info.sync,'boolean'); if(node!==a[root])assert(info.fileId); // serialize-compiled/parser.canDiscardByPrefabRoot reads this without a null guard. assert.equal(typeof a[a[info.root.__id__]._prefab.__id__].sync,'boolean'); } } }); test('home contains only overview; matching contains only matching; stages own their route and both outcomes',()=>{ const home=read('home'),p=home.find(o=>o.pages&&o.buttons); assert.equal(home[p.pages[0].__id__]._name,'Overview');assert(p.pages.slice(1).every(v=>v===null));assert(!p.stageArt||p.stageArt.length===0); assert(!home.some(o=>o._name==='Victory'||o._name==='Matching'||o._name==='Progress')); const match=read('matching'),m=fields(match);assert.equal(match[m['pages:1'].__id__]._name,'Matching');assert(!match.some(o=>o._name==='Victory'||o._name==='Progress')); for(let stage=1;stage<=3;stage++){const a=read('stage'+stage),f=fields(a); assert.equal(a[f['tracks:'+(stage-1)].__id__]._children.length,[4,6,8][stage-1]); assert.equal(Object.keys(f).filter(k=>k.startsWith('tracks:')).length,1); assert.equal(Object.keys(f).filter(k=>k.startsWith('stageArt:')).length,1); for(const k of ['failureMessage','victoryPage','victoryReward','rowTemplate','avatarTemplates:0','avatarTemplates:1','avatarTemplates:2'])assert(f[k],k); assert(!a.some(o=>o._name==='Matching'||o._name==='Overview')); } }); test('stage and matching prefabs never reference another lazy page bundle',()=>{ const owners=new Map();for(const name of [...names,'art']){function scan(dir){for(const item of fs.readdirSync(dir,{withFileTypes:true})){const file=dir+'/'+item.name;if(item.isDirectory())scan(file);else if(file.endsWith('.meta')){const m=JSON.parse(fs.readFileSync(file));owners.set(m.uuid,name);Object.values(m.subMetas||{}).forEach(s=>owners.set(s.uuid,name));}}}scan(assetDir(name));} for(const name of names)walk(read(name),o=>{if(o.__uuid__&&owners.has(o.__uuid__))assert([name,'home','art'].includes(owners.get(o.__uuid__)),`${name} depends on ${owners.get(o.__uuid__)}`);}); }); function loaderFixture(){const calls=[],fail=new Set();const cc={Prefab:class{},assetManager:{loadBundle(name,cb){calls.push(name);if(fail.has(name))return cb(Error('offline'));cb(null,{load(path,type,done){done(null,{addRef(){},name});}});}}};const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/module/Config/CloudRiseService.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2018}}).outputText,{exports,cc,require:()=>({default:{}})});return {service:exports.default,calls,fail};} test('opening home loads no lazy bundle; concurrent page loads coalesce and failures are retryable',async()=>{ const {service,calls,fail}=loaderFixture();await service.pagePrefab();assert.deepEqual(calls,['cloud_rise_home']); await Promise.all([service.pageFragment('matching'),service.pageFragment('matching')]);assert.deepEqual(calls,['cloud_rise_home','art','matching']); fail.add('stage2');await assert.rejects(service.pageFragment('stage',2));fail.clear();await service.pageFragment('stage',2); assert.equal(calls.filter(n=>n==='stage2').length,2);assert(!calls.includes('stage1'));assert(!calls.includes('stage3')); await assert.rejects(service.pageFragment('stage',4)); }); function panelFixture(){ const pending=[],attached=[],cancellations=[],notices=[];let starts=0,commits=0,matching=null,current={status:'waiting',stage:1},confirm=async()=>true; class Node{constructor(){this.children=[];}addChild(n){this.children.push(n);}setSiblingIndex(){}destroy(){this.destroyed=true;}on(type,fn,target){if(type==='click')this.click=fn.bind(target);}} const service={run:()=>current,stage:()=>({stage:current.stage}),data:()=>({matching}), prepareMatch:async id=>{starts++;const ok=await confirm();if(ok)matching={id,stage:{stage:2}};return ok;}, cancelMatch:id=>{cancellations.push(id);matching=null;}, confirmMatch:async id=>{assert.equal(matching.id,id);commits++;current={status:'playing',stage:2};return true;}}; const bridge={challengeActive:()=>true,registrationOpen:()=>true,pageFragment:(kind,stage)=>new Promise((resolve,reject)=>pending.push({kind,stage,resolve,reject}))}; const cc={_decorator:{ccclass:c=>c,property:()=>()=>{}},Component:class{},Node,Label:class{},Sprite:class{},SpriteFrame:class{},String, isValid:n=>!!n&&!n.destroyed,warn(){},Tween:{stopAllByTarget(){}},instantiate(prefab){const node=new Node();node.getComponent=()=>({bind(target){attached.push(prefab);}});return node;}}; const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2018,experimentalDecorators:true}}).outputText, {exports,cc,require:n=>({MiniGameSdk:{API:{showToast:msg=>notices.push(msg)}},default:n.endsWith('CloudRiseRuntime')?service:n.endsWith('CloudRiseService')?bridge:class{}})}); const panel=new exports.default();panel.openPopup=()=>{};panel.node=new Node();panel.design=new Node();panel.errorLabel={node:{}}; panel.buttons=[new Node()];panel.bindButtons();panel.mode='overview'; panel.show=mode=>{panel.mode=mode;panel.buttons[0].active=mode!=='matching'&&(!panel.busy||panel.matchPhase==='preparing');}; panel.matchingCount={string:''};panel.matchingPrompt={active:false};panel.matchingTitle={node:{active:false},string:''};panel.matchingReward={setValue(){}}; panel.waitAnimation=async()=>true;panel.waitForContinue=async()=>true;panel.resetMatchingAvatars=()=>{};panel.playMatching=async()=>{panel.matchingCount.string='100/100';}; panel.addSimulatedMatch=()=>{if(panel.matchingDisplayedCount<99)panel.matchingCount.string=(++panel.matchingDisplayedCount)+'/100';}; return {panel,pending,attached,cancellations,notices,service,cc,starts:()=>starts,commits:()=>commits,holdConfirmation:()=>new Promise(resolve=>{confirm=()=>new Promise(done=>resolve(done));})}; } test('closing while a page downloads does not attach stale nodes or throw during promise cleanup',async()=>{ const f=panelFixture(),task=f.panel.preparePage('progress',1);f.panel.node.destroyed=true;f.panel.pageLoads=null; f.pending[0].resolve({});await task;assert.equal(f.attached.length,0); }); test('page download failure leaves enrollment untouched and can be retried',async()=>{ const f=panelFixture(),task=f.panel.match();assert.deepEqual(f.pending.map(p=>[p.kind,p.stage]),[['matching',1],['stage',2]]); f.pending[0].resolve({});f.pending[1].reject(Error('offline'));assert.equal(await task,false);assert.equal(f.starts(),0); assert.equal(f.panel.busy,false);assert.match(f.panel.errorLabel.string,/加载失败/); const retry=f.panel.preparePage('progress',2);f.pending[2].resolve({});await retry;assert.equal(f.panel.loadedStage,2); }); test('changing stages replaces the old stage root instead of retaining its animated nodes',async()=>{ const f=panelFixture();let task=f.panel.preparePage('progress',1);f.pending[0].resolve({});await task; const first=f.panel.stagePage;task=f.panel.preparePage('progress',2);f.pending[1].resolve({});await task; assert(first.destroyed);assert.notEqual(f.panel.stagePage,first);assert.equal(f.panel.loadedStage,2); }); const settle=async()=>{for(let i=0;i<20;i++)await Promise.resolve();}; test('overview prepares a hidden matching page without enrollment and start reuses that preparation',async()=>{ const f=panelFixture(),p=f.panel;p.warmMatching();p.warmMatching(); assert.equal(f.pending.length,1);assert.equal(f.pending[0].kind,'matching');assert.equal(f.starts(),0);assert.equal(p.mode,'overview'); f.pending[0].resolve({});await settle();assert.equal(p.matchingPage.active,false); const task=p.match();await settle();assert.equal(f.pending.length,2);assert.equal(f.pending[1].kind,'stage');assert.equal(p.mode,'matching'); f.pending[1].resolve({});assert.equal(await task,true);assert.equal(f.starts(),1); }); test('failed hidden page preparation leaves enrollment untouched and manual start retries it',async()=>{ const f=panelFixture(),p=f.panel;p.warmMatching();f.pending[0].reject(Error('offline'));await settle(); assert.equal(p.mode,'overview');assert.equal(f.starts(),0);assert.equal(p.busy,false); const task=p.match();f.pending[1].resolve({});f.pending[2].resolve({});assert.equal(await task,true); }); test('matching is visible while the next stage loads; confirmation starts only after stage readiness',async()=>{ const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});await settle(); assert.equal(p.mode,'matching');assert.equal(p.matchingCount.string,'2/100');assert.equal(p.buttons[0].active,false);assert.equal(f.starts(),0); f.pending[1].resolve({});const confirm=await confirmation; assert.equal(p.matchPhase,'submitting');assert.equal(p.buttons[0].active,false);assert.equal(p.matchingCount.string,'2/100'); assert.equal(f.commits(),0); assert.equal(await p.match(),false);assert.equal(f.starts(),1); confirm(true);assert.equal(await task,true);assert.equal(p.mode,'progress');assert.equal(p.matchingCount.string,'100/100'); }); test('cancelling while stage loads returns to overview and late completion cannot enroll or switch pages',async()=>{ const f=panelFixture(),p=f.panel,task=p.match();f.pending[0].resolve({});await settle(); p.cancelMatching();assert.equal(p.mode,'overview');assert.equal(p.busy,false); f.pending[1].resolve({});assert.equal(await task,false);assert.equal(f.starts(),0);assert.equal(p.mode,'overview'); assert.equal(await p.match(),true);assert.equal(f.starts(),1);assert.equal(p.mode,'progress'); }); test('a cancelled request cannot reset a newer match that shares the in-flight stage download',async()=>{ const f=panelFixture(),p=f.panel,first=p.match();f.pending[0].resolve({});await settle(); p.cancelMatching();const confirmation=f.holdConfirmation(),second=p.match();await settle(); f.pending[1].resolve({});const confirm=await confirmation; assert.equal(await first,false);assert.equal(p.busy,true);assert.equal(p.matchPhase,'submitting');assert.equal(f.starts(),1); confirm(true);assert.equal(await second,true);assert.equal(f.starts(),1); }); test('cancelling before matching loads ignores late resource errors and does not report a stale failure',async()=>{ const f=panelFixture(),p=f.panel,task=p.match();p.buttons[0].click(); f.pending[1].reject(Error('offline'));f.pending[0].resolve({});assert.equal(await task,false); assert.equal(p.mode,'overview');assert.equal(f.starts(),0);assert(!p.errorLabel.string); }); test('destroying the panel during preparation never submits enrollment',async()=>{ const f=panelFixture(),task=f.panel.match();f.pending[0].resolve({});await settle();f.panel.node.destroyed=true; f.pending[1].resolve({});assert.equal(await task,false);assert.equal(f.starts(),0); }); test('failed server confirmation never reaches 100 and leaves the button retryable',async()=>{ const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});f.pending[1].resolve({});const confirm=await confirmation;confirm(false); assert.equal(await task,false);assert.equal(p.mode,'overview');assert.equal(p.busy,false);assert.equal(p.matchingCount.string,'2/100'); }); test('shared art loads before pages, coalesces across stages and retries without loading pages after failure',async()=>{ const {service,calls,fail}=loaderFixture();fail.add('art'); await assert.rejects(service.pageFragment('stage',1),/offline/); assert.deepEqual(calls,['cloud_rise_home','art']); fail.clear();await Promise.all([service.pageFragment('matching'),service.pageFragment('stage',1),service.pageFragment('stage',2)]); assert.equal(calls.filter(n=>n==='art').length,2); assert.deepEqual(calls.slice(2),['art','matching','stage1','stage2']); }); test('shared art owns one copy of common images while themes stay in their stage',()=>{ const path=require('node:path'),crypto=require('node:crypto'); const meta=JSON.parse(fs.readFileSync('assets/cloud_rise/art.meta')); assert(meta.isBundle);assert.equal(meta.bundleName,'art');assert.equal(meta.compressionType.wechatgame,'merge_all_json');assert(meta.isRemoteBundle.wechatgame); assert(meta.priorityJSON.parse(fs.readFileSync(`${assetDir(name)}.meta`)).priority); const dir=`${assetDir(name)}/images`; for(const file of fs.readdirSync(dir).filter(f=>/\.(png|jpe?g)$/i.test(f))){ const p=path.join(dir,file),hash=crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); assert(!hashes.has(hash),`${p} duplicates ${hashes.get(hash)}`);hashes.set(hash,p); } } for(const name of ['matching','stage1','stage2','stage3']){ const refs=new Set();walk(read(name),o=>{if(o.__uuid__)refs.add(o.__uuid__);}); for(const file of ['background.jpg','chest.png','cloud.png']){ const m=JSON.parse(fs.readFileSync(`${file === "chest.png" ? "assets/cloud_rise_home" : "assets/cloud_rise/art"}/images/${file}.meta`)); assert(refs.has(Object.values(m.subMetas)[0].uuid),`${name} does not use shared ${file}`); } } for(let n=1;n<=3;n++)assert(fs.existsSync(`assets/cloud_rise/stage${n}/images/stage${n}.png`)); }); test('completed matching waits for a fresh tap before showing the stage',async()=>{ const f=panelFixture(),p=f.panel;p.continueHint={active:false};p.waitForContinue=Object.getPrototypeOf(p).waitForContinue; const task=p.match();let finished=false;task.then(()=>finished=true); f.pending[0].resolve({});f.pending[1].resolve({});await settle(); assert.equal(p.matchingCount.string,'100/100');assert.equal(p.mode,'matching');assert.equal(finished,false); assert.equal(p.continueHint.active,true);assert.equal(p.buttons[0].active,false); p.onResultTouchEnd({});await settle();assert.equal(finished,false,'a release without a fresh press cannot continue'); p.onResultTouchStart({});p.onResultTouchEnd({});assert.equal(await task,true); assert.equal(p.mode,'progress');assert.equal(p.continueHint.active,false);assert.equal(f.starts(),1); }); test('matching has no cancel button or stale binding',()=>{ const a=read('matching'),f=fields(a); assert.equal(f.matchingCancel,undefined); assert(!a.some(n=>n._name==='CancelMatching'||n._string==='取消匹配')); }); test('cancel during submitted preparation returns immediately and a late response cannot commit',async()=>{ const f=panelFixture(),p=f.panel,reply=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});f.pending[1].resolve({});const resolve=await reply; p.cancelMatching(); assert.equal(p.mode,'overview');assert.equal(p.busy,false);assert.equal(f.cancellations.length,1); resolve(true);assert.equal(await task,false);assert.equal(f.commits(),0);assert.equal(p.mode,'overview'); }); test('cancel during avatar animation stops that match; 100/100 instead waits for tap',async()=>{ const f=panelFixture(),p=f.panel;let end; p.playMatching=()=>new Promise(resolve=>{end=resolve;});const task=p.match(); f.pending[0].resolve({});f.pending[1].resolve({});await settle(); p.matchingCount.string='67/100';assert.equal(p.matchPhase,'animating'); p.cancelMatching();assert.equal(p.mode,'overview');end();assert.equal(await task,false);assert.equal(f.commits(),0); p.playMatching=async()=>{p.matchingCount.string='100/100';};p.waitForContinue=Object.getPrototypeOf(p).waitForContinue; const retry=p.match();await settle();assert.equal(f.commits(),1); p.cancelMatching();assert.equal(p.mode,'matching');assert.equal(f.cancellations.length,1); p.onResultTouchStart({});p.onResultTouchEnd({});assert.equal(await retry,true); }); test('matching simulates immediately while resources or API wait and a rejected match shows a toast',async()=>{ const f=panelFixture(),p=f.panel,reply=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});await settle();assert.equal(p.matchingCount.string,'2/100'); p.update(2.001);assert.equal(p.matchingCount.string,'7/100'); p.update(60);assert.equal(p.matchingCount.string,'99/100');assert.equal(f.starts(),0); f.pending[1].resolve({});const resolve=await reply;resolve(false);assert.equal(await task,false); assert.deepEqual(f.notices,['匹配失败请重试']);assert.equal(p.mode,'overview');assert.equal(f.commits(),0); }); test('overview layers the shared chest below its sparkles without depending on remote art',()=>{ const a=read('home'),topIndex=a.findIndex(o=>o._name==='PanelTop'),top=a[topIndex]; const children=top._children.map(r=>a[r.__id__]),chest=children.find(n=>n._name==='Chest'); assert(chest);assert(children.indexOf(chest)n._name==='ChestSparkles')); assert.deepEqual(chest._contentSize,{__type__:'cc.Size',width:452,height:323}); assert.deepEqual(chest._trs.array.slice(0,2),[0,321]); const frame=Object.values(JSON.parse(fs.readFileSync('assets/cloud_rise_home/images/chest.png.meta')).subMetas)[0]; assert.equal(a[chest._components[0].__id__]._spriteFrame.__uuid__,frame.uuid); assert.equal(a[chest._prefab.__id__].root.__id__,a[0].data.__id__); assert(!fs.existsSync('assets/cloud_rise/art/images/chest.png'),'keep only one chest texture'); }); test('matching waits for confirmation before displaying 100 and reports confirmation failure',async()=>{ for(const success of [false,true]){ const f=panelFixture(),p=f.panel;let resolve;f.service.confirmMatch=()=>new Promise(done=>resolve=done); p.playMatching=async()=>{p.matchingCount.string='99/100';}; const task=p.match();f.pending[0].resolve({});f.pending[1].resolve({});await settle(); assert.equal(p.matchingCount.string,'99/100');assert(resolve); resolve(success);await task; assert.equal(p.matchingCount.string,success?'100/100':'99/100'); assert.deepEqual(f.notices,success?[]:['匹配失败请重试']); } }); test('network retry exhaustion closes a home overlay or returns from gameplay without reopening matching',async()=>{ for(const scene of ['HomeScene','GameScene'])for(const phase of ['prepare','confirm']){ const f=panelFixture(),p=f.panel,exits=[]; f.cc.director={getScene:()=>({name:scene}),loadScene:name=>exits.push(name)}; p.closePage=()=>exits.push('close'); const fail=async()=>{f.service.matchRetryExhausted=true;return false;}; if(phase==='prepare')f.service.prepareMatch=fail;else f.service.confirmMatch=fail; const task=p.match();f.pending[0].resolve({});f.pending[1].resolve({}); assert.equal(await task,false);assert.deepEqual(exits,[scene==='HomeScene'?'close':'HomeScene']); assert.equal(p.matchPhase,'idle');assert.equal(p.busy,false); assert.equal(p.mode,'matching','finally must not reopen overview after navigation'); } });