301 lines
32 KiB
JavaScript
301 lines
32 KiB
JavaScript
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),ts=require('typescript');
|
|
const root=path.resolve(__dirname,'..'),copy=x=>x===undefined?x:structuredClone(x);
|
|
function fixture(){
|
|
const info={uid:'u',userId:123,level:50,token:'t',coin:100,freezeAmount:0,hammerAmount:0,magicAmount:0,userPowerTime:0};
|
|
const storage=new Map(),calls=[],toasts=[],paymentCalls=[],customerCalls=[],showListeners=[],events=[],payments=[];let dropSave=false,dropDraw=false,dropConfirm=false,delay=null,paymentMode='cancel',paymentStatus='pending',orderCount=0,orderFailure=false;
|
|
const grant={grantId:'g',periodId:'p',source:'draw',goods:[{id:1001,count:20}],rewardIds:['r'],status:'pending'};
|
|
const state={periodId:'p',offerTier:3,status:'active',startAt:Date.now()-1000,endAt:Date.now()+100000,currentLevel:1,canPay:false,canDraw:true,canClaimFinal:false,drawCount:1,pendingOrder:null,pendingGrants:[],levels:[{level:1,itemId:'pay_test',itemPrice:100,rewards:[{rewardId:'r'}]}],serverTime:Date.now()};
|
|
let inventory={coinAmount:100,freezeAmount:0,hammerAmount:0,magicAmount:0,userPowerTime:0},saved=false,confirmed=false,drawId=null;
|
|
class XHR{open(_method,url){this.openCount=(this.openCount||0)+1;this.route=url.replace('local/','');this.contentType=null;}setRequestHeader(_name,value){this.contentType=value;}send(raw){const b=this.contentType==='application/json'?JSON.parse(raw):Object.fromEntries(new URLSearchParams(raw));calls.push({route:this.route,contentType:this.contentType,openCount:this.openCount,...b});
|
|
const execute=()=>{let data=null;if(this.route==='activityConfig/list')data={schemaVersion:1,serverNow:Date.now(),windowEndsAt:Date.now()+604800000,refreshAt:Date.now()+30000,
|
|
activities:[{activityId:'pyramid',periodId:state.periodId,configVersion:'v1',startsAt:state.startAt,endsAt:state.endAt,unlockLevel:50}]};
|
|
else if(this.route==='pyramid/api'&&b.action==='info')data=state;
|
|
else if(this.route==='pyramid/api'&&b.action==='draw'){if(drawId)assert.equal(b.requestId,drawId);drawId=b.requestId;state.pendingGrants=[grant];state.drawCount=0;state.canDraw=false;data={requestId:b.requestId,rewardId:'r',grant};if(dropDraw){dropDraw=false;this.onerror();return;}}
|
|
else if(this.route==='pyramid/api'&&b.action==='delivery_list')data={grants:confirmed?[]:[grant],nextCursor:null};
|
|
else if(this.route==='pyramid/api'&&b.action==='delivery_save'){if(b.phase==='save'&&!saved){inventory=copy(b.inventory);saved=true;}data={saved,inventory,serverTime:Date.now()};if(b.phase==='save'&&dropSave){dropSave=false;this.onerror();return;}}
|
|
else if(this.route==='pyramid/api'&&b.action==='delivery_confirm'){assert.ok(saved);confirmed=true;state.pendingGrants=[];state.canPay=true;data={status:'delivered'};if(dropConfirm){dropConfirm=false;this.onerror();return;}}
|
|
else if(this.route==='wx/orderPaySig'||this.route==='wx/iosorderPaySig'){if(orderFailure){this.onerror();return;}data={outTradeNo:b.outTradeNo || 'o'+(++orderCount),signData:'signed',paySig:'s',signature:'s'};}
|
|
else if((this.route==='pyramid/api'&&b.action==='query')||this.route==='wx/iosgetPayInfo')data={paymentStatus,drawCount:state.drawCount};
|
|
else if(this.route==='echo')data=b;
|
|
else throw Error(this.route);
|
|
this.status=200;this.readyState=4;this.responseText=JSON.stringify({code:1,data:copy(data)});this.onreadystatechange();};
|
|
if(delay)delay.push(execute);else queueMicrotask(execute);
|
|
}}
|
|
const cc={warn(){},fx:{GameConfig:{GM_INFO:info},GameTool:{getWechatGameVersion:()=> '开发版',shushu_Track:(name,data)=>events.push({name,data:copy(data)})},StorageMessage:{getStorage:k=>copy(storage.get(k)),setStorage:(k,v)=>storage.set(k,copy(v))}},loader:{getXMLHttpRequest:()=>new XHR()}};
|
|
cc.fx.GameTool.coinEvents=[];
|
|
cc.fx.GameTool.recordActivityCoin=amount=>cc.fx.GameTool.coinEvents.push(amount);
|
|
const platform={getSystemInfoSync:()=>({platform:paymentMode==='fallback'||paymentMode==='ios-success'?'ios':'android'}),showToast:o=>toasts.push(o.title),
|
|
requestMidasPaymentGameItem:o=>{paymentCalls.push(o);if(paymentMode==='success'||paymentMode==='ios-success')o.success({});else if(paymentMode==='cancel')o.fail({errCode:-2});else if(paymentMode==='fallback')o.fail({errCode:16});},
|
|
openCustomerServiceConversation:o=>{customerCalls.push(JSON.parse(o.sessionFrom));o.success();},onShow:fn=>showListeners.push(fn)};
|
|
const source=ts.createSourceFile('Utils.ts',fs.readFileSync(path.join(root,'assets/Script/module/Pay/Utils.ts'),'utf8'),ts.ScriptTarget.Latest,true);
|
|
const cls=source.statements.find(s=>ts.isClassDeclaration(s));
|
|
const methods=cls.members.filter(m=>m.name&&['POST','buyProp','GoKEFu','createPaymentOrderNo'].includes(m.name.getText(source))).map(m=>m.getText(source)).join('\n');
|
|
const utilsContext={exports:{},cc,wx:platform,console:{log(){}},MiniGameSdk:{API:{showToast:s=>toasts.push(s)}}};
|
|
vm.runInNewContext(ts.transpileModule('export default class Utils {static uid="u";static outTradeNo="";static testHttpip="local/";static httpip="production/";static apiBaseUrl(){return "local/";}'+methods+'}',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,utilsContext);
|
|
const U=utilsContext.exports.default;
|
|
// Wallet-transfer tracking is outside this fixture; exercise the real POST serialization and XHR lifecycle.
|
|
U.trackWucaiRequest=(_path,_body,callback)=>callback;
|
|
const context={exports:{},require:()=>({default:U}),cc,console,Date,Math,setTimeout:(fn,_delay)=>setTimeout(fn,0),clearTimeout,window:{wx:platform}};
|
|
const serviceCode=ts.transpileModule(fs.readFileSync(path.join(root,'assets/Script/module/Config/PyramidService.ts'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText;
|
|
let currentSchedule;
|
|
const restart=()=>{
|
|
const schedule={...context,exports:{},require:name=>({default:name==='./ActivityBundleLoader'?{beginCycle(){}}:U})};
|
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'assets/Script/module/Config/ActivityScheduleService.ts'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,schedule);
|
|
currentSchedule=schedule.exports.default;
|
|
const fresh={...context,exports:{},require:name=>({MiniGameSdk:{API:{yinli_Pay:(...args)=>payments.push(args)}},default:name==='./ActivityScheduleService'?schedule.exports.default:U})};
|
|
vm.runInNewContext(serviceCode,fresh);return fresh.exports.default;
|
|
};
|
|
return {S:restart(),get schedule(){return currentSchedule;},restart,U,info,calls,toasts,paymentCalls,customerCalls,storage,state,platform,events,payments,grant,cc,get inventory(){return inventory;},mode:v=>paymentMode=v,failOrder:()=>orderFailure=true,paid:()=>{paymentStatus='succeeded';state.canPay=false;state.canDraw=true;state.drawCount=1;},show:()=>showListeners.forEach(fn=>fn()),dropSave:()=>dropSave=true,dropDraw:()=>dropDraw=true,dropConfirm:()=>dropConfirm=true,hold:()=>delay=[],release:()=>{const q=delay;delay=null;q.forEach(f=>f());}};
|
|
}
|
|
|
|
test('collect_pyramid reports only after inventory save, using the original slot after advancing',async()=>{
|
|
const f=fixture();f.state.currentLevel=2;f.state.levels[0].rewards.unshift({rewardId:'left'});
|
|
await f.S.sync();f.dropSave();assert.equal(await f.S.deliver(),false);assert.deepEqual(f.events,[]);
|
|
assert.equal(await f.S.deliver(),true);assert.deepEqual(f.events,[{name:'collect_pyramid',data:{tier:3,level:1,position:2}}]);
|
|
});
|
|
test('collect_pyramid summit is one event and repeated delivery after restart does not report twice',async()=>{
|
|
const f=fixture();f.grant.source='final_claim';f.grant.rewardIds=['r','remaining'];f.state.currentLevel=6;
|
|
await f.S.sync();const post=f.U.POST;let rejectConfirm=true;
|
|
f.U.POST=(route,body,callback,options)=>body.action==='delivery_confirm'&&rejectConfirm
|
|
? callback({code:0,errorCode:'RETRYABLE'}) : post.call(f.U,route,body,callback,options);
|
|
assert.equal(await f.S.deliver(),false);assert.deepEqual(f.events,[{name:'collect_pyramid',data:{tier:3,level:6,position:1}}]);
|
|
const restarted=f.restart();rejectConfirm=false;await restarted.sync();assert.equal(await restarted.deliver(),true);
|
|
assert.equal(f.events.length,1);
|
|
});
|
|
test('collect_pyramid recovery uses the grant period and analytics failure does not block rewards',async()=>{
|
|
const f=fixture();await f.S.sync();const post=f.U.POST;
|
|
f.grant.periodId='old';f.U.POST=(route,body,callback,options)=>body.action==='info'&&body.periodId==='old'
|
|
? callback({code:1,data:{...f.state,periodId:'old',offerTier:1}}) : post.call(f.U,route,body,callback,options);
|
|
assert.equal(await f.S.deliver(),true);assert.deepEqual(f.events,[{name:'collect_pyramid',data:{tier:1,level:1,position:1}}]);
|
|
const failed=fixture();await failed.S.sync();failed.cc.fx.GameTool.shushu_Track=()=>{throw Error('SDK unavailable');};
|
|
assert.equal(await failed.S.deliver(),true);assert.equal(failed.info.coin,120);
|
|
});
|
|
test('collect_pyramid properties reach the shared analytics SDK',()=>{
|
|
const source=ts.createSourceFile('GameTool.ts',fs.readFileSync(path.join(root,'assets/Script/module/Tool/GameTool.ts'),'utf8'),ts.ScriptTarget.Latest,true);
|
|
let track;const find=node=>{if(ts.isPropertyAssignment(node)&&node.name.getText(source)==='shushu_Track')track=node.initializer.getText(source);ts.forEachChild(node,find);};find(source);
|
|
const events=[],context={MiniGameSdk:{API:{shushu_Track:(name,data)=>events.push({name,data:copy(data)})}}};
|
|
vm.runInNewContext('('+track+') ("collect_pyramid", {tier:4,level:5,position:2})',context);
|
|
assert.deepEqual(events,[{name:'collect_pyramid',data:{tier:4,level:5,position:2}}]);
|
|
});
|
|
test('settlement refresh shares concurrent calls and preserves unread local receipts across later queries',async()=>{
|
|
const f=fixture(),notice={noticeId:'notice',periodId:'ended',goods:[{id:1001,count:20}]};let reads=0;
|
|
f.U.POST=(route,body,callback)=>{assert.equal(body.action,'settlement_read');reads++;queueMicrotask(()=>callback({code:1,data:{notices:[notice],inventory:{coinAmount:120},serverTime:Date.now()}}));};
|
|
await Promise.all([f.S.readSettlements(),f.S.readSettlements()]);assert.equal(reads,1);assert.equal(f.info.coin,100);
|
|
await f.S.readSettlements();assert.equal(reads,2);assert.equal(f.S.settlements().length,1);
|
|
f.U.POST=(route,body,callback)=>{reads++;callback({code:1,data:{status:'ready',notices:[],inventory:null}});};
|
|
await f.S.readSettlements();assert.equal(reads,3);assert.equal(f.S.settlements().length,1);assert.equal(f.info.coin,100);
|
|
f.S.dismissSettlement('notice');assert.equal(f.S.settlements().length,0);assert.equal(f.info.coin,100);
|
|
f.S.resetLogin();await f.S.readSettlements();assert.equal(reads,4);
|
|
});
|
|
test('failed or still preparing settlement requests can retry in the same login; empty ready results finish',async()=>{
|
|
for(const response of [{code:1,data:{status:'settling',notices:[],inventory:null}},{code:0,errorCode:'RETRYABLE'}]){
|
|
const f=fixture();let reads=0;f.U.POST=(route,body,callback)=>{reads++;callback(reads===1?response:{code:1,data:{status:'ready',notices:[],inventory:null}});};
|
|
assert.equal(await f.S.readSettlements(),false);assert.equal(await f.S.readSettlements(),true);
|
|
assert.equal(reads,2);assert.equal(f.info.coin,100);assert.equal(f.S.settlements().length,0);
|
|
}
|
|
});
|
|
test('settlement response from a prior account or login cannot apply inventory or notices',async()=>{
|
|
for(const account of [true,false]){
|
|
const f=fixture();let reply;f.U.POST=(route,body,callback)=>reply=callback;
|
|
const task=f.S.readSettlements();if(account)f.info.uid='other';else f.S.resetLogin();
|
|
reply({code:1,data:{notices:[{noticeId:'old',goods:[{id:1001,count:20}]}],inventory:{coinAmount:120}}});await task;
|
|
assert.equal(f.info.coin,100);assert.equal(f.S.settlements().length,0);
|
|
}
|
|
});
|
|
test('settlement claim applies absolute inventory only on success and is safe to repeat',async()=>{
|
|
const f=fixture(),notice={noticeId:'n',periodId:'p',goods:[{id:1001,count:20}]};
|
|
f.U.POST=(route,body,callback)=>{assert.equal(body.action,'settlement_claim');assert.equal(body.noticeId,'n');assert.equal(body.periodId,'p');callback({code:1,data:{notice,inventory:{coinAmount:120}}});};
|
|
await f.S.claimSettlement(notice);assert.equal(f.info.coin,120);await f.S.claimSettlement(notice);assert.equal(f.info.coin,120);
|
|
f.U.POST=(route,body,callback)=>callback({code:0,msg:'领取失败,请重试'});
|
|
await assert.rejects(f.S.claimSettlement(notice));assert.equal(f.info.coin,120);assert.equal(f.S.error,'领取失败,请重试');
|
|
assert.deepEqual(f.cc.fx.GameTool.coinEvents,[20]);
|
|
});
|
|
|
|
test('coin tasks count pyramid grant once despite confirmation retries and mixed rewards',async()=>{
|
|
const f=fixture();f.grant.goods.push({id:2001,count:3});await f.S.sync();
|
|
const post=f.U.POST;let fail=true;
|
|
f.U.POST=(route,body,callback,options)=>body.action==='delivery_confirm'&&fail
|
|
? callback({code:0,errorCode:'RETRYABLE'}) : post.call(f.U,route,body,callback,options);
|
|
assert.equal(await f.S.deliver(),false);assert.deepEqual(f.cc.fx.GameTool.coinEvents,[20]);
|
|
assert.equal(await f.S.deliver(),false);assert.deepEqual(f.cc.fx.GameTool.coinEvents,[20]);
|
|
fail=false;assert.equal(await f.restart().deliver(),true);assert.deepEqual(f.cc.fx.GameTool.coinEvents,[20]);
|
|
assert.equal(f.info.coin,120);
|
|
});
|
|
|
|
test('coin tasks ignore non-coin rewards and failed pyramid deliveries',async()=>{
|
|
const f=fixture();await f.S.sync();f.dropSave();assert.equal(await f.S.deliver(),false);
|
|
assert.deepEqual(f.cc.fx.GameTool.coinEvents,[]);assert.equal(await f.S.deliver(),true);
|
|
assert.deepEqual(f.cc.fx.GameTool.coinEvents,[20]);
|
|
const props=fixture();props.grant.goods=[{id:2001,count:3}];await props.S.sync();await props.S.deliver();
|
|
assert.deepEqual(props.cc.fx.GameTool.coinEvents,[]);
|
|
});
|
|
test('settlement claim cannot apply inventory after switching account or resetting login',async()=>{
|
|
for(const account of [true,false]){
|
|
const f=fixture();let reply;f.U.POST=(route,body,callback)=>reply=callback;
|
|
const notice={noticeId:'n',periodId:'p',goods:[{id:1001,count:20}]};
|
|
const task=f.S.claimSettlement(notice);if(account)f.info.uid='other';else f.S.resetLogin();
|
|
reply({code:1,data:{notice,inventory:{coinAmount:120}}});await assert.rejects(task);assert.equal(f.info.coin,100);
|
|
}
|
|
});
|
|
test('lost save reply restores persisted grant without increasing inventory twice',async()=>{const f=fixture();await f.S.sync();f.dropSave();assert.equal(await f.S.deliver(),false);assert.equal(f.inventory.coinAmount,120);assert.equal(f.info.coin,100);assert.equal(await f.S.deliver(),true);assert.equal(f.info.coin,120);assert.equal(f.calls.filter(c=>c.route==='pyramid/api'&&c.action==='delivery_save'&&c.phase==='save').length,1);});
|
|
test('pyramid detail uses its own info action and database identity without refreshing the schedule',async()=>{
|
|
const f=fixture();assert.equal(await f.S.sync(),true);assert.equal(f.S.data().periodId,'p');
|
|
const queries=f.calls.filter(c=>c.action==='info');assert.equal(queries.length,1);assert.equal(queries[0].uid,'u');assert.equal(queries[0].contentType,'application/json');assert.equal(queries[0].openCount,1);
|
|
await f.S.sync(false,false);assert.equal(f.calls.filter(c=>c.action==='info').length,1);
|
|
await f.S.sync();assert.equal(f.calls.at(-1).periodId,'p');
|
|
assert.equal(f.calls.filter(c=>c.route==='activityConfig/list').length,0);
|
|
assert.equal(f.calls.filter(c=>c.route==='pyramid/api'&&c.action==='list').length,0);
|
|
});
|
|
test('draw and delivery refresh only private info and expose the new reward state',async()=>{
|
|
const f=fixture();await f.S.sync();await f.S.act('draw');assert.equal(f.S.data().drawCount,0);assert.equal(f.S.data().pendingGrants.length,1);
|
|
await f.S.deliver();assert.equal(f.S.data().pendingGrants.length,0);
|
|
assert.equal(f.calls.filter(c=>c.action==='info').length,3);assert.equal(f.calls.filter(c=>c.route==='activityConfig/list').length,0);
|
|
});
|
|
test('info failure preserves the server Chinese error message without touching the schedule',async()=>{
|
|
const f=fixture();f.U.POST=(_path,_body,callback)=>callback({code:0,data:null,errorCode:'INSUFFICIENT_TIME',msg:'本期活动剩余时间不足,暂无法参与,请等待下一期'});
|
|
assert.equal(await f.S.sync(),false);assert.equal(f.S.error,'本期活动剩余时间不足,暂无法参与,请等待下一期');assert.equal(f.S.data(),null);
|
|
});
|
|
test('an in-flight schedule neither blocks a draw refresh nor overwrites its private state',async()=>{
|
|
const f=fixture();await f.S.sync();const post=f.U.POST;let reply;
|
|
f.U.POST=(route,body,callback,options)=>route==='activityConfig/list'?reply=callback:post.call(f.U,route,body,callback,options);
|
|
const list=f.schedule.sync();assert.equal(await f.S.act('draw'),true);assert.equal(f.S.data().drawCount,0);
|
|
reply({code:1,data:{schemaVersion:1,serverNow:Date.now(),windowEndsAt:Date.now()+604800000,refreshAt:Date.now()+30000,
|
|
activities:[{activityId:'pyramid',periodId:'p',configVersion:'v1',startsAt:f.state.startAt,endsAt:f.state.endAt,unlockLevel:50}]}});
|
|
await list;assert.equal(f.S.data().drawCount,0);assert.equal(f.S.data().pendingGrants.length,1);
|
|
});
|
|
test('a new scheduled period replaces old detail and info calibrates server time',async()=>{
|
|
const f=fixture();await f.S.sync();f.state.periodId='next';await f.schedule.sync();
|
|
assert.equal(f.S.data().periodId,'p');f.state.serverTime=Date.now()+60000;
|
|
await f.S.sync(false,false);assert.equal(f.calls.at(-1).periodId,'next');assert.equal(f.S.data().periodId,'next');
|
|
assert.ok(Math.abs(f.S.now()-Date.now()-60000)<1000);
|
|
});
|
|
test('lost confirmation preserves local award and retries without another save',async()=>{const f=fixture();await f.S.sync();f.dropConfirm();assert.equal(await f.S.deliver(),false);assert.equal(f.info.coin,120);assert.equal(await f.S.deliver(),true);assert.equal(f.inventory.coinAmount,120);});
|
|
test('lost draw reply reuses durable requestId on sync',async()=>{const f=fixture();await f.S.sync();f.dropDraw();assert.equal(await f.S.act('draw'),false);assert.equal(await f.S.sync(),true);const draws=f.calls.filter(c=>c.route==='pyramid/api'&&c.action==='draw');assert.equal(draws.length,2);assert.equal(draws[0].requestId,draws[1].requestId);});
|
|
test('draw follows the server draw count, preserving free first draw but blocking unpaid tiers',async()=>{
|
|
const free=fixture();await free.S.sync();assert.equal(free.S.data().canDraw,true);
|
|
const unpaid=fixture();unpaid.state.canDraw=true;unpaid.state.drawCount=0;unpaid.state.canPay=true;await unpaid.S.sync();
|
|
assert.equal(unpaid.S.data().canDraw,false);await unpaid.S.act('draw');assert.equal(unpaid.calls.filter(c=>c.action==='draw').length,0);
|
|
});
|
|
test('cancel only toasts and every new click creates a new order without payment polling',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.state.drawCount=0;await f.S.sync();await f.S.buy();await f.S.sync();await f.S.buy();
|
|
const orders=f.calls.filter(c=>c.route==='wx/orderPaySig');assert.equal(orders.length,2);assert.notEqual(orders[0].requestId,orders[1].requestId);
|
|
assert.equal(f.S.data().pendingOrder,null);assert.equal(f.S.data().canPay,true);assert.equal(f.S.busy,false);assert.deepEqual(f.toasts,['取消充值','取消充值']);
|
|
assert.equal(f.calls.filter(c=>c.action==='query').length,0);assert.equal(orders[0].activityType,'pyramid');assert.equal(orders[0].itemid,'pay_test');
|
|
});
|
|
test('only SDK success starts confirmation and blocks another purchase until verified',async()=>{
|
|
const f=fixture(),confirming=[];f.S.subscribe(()=>confirming.push(f.S.paymentConfirming));f.state.canPay=true;f.state.canDraw=false;f.state.drawCount=0;f.mode('success');await f.S.sync();await f.S.buy();
|
|
assert.equal(f.calls.filter(c=>c.action==='query').length,9);assert.equal(f.S.data().pendingOrder.orderId,f.calls.find(c=>c.outTradeNo).outTradeNo);assert.equal(f.S.data().canPay,false);
|
|
const started=confirming.indexOf(true),finished=confirming.indexOf(false,started);assert.ok(started>=0&&finished>started);assert.ok(confirming.slice(started,finished).every(Boolean));assert.equal(f.S.paymentConfirming,false);
|
|
await f.S.buy();assert.equal(f.paymentCalls.length,1);f.paid();await f.S.sync();assert.equal(f.calls.filter(c=>c.action==='query').length,9);await f.S.sync(true);assert.equal(f.S.data().pendingOrder,null);assert.equal(f.S.data().canDraw,true);
|
|
assert.equal(f.calls.at(-1).action,'info');assert.equal(f.calls.filter(c=>c.route==='activityConfig/list').length,0);
|
|
});
|
|
test('iOS Midas success uses the iOS signing endpoint without opening customer service',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.mode('ios-success');await f.S.sync();await f.S.buy();
|
|
assert.equal(f.calls.filter(c=>c.route==='wx/iosorderPaySig').length,1);assert.equal(f.paymentCalls.length,1);assert.equal(f.customerCalls.length,0);
|
|
assert.equal(f.S.data().pendingOrder.orderId,f.calls.find(c=>c.outTradeNo).outTradeNo);
|
|
});
|
|
test('iOS error 16 reuses the validated order for customer service and resumes it on show',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.state.drawCount=0;f.mode('fallback');await f.S.sync();await f.S.buy();
|
|
assert.equal(f.customerCalls.length,1);assert.deepEqual(f.customerCalls[0],{tpye:'ios',outTradeNo:f.calls.find(c=>c.outTradeNo).outTradeNo,propName:'pay_test',count:1,price:100,activityType:'pyramid',periodId:'p',level:1});
|
|
assert.equal(f.S.data().pendingOrder.orderId,f.calls.find(c=>c.outTradeNo).outTradeNo);assert.equal(f.S.data().pendingOrder.channel,'ios_customer');
|
|
assert.equal(f.calls.filter(c=>c.route==='wx/iosgetPayInfo').length,9);
|
|
f.paid();f.show();await f.S.sync();assert.equal(f.S.data().pendingOrder,null);assert.equal(f.S.data().canDraw,true);
|
|
});
|
|
test('payment window blocks rapid double-clicks but cancellation releases the button',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.mode('hold');await f.S.sync();const a=f.S.buy(),b=f.S.buy();
|
|
await new Promise(resolve=>setImmediate(resolve));assert.equal(f.paymentCalls.length,1);assert.equal(f.S.busy,true);
|
|
f.paymentCalls[0].fail({errCode:-2});await Promise.all([a,b]);assert.equal(f.S.busy,false);assert.equal(f.S.data().canPay,true);
|
|
});
|
|
test('restarting resumes only a successful SDK payment confirmation, never a canceled order',async()=>{
|
|
for(const mode of ['cancel','success']){
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.mode(mode);await f.S.sync();await f.S.buy();
|
|
const before=f.calls.filter(c=>c.action==='query').length;const resumed=f.restart();await resumed.sync();
|
|
assert.equal(f.calls.filter(c=>c.action==='query').length-before,mode==='success'?9:0);
|
|
assert.equal(f.paymentCalls.length,1);
|
|
}
|
|
});
|
|
test('an SDK callback arriving after account switch cannot poll or save confirmation for the new user',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.mode('hold');await f.S.sync();const buy=f.S.buy();
|
|
await new Promise(resolve=>setImmediate(resolve));f.info.uid='other';f.S.data();f.paymentCalls[0].success({});await buy;
|
|
assert.equal(f.calls.filter(c=>c.action==='query').length,0);assert.equal(f.storage.has('pyramid:v1:local/:other'),false);
|
|
});
|
|
test('stale unpaid orders never start confirmation; order creation failures never poll',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.state.pendingOrder={orderId:'old'};
|
|
f.storage.set('pyramid:v1:local/:u',{order:{orderId:'old'}});await f.S.sync();assert.equal(f.S.data().pendingOrder,null);
|
|
f.failOrder();assert.equal(await f.S.buy(),false);assert.equal(f.paymentCalls.length,0);assert.equal(f.calls.filter(c=>c.action==='query').length,0);
|
|
});
|
|
test('shared POST preserves nested JSON and legacy form requests',async()=>{
|
|
const f=fixture();const input={base:{coinAmount:3},inventory:{coinAmount:5}};
|
|
const json=await new Promise(resolve=>f.U.POST('echo',input,resolve,{json:true}));assert.deepEqual(copy(json.data.inventory),{coinAmount:5});assert.equal(input.token,undefined);
|
|
const form=await new Promise(resolve=>f.U.POST('echo',{uid:'u',passEnd:'123'},resolve));assert.equal(form.data.passEnd,'123');assert.equal(f.calls.at(-1).contentType,'application/x-www-form-urlencoded');
|
|
assert(f.calls.every(call=>call.openCount===1),'opening twice resets headers before send');
|
|
});
|
|
test('shared buyProp still sends legacy pass data and selects the iOS endpoint',async()=>{
|
|
const f=fixture();f.mode('success');const result=await new Promise(resolve=>f.U.buyProp('battlepass_12',1,1200,'ios','pass',resolve,'123'));
|
|
const call=f.calls[0];assert.equal(call.route,'wx/iosorderPaySig');assert.equal(call.passEnd,'123');assert.equal(call.passVersion,'2');assert.equal(call.contentType,'application/x-www-form-urlencoded');assert.equal(result.outTradeNo,'o1');
|
|
});
|
|
test('pyramid creates the order number before signing and keeps it through payment and polling',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;f.mode('success');await f.S.sync();await f.S.buy();
|
|
const order=f.calls.find(c=>c.route==='wx/orderPaySig');assert.match(order.outTradeNo,/^wct_[0-9]+$/);assert.ok(order.outTradeNo.length<=32);
|
|
assert.equal(order.contentType,'application/json');assert.equal(order.activityType,'pyramid');assert.equal(order.periodId,'p');assert.equal(order.level,1);
|
|
assert.equal(f.S.data().pendingOrder.orderId,order.outTradeNo);assert.ok(f.calls.filter(c=>c.action==='query').every(c=>c.orderId===order.outTradeNo));
|
|
const other=fixture();other.U.apiBaseUrl=()=> 'production/';assert.match(other.U.createPaymentOrderNo(),/^wcx_[0-9]+$/);
|
|
});
|
|
test('a mismatched signing response never opens the pyramid payment window',async()=>{
|
|
const f=fixture();f.U.POST=(_path,_body,callback)=>callback({code:1,data:{outTradeNo:'different'}});
|
|
const result=await new Promise(resolve=>f.U.buyProp('pay_test',1,100,'ios','金字塔',resolve,undefined,undefined,undefined,{params:{activityType:'pyramid',outTradeNo:'wct_expected'}}));
|
|
assert.equal(result.err,'支付订单号不一致');assert.equal(f.paymentCalls.length,0);
|
|
});
|
|
test('pyramid customer payment requires the original order and passes failure back',()=>{
|
|
const f=fixture();let result;f.U.GoKEFu({activityType:'pyramid',payment_name:'pay_test'},(...args)=>result=args);
|
|
assert.equal(result[0],'fail');assert.equal(f.customerCalls.length,0);
|
|
f.platform.openCustomerServiceConversation=o=>o.fail({errMsg:'mock failure'});
|
|
f.U.GoKEFu({activityType:'pyramid',payment_name:'pay_test',outTradeNo:'wct_original'},(...args)=>result=args);
|
|
assert.equal(result[0],'fail');assert.equal(result[1].errMsg,'mock failure');
|
|
});
|
|
test('an account change before signing response prevents opening the payment window',async()=>{
|
|
const f=fixture();f.state.canPay=true;f.state.canDraw=false;await f.S.sync();f.hold();const pending=f.S.buy();
|
|
f.info.uid='other';f.S.data();f.release();await pending;assert.equal(f.paymentCalls.length,0);assert.equal(f.S.data(),null);
|
|
});
|
|
test('response from previous account cannot mutate new account state',async()=>{const f=fixture();f.hold();const first=f.S.sync();await Promise.resolve();f.info.uid='other';f.S.data();f.release();assert.equal(await first,false);assert.equal(f.S.data(),null);});
|
|
test('all twenty supplied digit images are present and not substituted',()=>{const manifest=require('./pyramid-art-manifest.json');const crypto=require('node:crypto');for(const style of ['digits','price'])for(let n=0;n<=9;n++){const item=manifest[`${style}/${n}`];assert.ok(item);const output=fs.readFileSync(path.join(root,'assets/pyramid/art',style,n+'.png'));const source=fs.readFileSync(path.resolve(root,item.source));assert.equal(crypto.createHash('sha256').update(output).digest('hex'),crypto.createHash('sha256').update(source).digest('hex'));}});
|
|
test('numeric renderer uses Sprite and rejects missing glyphs instead of Labels',()=>{const code=fs.readFileSync(path.join(root,'assets/pyramid/scripts/PyramidDigits.ts'),'utf8');assert.ok(!code.includes('addComponent(cc.Label)'));assert.match(code,/addComponent\(cc.Sprite\)/);assert.match(code,/missing.length/);const panel=fs.readFileSync(path.join(root,'assets/pyramid/scripts/PyramidPanel.ts'),'utf8');assert.match(panel,/if \(\/\\d\/\.test\(text\)\) throw/);});
|
|
test('prefab artwork references resolve to supplied metadata',()=>{const prefab=JSON.parse(fs.readFileSync(path.join(root,'assets/pyramid/prefab/Pyramid.prefab'))),manifest=require('./pyramid-art-manifest.json');const frames=new Set(Object.values(manifest).map(v=>v.uuid));for(const c of prefab.filter(x=>x.__type__==='cc.Sprite'))assert.ok(frames.has(c._spriteFrame.__uuid__));for(const name of ['Head','Body','Footer','Action','Countdown','Rewards'])assert.ok(prefab.some(x=>x._name===name));});
|
|
test('reward rows and slots are authored in the prefab instead of created by code',()=>{
|
|
const prefab=JSON.parse(fs.readFileSync(path.join(root,'assets/pyramid/prefab/Pyramid.prefab'))),byId=id=>prefab[id];
|
|
const rewards=prefab.find(x=>x&&x.__type__==='cc.Node'&&x._name==='Rewards');assert.ok(rewards);assert.equal(rewards._children.length,5);
|
|
[6,5,4,3,2].forEach((count,index)=>{const row=byId(rewards._children[index].__id__);assert.equal(row._name,`Level_${index+1}`);const children=row._children.map(ref=>byId(ref.__id__));assert.equal(children[0]._name,'CurrentLevelBackground');assert.deepEqual(children.slice(1).map(node=>node._name),Array.from({length:count},(_,slot)=>`Slot_${slot+1}`));children.slice(1).forEach(slot=>assert.deepEqual(slot._trs.array.slice(7,9),[0.9,0.9]));});
|
|
const panel=fs.readFileSync(path.join(root,'assets/pyramid/scripts/PyramidPanel.ts'),'utf8');assert.doesNotMatch(panel,/new cc\.Node\("Level_/);assert.doesNotMatch(panel,/new cc\.Node\("Reward_/);assert.match(panel,/fillRewardRows\(s\)/);
|
|
});
|
|
test('draw throttling preserves button artwork and activity expiry closes the panel',()=>{
|
|
const panel=fs.readFileSync(path.join(root,'assets/pyramid/scripts/PyramidPanel.ts'),'utf8'),guard=panel.indexOf('if (this.drawing || pricePending) {'),redraw=panel.indexOf('action.children.slice()',guard);
|
|
assert.ok(guard>=0&&redraw>guard);assert.doesNotMatch(panel,/"加载中"|"点击重试"|"已结束"|"奖励到账中"|"确认支付"/);assert.match(panel,/if \(s\.canDraw\) this\.sprite\(action, "Draw"/);assert.match(panel,/remaining < 60000 \? `\$\{secs\}秒`/);
|
|
const view=fs.readFileSync(path.join(root,'assets/Script/PyramidView.ts'),'utf8');assert.match(view,/if \(ended\) this\.closeOverlay\(\)/);assert.match(view,/Service\.now\(\) >= state\.endAt\)\) this\.render\(\)/);assert.match(view,/Service\.paymentConfirming.*this\.startLoading\(\)/);assert.doesNotMatch(view,/pendingOrder \? 3000/);
|
|
});
|
|
|
|
function probabilityModule(){const context={exports:{}};vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'assets/pyramid/scripts/PyramidProbability.ts'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText,context);return context.exports;}
|
|
test('initial probability text matches all five supplied rows',()=>{
|
|
const {remainingProbabilities:calculate,probabilityText:format}=probabilityModule();
|
|
const expected=[['0.49%','0.05%','49.46%','49.46%','0.05%','0.49%'],['0.49%','49.49%','0.04%','49.49%','0.49%'],['0.97%','97.97%','0.09%','0.97%'],['33.3%','33.3%','33.3%'],['50%','50%']];
|
|
expected.forEach((row,i)=>assert.deepEqual(Array.from(calculate(i+1,row.map(()=>({gain:false}))),p=>format(p,i+1)),row));
|
|
});
|
|
test('every remaining subset renormalizes and awarded slots have no chance',()=>{
|
|
const {remainingProbabilities:calculate,PYRAMID_WEIGHTS:weights}=probabilityModule();
|
|
weights.forEach((row,i)=>{for(let mask=0;mask<1<<row.length;mask++){
|
|
const rewards=Array.from(row,(weight,j)=>({weight,gain:!!(mask&(1<<j))}));
|
|
const actual=calculate(i+1,rewards),total=rewards.reduce((s,r)=>s+(r.gain?0:r.weight),0);
|
|
actual.forEach((p,j)=>assert.equal(p,rewards[j].gain||!total?0:row[j]/total));
|
|
assert.ok(Math.abs(actual.reduce((a,b)=>a+b,0)-(total?1:0))<1e-12);
|
|
}});
|
|
const oldSnapshot=[{weight:1,gain:false},{weight:1,gain:false},{weight:1,gain:true},{weight:1,gain:false}];
|
|
assert.deepEqual(Array.from(calculate(3,oldSnapshot)),[1/3,1/3,0,1/3]);
|
|
assert.deepEqual(Array.from(calculate(6,[{gain:false}])),[0]);
|
|
});
|
|
test('probability assets preserve supplied decimal, percent and digit images',()=>{
|
|
const manifest=require('./pyramid-art-manifest.json');
|
|
for(const name of [...Array.from({length:10},(_,i)=>String(i)),'point','percent']){
|
|
const key='probability/'+name;assert.ok(manifest[key]);
|
|
assert.deepEqual(fs.readFileSync(path.join(root,'assets/pyramid/art',key+'.png')),fs.readFileSync(path.resolve(root,manifest[key].source)));
|
|
}
|
|
});
|