245 lines
16 KiB
JavaScript
245 lines
16 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const { loadMembers, loadModule } = require('./new-player-ab-test-support.cjs');
|
|
const catalog = loadModule('assets/Script/module/Config/ShopCatalog.ts');
|
|
const plain = value => JSON.parse(JSON.stringify(value));
|
|
const configFile = 'assets/Script/module/Config/GameConfig.ts';
|
|
const shopFile = 'assets/shop/script/shop.ts';
|
|
const coins = [1200, 6800, 16000, 32000, 84000, 168000];
|
|
const prices = [600, 3000, 6800, 12800, 32800, 64800];
|
|
const bundles = [[8,1500,2,0], [18,3000,5,1800], [36,6000,10,7200], [68,10000,20,21600], [98,20000,25,43200]];
|
|
|
|
test('layer3 selects one shop and remains independent of layer1', () => {
|
|
const Config = loadMembers(configFile, ['isShopB', 'getShopPrefabPath']);
|
|
for (const [info, expected] of [
|
|
[{}, 'shop'], [{abTests:{layer_one:2,layer_three:1}}, 'shop'],
|
|
[{abTests:{layer_three:2}}, 'shop2'],
|
|
[{abTestAssignments:{layer_3:{group:'B'}}}, 'shop2'],
|
|
[{abTestAssignments:{layer_3:{group:'A'}},abTests:{layer_three:2}}, 'shop'],
|
|
[{abTestAssignments:{layer_3:{group:'B',enabled:false}},abTests:{layer_three:2}}, 'shop'],
|
|
[{abTestAssignments:{experiment:{layer:3,group:2}}}, 'shop2'],
|
|
]) {
|
|
Config.GM_INFO = info;
|
|
assert.equal(Config.getShopPrefabPath(), 'prefab/' + expected);
|
|
}
|
|
});
|
|
|
|
function rewardFixture() {
|
|
const info = { uid:'player', shopDouble:Object.fromEntries(coins.map((_,i)=>['gold_'+(i+1),false])) };
|
|
const storage = new Map(), calls = { coins:[], props:[], health:[], rewards:[], double:[] };
|
|
const cc = { find:()=>null, fx:{ GameConfig:{GM_INFO:info}, StorageMessage:{
|
|
getStorage:key=>storage.get(key), setStorage:(key,value)=>storage.set(key,value)
|
|
} } };
|
|
const Tool = loadMembers('assets/Script/module/Tool/GameTool.ts', ['shopBuy'], {
|
|
cc, ...catalog, Utils:{setShopDouble:(_,data)=>calls.double.push(plain(data))},
|
|
MapConroler:{_instance:{SceneManager:{openRewardWindow:data=>calls.rewards.push(plain(data))}}},
|
|
MiniGameSdk:{API:{showToast(){},shushu_SetSuperProperties(){}}}, console:{log(){}}
|
|
});
|
|
const tool = cc.fx.GameTool = Object.assign(new Tool(), {
|
|
changeCoin:n=>calls.coins.push(n), getShopProp:(data,compensate)=>calls.props.push([plain(data),compensate]),
|
|
setUserPowerTime:(...args)=>calls.health.push(args), shushu_Track(){}
|
|
});
|
|
return {tool,info,calls};
|
|
}
|
|
|
|
test('all new gold amounts retain old double flags; duplicate order does not reward twice', () => {
|
|
for (let i=0;i<6;i++) {
|
|
const f=rewardFixture(), id='new_gold_'+(i+1), key='gold_'+(i+1);
|
|
f.tool.shopBuy(id,false,{outTradeNo:'first'});
|
|
f.tool.shopBuy(id,true,{outTradeNo:'first'});
|
|
f.tool.shopBuy(id,false,{outTradeNo:'second'});
|
|
assert.deepEqual(f.calls.coins,[coins[i]*2,coins[i]]);
|
|
assert.equal(f.info.shopDouble[key],true);
|
|
assert.equal(f.calls.double.length,1);
|
|
const old=rewardFixture(); old.info.shopDouble[key]=true;
|
|
old.tool.shopBuy(id,true,{outTradeNo:'old-used'});
|
|
assert.deepEqual(old.calls.coins,[coins[i]]);
|
|
assert.equal(catalog.getNewShopProduct(id).price,prices[i]);
|
|
}
|
|
});
|
|
|
|
test('five new bundles grant exact rewards; 8 yuan has no health reward; compensation deduplicates', () => {
|
|
for (const [yuan,coin,count,seconds] of bundles) {
|
|
const f=rewardFixture(), id='new_unlimited_health_bundle_'+yuan;
|
|
f.tool.shopBuy(id,true,{outTradeNo:'bundle'});
|
|
f.tool.shopBuy(id,false,{outTradeNo:'bundle'});
|
|
assert.deepEqual(f.calls.coins,[coin]);
|
|
assert.deepEqual(f.calls.props,[[{hammer:count,freeze:count,magic_wand:count,price:yuan*100},true]]);
|
|
assert.deepEqual(f.calls.health,seconds?[[seconds,id,true]]:[]);
|
|
assert.deepEqual(f.calls.rewards[0],[{type:'coin',count:coin},{type:'hammer',count},
|
|
{type:'freeze',count},{type:'magic',count},...(seconds?[{type:'infinite_health',count:seconds}]:[])]);
|
|
}
|
|
});
|
|
|
|
test('A gold rewards and shared first-purchase state remain compatible with B', () => {
|
|
for (const [i,coin] of [1200,8000,16000,32000,100000,240000].entries()) {
|
|
const f=rewardFixture();
|
|
f.tool.shopBuy('gold_'+(i+1),false);
|
|
f.tool.shopBuy('new_gold_'+(i+1),false,{outTradeNo:'b'});
|
|
assert.deepEqual(f.calls.coins,[coin*2,coins[i]]);
|
|
}
|
|
});
|
|
|
|
test('authored prefabs retain correct purchase IDs, coin list references and no hp in 8 yuan bundle', () => {
|
|
for (const variant of ['shop','shop2']) {
|
|
const data=JSON.parse(fs.readFileSync(`assets/shop/prefab/${variant}.prefab`,'utf8'));
|
|
const ids=data.filter(x=>x.__type__==='cc.ClickEvent'&&x.handler==='buyProduct').map(x=>x.customEventData);
|
|
const expected=variant==='shop2'?catalog.SHOP_B_GOLD.concat(catalog.SHOP_B_BUNDLES).map(x=>x.id)
|
|
:catalog.SHOP_A_GOLD.map(x=>x.id).concat([10,20,30].map(x=>'unlimited_health_bundle_'+x));
|
|
assert.deepEqual(ids.slice().sort(),Array.from(expected).sort());
|
|
const script=data.find(x=>x.itemList&&x.doubleInfo!==undefined)||data.find(x=>x.itemList);
|
|
const content=data[script.itemList.__id__], coin=data[content._children[3].__id__];
|
|
assert.equal(coin._name,'Coin');
|
|
if(variant==='shop2') {
|
|
const pack=content._children.map(ref=>data[ref.__id__]).find(node=>node._name==='Pack'), first=data[pack._children[0].__id__];
|
|
assert.equal(pack._children.length,5);
|
|
assert.equal(first._children.some(ref=>data[ref.__id__]._name==='hp'),false);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('coin UI preserves B authored numbers and sprite positions while A still renders dynamically', () => {
|
|
for(const b of [false,true]) {
|
|
const nodes=Array.from({length:8},()=>({children:Array.from({length:8},()=>({active:true,getComponent:()=>({}),children:[{position:{x:0,y:0}}],x:0,y:0}))}));
|
|
const rendered=new Map(), render=(n,a,z,p,node)=>rendered.set(node,n);
|
|
const cc={Sprite:'sprite',v2:(x,y)=>({x,y}),fx:{GameConfig:{isShopB:()=>b}}};
|
|
const Shop=loadMembers(shopFile,['initCoin'],{cc,...catalog,console:{log(){}},NumberToImage:{numberToImageNodesShop:render,numberToImageNodes:render}});
|
|
const shop=new Shop(); shop.itemList={children:[null,null,null,{children:nodes}]};
|
|
shop.doubleInfo={gold_1:false,gold_2:true}; shop.initCoin();
|
|
const expected=b?catalog.SHOP_B_GOLD:catalog.SHOP_A_GOLD;
|
|
expected.forEach((p,i)=>{
|
|
const children=nodes[i+2].children;
|
|
assert.equal(rendered.get(children[2]),b?undefined:p.price/100);
|
|
assert.equal(rendered.get(children[3]),b?undefined:p.coin);
|
|
assert.equal(rendered.get(children[5]),b?undefined:p.coin);
|
|
assert.equal(children[6].x,b?0:(i%2===0?-34:-42));
|
|
assert.equal(children[6].y,0);
|
|
});
|
|
assert.equal(nodes[2].children[5].active,true);assert.equal(nodes[3].children[5].active,false);
|
|
}
|
|
});
|
|
|
|
test('Android purchase pins its order across another popup payment and refreshes new bundle health', () => {
|
|
const calls={}, info={iosCanPay:true,coin:0};
|
|
const Utils={buyProp:(id,n,price,platform,product,done)=>{calls.price=price;calls.pay=done;},
|
|
getPayInfo:(done,order)=>{calls.poll=done;calls.polled=order;},
|
|
setPayInfo:(done,order)=>{calls.ack=order;done({code:1});}};
|
|
const cc={fx:{GameConfig:{GM_INFO:info},GameTool:{shushu_Track(){},shopBuy:(id,comp,context)=>calls.grant=[id,context.outTradeNo]}},sys:{}};
|
|
const Shop=loadMembers(shopFile,['buyProduct'],{cc,Utils,...catalog,console:{log(){},error(){}},
|
|
wx:{getSystemInfoSync:()=>({platform:'android'})},MiniGameSdk:{API:{showToast(){},yinli_Pay(){}}},
|
|
NumberToImage:{numberToImageNodesCoin(){}},setTimeout:fn=>fn()});
|
|
const shop=Object.assign(new Shop(),{btn_Touch:true,openLoad(){},closeLoad(){},updatePower(){calls.health=true;},
|
|
node:{parent:{getComponent:name=>name==='JiaZai'?{updateCoin(){}}:null}}});
|
|
shop.buyProduct(null,'new_unlimited_health_bundle_18');
|
|
assert.equal(calls.price,1800);
|
|
calls.pay({outTradeNo:'shop-order'}); Utils.outTradeNo='other-popup-order';
|
|
calls.poll({code:1,data:{pay_state:2}});
|
|
assert.equal(calls.polled,'shop-order');assert.equal(calls.ack,'shop-order');
|
|
assert.deepEqual(Array.from(calls.grant),['new_unlimited_health_bundle_18','shop-order']);assert.equal(calls.health,true);
|
|
});
|
|
|
|
test('login compensation recognizes every B SKU even when currently assigned to A', () => {
|
|
const ids=catalog.SHOP_B_GOLD.concat(catalog.SHOP_B_BUNDLES).map(x=>x.id), granted=[];
|
|
const info={allOutTradeNo:ids.map((id,i)=>({itemid:id,outTradeNo:'order-'+i,goodsPrice:1}))};
|
|
const cc={fx:{GameConfig:{GM_INFO:info},GameTool:{shopBuy:(id,comp,ctx)=>granted.push([id,comp,ctx.outTradeNo]),shushu_Track(){}}}};
|
|
const Home=loadMembers('assets/Script/JiaZai.ts',['getOrder'],{cc,...catalog,wx:{},console:{log(){},warn(){},error(){}},
|
|
Utils:{getShopDouble:done=>done({code:1,data:{shopDouble:{}}}),setPayInfo:(done)=>done({code:1,data:'ok'})},
|
|
MiniGameSdk:{API:{yinli_Pay(){},shushu_SetSuperProperties(){}}}});
|
|
const home=Object.assign(new Home(),{deferHomePopupWhileTransfer:()=>false,isHomeRuntimeAlive:()=>false});
|
|
home.getOrder();
|
|
assert.deepEqual(granted,Array.from(ids,(id,i)=>[id,true,'order-'+i]));assert.equal(info.allOutTradeNo.length,0);
|
|
});
|
|
|
|
test('home and gameplay open the selected prefab, reuse it, and replace a cached old variant', () => {
|
|
for(const file of ['assets/Script/JiaZai.ts','assets/Script/SceneManager.ts']) {
|
|
const Config=loadMembers(configFile,['isShopB','getShopPrefabPath']);
|
|
Config.GM_INFO={canIos:true,abTests:{layer_three:1}};
|
|
Config.isGameplayEntryPreparing=()=>false;
|
|
const selected=[], calls=[];
|
|
const cc={isValid:()=>true,error:assert.fail,fx:{GameConfig:Config,GameTool:{shushu_Track(){},tryReactivateStarterPack(){}}},
|
|
instantiate:prefab=>{selected.push(prefab);return {active:true,destroy(){this.destroyed=true;},getComponent:()=>({init:data=>calls.push(data)})};}};
|
|
const globals={
|
|
cc,console:{log(){}},MapConroler:{_instance:null},Utils:{getShopDouble:done=>done({code:1,data:{shopDouble:{gold_2:true}}})}
|
|
};
|
|
const Type=loadMembers(file,['openShop','clearInvalidShopPrefabCaches'].filter(name=>file.includes('SceneManager')||name==='openShop'),globals);
|
|
globals.JiaZai=Type;globals.SceneManager=Type;
|
|
Type.cachedShopPrefab='prefab-A';Type.cachedShop2Prefab='prefab-B';
|
|
const controller=Object.assign(new Type(),{node:{addChild(){}},vibrateButtonClick(){},
|
|
ensureHomePopupPrefab:(bundle,path,key)=>{assert.equal(bundle,'shop');assert.equal(path,Config.getShopPrefabPath());assert.equal(Type[key],Config.isShopB()?'prefab-B':'prefab-A');return true;},
|
|
isHomeRuntimeAlive:()=>true,isPopupRuntimeAlive:()=>true});
|
|
controller.openShop();const old=controller.shopNode;controller.openShop();
|
|
Config.GM_INFO.abTests.layer_three=2;controller.openShop();controller.openShop();
|
|
assert.deepEqual(selected,['prefab-A','prefab-B']);assert.equal(old.destroyed,true);
|
|
assert.equal(calls.length,4);assert.equal(calls.every(data=>data.gold_2===true),true);
|
|
}
|
|
});
|
|
|
|
test('tab buttons address Pack and type by name in both authored node orders', () => {
|
|
const Shop=loadMembers(shopFile,['showCoinList','showPackList']);
|
|
for(const file of ['shop','shop2']) {
|
|
const data=JSON.parse(fs.readFileSync(`assets/shop/prefab/${file}.prefab`,'utf8'));
|
|
function node(id) {
|
|
const source=data[id];
|
|
return {...source,active:source._active,opacity:source._opacity,
|
|
children:source._children.map(ref=>node(ref.__id__)),getChildByName(name){return this.children.find(n=>n._name===name);}};
|
|
}
|
|
const shop=new Shop(); shop.itemList=node(data.find(x=>x.itemList).itemList.__id__);
|
|
const pack=shop.itemList.getChildByName('Pack'), before=pack.children.map(n=>n.opacity);
|
|
const title=shop.itemList.getChildByName('jinbi'), active=title?.active;
|
|
shop.showCoinList();assert.equal(pack.active,false);assert.equal(shop.itemList.getChildByName('Coin').active,true);
|
|
assert.deepEqual(pack.children.map(n=>n.opacity),before);assert.equal(title?.active,active);
|
|
shop.showPackList();assert.equal(pack.active,true);assert.equal(shop.itemList.getChildByName('Coin').active,true);
|
|
}
|
|
});
|
|
|
|
function activityFixture() {
|
|
const calls=[], widget={top:931,updateAlignment(){calls.push(this.top);}};
|
|
const info={canIos:true,level:30,starterPackPurchased:false,starter_packTime:Date.now()/1000+60,
|
|
jungleTreasureAvailable:true,jungleTreasureState:{expiresAt:Date.now()+60000,tierStates:[1,0]}};
|
|
const config={GM_INFO:info,getFeatureUnlockLevel:()=>16,getJungleTreasureUnlockLevel:()=>25};
|
|
const cc={Widget:'widget',find:()=>({}),fx:{GameConfig:config}};
|
|
const rules=loadModule('assets/Script/module/Config/StarterPack.ts',{}, {cc});
|
|
const Shop=loadMembers(shopFile,['refreshActivityEntries','isJungleTreasureUnavailable','activityContentTop'],{
|
|
cc,...rules,ShopActivityCarousel:'carousel'
|
|
});
|
|
const carousel={setEntries(names,reset){this.names=Array.from(names);this.reset=reset;}};
|
|
const background={active:true};
|
|
const shop=Object.assign(new Shop(),{node:{getComponent:()=>carousel,getChildByName:name=>name==='shop_3'?background:({getComponent:()=>widget})}});
|
|
return {shop,info,config,carousel,widget,calls,cc,background};
|
|
}
|
|
|
|
test('B activity filters all eight availability combinations and restores Widget top', () => {
|
|
const f=activityFixture();
|
|
for(let mask=0;mask<8;mask++) {
|
|
f.info.starterPackPurchased=!(mask&1);
|
|
f.info.jungleTreasureAvailable=!!(mask&2);
|
|
f.config.getFeatureUnlockLevel=()=>mask&4?16:99;
|
|
f.shop.refreshActivityEntries(true);
|
|
assert.deepEqual(f.carousel.names,['newbieGift','jungle','banre'].filter((_,i)=>mask&(1<<i)));
|
|
assert.equal(f.carousel.reset,true);assert.equal(f.widget.top,mask?931:510);
|
|
assert.equal(f.background.active,mask!==0);
|
|
}
|
|
f.info.canIos=false;f.shop.refreshActivityEntries();assert.equal(f.widget.top,510);assert.equal(f.background.active,false);
|
|
f.info.canIos=true;f.shop.refreshActivityEntries();assert.equal(f.widget.top,931);assert.equal(f.background.active,true);
|
|
});
|
|
|
|
test('B activity follows real unlock levels, gift expiry/purchase and Jungle claimable state', () => {
|
|
const f=activityFixture();
|
|
f.info.level=14;f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,[]);
|
|
f.info.level=15;f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift']);
|
|
f.info.level=16;f.config.getFeatureUnlockLevel=()=>20;
|
|
f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift']);
|
|
f.info.level=30;f.info.starter_packTime=Date.now()/1000-1;
|
|
f.info.jungleTreasureState={expiresAt:Date.now()+60000,tierStates:[2,2]};
|
|
f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['banre']);
|
|
f.info.starter_packTime=Date.now()/1000+60;
|
|
f.info.jungleTreasureState=JSON.stringify({expiresAt:Date.now()-1,tierStates:[2,1,0]});
|
|
f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift','jungle','banre']);
|
|
f.info.starterPackPurchased=true;
|
|
f.info.jungleTreasureState={expiresAt:Date.now()-1,tierStates:[2,0,1]};
|
|
f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['banre']);
|
|
f.cc.find=()=>null;f.carousel.names=['untouched'];f.shop.refreshActivityEntries();
|
|
assert.deepEqual(f.carousel.names,['untouched'],'A prefab without activity is unchanged');
|
|
});
|