520 lines
32 KiB
JavaScript
520 lines
32 KiB
JavaScript
// Run with TypeScript 4.9+ available through NODE_PATH (or TYPESCRIPT_PATH).
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const ts = require(process.env.TYPESCRIPT_PATH || 'typescript');
|
|
const root = path.resolve(__dirname, '..');
|
|
const configPath = 'assets/Script/module/Config/StarterPack.ts';
|
|
const giftPath = 'assets/action_bundle/script/NewbieGift.ts';
|
|
const homePath = 'assets/Script/JiaZai.ts';
|
|
const utilsPath = 'assets/Script/module/Pay/Utils.ts';
|
|
const source = file => ts.createSourceFile(file, fs.readFileSync(path.join(root, file), 'utf8'), ts.ScriptTarget.Latest, true);
|
|
function compile(code, globals) {
|
|
return vm.runInNewContext(ts.transpileModule(code, { compilerOptions: {
|
|
target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS, experimentalDecorators: true,
|
|
} }).outputText, globals);
|
|
}
|
|
function moduleFrom(file, globals, imports = {}) {
|
|
const exports = {};
|
|
compile(fs.readFileSync(path.join(root, file), 'utf8'), { ...globals, exports, require(name) {
|
|
if (!(name in imports)) throw new Error('Unmocked import: ' + name);
|
|
return imports[name];
|
|
} });
|
|
return exports;
|
|
}
|
|
function methods(file, names, globals) {
|
|
const ast = source(file), cls = ast.statements.find(ts.isClassDeclaration);
|
|
const parts = names.map(name => {
|
|
const member = cls.members.find(m => m.name?.getText(ast) === name);
|
|
assert.ok(member, name); return member.getText(ast);
|
|
});
|
|
return compile(`class ${cls.name.text} { ${parts.join('\n')} }\n${cls.name.text};`, globals);
|
|
}
|
|
function fixture() {
|
|
let now = 1800000000000;
|
|
const timers = [], storage = new Map(), events = [], calls = [];
|
|
const info = { uid: 'u1', level: 15, canIos: true, iosCanPay: true, starter_packTime: (now + 172800000) / 1000,
|
|
starterPackPurchased: false, starterPackServerOffset: 0, iosStarterOrder: '' };
|
|
class TestDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }
|
|
const cc = {
|
|
Node: class {}, Label: 'Label', Button: 'Button', Prefab: 'Prefab',
|
|
Component: class { schedule() {} unschedule() {} },
|
|
_decorator: { ccclass: x => x, property: () => () => {} },
|
|
isValid: value => !!value && !value.destroyed,
|
|
fx: { GameConfig: { GM_INFO: info, isGameplayEntryPreparing: () => false },
|
|
StorageMessage: { getStorage: k => storage.get(k), setStorage: (k,v) => storage.set(k,v) },
|
|
GameTool: { shushu_Track() {}, getWechatGameVersion: () => '体验版',
|
|
tryReactivateStarterPack() {}, recordStarterPackShown() {} } },
|
|
find: (name, node) => node ? name.split('/').reduce((n, part) => n?.children.find(c => c.name === part), node) : null,
|
|
error() {}, sys: { platform: 'web' },
|
|
};
|
|
const globals = { cc, console: { log() {}, warn() {}, error() {} }, Date: TestDate,
|
|
setTimeout: fn => { timers.push(fn); return timers.length; }, clearTimeout() {} };
|
|
const rules = moduleFrom(configPath, globals);
|
|
Object.assign(globals, rules);
|
|
const SDK = { API: { showToast: text => events.push(text), yinli_Pay() {}, shushu_SetSuperProperties() {} } };
|
|
const api = { getStarter_pack: done => done({ code: 1, data: { starter_pack: info.starter_packTime * 1000,
|
|
starter_packState: info.starterPackPurchased ? 1 : 0, serverTime: now } }) };
|
|
globals.Utils = api; globals.MiniGameSdk = SDK;
|
|
// Instantiate the actual authored prefab hierarchy and component bindings.
|
|
const prefab = JSON.parse(fs.readFileSync(path.join(root, 'assets/action_bundle/prefab/newbieGift.prefab')));
|
|
function makeNode(id) {
|
|
const raw = prefab[id];
|
|
const node = { name: raw._name, active: raw._active, opacity: raw._opacity, children: [], components: {},
|
|
emit() {}, getChildByName(name) { return this.children.find(c => c.name === name); },
|
|
getComponent(type) { return this.components[type]; } };
|
|
for (const ref of raw._components || []) {
|
|
const comp = prefab[ref.__id__];
|
|
if (comp.__type__ === 'cc.Label') node.components.Label = { string: comp._string };
|
|
if (comp.__type__ === 'cc.Button') node.components.Button = { interactable: true, clickEvents: comp.clickEvents.map(e => prefab[e.__id__]) };
|
|
}
|
|
node.children = (raw._children || []).map(c => makeNode(c.__id__));
|
|
return node;
|
|
}
|
|
const Gift = moduleFrom(giftPath, globals, {
|
|
'../../Script/JiaZai': { default: class {} },
|
|
'../../Script/LoadingCatAnimation': { default: { play() {}, stop() {} } },
|
|
'../../Script/module/Pay/Utils': { default: api },
|
|
'../../Script/Sdk/MiniGameSdk': { MiniGameSdk: SDK },
|
|
'../../Script/module/Config/StarterPack': rules,
|
|
}).default;
|
|
const gift = new Gift(); gift.node = makeNode(1);
|
|
return { cc, info, globals, rules, gift, api, storage, timers, events, calls, setNow: value => { now = value; }, getNow: () => now };
|
|
}
|
|
|
|
test('authored prefab wiring initializes without old Spine or root-level time nodes', () => {
|
|
const f = fixture(); f.gift.init(true);
|
|
const time = f.cc.find('timeContainer/time', f.gift.node).getComponent('Label');
|
|
assert.equal(time.string, '48:00:00');
|
|
const button = f.cc.find('btnContainer/btn', f.gift.node).getComponent('Button');
|
|
assert.equal(button.clickEvents[0].handler, 'buyProduct');
|
|
assert.equal(button.clickEvents[0].customEventData, 'starter_pack');
|
|
assert.notEqual(f.gift.node.getChildByName('gold_1')?.active, true);
|
|
assert.notEqual(f.gift.node.getChildByName('propBg1')?.active, true);
|
|
f.gift.closeStarter_pack(); assert.equal(f.gift.node.active, false);
|
|
f.gift.node.active = true; f.gift.init(false); assert.equal(button.interactable, true);
|
|
});
|
|
test('countdown remains HH:MM:SS below one hour and ends at zero without a new order', () => {
|
|
const f = fixture(); f.setNow(f.info.starter_packTime * 1000 - 3599000); f.gift.refreshCountdown();
|
|
assert.equal(f.cc.find('timeContainer/time', f.gift.node).components.Label.string, '00:59:59');
|
|
f.setNow(f.info.starter_packTime * 1000); f.gift.refreshCountdown();
|
|
assert.equal(f.cc.find('timeContainer/time', f.gift.node).components.Label.string, '已结束');
|
|
assert.equal(f.cc.find('btnContainer/btn', f.gift.node).components.Button.interactable, false);
|
|
f.api.buyProp = () => assert.fail('expired order created'); f.gift.buyProduct();
|
|
});
|
|
test('server clock correction and confirmed purchase win over stale local status', () => {
|
|
const f = fixture();
|
|
f.rules.applyStarterPackStatus({ starter_pack: f.getNow() + 10000, serverTime: f.getNow() + 5000, starter_packState: 0 });
|
|
assert.equal(f.rules.starterPackRemaining(), 5);
|
|
f.info.starterPackPurchased = true;
|
|
f.rules.applyStarterPackStatus({ starter_pack: f.getNow() + 10000, starter_packState: 0 });
|
|
assert.equal(f.rules.starterPackRemaining(), 0);
|
|
});
|
|
function homeFixture() {
|
|
const f = fixture();
|
|
const Home = methods(homePath, ['checkStarter_pack', 'openStarter_pack', 'startStarter_pack', 'stopStarter_pack'], f.globals);
|
|
const h = new Home(); h.node = { addChild() {} }; h.newbieGift = false;
|
|
h.isHomeRuntimeAlive = () => true; h.deferHomePopupWhileTransfer = () => false;
|
|
h.vibrateButtonClick = () => {}; h.schedule = () => {}; h.unschedule = () => {};
|
|
const button = { getChildByName: () => ({ getComponent: () => ({}) }) };
|
|
h.getStarterPackHomeButton = () => button;
|
|
h.setStarterPackHomeButtonVisible = v => { h.visible = v; };
|
|
let opens = 0;
|
|
f.cc.instantiate = () => ({ active: false, getComponent: () => ({ init() { opens++; }, refreshCountdown() {} }) });
|
|
Home.cachedActionPrefab = {};
|
|
return { ...f, Home, h, opens: () => opens };
|
|
}
|
|
test('first activation counts today; same-day reentry does not popup; next day does', () => {
|
|
const f = homeFixture();
|
|
f.api.getStarter_pack = done => done({ code: 1, data: { starter_pack: 0, starter_packState: 0 } });
|
|
f.api.setStarter_pack = done => done({ code: 1, data: { starter_pack: f.info.starter_packTime * 1000, starter_packState: 0 } });
|
|
f.h.checkStarter_pack(); assert.equal(f.opens(), 1);
|
|
assert.equal(f.storage.get(f.rules.starterPackPopupKey()), f.rules.starterPackDay());
|
|
f.api.getStarter_pack = f.api.setStarter_pack;
|
|
f.h.actionpNode.active = false; f.h.checkStarter_pack(); assert.equal(f.opens(), 1);
|
|
assert.equal(f.h.visible, true);
|
|
f.setNow(f.getNow() + 86400000); f.h.checkStarter_pack(); assert.equal(f.opens(), 2);
|
|
});
|
|
test('failed prefab load does not consume daily reminder; account keys differ', () => {
|
|
const f = homeFixture(); f.Home.cachedActionPrefab = null;
|
|
f.h.loadHomeBundleWithDependencies = (name, priority, done) => done(new Error('offline'));
|
|
f.h.checkStarter_pack(); assert.equal(f.storage.size, 0);
|
|
const key = f.rules.starterPackPopupKey(); f.info.uid = 'other'; assert.notEqual(f.rules.starterPackPopupKey(), key);
|
|
});
|
|
test('eligible level, paid flag and expiry gate automatic popup and homepage entry', () => {
|
|
for (const patch of [{ level: 14 }, { canIos: false }, { starterPackPurchased: true }, { starter_packTime: 0.1 }]) {
|
|
const f = homeFixture(); Object.assign(f.info, patch);
|
|
f.h.checkStarter_pack(); assert.equal(f.opens(), 0);
|
|
}
|
|
});
|
|
test('one successful payment carries its original order id through polling and claim', () => {
|
|
const f = fixture(); const requests = [];
|
|
f.api.buyProp = (id, count, price, platform, name, done) => { requests.push([id,count,price]); done({ outTradeNo: 'new-order' }); };
|
|
f.api.getPayInfo = (done, order) => { assert.equal(order, 'new-order'); done({ code: 1, data: { pay_state: 2 } }); };
|
|
f.api.setPayInfo = (done, order) => { assert.equal(order, 'new-order'); done({ code: 1, data: 'ok' }); };
|
|
f.cc.fx.GameTool.shopBuy = (id, compensate, data) => requests.push(data.outTradeNo);
|
|
f.gift.buyProduct();
|
|
assert.deepEqual(requests, [['starter_pack',1,300],'new-order']); assert.equal(f.info.starterPackPurchased,true);
|
|
});
|
|
test('pending order still completes after expiry while a fresh order is forbidden', () => {
|
|
const f = fixture(); f.gift.pendingOrder = 'late'; f.info.starter_packTime = f.getNow()/1000 - 1;
|
|
f.api.buyProp = () => assert.fail('created new order');
|
|
f.api.getPayInfo = (done, order) => { assert.equal(order, 'late'); done({ code: 1, data: { pay_state: 2 } }); };
|
|
f.api.setPayInfo = (done, order) => done({ code: 1, data: 'ok' });
|
|
let grantedOrder; f.cc.fx.GameTool.shopBuy = (id, comp, data) => { grantedOrder = data.outTradeNo; };
|
|
f.gift.onShow(); assert.equal(grantedOrder, 'late'); assert.equal(f.gift.node.active, false);
|
|
});
|
|
for (const remaining of [0, -60, 600]) test(`starter pack grants 3000/3 and 30 minutes for purchases and compensation, with ${remaining}s existing health`, () => {
|
|
const f = fixture();
|
|
const ast = source('assets/Script/module/Tool/GameTool.ts');
|
|
const obj = ast.statements.find(n => ts.isVariableStatement(n) && n.declarationList.declarations.some(d => d.name.getText(ast) === 'GameTool'));
|
|
const members = obj.declarationList.declarations.find(d => d.name.getText(ast) === 'GameTool').initializer.properties;
|
|
const code = ['shopBuy', 'setUserPowerTime'].map(name => members.find(m => m.name?.getText(ast) === name).getText(ast)).join(',');
|
|
const coins = [], props = [], synced = [], rewards = [], healthEvents = [];
|
|
const tool = compile(`const tool = { ${code} }; tool;`, { ...f.globals, wx: {},
|
|
MapConroler: { _instance: { SceneManager: { openRewardWindow: data => rewards.push(data) } } } });
|
|
const nowSeconds = Math.floor(f.getNow() / 1000);
|
|
if (remaining) f.storage.set('userPowerTime', nowSeconds + remaining);
|
|
f.api.setUserPowerTime = (expiry, done) => { synced.push(expiry); done({ code: 1 }); };
|
|
f.cc.fx.GameTool.shushu_Track = (event, data) => { if (event === 'resource_get' && data.id === 2005) healthEvents.push(data); };
|
|
f.cc.fx.GameTool.changeCoin = n => coins.push(n); tool.getShopProp = data => props.push(data);
|
|
tool.shopBuy('starter_pack', false, { outTradeNo: 'old', starterPackVersion: 1, starterPackRewards: { coin: 3000, hammer: 5, freeze: 5, magic_wand: 5 } });
|
|
tool.shopBuy('starter_pack', true, { outTradeNo: 'new', starterPackVersion: 2 });
|
|
tool.shopBuy('starter_pack', false, { outTradeNo: 'new', starterPackVersion: 2 });
|
|
assert.deepEqual(coins, [3000,3000]);
|
|
assert.deepEqual(props.map(p => [p.hammer,p.freeze,p.magic_wand]), [[3,3,3],[3,3,3]]);
|
|
const base = nowSeconds + Math.max(0, remaining);
|
|
assert.deepEqual(synced, [base + 1800, base + 3600]);
|
|
assert.equal(f.storage.get('userPowerTime'), base + 3600);
|
|
assert.equal(f.info.userPowerTime, base + 3600);
|
|
assert.deepEqual(rewards.map(data => data.find(item => item.type === 'infinite_health').count), [1800, 1800]);
|
|
assert.deepEqual(healthEvents.map(data => [data.num, data.change_reason, data.compensate]),
|
|
[[1800, 'starter_pack', false], [1800, 'starter_pack', true]]);
|
|
});
|
|
test('HTTP timeout or empty response terminates once, allowing payment UI to recover', () => {
|
|
const f = fixture(), xhr = { open() {}, setRequestHeader() {}, send() {} };
|
|
f.cc.loader = { getXMLHttpRequest: () => xhr };
|
|
f.cc.fx.GameTool.getWechatGameVersion = () => '开发版';
|
|
const Utils = methods(utilsPath, ['POST'], f.globals); Utils.testHttpip = 'https://test.invalid/';
|
|
const responses = [];
|
|
Utils.POST('limitedTimeEvent', {}, res => responses.push(res));
|
|
xhr.ontimeout(); xhr.onerror(); xhr.readyState=4; xhr.status=0; xhr.responseText=''; xhr.onreadystatechange();
|
|
assert.equal(responses.length,1); assert.equal(responses[0].code,0);
|
|
});
|
|
test('visible gift still suppresses monthly popup after its daily reminder is recorded', () => {
|
|
const f = fixture(), Home = methods(homePath, ['update'], f.globals);
|
|
const h = new Home(); h.newbieGift = false;
|
|
h.actionpNode = { active: true }; h.monthlyCardNode = { active: true };
|
|
h.update(0); assert.equal(h.monthlyCardNode.active, false);
|
|
});
|
|
test('time-only activity response can create a 3 yuan order without reward version', () => {
|
|
const f = fixture();
|
|
f.api.getStarter_pack = done => done({ code: 1, data: { starter_pack: f.info.starter_packTime * 1000, starter_packState: 0 } });
|
|
let requested = false;
|
|
f.api.buyProp = (id, count, price, platform, name, done) => {
|
|
requested = true; assert.equal(price, 300); assert.equal(count, 1);
|
|
done({ err: true, errMsg: 'cancelled' });
|
|
};
|
|
f.gift.buyProduct(); assert.equal(requested, true); assert.equal(f.gift.paying, false);
|
|
assert.equal(f.events.includes('礼包正在更新,请稍后重试'), false);
|
|
});
|
|
test('failed customer-service launch clears the unusable pending order for retry', () => {
|
|
const f = fixture();
|
|
f.api.GoKEFu = (info, done) => { f.info.iosStarterOrder = 'never-created'; done('fail'); };
|
|
f.gift.iosOldPay(); assert.equal(f.info.iosStarterOrder, '');
|
|
assert.equal(f.gift.paying, false);
|
|
});
|
|
test('foreground events during reward confirmation do not start a second claim', () => {
|
|
const f = fixture(); f.gift.pendingOrder = 'one-order';
|
|
let confirmations = 0, finish;
|
|
f.api.getPayInfo = done => done({ code: 1, data: { pay_state: 2 } });
|
|
f.api.setPayInfo = done => { confirmations++; finish = done; };
|
|
f.cc.fx.GameTool.shopBuy = () => {};
|
|
f.gift.onShow(); f.gift.onShow(); assert.equal(confirmations, 1);
|
|
finish({ code: 1, data: 'ok' });
|
|
});
|
|
|
|
test('legacy iOS success after expiry grants immediately without another acknowledgement', () => {
|
|
const f = fixture(); f.info.iosStarterOrder = 'ios-order'; f.info.starter_packTime = 0.1;
|
|
f.api.getIosPayInfo = (order, done) => {
|
|
assert.equal(order, 'ios-order');
|
|
done({ code: 1, data: { goodsPrice: 300, payment_name: 'starter_pack' } });
|
|
};
|
|
f.api.setPayInfo = () => assert.fail('iOS order already completed by payment query');
|
|
let granted;
|
|
f.cc.fx.GameTool.shopBuy = (id, compensate, context) => { granted = context.outTradeNo; };
|
|
f.gift.onShow();
|
|
assert.equal(granted, 'ios-order'); assert.equal(f.info.iosStarterOrder, '');
|
|
assert.equal(f.gift.node.active, false);
|
|
});
|
|
|
|
test('completed legacy orders clear pending UI without granting again', () => {
|
|
for (const direct of [true, false]) {
|
|
const f = fixture();
|
|
if (direct) f.gift.pendingOrder = 'completed'; else f.info.iosStarterOrder = 'completed';
|
|
f.api.getPayInfo = done => done({ code: 1, data: { pay_state: 1 }, msg: '已领取奖励' });
|
|
f.api.getIosPayInfo = (order, done) => done({ code: 0, data: null, msg: '已经获取到奖励' });
|
|
f.api.setPayInfo = () => assert.fail('completed order acknowledged again');
|
|
f.cc.fx.GameTool.shopBuy = () => assert.fail('completed order granted again');
|
|
f.gift.onShow();
|
|
assert.equal(f.gift.pendingOrder, ''); assert.equal(f.info.iosStarterOrder, '');
|
|
assert.equal(f.gift.polling, false); assert.equal(f.info.starterPackPurchased, true);
|
|
}
|
|
});
|
|
|
|
test('acknowledgement retry retains confirmed order even if the server has completed it', () => {
|
|
const f = fixture(); f.gift.pendingOrder = 'retry-order';
|
|
let polls = 0, confirms = 0, grants = 0;
|
|
f.api.getPayInfo = done => { polls++; done({ code: 1, data: { pay_state: 2 } }); };
|
|
f.api.setPayInfo = (done, order) => {
|
|
assert.equal(order, 'retry-order'); confirms++;
|
|
done(confirms === 1 ? { code: 0 } : { code: 1, data: 'ok' });
|
|
};
|
|
f.cc.fx.GameTool.shopBuy = () => { grants++; };
|
|
f.gift.onShow(); assert.equal(grants, 0);
|
|
f.gift.againGet();
|
|
assert.equal(polls, 1); assert.equal(confirms, 2); assert.equal(grants, 1);
|
|
});
|
|
|
|
test('deadline passing during preflight prevents the client from requesting an order', () => {
|
|
const f = fixture();
|
|
f.api.getStarter_pack = done => done({ code: 1, data: { starter_pack: f.getNow(), starter_packState: 0 } });
|
|
f.api.buyProp = () => assert.fail('client created expired order');
|
|
f.gift.buyProduct(); assert.equal(f.gift.paying, false);
|
|
assert.equal(f.cc.find('btnContainer/btn', f.gift.node).components.Button.interactable, false);
|
|
});
|
|
|
|
test('login compensation accepts legacy ok response and keeps original order id without changing health', () => {
|
|
const f = fixture(); f.globals.wx = {};
|
|
f.info.allOutTradeNo = [{ itemid: 'starter_pack', outTradeNo: 'old-login-order', goodsPrice: 300 }];
|
|
Object.assign(f.info, { hp: 7, hp_Max: 7, doubleCoin: 2 });
|
|
f.api.getShopDouble = done => done({ code: 1, data: { shopDouble: {} } });
|
|
f.api.setPayInfo = (done, order) => {
|
|
assert.equal(order, 'old-login-order'); done({ code: 1, data: 'ok' });
|
|
};
|
|
let context;
|
|
f.cc.fx.GameTool.shopBuy = (id, compensate, data) => {
|
|
assert.equal(compensate, true); context = data;
|
|
};
|
|
const Home = methods(homePath, ['getOrder'], f.globals), h = new Home();
|
|
h.deferHomePopupWhileTransfer = () => false; h.isHomeRuntimeAlive = () => false;
|
|
h.getOrder();
|
|
assert.deepEqual(JSON.parse(JSON.stringify(context)), { outTradeNo: 'old-login-order' });
|
|
assert.equal(f.info.allOutTradeNo.length, 0);
|
|
assert.deepEqual([f.info.hp, f.info.hp_Max, f.info.doubleCoin], [7,7,2]);
|
|
});
|
|
|
|
function pollingFixture() {
|
|
const f = fixture(), timers = new Map(); let nextId = 0;
|
|
const globals = { ...f.globals,
|
|
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { fn, at: f.getNow() + delay }); return id; },
|
|
clearTimeout(id) { timers.delete(id); },
|
|
};
|
|
const Payment = methods(utilsPath, ['getPayInfo'], globals); Payment.uid = f.info.uid;
|
|
f.api.getPayInfo = Payment.getPayInfo.bind(Payment);
|
|
const advance = ms => {
|
|
const end = f.getNow() + ms;
|
|
while (timers.size) {
|
|
const [id, timer] = [...timers].sort((a,b) => a[1].at - b[1].at)[0];
|
|
if (timer.at > end) break;
|
|
timers.delete(id); f.setNow(timer.at); timer.fn();
|
|
}
|
|
f.setNow(end);
|
|
};
|
|
return { ...f, Payment, advance, timers };
|
|
}
|
|
|
|
test('WeChat success with backend still unpaid times out in 30 seconds, preserves order and later grants once', () => {
|
|
const f = pollingFixture(); let paid = false, polls = 0, grants = 0, claims = 0, created = 0;
|
|
f.api.buyProp = (id, count, price, platform, name, done) => {
|
|
created++; done({ errMsg: 'requestPayment:ok', outTradeNo: 'paid-on-phone' });
|
|
};
|
|
f.Payment.POST = (url, body, done) => {
|
|
polls++; assert.equal(body.outTradeNo, 'paid-on-phone');
|
|
done(paid ? { code: 1, data: { pay_state: 2 } } : { code: 0, data: { pay_state: 1 }, msg: '充值未成功' });
|
|
};
|
|
f.api.setPayInfo = done => { claims++; done({ code: 1, data: 'ok' }); };
|
|
f.cc.fx.GameTool.shopBuy = () => { grants++; };
|
|
f.gift.buyProduct(); f.gift.onShow(); assert.equal(polls, 1);
|
|
f.advance(30000);
|
|
assert.equal(grants, 0); assert.equal(claims, 0);
|
|
assert.equal(f.gift.paying, false); assert.equal(f.gift.polling, false);
|
|
assert.equal(f.gift.pendingOrder, 'paid-on-phone');
|
|
assert.equal(f.gift.node.getChildByName('ConfirmBox').active, true);
|
|
const stoppedPolls = polls; f.advance(60000); assert.equal(polls, stoppedPolls);
|
|
paid = true; f.gift.againGet(); f.gift.onShow();
|
|
assert.equal(created, 1); assert.equal(claims, 1); assert.equal(grants, 1);
|
|
assert.equal(f.gift.pendingOrder, ''); assert.equal(f.timers.size, 0);
|
|
});
|
|
|
|
test('payment poll deadline completes once and ignores a late successful response', () => {
|
|
const f = pollingFixture(), results = []; let respond;
|
|
f.Payment.POST = (url, body, done) => { respond = done; };
|
|
f.Payment.getPayInfo(res => results.push(res), 'slow-order', { timeoutMs: 30000 });
|
|
f.advance(30000); assert.equal(results.length, 1); assert.equal(results[0].code, 0);
|
|
respond({ code: 1, data: { pay_state: 2 } }); f.advance(60000);
|
|
assert.equal(results.length, 1); assert.equal(f.timers.size, 0);
|
|
});
|
|
|
|
test('malformed poll response does not throw or leave the payment UI locked forever', () => {
|
|
const f = pollingFixture(); f.gift.pendingOrder = 'bad-response';
|
|
f.Payment.POST = (url, body, done) => done({ code: 1, data: null });
|
|
f.cc.fx.GameTool.shopBuy = () => assert.fail('malformed response granted rewards');
|
|
assert.doesNotThrow(() => f.gift.onShow()); f.advance(30000);
|
|
assert.equal(f.gift.paying, false); assert.equal(f.gift.polling, false);
|
|
assert.equal(f.gift.pendingOrder, 'bad-response');
|
|
});
|
|
|
|
test('payment callback without an order id releases the loading state instead of silently stopping', () => {
|
|
const f = fixture(); f.api.buyProp = (id, count, price, platform, name, done) => done({ errMsg: 'requestPayment:ok' });
|
|
f.cc.fx.GameTool.shopBuy = () => assert.fail('missing order id granted rewards');
|
|
f.gift.buyProduct(); assert.equal(f.gift.paying, false);
|
|
assert.ok(f.events.includes('支付订单号缺失,请重进游戏检查补单'));
|
|
});
|
|
|
|
for (const version of ['开发版','体验版','正式版']) {
|
|
test(version + ' requires real payment confirmation and ignores old local test grant records', () => {
|
|
const f = fixture(); let paid = false, created = 0, grants = 0, claims = 0;
|
|
f.cc.fx.GameTool.getWechatGameVersion = () => version;
|
|
f.storage.set('StarterPackGranted_' + f.info.uid + '_starter_test_' + f.info.starter_packTime, true);
|
|
f.api.buyProp = (id, count, price, platform, name, done) => {
|
|
created++; assert.equal(price,300); done({outTradeNo:'real-order'});
|
|
};
|
|
f.api.getPayInfo = done => done(paid ? {code:1,data:{pay_state:2}} : {code:0});
|
|
f.api.setPayInfo = (done,order) => {claims++; assert.equal(order,'real-order'); done({code:1,data:'ok'});};
|
|
f.cc.fx.GameTool.shopBuy = (id,compensate,context) => {grants++; assert.equal(context.outTradeNo,'real-order');};
|
|
f.gift.buyProduct();
|
|
assert.equal(created,1); assert.equal(grants,0); assert.equal(claims,0);
|
|
assert.equal(f.info.starterPackPurchased,false); assert.equal(f.gift.pendingOrder,'real-order');
|
|
paid=true; f.gift.againGet();
|
|
assert.equal(created,1); assert.equal(claims,1); assert.equal(grants,1);
|
|
assert.equal(f.info.starterPackPurchased,true); assert.ok(f.events.includes('充值成功'));
|
|
});
|
|
}
|
|
|
|
function gameToolMethods(f, names) {
|
|
const ast=source('assets/Script/module/Tool/GameTool.ts');
|
|
const obj=ast.statements.find(n=>ts.isVariableStatement(n)&&n.declarationList.declarations.some(d=>d.name.getText(ast)==='GameTool'));
|
|
const members=obj.declarationList.declarations.find(d=>d.name.getText(ast)==='GameTool').initializer.properties;
|
|
return compile(`const tool={ ${names.map(name=>members.find(m=>m.name?.getText(ast)===name).getText(ast)).join(',')} }; tool;`,
|
|
{...f.globals,JiaZai:'Home',SceneManager:'Scene'});
|
|
}
|
|
|
|
test('reactivation requests coalesce, preserve a different pending trigger and open the returned offer once', () => {
|
|
const f=fixture(); f.info.starter_packTime=f.getNow()/1000-1; f.info.coin=499;
|
|
const tool=gameToolMethods(f,['tryReactivateStarterPack']); const requests=[]; let opens=0;
|
|
f.cc.find=()=>({getComponent:kind=>kind==='Home'?{onStarterPackReactivated(){opens++;}}:null});
|
|
f.api.reactivateStarter_pack=(reason,count,shown,done)=>requests.push({reason,done});
|
|
tool.tryReactivateStarterPack('low_coin'); tool.tryReactivateStarterPack('shop'); tool.tryReactivateStarterPack('shop');
|
|
assert.equal(requests.length,1);
|
|
requests[0].done({code:1,data:{starter_pack:f.info.starter_packTime*1000,reactivated:false}});
|
|
assert.equal(requests.length,2); assert.equal(requests[1].reason,'shop');
|
|
const deadline=f.getNow()+48*3600000;
|
|
requests[1].done({code:1,data:{starter_pack:deadline,reactivated:true,serverTime:f.getNow()}});
|
|
assert.equal(opens,1); tool.tryReactivateStarterPack('shop'); assert.equal(requests.length,2);
|
|
// A late response from the old period cannot undo the new period.
|
|
f.rules.applyStarterPackStatus({starter_pack:f.getNow()-1000,starter_packState:0});
|
|
assert.equal(f.info.starter_packTime,deadline/1000);
|
|
});
|
|
test('reactivation guards cooldown, paid state and coin boundary; account switches ignore stale callbacks', () => {
|
|
const f=fixture(), tool=gameToolMethods(f,['tryReactivateStarterPack']); let done,requests=0;
|
|
f.api.reactivateStarter_pack=(reason,count,shown,callback)=>{requests++;done=callback;};
|
|
f.info.starter_packTime=f.getNow()/1000-1; f.info.coin=500;
|
|
tool.tryReactivateStarterPack('low_coin'); assert.equal(requests,0);
|
|
f.info.starterPackLastShownAt=f.getNow()-48*3600000;
|
|
tool.tryReactivateStarterPack('shop'); assert.equal(requests,0);
|
|
f.info.starterPackLastShownAt--; f.info.starterPackPurchased=true;
|
|
tool.tryReactivateStarterPack('shop'); assert.equal(requests,0);
|
|
f.info.starterPackPurchased=false; tool.tryReactivateStarterPack('shop'); assert.equal(requests,1);
|
|
f.info.uid='other';
|
|
done({code:1,data:{starter_pack:f.getNow()+48*3600000,reactivated:true}});
|
|
assert.equal(f.info.starter_packTime,f.getNow()/1000-1);
|
|
});
|
|
test('actual popup display records exact time and daily reminder; expired display does not', () => {
|
|
const f=fixture(),tool=gameToolMethods(f,['recordStarterPackShown']); const reports=[];
|
|
f.api.markStarterPackShown=expiry=>reports.push(expiry);
|
|
f.cc.fx.GameTool.recordStarterPackShown=tool.recordStarterPackShown.bind(tool);
|
|
f.gift.init(true);
|
|
assert.deepEqual(reports,[f.info.starter_packTime*1000]);
|
|
assert.equal(f.storage.get('StarterPackLastShown_u1'),f.getNow());
|
|
assert.equal(f.storage.get(f.rules.starterPackPopupKey()),f.rules.starterPackDay());
|
|
f.info.starter_packTime=f.getNow()/1000-1; tool.recordStarterPackShown(); assert.equal(reports.length,1);
|
|
});
|
|
test('four failed challenges on one level trigger once per attempt; revive failure is not a new attempt and win resets', () => {
|
|
const f=fixture(), requests=[];
|
|
const tool=gameToolMethods(f,['recordStarterPackLevelResult']);
|
|
tool.tryReactivateStarterPack=reason=>requests.push(reason);
|
|
f.cc.fx.GameTool.recordStarterPackLevelResult=tool.recordStarterPackLevelResult.bind(tool);
|
|
const Map=methods('assets/Script/Map.ts',['failLevel'],f.globals);
|
|
for(let i=0;i<4;i++) {
|
|
const map=new Map(); Object.assign(map,{blocks:[1],blockNum:1,pause:false,
|
|
isMapRuntimeAlive:()=>true,canCompleteLevel:()=>false,stopBoom(){},stopTimeCutDown(){},openLosePanel(){}});
|
|
map.failLevel('time'); f.timers.shift()();
|
|
map.gameOver=false; map.failLevel('time'); f.timers.shift()();
|
|
}
|
|
assert.deepEqual(requests,['four_failures']); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),4);
|
|
tool.recordStarterPackLevelResult(true); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),0);
|
|
tool.recordStarterPackLevelResult(false); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),1);
|
|
f.info.level++; assert.equal(f.rules.starterPackRecordResult(false),1);
|
|
f.info.uid='another'; assert.equal(f.rules.starterPackRecordResult(false),1);
|
|
});
|
|
test('coin trigger waits for successful server coin save', () => {
|
|
const f=fixture(); f.globals.wx={}; f.info.coin=499;
|
|
const tool=gameToolMethods(f,['setUserCoin']); const reasons=[]; let complete;
|
|
tool.tryReactivateStarterPack=reason=>reasons.push(reason);
|
|
f.api.setUserCoin=done=>{complete=done;};
|
|
tool.setUserCoin(()=>{}); assert.equal(reasons.length,0);
|
|
complete({code:0}); assert.equal(reasons.length,0);
|
|
complete({code:1}); assert.deepEqual(reasons,['low_coin']);
|
|
});
|
|
test('in-level gift shares the prefab and restores only the pause it owns', () => {
|
|
for(const scenario of ['playing','shop','failed','loading-error','freeze-ended']) {
|
|
const f=fixture(); let closed,opened=0,boom=0;
|
|
let frozen=scenario==='freeze-ended';
|
|
const map={node:{},pause:scenario==='shop'||scenario==='failed'||frozen,shopPause:scenario==='shop',
|
|
gameOver:scenario==='failed',gameWin:false,gameStart:true,stopBoom(){},startBoom(){boom++;},iceTrue:()=>frozen};
|
|
const Scene=methods('assets/Script/SceneManager.ts',['openStarterPack'],{...f.globals,MapConroler:{_instance:map}});
|
|
const scene=new Scene(); scene.node={}; scene.isPopupRuntimeAlive=()=>true; scene.isPauseOpen=()=>false;
|
|
scene.finishDynamicPopupLoad=()=>{};
|
|
const root={active:false,once(name,fn){closed=fn;},getComponent:()=>({init(){opened++;}})};
|
|
scene.getDynamicPopup=name=>name==='starterPack'&&opened?root:null;
|
|
scene.loadDynamicPopup=(name,bundle,path,parent,done)=>{
|
|
assert.equal(bundle,'action_bundle'); assert.equal(path,'prefab/newbieGift');
|
|
done(scenario==='loading-error'?Error('offline'):null,scenario==='loading-error'?null:root);
|
|
};
|
|
f.cc.macro={MAX_ZINDEX:32767};
|
|
scene.openStarterPack();
|
|
if(scenario!=='loading-error') {scene.openStarterPack(); assert.equal(opened,1); root.active=false; frozen=false; closed();}
|
|
assert.equal(map.pause,scenario==='shop'||scenario==='failed');
|
|
assert.equal(boom,scenario==='playing'||scenario==='loading-error'||scenario==='freeze-ended'?1:0);
|
|
}
|
|
});
|
|
|
|
test('in-level gift does not appear if its deadline passes during prefab loading', () => {
|
|
const f=fixture(); let finish;
|
|
const map={node:{},pause:false,shopPause:false,gameOver:false,gameWin:false,gameStart:true,
|
|
stopBoom(){},startBoom(){},iceTrue:()=>false};
|
|
const Scene=methods('assets/Script/SceneManager.ts',['openStarterPack'],{...f.globals,MapConroler:{_instance:map}});
|
|
const scene=new Scene(); Object.assign(scene,{node:{},isPopupRuntimeAlive:()=>true,isPauseOpen:()=>false,
|
|
getDynamicPopup:()=>null,finishDynamicPopupLoad(){},loadDynamicPopup:(...args)=>{finish=args[4];}});
|
|
scene.openStarterPack(); assert.equal(map.pause,true);
|
|
f.setNow(f.info.starter_packTime*1000);
|
|
const root={active:false,getComponent:()=>assert.fail('expired gift shown')};
|
|
finish(null,root); assert.equal(root.active,false); assert.equal(map.pause,false); assert.equal(map.shopPause,false);
|
|
});
|
|
|
|
test('gift popup cleanup preserves shared Home assets while other popup bundles still release', () => {
|
|
const f=fixture(); let released=0,removed=0;
|
|
const bundle={releaseAll(){released++;}};
|
|
f.cc.assetManager={getBundle:()=>bundle,removeBundle(){removed++;}};
|
|
const Scene=methods('assets/Script/SceneManager.ts',['releaseDynamicPopupBundle','releaseOrphanedPopupBundle'],f.globals);
|
|
const scene=new Scene();
|
|
scene.releaseDynamicPopupBundle('action_bundle',bundle);
|
|
Scene.releaseOrphanedPopupBundle('action_bundle',bundle);
|
|
assert.equal(f.timers.length,0); assert.equal(released,0); assert.equal(removed,0);
|
|
scene.releaseDynamicPopupBundle('pause',bundle);
|
|
assert.equal(released,1); assert.equal(removed,1);
|
|
});
|