MatchMaster/tools/test-battle-pass-settlement-check.cjs

140 lines
8.9 KiB
JavaScript

const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), vm = require('node:vm'), ts = require('typescript');
function load(file, deps = {}, globals = {}) {
const exports = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: 99, module: 1, experimentalDecorators: true }
}).outputText, { exports, require: name => deps[name], Date, console, ...globals });
return exports;
}
const model = load('assets/Script/module/Config/BattlePassModel.ts');
function fixture() {
let now = 1800000000000;
class Clock extends Date { static now() { return now; } }
const Check = load('assets/Script/module/Config/BattlePassSettlementCheck.ts', { './BattlePassModel': model }, { Date: Clock }).default;
const gm = { uid: 'u', token: 't', allOutTradeNo: [] };
const cache = Check.forPlayer(gm), end = now + 60000;
cache.observeStart(end - model.PASS_PERIOD);
return { Check, cache, gm, end, Clock, now: () => now, advance: value => now += value };
}
test('login catch-up is mandatory even with 30 days remaining; same-period result is cached', () => {
const f = fixture();
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), f.cache.version);
for (let i = 0; i < 100; i++) assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
f.advance(59999); assert.equal(f.cache.needsCheck(), false);
f.advance(1); assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), f.cache.version);
assert.equal(f.cache.needsCheck(), false);
});
test('new-season reads before returning home cannot hide a missed cutoff', () => {
const f = fixture(); f.cache.complete(f.now(), 0);
f.advance(60001 + model.PASS_PERIOD * 2);
f.cache.observeStart(f.end + model.PASS_PERIOD * 2);
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), 0);
assert.equal(f.cache.needsCheck(), false);
});
test('deadline uses server-clock offset, not an inaccurate device clock', () => {
const f = fixture(), serverNow = f.now();
f.advance(3600000); f.cache.syncClock(serverNow); f.cache.complete(serverNow, 0);
assert.equal(f.cache.needsCheck(), false);
f.advance(60000); assert.equal(f.cache.needsCheck(), true);
});
test('account/token changes and a new login with the same token reset the cache', () => {
const f = fixture(); f.cache.complete(f.now(), 0);
f.gm.token = 'new'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.Check.forPlayer(f.gm).complete(f.now(), 0);
f.gm.uid = 'other'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.Check.forPlayer(f.gm).complete(f.now(), 0);
f.Check.resetLogin(); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.cache.complete(f.now(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true, 'old async completion cannot cache a new login');
});
test('payment invalidation during a read survives its later completion; pending orders bypass cache', () => {
const f = fixture(); f.cache.invalidate(); f.cache.complete(f.now(), 0);
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), 1);
assert.equal(f.cache.needsCheck([{ itemid: 'other', outTradeNo: 'a' }]), false);
const order = { itemid: 'battlepass_12', outTradeNo: 'b' };
assert.equal(f.cache.needsCheck([order]), true);
f.cache.reviewOrders.add('b'); assert.equal(f.cache.needsCheck([order]), false);
});
function runFixture() {
const f = fixture(), calls = [], retries = [];
let error, paidError, readResponse, pendingRead;
const cc = { _decorator: { ccclass: T => T }, Component: class {
scheduleOnce(fn, delay) { retries.push({ fn, delay }); }
destroy() { this.destroyed = true; }
}, isValid: value => !!value && !value.destroyed, log() {}, warn() {},
systemEvent: { off() {} }, SystemEvent: { EventType: { KEY_DOWN: 'key' } },
fx: { GameConfig: { GM_INFO: f.gm } } };
const utils = {
POSTJSON(url, body, callback) { calls.push(url); callback({ code: 1, data: { time: f.end - model.PASS_PERIOD } }); },
async completePassOrder() { calls.push('payment'); if (paidError) throw paidError; f.cache.invalidate(); return { data: { alreadyGranted: true } }; },
async settlementRequest() { calls.push('read'); if (error) throw error; if (pendingRead) return pendingRead; return readResponse || { data: { serverTime: f.now(), rows: [] } }; }
};
const C = load('assets/Script/BattlePassSettlement.ts', {
'./module/Pay/Utils': { default: utils },
'./module/Config/BattlePassSettlementCheck': { default: f.Check },
'./module/Config/BattlePassSettlementModel': { settlementSteps: rows => rows },
'./BattlePassArt': { default: { load: async () => { throw Error('art failed'); } } }
}, { cc, Date: f.Clock }).default;
const view = new C(); view.node = {}; let done = 0;
return { ...f, view, calls, retries, utils, run: () => view.run(() => done++), done: () => done,
fail: value => error = value, paidFail: value => paidError = value,
rows: rows => readResponse = { data: { serverTime: f.now(), rows } },
defer: () => new Promise(resolve => { pendingRead = new Promise(r => resolve(r)); }) };
}
test('successful empty read caches, and an unavailable period is fetched before caching', async () => {
const f = runFixture(); f.Check.resetLogin(); await f.run();
assert.deepEqual(f.calls, ['passCheck', 'read']); assert.equal(f.done(), 1);
assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
});
test('network failure is not cached; retry backoff caps at 60 seconds and stops after success', async () => {
const f = runFixture(); f.fail({ code: 0 }); await f.run();
for (let i = 0; i < 5; i++) await f.retries[i].fn();
assert.deepEqual(f.retries.map(r => r.delay), [5, 10, 20, 40, 60, 60]);
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
f.fail(null); await f.retries[5].fn();
assert.equal(f.done(), 1); assert.equal(f.cache.needsCheck(), false); assert.equal(f.retries.length, 6);
});
test('transient paid-order failure retries; 410 is reviewed once per login without deleting the order', async () => {
const f = runFixture(); f.gm.allOutTradeNo = [{ itemid: 'battlepass', outTradeNo: 'paid' }];
f.paidFail({ code: 500 }); await f.run(); assert.deepEqual(f.calls, ['payment']);
assert.equal(f.cache.needsCheck(), true);
f.paidFail({ code: 410 }); await f.retries[0].fn();
assert.equal(f.cache.needsCheck(f.gm.allOutTradeNo), false); assert.equal(f.gm.allOutTradeNo.length, 1);
f.view.destroyed = false; await f.run();
assert.equal(f.calls.filter(v => v === 'payment').length, 2);
});
test('late read after scene destruction and failed popup construction never cache completion', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.view.node.destroyed = true; resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
const g = runFixture(); g.rows([{ end: g.end - model.PASS_PERIOD }]); await g.run();
assert.equal(g.done(), 0); assert.equal(g.cache.needsCheck(), true); assert.equal(g.retries.length, 1);
});
test('a read crossing the deadline triggers one follow-up instead of caching the pre-expiry snapshot', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.advance(60001); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.cache.needsCheck(), true);
});
test('a relogin discards the previous session response without caching or releasing its UI', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.Check.resetLogin(); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.done(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
});
test('payment wrapper invalidates on both success and failure', async () => {
const f = fixture(), source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true);
const method = source.statements.find(ts.isClassDeclaration).members.find(m => m.name?.getText(source) === 'completePassOrder').getText(source);
const context = { cc: { fx: { GameConfig: { GM_INFO: f.gm } } }, BattlePassSettlementCheck: f.Check };
vm.runInNewContext(ts.transpileModule('class Utils {' + method + '} globalThis.Utils=Utils;', { compilerOptions: { target: 99 } }).outputText, context);
for (const failed of [false, true]) {
f.cache.complete(f.now(), f.cache.version);
context.Utils.deliverPassOrder = async () => { if (failed) throw Error('lost response'); return 'ok'; };
if (failed) await assert.rejects(context.Utils.completePassOrder('paid')); else await context.Utils.completePassOrder('paid');
assert.equal(f.cache.needsCheck(), true);
}
});