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

523 lines
33 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
function fixture() {
let now = 10000, data = {available:true, period:{enabled:true,startsAt:9000,endsAt:11000}, run:null};
const calls = {sync:0,play:0,close:0,start:0,prefab:0};
let listener;
const service = {data:()=>data, run:()=>bridge.currentRun(data), stage:()=>{const run=service.run();return run&&run.stages.at(-1);},
now:()=>now, error:'', elimination:()=>null, unseenResult:()=>false, resultKey:()=>'', acknowledgeResult(){},
subscribe(fn){listener=fn;return ()=>{};}, sync:async()=>{calls.sync++;return true;},
startStage:async()=>{calls.start++;return true;}};
class Component {schedule(){} unschedule(){} scheduleOnce(fn,delay){(this.scheduled||(this.scheduled=[])).push({fn,delay});}}
const cc = {_decorator:{ccclass:c=>c,property:()=>()=>{}}, Component, Node:class{}, Label:class{}, Sprite:class{}, SpriteFrame:class{},
String, isValid:()=>true, warn(){}, view:{getVisibleSize:()=>({width:720,height:1280})},
fx:{GameConfig:{GM_INFO:{userId:1001,coin:0}}}};
function load(file, imports) {
const exports={};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,
target:ts.ScriptTarget.ES2017,experimentalDecorators:true}}).outputText,
{exports,cc,Date:{now:()=>now},console,setTimeout,clearTimeout,require:n=>{assert(n in imports,n);return imports[n];}});
return exports.default;
}
const bridge=load('assets/Script/module/Config/CloudRiseService.ts',{'../Pay/Utils':{default:{}}});
bridge.install(service);
const Panel=load('assets/cloud_rise_home/scripts/CloudRisePanel.ts',{'./CloudRiseRuntime':{default:service},
'../../Script/module/Config/CloudRiseService':{default:bridge}, './CloudRiseNumberToImage':{default:class{}}, './CloudRisePage':{default:class{}}});
const node=()=>({active:true,setContentSize(){},getChildByName(){return null;},width:720,height:1280,events:{},on(type,fn,target,capture){this.events[type]={fn,target,capture};if(type==='click')this.click=fn;},off(){}});
function panel(mode='overview', beforeInitialize) {
const p=new Panel();p.node=node();p.background=node();p.design=node();
p.pages=Array.from({length:5},node);p.pages[3]=null;p.buttons=Array.from({length:6},node);
for(const key of ['overviewDescription','overviewAction','progressArt','progressCount','survivorCount','progressPool',
'playAction','resultArt','resultTitle','reward','resultDescription','totalReward','resultAction','errorLabel']) p[key]={string:'',node:node()};
p.failureMessage=node();p.expiredMessage=node();p.modalBackground=node();p.progressBackground=node();p.progressRoster=node();p.progressPool.setValue=()=>{};p.renderProgressRoster=()=>{};p.rewardGroup=node();p.overviewCopy=['open','waiting','completed','expired','failed','closed'];p.clocks=[{string:''}];
p.loadedStage=service.stage()?.stage||1;p.matchingPage=node();p.stagePage=node();
if(beforeInitialize)beforeInitialize(p);
p.initialize(mode,false,()=>calls.close++,()=>calls.play++);return p;
}
function run(status='playing') {
data.available=false;
data.run={id:'r',status,stage:1,expiresAt:12000,stages:[{stage:1,status:status==='waiting'?'won':'playing',
rewardSaved:true,reward:100,success_num:0,target:5,survivors:100,pool:10000,opponents:[]}]};
}
return {bridge,service,calls,panel,run,cc,setNow:n=>{now=n;},setData:d=>{data=d;},get data(){return data;},emit:()=>listener(),load};
}
test('authored overview caption is bound and renders enrollment, next-stage and closed states',()=>{
const a=JSON.parse(fs.readFileSync('assets/cloud_rise_home/prefab/CloudRise.prefab'));
const binding=a.find(o=>o.pages&&o.buttons),ref=binding.overviewAction;
const label=ref&&a[ref.__id__];
const f=fixture(),cover={active:false};let p;
assert.doesNotThrow(()=>{p=f.panel('overview',panel=>{
panel.overviewAction=label?{string:label._string,node:{active:a[label.node.__id__]._active}}:null;
panel.buttons[1].getChildByName=name=>name==='CaptionCover'?cover:null;
});},'opening the authored prefab must not fail in initialize/render');
assert.equal(label.__type__,'cc.Label');
const button=a[binding.buttons[1].__id__],caption=a[label.node.__id__];
assert.equal(caption._parent.__id__,binding.buttons[1].__id__);
const children=button._children.map(r=>a[r.__id__]);
assert(children.indexOf(caption)>children.findIndex(n=>n._name==='CaptionCover'));
assert.equal(p.overviewAction.string,'开始');assert.equal(p.overviewAction.node.active,false);assert.equal(cover.active,false);
for(const stage of [1,2]){
f.run('waiting');f.data.run.stage=stage;p.render();
assert.equal(p.overviewAction.string,'开始下一阶段');assert.equal(p.overviewAction.node.active,true);assert.equal(cover.active,true);
}
f.run('completed');p.render();assert.equal(p.overviewAction.string,'知道了');
});
function continuationFixture() {
const f=fixture(),popups=[],notices=[];let match=async()=>true;
const View=f.load('assets/Script/CloudRiseView.ts',{
'./module/Config/CloudRiseService':{default:f.bridge},
'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast:s=>notices.push(s)}}},
'../home_popup_queue/HomePopupQueue':{default:{get:()=>({enqueue:(key,priority,show)=>popups.push({key,show})})}}});
const view=new View();view.node={};view.entry={};
const host={getChildByName:()=>({getComponent:()=>view}),addChild(){},getComponent:()=>null};view.host=host;
f.cc.macro={MAX_ZINDEX:10000};f.cc.instantiate=()=>({destroy(){},getComponent:()=>({preparePage:async()=>{},initialize(){},match:()=>match()})});
f.bridge.pagePrefab=async()=>({});f.bridge.pageFragment=async()=>({});
return {...f,View,view,host,popups,notices,setMatch:fn=>match=fn};
}
test('Next never automatically matches another stage, including waiting stages 1 and 2',async()=>{
for(const stage of [1,2]) {
const f=continuationFixture();f.run('waiting');f.data.run.stage=stage;
let starts=0;f.setMatch(async()=>{starts++;f.data.run.stage++;f.data.run.status='playing';return true;});
assert.equal(await f.View.continueFromNextLevel(f.host),true);
assert.equal(starts,0);assert.equal(f.data.run.stage,stage);
assert.equal(f.view.overlay,null);assert.equal(f.view.busy,false);
}
for(const status of ['playing','completed','expired']) {
const f=continuationFixture();f.run(status);let starts=0;f.setMatch(async()=>{starts++;return true;});
assert.equal(await f.View.continueFromNextLevel(f.host),true);assert.equal(starts,0);
}
});
test('failed result sync remains retryable without ever starting a stage',async()=>{
const f=continuationFixture();f.run('waiting');let starts=0;
f.setMatch(async()=>{starts++;return true;});
f.service.sync=async()=>false;
assert.equal(await f.View.continueFromNextLevel(f.host),false);
f.service.sync=async()=>{throw Error('offline');};
assert.equal(await f.View.continueFromNextLevel(f.host),false);
f.service.sync=async()=>true;
assert.equal(await f.View.continueFromNextLevel(f.host),true);
assert.equal(starts,0);assert.equal(f.data.run.status,'waiting');
});
test('Next presents delayed stage 1/2 rewards without auto-matching when the motion queue is empty',async()=>{
for(const stage of [1,2]) {
const f=continuationFixture();f.run('playing');f.data.run.stage=stage;f.data.run.stages[0].stage=stage;
let unseen=false,close,matched=0,syncs=0;const order=[];
f.service.unseenResult=()=>unseen;
f.service.sync=async()=>{
if(++syncs===1) {f.data.run.status='waiting';f.data.run.stages[0].status='won';unseen=true;}
return true;
};
f.cc.instantiate=()=>({destroy(){},getComponent:()=>({preparePage:async()=>{},
initialize:(mode,inGame,onClose)=>{order.push(mode);if(mode==='result')close=onClose;},
acknowledge:()=>{unseen=false;order.push('ack');},
match:async()=>{matched++;f.data.run.stage++;f.data.run.status='playing';return true;}})});
const next=f.View.continueFromNextLevel(f.host);
for(let i=0;i<30&&!close;i++)await Promise.resolve();
assert.equal(typeof close,'function');assert.equal(matched,0);assert.deepEqual(order,['result']);
close();assert.equal(await next,true);assert.equal(matched,0);assert.deepEqual(order,['result','ack']);
}
});
test('an unseen reward whose prefab fails to load blocks stage advancement and remains retryable',async()=>{
const f=continuationFixture();f.run('waiting');f.service.unseenResult=()=>true;
f.bridge.pagePrefab=async()=>{throw Error('offline');};let matched=0;f.setMatch(async()=>{matched++;return true;});
assert.equal(await f.View.continueFromNextLevel(f.host),false);assert.equal(matched,0);
assert.equal(f.service.unseenResult(),true);assert.equal(f.view.overlay,null);
});
test('returning to a fresh home never queues an already settled waiting-stage overview',async()=>{
for(const stage of [1,2,3]) {
const f=continuationFixture();f.run(stage===3?'completed':'waiting');f.data.run.stage=stage;
f.view.refresh();f.view.refresh();assert.equal(f.popups.length,0);assert.equal(f.calls.start,0);
await f.view.open('overview');assert.equal(!!f.view.overlay,stage!==3,'completed entry stays closed');
}
});
test('home still presents genuinely unseen rewards and pending win/loss animations',()=>{
for(const outcome of ['reward','won','lost']) {
const f=continuationFixture();f.run(outcome==='lost'?'failed':'waiting');
if(outcome==='reward') {f.service.unseenResult=()=>true;f.service.resultKey=()=>'r:1:waiting';f.bridge.allowsAutomaticResults=()=>true;}
else f.service.elimination=()=>({key:'r:1:'+outcome,after:{stage:1,status:outcome}});
f.view.refresh();f.view.refresh();
assert.deepEqual(f.popups.map(p=>p.key),[outcome==='reward'?'cloudRiseResult':'cloudRiseElimination']);
}
});
test('final cloud movement opens victory before Next continues and waits for close',async()=>{
const f=continuationFixture();f.run('waiting');const order=[];let close,ended=false;
let item={key:'r:1:5',after:{stage:1,status:'won'}};
f.service.elimination=()=>item;f.service.acknowledgeElimination=()=>{item=null;order.push('ack-motion');};
f.service.unseenResult=()=>true;
f.cc.instantiate=()=>({destroy:()=>order.push('destroy'),getComponent:()=>({preparePage:async()=>{},
initialize:(mode,inGame,onClose)=>{assert.equal(mode,'elimination');close=onClose;},
playElimination:async()=>{order.push('motion');return true;},
openResult:()=>order.push('victory'),acknowledge:()=>order.push('ack-result')})});
const task=f.view.showElimination().then(()=>ended=true);
for(let i=0;i<20&&!order.includes('victory');i++)await Promise.resolve();
assert.deepEqual(order,['motion','ack-motion','victory']);assert.equal(ended,false);
assert(f.view.overlay.zIndex > f.cc.macro.MAX_ZINDEX - 2, 'activity settlement covers the settings popup');
assert(f.view.overlay.zIndex < f.cc.macro.MAX_ZINDEX, 'loading overlay remains above settlement');
close();await task;
assert.deepEqual(order,['motion','ack-motion','victory','ack-result','destroy']);assert.equal(f.view.overlay,null);
});
test('an abandoned loss waits in gameplay and is queued once on HomeScene, including late responses',async()=>{
for(const late of [false,true]) {
const f=continuationFixture();f.run('failed');f.data.run.stages[0].status='lost';
let item=late?null:{key:'r:1:lost',after:{stage:1,status:'lost'}};
f.service.elimination=()=>item;f.view.layoutEntry=()=>{};f.view.map={};
f.view.refresh();assert.equal(f.popups.length,0,'gameplay must not auto-play the loss');
f.view.map=null;f.view.refresh();
if(late){assert.equal(f.popups.length,0);item={key:'r:1:lost',after:{stage:1,status:'lost'}};}
f.view.refresh();f.view.refresh();assert.deepEqual(f.popups.map(p=>p.key),['cloudRiseElimination']);
let shown=0,done=0;f.view.showElimination=async()=>{shown++;item=null;};
f.popups[0].show(()=>done++);await Promise.resolve();await Promise.resolve();
assert.equal(shown,1);assert.equal(done,1);f.view.refresh();assert.equal(f.popups.length,1);
}
});
test('loss movement immediately opens the failure state and closing prevents another home result popup',async()=>{
const f=continuationFixture();f.run('failed');f.data.run.stages[0].status='lost';
const order=[];let close,ended=false,unseen=true,item={key:'r:1:lost',after:{stage:1,status:'lost'}};
f.service.elimination=()=>item;f.service.acknowledgeElimination=()=>{item=null;order.push('ack-motion');};
f.service.unseenResult=()=>unseen;
f.cc.instantiate=()=>({destroy:()=>order.push('destroy'),getComponent:()=>({preparePage:async()=>{},
initialize:(mode,inGame,onClose)=>{close=onClose;},playElimination:async()=>{order.push('motion');return true;},
openResult:()=>order.push('failure'),acknowledge:()=>{unseen=false;order.push('ack-result');}})});
const task=f.view.showElimination().then(()=>ended=true);
for(let i=0;i<20&&!order.includes('failure');i++)await Promise.resolve();
assert.deepEqual(order,['motion','ack-motion','failure']);assert.equal(ended,false);
close();await task;f.view.refresh();
assert.equal(unseen,false);assert.equal(f.popups.length,0);assert.equal(f.view.overlay,null);
});
test('failure result uses the cloud progress scene, hides counters and avatars, and allows tap dismissal',()=>{
const f=fixture();f.run('failed');f.data.run.stages[0].status='lost';const p=f.panel('overview');p.mode='elimination';
p.failureMessage={active:false};p.progressStatsContents=Array.from({length:5},()=>({active:true}));
p.progressRoster={active:true};p.modalBackground={active:true};p.fitProgressBackground=()=>{};
let pool;p.progressPool={setValue:v=>pool=v};p.busy=true;
p.openResult();
assert.equal(p.mode,'result');assert.equal(p.busy,false);assert.equal(p.buttons[0].active,false);
assert.equal(p.pages[2].active,true);assert.equal(p.pages[3],null);assert.equal(p.modalBackground.active,false);
assert.equal(p.failureMessage.active,true);assert.equal(p.progressRoster.active,false);
assert(p.progressStatsContents.every(n=>!n.active));assert.equal(pool,10000);
assert.equal(p.clocks[0].string,'00:00:02','failed state retains the personal deadline');
});
test('legacy flags do not gate enrollment; open, playing and waiting share the description art',()=>{
const f=fixture();f.data.period.enabled=false;f.data.period.status='inactive';
assert.equal(f.bridge.registrationOpen(),true);assert.equal(f.bridge.shouldPreload(f.data),true);
const p=f.panel();p.overviewDescriptionImage={active:false};p.render();
assert.equal(p.overviewDescriptionImage.active,true);
assert.equal(p.overviewAction.string,'开始');
for(const status of ['playing','waiting','completed','failed','expired']) {
f.run(status);p.render();
const active=['playing','waiting'].includes(status);
assert.equal(p.overviewDescriptionImage.active,active);
assert.equal(f.bridge.visible(),active);
}
});
test('entry follows registration boundaries and rejects stale historical runs',()=>{
const f=fixture();assert.equal(f.bridge.visible(),true);
f.setNow(11000);assert.equal(f.bridge.visible(),false);
f.setNow(8000);assert.equal(f.bridge.visible(),false);
for(const status of ['completed','failed','expired']) {
f.run(status);f.setNow(10000);assert.equal(f.bridge.visible(),false);f.setNow(13000);assert.equal(f.bridge.visible(),false);
}
f.run('completed');f.data.run.stages[0].status='won';f.data.run.stages[0].rewardSaved=false;
assert.equal(f.bridge.visible(),false,'terminal runs never expose the home entry');
assert.equal(f.bridge.shouldPreload(f.data),true,'unsaved rewards still retry independently');
});
test('personal playing and waiting stay accessible after signup closes, only until personal expiry',()=>{
for(const status of ['playing','waiting']) {
const f=fixture();f.run(status);f.setNow(11000);assert.equal(f.bridge.visible(),true);
f.setNow(12000);assert.equal(f.bridge.visible(),false);
}
});
test('open registration page removes start at the exact deadline, even offline; sync retries are throttled',()=>{
const f=fixture(),p=f.panel();assert.equal(p.overviewAction.string,'开始');
f.setNow(11000);p.tick();assert.equal(p.overviewAction.string,'知道了');assert.equal(p.overviewDescription.string,'');
p.buttons[1].click();assert.equal(f.calls.start,0);
const count=f.calls.sync;for(let i=0;i<10;i++)p.tick();assert.equal(f.calls.sync,count);
f.setNow(41000);p.tick();assert.equal(f.calls.sync,count+1);
});
test('registration closure preserves manual next-stage action for an enrolled player',async()=>{
const f=fixture();f.run('waiting');const p=f.panel();f.setNow(11000);p.tick();
assert.equal(p.overviewAction.string,'开始下一阶段');assert.equal(p.mode,'overview');
p.preparePage=async()=>{};p.buttons[1].click();for(let i=0;i<6;i++)await Promise.resolve();assert.equal(f.calls.start,1);
});
test('personal expiry switches waiting/progress/roster pages to timeout without server response',()=>{
for(const mode of ['overview','progress','participants']) {
const f=fixture();f.run(mode==='overview'?'waiting':'playing');const p=f.panel();
p.mode=mode;f.setNow(12000);p.tick();
assert.equal(p.mode,'result');assert.equal(p.expiredMessage.active,true);assert.equal(p.failureMessage.active,false);
assert.equal(f.calls.play,0);
touch(p,'touchstart');touch(p,'touchend');assert.equal(f.calls.close,1);
}
});
test('server closure updates a page immediately',()=>{
const f=fixture(),p=f.panel();f.setData(null);f.emit();
assert.equal(p.overviewDescription.string,'');assert.equal(p.overviewAction.string,'知道了');
});
test('home opening rechecks eligibility before loading activity resources',async()=>{
const f=fixture();f.setData(null);
f.bridge.pagePrefab=async()=>{f.calls.prefab++;};
const View=f.load('assets/Script/CloudRiseView.ts',{
'./module/Config/CloudRiseService':{default:f.bridge},'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}},
'../home_popup_queue/HomePopupQueue':{default:{}}});
const v=new View();v.node={};await v.open();assert.equal(f.calls.prefab,0);assert.equal(v.overlay,null);
});
test('locked home entry shows the configured level instead of loading or opening the activity',async()=>{
for(const completed of [103,109]) {
const f=continuationFixture();f.data.period.unlockLevel=110;f.cc.fx.GameConfig.GM_INFO.level=completed;
let loaded=0;f.bridge.pagePrefab=async()=>{loaded++;return {};};
assert.equal(f.bridge.visible(),true);assert.equal(f.bridge.shouldPreload(f.data),false);
await f.view.open();
assert.deepEqual(f.notices,['月光宝盒活动通过第110关后解锁']);
assert.equal(loaded,0);assert.equal(f.view.overlay,null);assert.equal(f.view.busy,false);
}
});
test('passing the unlock level opens the activity and enrolled runs remain accessible after a threshold edit',async()=>{
for(const enrolled of [false,true]) {
const f=continuationFixture();f.data.period.unlockLevel=110;
f.cc.fx.GameConfig.GM_INFO.level=enrolled?103:110;
if(enrolled)f.run('waiting');
assert.equal(f.bridge.lockedUntilLevel(),0);assert.equal(f.bridge.shouldPreload(f.data),true);
await f.view.open();assert.ok(f.view.overlay);assert.deepEqual(f.notices,[]);
}
});
test('home entry uses the refreshed unlock level and rechecks it after resource loading',async()=>{
const f=continuationFixture();f.data.period.unlockLevel=100;f.cc.fx.GameConfig.GM_INFO.level=103;
f.service.sync=async()=>{f.data.period.unlockLevel=110;return true;};
await f.view.open();assert.equal(f.view.overlay,null);assert.deepEqual(f.notices,['月光宝盒活动通过第110关后解锁']);
f.service.sync=async()=>true;f.data.period.unlockLevel=100;f.notices.length=0;
f.bridge.pagePrefab=async()=>{f.data.period.unlockLevel=120;return {};};
await f.view.open();assert.equal(f.view.overlay,null);assert.deepEqual(f.notices,['月光宝盒活动通过第120关后解锁']);
});
test('home refreshes on elapsed deadlines, polls new events and resyncs on returning to foreground',async()=>{
const f=fixture();
const View=f.load('assets/Script/CloudRiseView.ts',{
'./module/Config/CloudRiseService':{default:f.bridge},'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}},
'../home_popup_queue/HomePopupQueue':{default:{}}});
const v=new View();let visible;v.refresh=()=>{visible=f.bridge.visible();};
v.tick();await Promise.resolve();assert.equal(visible,true);const initial=f.calls.sync;
v.tick();assert.equal(f.calls.sync,initial);
f.setNow(11000);v.tick();await Promise.resolve();assert.equal(visible,false);assert.equal(f.calls.sync,initial+1);
f.setNow(41000);v.tick();await Promise.resolve();assert.equal(f.calls.sync,initial+2);
v.onShow();await Promise.resolve();assert.equal(f.calls.sync,initial+3);
});
test('an event ending while resources load cannot open a stale registration page',async()=>{
const f=fixture();let release;
f.bridge.pagePrefab=()=>new Promise(resolve=>{release=resolve;});
const View=f.load('assets/Script/CloudRiseView.ts',{
'./module/Config/CloudRiseService':{default:f.bridge},'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}},
'../home_popup_queue/HomePopupQueue':{default:{}}});
const v=new View();v.node={};const task=v.open();
for(let i=0;i<5&&!release;i++)await Promise.resolve();
assert.equal(typeof release,'function');f.setNow(11000);release({});await task;assert.equal(v.overlay,null);
});
test('overview uses the configured next pool and freezes an enrolled stage pool',()=>{
const f=fixture(),p=f.panel();let amount;p.overviewPool={setValue:value=>{amount=value;}};
f.data.pools=[12000,18000,24000];p.render();assert.equal(amount,12000);
f.run('waiting');p.render();assert.equal(amount,18000);
f.run('playing');p.render();assert.equal(amount,10000,'current run keeps its own pool');
assert.match(p.clocks[0].string,/^\d{2}:\d{2}:\d{2}$/);
});
test('image number row reuses five slots, centers with coin, and rejects more than five digits',()=>{
class Node {
constructor(){this.children=[];this.width=0;this.height=0;this.scale=1;this.x=0;}
addComponent(){return {};}
getComponent(){return {setMaterial(){}};}
addChild(n){n.parent=this;this.children.push(n);}
setContentSize(w,h){this.width=w;this.height=h;}
setPosition(x,y){this.x=x;this.y=y;}
removeFromParent(){this.parent.children=this.parent.children.filter(n=>n!==this);}
destroy(){}
}
class Sprite{}Sprite.SizeMode={CUSTOM:0};
const cc={_decorator:{ccclass:c=>c,executeInEditMode:c=>c,property:(...args)=>args.length>1?undefined:()=>{}},Node,Sprite,SpriteFrame:class{},
Material:class {static getBuiltinMaterial(){return {};}},Component:class{},Color:{WHITE:'white'},color:(r,g,b)=>[r,g,b]};
const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRiseNumberToImage.ts','utf8'),
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017,experimentalDecorators:true}}).outputText,{exports,cc});
const n=new exports.default();n.node=new Node();n.node.width=380;n.coin=new Node();n.coin.width=74;n.content=new Node();
for(let i=0;i<5;i++)n.content.addChild(new Node());
n.digits=Array.from({length:10},()=>({getRect:()=>({width:35,height:39})}));
for(const value of [10000,15000,20000,9,0,99999]) {
n.setValue(value);assert.equal(n.content.children.length,5);
assert.equal(n.content.children.filter(child=>child.active).length,String(value).length);
const left=n.coin.x-n.coin.width*n.coin.scale/2;
const right=n.content.x+(String(value).length*n.digitHeight*35/39*n.widthScale+(String(value).length-1)+8)*n.content.scale;
assert(Math.abs(left+right)<1e-6);assert(right<=190);
const child=n.content.children[0];n.setValue(value);assert.equal(n.content.children[0],child,'unchanged value reuses nodes');
}
n.setValue(100000);assert.equal(n.content.active,false);assert.equal(n.coin.active,false);assert.equal(n.content.children.length,5);
n.setValue(10000);assert.equal(n.content.active,true);assert.equal(n.coin.active,true);
});
test('a settled previous period cannot replace the new signup page, clock, pool or stage checks',async()=>{
for(const status of ['expired','failed','completed']) {
const f=fixture();f.run(status);const historical=f.data.run;
historical.periodId='p1';historical.expiresAt=9000;
f.data.period.periodId='p2';f.data.available=true;f.data.pools=[12000,18000,24000];
const p=f.panel('result');let pool;p.overviewPool={setValue:value=>pool=value};p.render();
assert.equal(f.bridge.run(),null);assert.equal(f.data.run,historical,'keep the raw record for recovery');
assert.equal(p.mode,'overview');assert.equal(p.overviewAction.string,'开始');assert.equal(pool,12000);
assert.equal(p.clocks[0].string,'00:00:01','count down the new registration window');
p.tick();assert.equal(p.mode,'overview','old expiry must not reopen');
p.preparePage=async()=>{};p.buttons[1].click();for(let i=0;i<6;i++)await Promise.resolve();assert.equal(f.calls.start,1);
}
});
test('a previous personal challenge and an unsaved old award still take priority over new signup',()=>{
for(const status of ['playing','waiting','expired']) {
const f=fixture();f.run(status);f.data.run.periodId='p1';f.data.period.periodId='p2';
if(status==='expired')Object.assign(f.data.run.stages[0],{status:'won',rewardSaved:false});
assert.equal(f.bridge.run(),f.data.run);
}
});
test('enrolled mainline hides the entry while the home entry stays available', () => {
const f=fixture();f.run();
const View=f.load('assets/Script/CloudRiseView.ts',{
'./module/Config/CloudRiseService':{default:f.bridge},'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}},
'../home_popup_queue/HomePopupQueue':{default:{}}});
const view=new View();view.node={};view.entry={};view.map={};view.refresh();
assert.equal(view.entry.active,false);
view.map=null;view.displayedCoin=0;view.refresh();assert.equal(view.entry.active,true);
});
function touch(panel,type) {
const binding=panel.node.events[type];let stopped=false;
assert.equal(binding.capture,true,'result taps intercept children before their buttons');
binding.fn.call(binding.target,{stopPropagation(){stopped=true;}});
return stopped;
}
test('solo and shared victories use the same artwork with the number of other winners',()=>{
for(const survivors of [1,2,43]) {
const f=fixture();f.run('waiting');const p=f.panel();
p.victoryPage={};p.modalBackground={};p.design.scale=1;p.victoryShade={setContentSize(){}};
p.fitProgressBackground=()=>{};p.renderProgressStage=()=>{};p.renderProgressRoster=()=>{};
p.victoryReward={node:{},setValue(){}};p.victoryPending={node:{}};
p.victoryShared={active:false};p.victorySolo={node:{active:true}};p.victoryOthers={};p.victorySlots=[];
p.victoryKey='r:1';const stage=f.data.run.stages[0];stage.survivors=survivors;
p.renderVictory(stage);
assert.equal(p.victoryShared.active,true);
assert.equal(p.victoryOthers.string,String(survivors-1));
}
});
test('intermediate wins open overview but final victory closes after the manual tap',()=>{
for(const stage of [1,2,3]) {
const f=fixture();f.run(stage===3?'completed':'waiting');
f.data.run.stage=stage;Object.assign(f.data.run.stages[0],{stage,status:'won'});
let ack=0;f.service.acknowledgeResult=()=>ack++;
const p=f.panel('result');p.match=async()=>{f.calls.start++;return true;};
assert.equal(p.buttons[0].active,false);
assert.equal(touch(p,'touchstart'),true);assert.equal(touch(p,'touchend'),true);
if(stage===3) {assert.equal(f.calls.close,1);assert.equal(ack,1);assert.equal(f.calls.start,0);continue;}
assert.equal(p.mode,'overview');assert.equal(ack,1);assert.equal(f.calls.close,0);assert.equal(f.calls.start,0);
assert.equal(p.overviewAction.string,stage===3?'知道了':'开始下一阶段');
touch(p,'touchend');f.emit();assert.equal(f.calls.start,0);assert.equal(ack,1);
p.buttons[1].click();assert.equal(f.calls.start,stage===3?0:1);
}
});
test('animation-time touches and cancelled gestures cannot dismiss the result',()=>{
const f=fixture();f.run('waiting');const p=f.panel('result');
let ack=0;f.service.acknowledgeResult=()=>ack++;
p.busy=true;touch(p,'touchstart');touch(p,'touchend');assert.equal(p.mode,'result');
touch(p,'touchstart');p.busy=false;touch(p,'touchend');assert.equal(p.mode,'result','holding through animation completion is not a fresh tap');
touch(p,'touchstart');touch(p,'touchcancel');touch(p,'touchend');assert.equal(ack,0);
touch(p,'touchstart');touch(p,'touchend');assert.equal(p.mode,'overview');assert.equal(ack,1);
});
test('loss and expiry results close by tapping after their presentation',()=>{
for(const status of ['failed','expired']) {
const f=fixture();f.run(status);f.data.run.stages[0].status='lost';
if(status==='expired')f.setNow(12000);
const p=f.panel('result');assert.equal(p.buttons[0].active,false);
touch(p,'touchstart');touch(p,'touchend');assert.equal(f.calls.close,1);
}
});
test('closing an already displayed waiting overview does not queue it again on the same home',()=>{
const f=continuationFixture();f.run('waiting');f.view.panel={acknowledge(){}};
f.view.overlay={destroy(){}};f.view.close();f.view.refresh();
assert.equal(f.popups.length,0);assert.equal(f.calls.start,0);
});
test('Next stays pending through victory dismissal and overview, with optional manual enrollment',async()=>{
for(const manual of [false,true]) {
const f=continuationFixture();f.run('waiting');f.view.map={};let unseen=true,finished=false;
f.service.unseenResult=()=>unseen;f.service.acknowledgeResult=()=>{unseen=false;};
const p=f.panel('overview');
p.match=async()=>{f.calls.start++;f.data.run.stage=2;f.data.run.status='playing';return true;};
f.cc.instantiate=()=>({destroy(){},getComponent:()=>p});
const next=f.View.continueFromNextLevel(f.host).then(value=>{finished=true;return value;});
for(let i=0;i<30&&p.mode!=='result';i++)await Promise.resolve();
assert.equal(p.mode,'result');assert.equal(finished,false);
touch(p,'touchstart');touch(p,'touchend');await Promise.resolve();
assert.equal(p.mode,'overview');assert.equal(unseen,false);assert.equal(finished,false);
assert.equal(f.calls.start,0);
if(manual)p.buttons[1].click();
p.buttons[0].click();assert.equal(await next,true);
assert.equal(f.calls.start,manual?1:0);assert.equal(f.data.run.stage,manual?2:1);
}
});
test('elimination -> reward -> overview restores actual visible controls for stages 1 and 2',()=>{
for(const stage of [1,2]) {
const f=fixture();f.run('waiting');f.data.run.stage=stage;f.data.run.stages[0].stage=stage;
const p=f.panel('overview');p.fitProgressBackground=()=>{};p.show('elimination');
assert(p.buttons.every(b=>!b.active));p.openResult();touch(p,'touchstart');touch(p,'touchend');
assert.equal(p.pages[0].active,true);assert.equal(p.buttons[1].active,true);
assert.equal(p.buttons[0].active,true);assert.equal(p.buttons[2].active,false);
let matched=0;p.match=async()=>{matched++;return true;};
if(p.buttons[1].active)p.buttons[1].click();assert.equal(matched,1);
}
});
test('failure waits for a fresh tap before releasing pending retry/home presentation, including after polling',async()=>{
for(const inGame of [true,false]) {
const f=continuationFixture();f.run('failed');f.data.run.stages[0].status='lost';
f.view.map=inGame?{}:null;let unseen=true,resumed=false;
f.service.unseenResult=()=>unseen;f.service.acknowledgeResult=()=>{unseen=false;};
const p=f.panel('overview');f.cc.instantiate=()=>({destroy(){},getComponent:()=>p});
const task=f.view.showElimination().then(()=>resumed=true);
for(let i=0;i<30&&p.mode!=='result';i++)await Promise.resolve();
assert.equal(p.mode,'result');assert.equal(resumed,false);
f.emit();p.render();assert.equal((p.scheduled||[]).length,0);
f.setNow(11000);f.emit();await Promise.resolve();
assert.equal(resumed,false);assert.equal(unseen,true);
touch(p,'touchstart');touch(p,'touchend');await task;
assert.equal(resumed,true);assert.equal(unseen,false);assert.equal(f.view.overlay,null);
f.view.refresh();assert.equal(f.popups.length,0);
}
});
test('neither victory nor loss schedules automatic dismissal',()=>{
const f=fixture();f.run('waiting');const win=f.panel('result');assert.equal(win.scheduled,undefined);
f.data.run.status='failed';f.data.run.stages[0].status='lost';const lose=f.panel('result');
assert.equal(lose.scheduled,undefined);assert.equal(f.calls.close,0);
});