MatchMaster/tools/test-cloud-rise-bundles.cjs
2026-09-16 18:19:34 +08:00

171 lines
15 KiB
JavaScript

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 read=name=>JSON.parse(fs.readFileSync(`assets/cloud_rise_${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(`assets/cloud_rise_${name}.meta`)).priority<home.priority);
}
for(const ext of ['png','png.meta','plist','plist.meta'])assert(!fs.existsSync(`assets/cloud_rise_home/images/scoin_digits.${ext}`));
});
test('home ships in the main package; four page bundles are emitted for the WeChat grouping hook',()=>{
for(const name of names){const m=JSON.parse(fs.readFileSync(`assets/cloud_rise_${name}.meta`));assert(m.isBundle);assert.equal(m.bundleName,'cloud_rise_'+name);assert.equal(m.compressionType.wechatgame,name==='home'?'default':'subpackage');assert(!m.isRemoteBundle.wechatgame);}
assert(!fs.existsSync('assets/cloud_rise/prefab/CloudRise.prefab'));
});
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(`assets/cloud_rise_${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){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('assets/cloud_rise_'+name);}
for(const name of names)walk(read(name),o=>{if(o.__uuid__&&owners.has(o.__uuid__))assert([name,'home'].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','cloud_rise_matching']);
fail.add('cloud_rise_stage2');await assert.rejects(service.pageFragment('stage',2));fail.clear();await service.pageFragment('stage',2);
assert.equal(calls.filter(n=>n==='cloud_rise_stage2').length,2);assert(!calls.includes('cloud_rise_stage1'));assert(!calls.includes('cloud_rise_stage3'));
await assert.rejects(service.pageFragment('stage',4));
});
function panelFixture(){
const pending=[],attached=[];let starts=0,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){if(type==='click')this.click=fn;}}
const service={run:()=>current,stage:()=>({stage:current.stage}),data:()=>({}),startStage:async()=>{starts++;const ok=await confirm();if(ok)current={status:'playing',stage:2};return ok;}};
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=>({default:n.endsWith('CloudRiseRuntime')?service:n.endsWith('CloudRiseService')?bridge:class{}})});
const panel=new exports.default();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=!panel.busy||panel.matchPhase==='preparing';};
panel.matchingCount={string:''};panel.matchingPrompt={active:false};panel.matchingTitle={node:{active:false},string:''};panel.matchingReward={setValue(){}};
panel.resetMatchingAvatars=()=>{};panel.playMatching=async()=>{panel.matchingCount.string='100/100';};
return {panel,pending,attached,starts:()=>starts,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,'1/100');assert.equal(p.buttons[0].active,true);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,'1/100');
p.buttons[0].click();assert.equal(p.mode,'matching');assert.equal(p.matchPhase,'submitting');
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.buttons[0].click();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.buttons[0].click();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,'1/100');
});