789 lines
52 KiB
JavaScript
789 lines
52 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:'', pending:()=>false, elimination:()=>null, hasPendingLoss:()=>false, unseenResult:()=>false, resultKey:()=>'', acknowledgeResult(){},
|
|
dismissExpiryPresentation(){
|
|
const item=service.elimination();
|
|
if(item?.after?.status==='expired')service.acknowledgeElimination(item.key);
|
|
if(service.run()?.status==='expired')service.acknowledgeResult();
|
|
},
|
|
subscribe(fn){listener=fn;return ()=>{};}, sync:async()=>{calls.sync++;return true;},
|
|
prepareMatch:async()=>{calls.start++;return false;}};
|
|
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) {
|
|
imports = {'./module/Config/ActivityScheduleService': {default:{active:()=>null,hasSchedule:()=>false,now:()=>now}}, ...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{}}, './CloudRiseCounter':{default:class{}},'../../Script/Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}}});
|
|
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.openPopup=()=>{};p.closePopup=close=>close();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','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.prepareProgressRoster=()=>{};p.continueHint=node();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('overview uses only the authored Start artwork without caption overlays',()=>{
|
|
const a=JSON.parse(fs.readFileSync('assets/cloud_rise_home/prefab/CloudRise.prefab'));
|
|
const binding=a.find(o=>o.pages&&o.buttons),button=a[binding.buttons[1].__id__];
|
|
assert.equal(binding.overviewAction,undefined);
|
|
assert(!button._children.some(r=>['Caption','CaptionCover'].includes(a[r.__id__]._name)));
|
|
const f=fixture(),p=f.panel();assert(p.buttons[1].active);
|
|
for(const status of ['playing','waiting']) {f.run(status);p.render();assert(p.buttons[1].active);}
|
|
assert.equal(f.calls.start,0);
|
|
});
|
|
|
|
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,priority,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),true);
|
|
f.service.sync=async()=>{throw Error('offline');};
|
|
assert.equal(await f.View.continueFromNextLevel(f.host),true);
|
|
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 timeout closes the entry and consumes presentation without loading a stage, including return from game',async()=>{
|
|
for(const number of [1,2,3]) {
|
|
const f=continuationFixture();f.run('expired');f.data.run.stage=number;f.data.run.stages[0].stage=number;
|
|
let item={key:'expiry',after:{stage:number,status:'expired'}},seen=false;
|
|
f.service.elimination=()=>item;f.service.acknowledgeElimination=()=>item=null;
|
|
f.service.unseenResult=()=>!seen;f.service.acknowledgeResult=()=>seen=true;
|
|
f.bridge.allowsAutomaticResults=()=>true;
|
|
f.bridge.pagePrefab=f.bridge.pageFragment=()=>assert.fail('home timeout must not load animation resources');
|
|
f.view.map={};f.view.refresh();assert.equal(item,null);assert.equal(seen,true,'ordinary gameplay quietly consumes activity expiry');
|
|
f.view.map=null;f.view.refresh();f.view.refresh();
|
|
assert.equal(f.view.entry.active,false);assert.equal(item,null);assert(seen);assert.equal(f.popups.length,0);
|
|
await f.view.showElimination();await f.view.open('result');
|
|
assert.equal(f.view.overlay,null);assert.equal(f.notices.length,0);
|
|
}
|
|
});
|
|
|
|
test('a home progress page closes quietly at expiry and still syncs server state',async()=>{
|
|
const f=continuationFixture();f.run();f.setNow(12000);f.view.expiryPending=true;
|
|
let destroyed=0,acknowledged=0;
|
|
f.view.overlay={destroy(){destroyed++;}};f.view.panel={acknowledge(){acknowledged++;}};
|
|
f.bridge.sync=async()=>{f.calls.sync++;return true;};
|
|
f.view.showElimination=()=>assert.fail('home expiry must not animate');
|
|
await f.view.showExpiry();
|
|
assert.equal(destroyed,1);assert.equal(acknowledged,1);assert.equal(f.view.overlay,null);
|
|
assert.equal(f.view.entry.active,false);assert.equal(f.view.expiryPending,false);assert.equal(f.calls.sync,1);
|
|
});
|
|
|
|
test('an already queued home expiry finishes its popup slot without showing an animation',async()=>{
|
|
const f=continuationFixture();f.run();
|
|
let pending=true,item=null,seen=true;
|
|
f.service.hasPendingLoss=()=>pending;f.service.elimination=()=>item;
|
|
f.service.acknowledgeElimination=()=>item=null;
|
|
f.service.unseenResult=()=>!seen;f.service.acknowledgeResult=()=>seen=true;
|
|
f.bridge.sync=async()=>{pending=false;f.data.run.status='expired';item={key:'expired',after:{status:'expired'}};seen=false;return true;};
|
|
f.bridge.pagePrefab=f.bridge.pageFragment=()=>assert.fail('late timeout must not load a stage');
|
|
f.view.refresh();assert.equal(f.popups.length,1);
|
|
let done=false;f.popups[0].show(()=>done=true);await new Promise(setImmediate);
|
|
assert(done);assert.equal(item,null);assert(seen);assert.equal(f.view.overlay,null);
|
|
});
|
|
|
|
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 new Promise(setImmediate);
|
|
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, retains survivors, 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,true);
|
|
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.buttons[1].active,true);
|
|
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.buttons[1].active,true);
|
|
f.setNow(11000);p.tick();assert.equal(p.buttons[1].active,true);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.buttons[1].active,true);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 hands waiting/progress/roster pages to their owner without acknowledging a result',()=>{
|
|
for(const mode of ['overview','progress','participants']) {
|
|
const f=fixture();f.run(mode==='overview'?'waiting':'playing');const p=f.panel();
|
|
let expired=0;p.onExpired=()=>expired++;
|
|
p.mode=mode;f.setNow(12000);p.tick();
|
|
assert.equal(expired,1);assert.equal(p.mode,mode);
|
|
assert.equal(f.calls.play,0);
|
|
assert.equal(f.calls.close,0);
|
|
}
|
|
});
|
|
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.buttons[1].active,true);
|
|
});
|
|
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('idle home no longer polls private status at boundaries, every 30 seconds or on 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);
|
|
v.tick();assert.equal(f.calls.sync,0);
|
|
f.setNow(11000);v.tick();await Promise.resolve();assert.equal(visible,false);assert.equal(f.calls.sync,0);
|
|
f.setNow(41000);v.tick();await Promise.resolve();assert.equal(f.calls.sync,0);
|
|
v.onShow();await Promise.resolve();assert.equal(f.calls.sync,0);
|
|
});
|
|
|
|
test('all stage timeouts reuse the same overlay until the final tap without loading or instantiating another page',async()=>{
|
|
for(const number of [1,2,3]) {
|
|
const f=continuationFixture();f.cc.warn=error=>{throw error;};f.run();f.data.run.stage=number;f.data.run.stages[0].stage=number;
|
|
const p=f.panel('progress');f.setNow(12000);
|
|
let destroyed=0,item=null,release,seen=false,syncs=0;
|
|
const overlay={destroy(){destroyed++;}};f.view.overlay=overlay;f.view.panel=p;
|
|
f.view.map={node:{},pause:true};f.view.priorPause=false;f.view.expiryPending=true;
|
|
f.cc.instantiate=()=>assert.fail('must retain the current stage');
|
|
f.bridge.pagePrefab=f.bridge.pageFragment=()=>assert.fail('must not reload the stage');
|
|
f.service.elimination=()=>item;f.service.acknowledgeElimination=()=>item=null;
|
|
f.service.unseenResult=()=>!seen;f.service.acknowledgeResult=()=>seen=true;
|
|
f.service.sync=async()=>{syncs++;f.data.run.status='expired';f.data.run.stages[0].status='expired';item={key:'expired',after:{stage:number,status:'expired'}};return true;};
|
|
f.bridge.sync=f.service.sync;
|
|
p.playElimination=()=>new Promise(resolve=>release=()=>resolve(true));
|
|
const task=f.view.showExpiry();await new Promise(setImmediate);
|
|
assert.equal(f.view.overlay,overlay);assert.equal(f.view.panel,p);assert.equal(destroyed,0);
|
|
assert.equal(p.mode,'elimination');assert.equal(f.view.map.pause,true);
|
|
await f.view.showExpiry();assert.equal(syncs,1,'polling cannot start a second animation');
|
|
release();await new Promise(setImmediate);
|
|
assert.equal(p.mode,'result');assert.equal(p.failureMessage.active,true);assert.equal(destroyed,0);
|
|
touch(p,'touchstart');touch(p,'touchend');await task;
|
|
assert.equal(destroyed,1);assert.equal(f.view.expiryPending,false);assert.equal(f.view.map.pause,false);
|
|
}
|
|
});
|
|
|
|
test('expiry synchronization failure retains the current page and remains retryable',async()=>{
|
|
const f=continuationFixture();f.run();f.setNow(12000);f.view.expiryPending=true;
|
|
let destroyed=0;const overlay={destroy(){destroyed++;}};f.view.overlay=overlay;f.service.sync=async()=>false;
|
|
f.view.map={node:{},pause:true};f.view.priorPause=false;
|
|
await f.view.showExpiry();assert.equal(destroyed,0);assert.equal(f.view.overlay,overlay);assert.equal(f.view.map.pause,true);
|
|
assert.equal(f.view.expiryPending,true);assert.equal(f.view.busy,false);
|
|
});
|
|
|
|
test('personal challenge and pending work still sync in the background and on foreground',async()=>{
|
|
for(const kind of ['playing','waiting','pending','unsaved','result','overlay']) {
|
|
const f=continuationFixture();f.view.refresh=()=>{};
|
|
if(['playing','waiting'].includes(kind))f.run(kind);
|
|
if(kind==='pending')f.service.pending=()=>true;
|
|
if(kind==='unsaved'){f.run('completed');Object.assign(f.data.run.stages[0],{status:'won',rewardSaved:false});}
|
|
if(kind==='result')f.service.unseenResult=()=>true;
|
|
if(kind==='overlay')f.view.overlay={};
|
|
f.view.tick();await Promise.resolve();assert.equal(f.calls.sync,1,kind);
|
|
f.view.tick();assert.equal(f.calls.sync,1,kind);
|
|
f.view.onShow();await Promise.resolve();assert.equal(f.calls.sync,2,kind);
|
|
f.setNow(41000);f.view.tick();await Promise.resolve();assert.equal(f.calls.sync,3,kind);
|
|
f.setData(null);f.service.pending=()=>false;f.service.unseenResult=()=>false;f.view.overlay=null;
|
|
f.setNow(71000);f.view.tick();assert.equal(f.calls.sync,3,'polling stops after personal work finishes');
|
|
}
|
|
});
|
|
|
|
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.buttons[1].active,true);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=Array.from({length:100},()=>({setPosition(){}}));p.avatar=()=>{};
|
|
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.buttons[1].active,true);
|
|
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);
|
|
});
|
|
|
|
test('public schedule shows new periods without hiding an unfinished personal challenge',()=>{
|
|
const f=fixture(), current={periodId:'p1'}, next={periodId:'p2'};
|
|
f.data.period.periodId='p1';
|
|
assert.equal(f.bridge.homeEntryVisible(current,true,10000),true);
|
|
assert.equal(f.bridge.homeEntryVisible(null,true,10000),false,'a successful empty schedule closes public registration');
|
|
assert.equal(f.bridge.homeEntryVisible(null,false,10000),false,'private registration data cannot replace a missing public schedule');
|
|
for(const status of ['playing','waiting','completed','failed','expired']) {
|
|
f.run(status);f.data.run.periodId='p1';
|
|
const active=['playing','waiting'].includes(status);
|
|
assert.equal(f.bridge.homeEntryVisible(current,true,10000),active);
|
|
assert.equal(f.bridge.homeEntryVisible(null,true,10000),active);
|
|
assert.equal(f.bridge.homeEntryVisible(null,false,10000),active,'an enrolled personal run survives a public schedule failure');
|
|
assert.equal(f.bridge.homeEntryVisible(next,true,10000),true);
|
|
assert.equal(f.bridge.homeEntryVisible(current,true,13000),false,'personal expiry must be respected');
|
|
}
|
|
});
|
|
|
|
test('home icon appears at cached start without relogin or a successful private status request',async()=>{
|
|
const f=fixture();f.setData(null);
|
|
f.cc.fx.GameConfig.GM_INFO.level=100;
|
|
f.cc.fx.GameConfig.GM_INFO.token='test-token';
|
|
f.cc.fx.StorageMessage={getStorage:()=>null,setStorage(){}};
|
|
let reply;
|
|
const schedules=f.load('assets/Script/module/Config/ActivityScheduleService.ts',{'../Pay/Utils':{default:{
|
|
apiBaseUrl:()=> 'test/',POST:(_path,_body,callback)=>{reply=callback;}}}});
|
|
const task=schedules.sync();reply({code:1,data:{schemaVersion:1,serverNow:10000,windowEndsAt:100000,refreshAt:40000,
|
|
activities:[{activityId:'cloudRise',periodId:'new',configVersion:'v1',startsAt:11000,endsAt:20000,unlockLevel:100}]}});
|
|
await task;
|
|
const View=f.load('assets/Script/CloudRiseView.ts',{
|
|
'./module/Config/CloudRiseService':{default:f.bridge},
|
|
'./module/Config/ActivityScheduleService':{default:schedules},
|
|
'./Sdk/MiniGameSdk':{MiniGameSdk:{API:{showToast(){}}}},'../home_popup_queue/HomePopupQueue':{default:{}}});
|
|
const view=new View();view.node={};view.entry={};view.displayedCoin=0;
|
|
const unsubscribe=schedules.subscribe(()=>view.tick());
|
|
view.tick();assert.equal(view.entry.active,false);const initial=f.calls.sync;
|
|
f.setNow(11000);schedules.tick();assert.equal(view.entry.active,true);assert.equal(f.calls.sync,initial);
|
|
f.setNow(20000);schedules.tick();assert.equal(view.entry.active,false);unsubscribe();
|
|
assert.equal(f.calls.sync,0,'public transitions never trigger a private status request');
|
|
});
|
|
|
|
test('home opening does not load a stale activity when private status synchronization fails',async()=>{
|
|
const f=continuationFixture();f.service.sync=async()=>false;
|
|
let loaded=0;f.bridge.pagePrefab=async()=>{loaded++;return {};};
|
|
await f.view.open();assert.equal(loaded,0);assert.equal(f.view.overlay,null);
|
|
assert.deepEqual(f.notices,['活动信息待同步,请重试']);assert.equal(f.view.busy,false);
|
|
});
|
|
|
|
test('an already earned pending result remains presentable when the status request is offline',async()=>{
|
|
const f=continuationFixture();f.run('completed');
|
|
f.service.sync=async()=>false;f.service.unseenResult=()=>true;
|
|
await f.view.open('result');assert(f.view.overlay);assert.deepEqual(f.notices,[]);
|
|
});
|
|
|
|
test('home progress uses close, round completion uses blank-area confirmation, and count taps do nothing',async()=>{
|
|
const f=fixture();f.run();const p=f.panel('progress');
|
|
assert.equal(p.buttons[0].active,true);assert.equal(p.continueHint.active,false);assert.equal(p.buttons[2].active,false);
|
|
p.buttons[2].click();assert.equal(p.mode,'progress');touch(p,'touchstart');touch(p,'touchend');assert.equal(f.calls.close,0);
|
|
p.buttons[0].click();assert.equal(f.calls.close,1);
|
|
p.show('elimination');let finished=false;const task=p.waitForProgressContinue().then(()=>finished=true);
|
|
assert.equal(p.buttons[0].active,false);assert.equal(p.continueHint.active,true);
|
|
p.buttons[0].click();assert.equal(f.calls.close,1);assert.equal(finished,false);
|
|
const chest={};p.rewardChest=chest;p.onResultTouchStart({target:chest});p.onResultTouchEnd({target:chest});
|
|
await Promise.resolve();assert.equal(finished,false,'chest bubble taps must not advance a progress page');
|
|
touch(p,'touchstart');touch(p,'touchend');await task;assert.equal(finished,true);assert.equal(p.continueHint.active,false);
|
|
});
|
|
|
|
test('victory renders every real survivor once and packs the roster into its available area',()=>{
|
|
for(const count of [1,2,8,43,100]) {
|
|
const f=fixture();f.run('waiting');const p=f.panel();const stage=f.data.run.stages[0];stage.status='won';stage.survivors=count;
|
|
stage.opponents=Array.from({length:99},(_,i)=>({username:'Player'+i,alive:i<count-1}));
|
|
const parent={addChild(n){n.parent=this;}};
|
|
const make=()=>({parent,setPosition(x,y){this.x=x;this.y=y;}});
|
|
f.cc.instantiate=make;p.victorySlots=Array.from({length:8},make);p.avatar=(slot,player)=>slot.player=player;
|
|
p.victoryPage={};p.victoryShade={setContentSize(){}};p.victoryReward={node:{},setValue(){}};
|
|
p.victoryPending={node:{}};p.victoryShared={};p.victoryOthers={};p.victoryKey='r:1';p.fitProgressBackground=()=>{};
|
|
p.renderVictory(stage);const active=p.victorySlots.filter(n=>n.active);
|
|
assert.equal(active.length,count);assert.equal(p.victoryOthers.string,String(count-1));
|
|
assert.equal(new Set(active.map(n=>n.player.username)).size,count);
|
|
assert(active.slice(1).every(n=>Math.abs(n.x)+70*n.scale<=400&&n.y-70*n.scale>=-630));
|
|
}
|
|
});
|
|
|
|
test('a non-final clear keeps the caller paused until the progress page receives a tap',async()=>{
|
|
const f=continuationFixture();f.run('playing');const p=f.panel('overview');let resumed=false;
|
|
let item={key:'r:1:2',after:{stage:1,status:'playing'}};
|
|
f.service.elimination=()=>item;f.service.acknowledgeElimination=()=>item=null;
|
|
p.playElimination=async()=>true;f.cc.instantiate=()=>({destroy(){},getComponent:()=>p});
|
|
const task=f.view.showElimination().then(()=>resumed=true);
|
|
for(let i=0;i<30&&!p.continueResolver;i++)await Promise.resolve();
|
|
assert(p.continueResolver);assert.equal(p.mode,'elimination');assert.equal(resumed,false);
|
|
assert.equal(p.continueHint.active,true);assert.equal(p.buttons[0].active,false);
|
|
f.emit();assert.equal(resumed,false);touch(p,'touchstart');touch(p,'touchend');await task;
|
|
assert.equal(resumed,true);assert.equal(f.view.overlay,null);assert.equal(item,null);
|
|
});
|
|
|
|
test('a pending abandon response reserves the first home popup until elimination is dismissed',async()=>{
|
|
const f=continuationFixture();f.run();f.view.layoutEntry=()=>{};
|
|
let pending=true,item=null,finishSync,closeAnimation,done=false,shown=false;
|
|
f.service.hasPendingLoss=()=>pending;f.service.elimination=()=>item;
|
|
f.bridge.sync=()=>new Promise(resolve=>finishSync=()=>{pending=false;item={key:'loss'};resolve(true);});
|
|
f.view.showElimination=()=>new Promise(resolve=>{shown=true;closeAnimation=()=>{item=null;resolve();};});
|
|
f.view.refresh();f.view.refresh();assert.equal(f.popups.length,1);assert.equal(f.popups[0].priority,0);
|
|
f.popups[0].show(()=>done=true);assert.equal(shown,false);assert.equal(done,false);
|
|
finishSync();await new Promise(setImmediate);assert.equal(shown,true);assert.equal(done,false);
|
|
f.view.refresh();assert.equal(f.popups.length,1);
|
|
closeAnimation();await new Promise(setImmediate);assert.equal(done,true);
|
|
});
|
|
|
|
test('activity guide blocks continuation and its close restores the same progress page',()=>{
|
|
const f=fixture();f.run();const p=f.panel('progress');p.design.scale=1;p.design.childrenCount=8;
|
|
p.activityGuide={active:false,children:[],setSiblingIndex(i){this.sibling=i;}};p.guideShade={setContentSize(){}};
|
|
p.continueResolver=()=>{throw Error('guide must not continue gameplay');};
|
|
p.showGuide(true);assert.equal(p.canContinue(),false);assert.equal(p.continueHint.active,false);
|
|
const event={stopPropagation(){this.stopped=true;}};
|
|
p.onResultTouchEnd(event);assert.equal(p.activityGuide.active,true,'opening tap cannot also close help');
|
|
p.onResultTouchStart(event);p.onResultTouchCancel(event);p.onResultTouchEnd(event);assert.equal(p.activityGuide.active,true);
|
|
p.onResultTouchStart(event);p.onResultTouchEnd(event);assert.equal(p.activityGuide.active,false);assert(event.stopped);
|
|
assert.equal(p.mode,'progress');assert.equal(p.canContinue(),true);assert.equal(p.continueHint.active,true);
|
|
assert.equal(f.calls.close,0);assert.equal(f.calls.play,0);
|
|
});
|
|
|
|
test('home entry hides below unlock level, including a previously opened personal run',()=>{
|
|
const f=fixture(),period={periodId:'p',unlockLevel:100};f.data.period=period;
|
|
for(const enrolled of [false,true]){
|
|
if(enrolled)f.run();
|
|
f.cc.fx.GameConfig.GM_INFO.level=99;
|
|
assert.equal(f.bridge.homeEntryVisible(period,true,10000),false);
|
|
assert.equal(f.bridge.homeEntryVisible(null,false,10000),false);
|
|
f.cc.fx.GameConfig.GM_INFO.level=100;
|
|
assert.equal(f.bridge.homeEntryVisible(period,true,10000),true);
|
|
}
|
|
});
|
|
|
|
test('home countdown follows the personal deadline, refreshes on resume and expires with the entry',()=>{
|
|
const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100;
|
|
const period={periodId:'p',unlockLevel:100,endsAt:10000+86400000};let hasSchedule=true;
|
|
const View=f.load('assets/Script/CloudRiseView.ts',{
|
|
'./module/Config/CloudRiseService':{default:f.bridge},
|
|
'./module/Config/ActivityScheduleService':{default:{
|
|
active:()=>hasSchedule&&f.service.now()<period.endsAt?period:null,hasSchedule:()=>hasSchedule,now:()=>f.service.now()}},
|
|
'./Sdk/MiniGameSdk':{MiniGameSdk:{}},'../home_popup_queue/HomePopupQueue':{default:{}}
|
|
});
|
|
const view=new View();view.node={};view.entry={};view.entryCountdown={string:''};view.displayedCoin=0;
|
|
view.refresh();assert.equal(view.entryCountdown.string,'24:00:00');assert.equal(view.entry.active,true);
|
|
f.setNow(11000);view.refresh();assert.equal(view.entryCountdown.string,'23:59:59');
|
|
f.run();f.data.run.expiresAt=10000+3600000;hasSchedule=false;
|
|
view.refresh();assert.equal(view.entryCountdown.string,'00:59:59');assert.equal(view.entry.active,true);
|
|
f.setNow(10000+1800000);view.refresh();assert.equal(view.entryCountdown.string,'00:30:00','resume calculates from the deadline');
|
|
f.setNow(f.data.run.expiresAt);view.refresh();assert.equal(view.entry.active,false);assert.equal(view.entryCountdown.string,'00:00:00');
|
|
assert.equal(f.calls.sync,0,'updating the clock makes no activity request');
|
|
});
|
|
|
|
test('guide parts pop top to bottom and closing cancels all pending animations',()=>{
|
|
const f=fixture(),p=f.panel(),actions=[],stops=[];
|
|
p.design.scale=1;p.design.childrenCount=8;p.guideShade={setContentSize(){}};
|
|
const parts=Array.from({length:7},(_,i)=>({name:'GuidePart0'+(i+1),scale:1}));
|
|
p.activityGuide={active:false,children:[{name:'Shade'},{name:'GuideContent',scale:0.85,children:parts}],setSiblingIndex(){}};
|
|
f.cc.Tween={stopAllByTarget:n=>stops.push(n)};
|
|
f.cc.tween=n=>{const action={node:n};actions.push(action);return {
|
|
delay(t){action.delay=t;return this;},to(t,props,options){Object.assign(action,{duration:t,props,options});return this;},start(){}
|
|
};};
|
|
p.showGuide(true);assert.equal(actions.length,7);assert(parts.every(n=>n.scale===0));
|
|
actions.forEach((a,i)=>{assert.equal(a.delay,(i+1)*0.3);assert.equal(a.options.easing,'backOut');});
|
|
p.showGuide(false);assert.equal(stops.length,14);assert(parts.every(n=>n.scale===1));
|
|
p.showGuide(true);assert.equal(actions.length,14);assert(parts.every(n=>n.scale===0));
|
|
});
|
|
|
|
|
|
test('expiry during a mainline board skips activity settlement on Next and direct finish paths for all stages',async()=>{
|
|
for(const stage of [1,2,3])for(const entry of ['next','finish']){
|
|
const f=continuationFixture();f.run();f.data.run.stage=stage;f.data.run.stages[0].stage=stage;f.view.map={};
|
|
let item=null,seen=true;f.service.elimination=()=>item;f.service.unseenResult=()=>!seen;
|
|
f.service.acknowledgeElimination=()=>item=null;f.service.acknowledgeResult=()=>seen=true;
|
|
f.service.sync=async()=>{f.data.available=false;f.data.run.status='expired';f.data.run.stages[0].status='lost';item={key:'expiry',after:{stage,status:'expired'}};seen=false;return true;};
|
|
f.view.showElimination=()=>assert.fail('expired activity must not open settlement over a completed board');
|
|
f.bridge.pagePrefab=f.bridge.pageFragment=()=>assert.fail('must not load expired result resources');
|
|
if(entry==='next')assert.equal(await f.View.continueFromNextLevel(f.host),true);else await f.View.playElimination(f.host);
|
|
assert.equal(item,null);assert.equal(seen,true);assert.equal(f.view.overlay,null);assert.deepEqual(f.notices,[]);
|
|
}
|
|
});
|