140 lines
8.9 KiB
JavaScript
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);
|
|
}
|
|
});
|