MatchMaster/tools/test-cloud-rise-bundles.cjs
2026-09-24 16:50:43 +08:00

297 lines
23 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 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<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; page bundles are independent remote resources',()=>{
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.priority<JSON.parse(fs.readFileSync('assets/cloud_rise_home.meta')).priority);
const hashes=new Map();
for(const name of ['art','matching','stage1','stage2','stage3']){
if(name!=='art')assert(meta.priority>JSON.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)<children.findIndex(n=>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');
}
});