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