MatchMaster/tools/test-shop-ab.cjs
2026-09-29 18:57:56 +08:00

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');
});