const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); function load(file, cc, imports, now, target = ts.ScriptTarget.ES2017) { const exports = {}; const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {compilerOptions: { module: ts.ModuleKind.CommonJS, target, experimentalDecorators: true }}).outputText; vm.runInNewContext(code, {exports, cc, Date: {now}, require: name => { assert(name in imports, name); return imports[name]; }}); return exports.default; } const period = (id = 'cloudRise', start = 11000, end = 20000) => ({ activityId: id, periodId: id + ':p1', configVersion: 'v1', startsAt: start, endsAt: end, unlockLevel: 100, phase: 'upcoming' }); const response = (activities = [period()], serverNow = 10000, refreshAt = serverNow + 30000) => ({code: 1, data: { schemaVersion: 1, serverNow, refreshAt, windowEndsAt: serverNow + 604800000, activities }}); function fixture(storage = new Map()) { let now = 10000, environment = 'test/', storageFails = false; const requests = [], events = new Map(); class Component { scheduled = new Set(); schedule(fn) {this.scheduled.add(fn);} unschedule(fn) {this.scheduled.delete(fn);} } const cc = {_decorator: {ccclass: c => c}, Component, game: { EVENT_SHOW: 'show', EVENT_HIDE: 'hide', on(name, fn, target) {events.set(name, {fn, target});}, off(name) {events.delete(name);} }, fx: {GameConfig: {GM_INFO: {userId: 1001, token: 'token-a'}}, StorageMessage: { getStorage(key) {if (storageFails) throw Error('storage'); return storage.get(key);}, setStorage(key, data) {if (storageFails) throw Error('storage'); storage.set(key, structuredClone(data));}, removeStorage(key) {if (storageFails) throw Error('storage'); storage.delete(key);} }}}; const service = load('assets/Script/module/Config/ActivityScheduleService.ts', cc, {'../Pay/Utils': {default: { apiBaseUrl: () => environment, POST(path, body, resolve) {requests.push({path, body, resolve});} }}}, () => now); return {service, cc, storage, requests, events, setNow: n => now = n, setEnv: e => environment = e, failStorage: () => storageFails = true, loadHome: () => load('assets/Script/HomeActivitySchedule.ts', cc, {'./module/Config/ActivityScheduleService': {default: service}}, () => now), emit: name => {const event = events.get(name); event.fn.call(event.target);}}; } const flush = async () => {for (let i = 0; i < 12; i++) await Promise.resolve();}; test('cache environment resolver exactly matches POST routing in development, trial and production', () => { let version, opened; const cc = {fx: {GameTool: {getWechatGameVersion: () => version}, GameConfig: {GM_INFO: {}}}, loader: { getXMLHttpRequest: () => ({open(_method, url) {opened = url;}, setRequestHeader() {}, send() {}}) }}; const Utils = load('assets/Script/module/Pay/Utils.ts', cc, { '../../Sdk/MiniGameSdk': {MiniGameSdk: {}}, '../Config/BattlePassModel': {} }, () => 10000, ts.ScriptTarget.ES5); // Match the project's target for existing Utils identifiers. for (version of ['开发版', '体验版', '正式版', 'web']) { const expected = ['开发版', '体验版'].includes(version) ? Utils.testHttpip : Utils.httpip; assert.equal(Utils.apiBaseUrl(), expected); Utils.POST('activityConfig/list', {}, () => {}); assert.equal(opened, expected + 'activityConfig/list'); } }); test('login and home share an in-flight request; all activity ids are cached, no private state', async () => { const f = fixture(), a = f.service.sync(true), b = f.service.sync(true); assert.equal(f.requests.length, 1); assert.equal(f.requests[0].path, 'activityConfig/list'); assert.deepEqual(JSON.parse(JSON.stringify(f.requests[0].body)), {uid: '1001'}); f.requests[0].resolve(response([period(), period('goldMiner'), period('futureGame')])); assert.equal(await a, true); assert.equal(await b, true); assert.equal(f.storage.size, 1); assert.equal(f.service.periods('futureGame').length, 1); assert.equal(f.service.active('cloudRise'), null); }); test('fresh upcoming schedule starts and ends locally between requests, ignoring stale phase', async () => { const f = fixture(), seen = []; const task = f.service.sync();f.requests[0].resolve(response());await task; const off = f.service.subscribe(() => seen.push(!!f.service.active('cloudRise'))); f.service.tick(); f.setNow(10999); f.service.tick(); assert.equal(f.service.active('cloudRise'), null); f.setNow(11000); f.service.tick(); assert.equal(f.service.active('cloudRise').periodId, 'cloudRise:p1'); f.setNow(20000); f.service.tick(); assert.equal(f.service.active('cloudRise'), null); assert.deepEqual(seen, [true, false]); assert.equal(f.requests.length, 1); off(); f.setNow(10000); f.service.tick(); assert.equal(seen.length, 2); }); test('server clock, current period selection and unknown activities are independent of device clock', async () => { const f = fixture(); f.setNow(900000); const task = f.service.sync(); const current = period('cloudRise', 10000, 20000), future = {...period('cloudRise', 18000, 28000), periodId: 'next'}; f.requests[0].resolve(response([future, current, {...period('goldMiner', 10000, 20000), purchaseEnabled: false}], 15000)); await task; assert.equal(f.service.now(), 15000); assert.equal(f.service.active('cloudRise').periodId, current.periodId); assert.equal(f.service.active('goldMiner').purchaseEnabled, false); f.setNow(903000); assert.equal(f.service.active('cloudRise').periodId, 'next'); }); test('refresh discovers an event published after login; polls at most every 30 seconds', async () => { const f = fixture(); const first = f.service.sync(); f.requests[0].resolve(response([])); await first; f.setNow(39999); f.service.tick(); assert.equal(f.requests.length, 1); f.setNow(40000); f.service.tick(); assert.equal(f.requests.length, 2); f.requests[1].resolve(response([period('cloudRise', 35000, 90000)], 40000)); await flush(); assert(f.service.active('cloudRise')); f.setNow(69999); f.service.tick(); assert.equal(f.requests.length, 2); }); test('refreshAt brings polling forward to a known boundary', async () => { const f = fixture(); const task = f.service.sync(); f.requests[0].resolve(response([period()], 10000, 11000)); await task; f.setNow(11000); f.service.tick(); assert.equal(f.requests.length, 2); assert.equal(f.service.active('cloudRise'), null); f.requests[1].resolve({code: 0}); await flush(); f.setNow(12000); f.service.tick(); assert.equal(f.requests.length, 2, 'failed boundary fetch backs off'); }); test('each actual refresh clears memory and disk first; failures never restore old schedules', async () => { const f = fixture();f.storage.set('cloudRise:1001',{run:'personal'});f.storage.set('activitySchedule:v2:prod/:1001',{otherEnvironment:true}); for (const res of [{code: 0}, {code: 1, data: null}, response([{...period(), endsAt: 'bad'}])]) { const fresh = f.service.sync(true);f.requests.at(-1).resolve(response([period('cloudRise',9000)]));await fresh; assert(f.service.active('cloudRise'));assert(f.storage.has('activitySchedule:v2:test/:1001')); const task = f.service.sync(true); assert.equal(f.service.hasSchedule(),false);assert.equal(f.storage.has('activitySchedule:v2:test/:1001'),false); f.requests.at(-1).resolve(res); assert.equal(await task, false); assert.equal(f.service.active('cloudRise'),null);assert.equal(f.storage.has('activitySchedule:v2:test/:1001'),false); } assert.deepEqual(f.storage.get('cloudRise:1001'),{run:'personal'}); assert.deepEqual(f.storage.get('activitySchedule:v2:prod/:1001'),{otherEnvironment:true}); const clear = f.service.sync(true); f.requests.at(-1).resolve(response([])); await clear; assert.equal(f.service.hasSchedule(), true); assert.equal(f.service.active('cloudRise'), null); }); test('cold-cache invalidation notifies observers and reentrant refreshes share the pending request',async()=>{ const f=fixture(new Map([['activitySchedule:v2:test/:1001',{receivedAt:10000,data:response([period('cloudRise',9000)]).data}]])); assert.equal(f.service.active('cloudRise'),null,'new login does not restore a previous personal period'); let invalidated=0; f.service.subscribe(()=>{if(!f.service.hasSchedule()){invalidated++;void f.service.sync(true);}}); const task=f.service.sync(true);assert.equal(invalidated,1);assert.equal(f.requests.length,1); assert.equal(f.storage.has('activitySchedule:v2:test/:1001'),false); f.requests[0].resolve(response([period('goldMiner',9000)]));await task; assert.equal(f.service.active('cloudRise'),null);assert(f.service.active('goldMiner')); f.service.tick();assert(f.service.active('goldMiner'),'throttled ticks do not clear a fresh schedule'); assert.equal(f.requests.length,1); }); test('expired cache window is not authoritative and a corrupt cache/storage failure does not block fetching', async () => { const f = fixture(); f.failStorage(); const task = f.service.sync(); f.requests[0].resolve(response([period('cloudRise', 9000, 999999999)])); assert.equal(await task, true); assert(f.service.active('cloudRise')); f.setNow(604810000); assert.equal(f.service.hasSchedule(), false); assert.equal(f.service.active('cloudRise'), null); }); test('test/prod caches stay isolated, including responses that finish after an environment switch', async () => { const f = fixture(), old = f.service.sync(); f.setEnv('prod/'); assert.equal(f.service.hasSchedule(), false); const current = f.service.sync(); f.requests[0].resolve(response()); assert.equal(await old, false); assert.equal(f.storage.size, 0); f.requests[1].resolve(response([period('goldMiner', 9000)])); assert.equal(await current, true); assert(f.storage.has('activitySchedule:v2:prod/:1001')); assert.equal(f.service.active('cloudRise'), null); f.setEnv('test/'); assert.equal(f.service.hasSchedule(), false); }); test('home lifecycle pauses polling in background, refreshes on foreground and removes callbacks on exit', async () => { const f = fixture(), Home = f.loadHome(), home = new Home(); home.onEnable(); assert.equal(home.scheduled.size, 1); assert.equal(f.requests.length, 1); f.requests[0].resolve(response()); await flush(); f.emit('hide'); assert.equal(home.scheduled.size, 0); f.setNow(15000); f.emit('show'); assert.equal(home.scheduled.size, 1); assert.equal(f.requests.length, 2); assert.equal(f.service.active('cloudRise'),null,'foreground request invalidates old cache immediately'); f.requests[1].resolve(response([period()], 15000)); await flush(); assert(f.service.active('cloudRise')); home.onDisable(); assert.equal(home.scheduled.size, 0); assert.equal(f.events.size, 0); home.onEnable(); assert.equal(home.scheduled.size, 1); home.onDisable(); }); test('personal activity cache and in-flight responses are isolated by account and login token',async()=>{ const f=fixture(),first=f.service.sync(); f.cc.fx.GameConfig.GM_INFO={userId:2002,token:'token-b'}; const second=f.service.sync();assert.equal(f.requests[1].body.uid,'2002'); f.requests[0].resolve(response([period('cloudRise',9000)]));assert.equal(await first,false); assert.equal(f.service.active('cloudRise'),null); f.requests[1].resolve(response([period('cloudRise',9500)]));assert.equal(await second,true); assert(f.storage.has('activitySchedule:v2:test/:2002'));assert(!f.storage.has('activitySchedule:v2:test/:1001')); const old=f.service.sync(true);f.cc.fx.GameConfig.GM_INFO.token='new-login'; const fresh=f.service.sync(true);assert.equal(f.requests.length,4); f.requests[2].resolve(response([period('cloudRise',9000)]));assert.equal(await old,false); f.requests[3].resolve(response([]));assert.equal(await fresh,true);assert.equal(f.service.active('cloudRise'),null); }); test('activity list waits for a logged-in account and token',async()=>{ const f=fixture();f.cc.fx.GameConfig.GM_INFO={};assert.equal(await f.service.sync(),false);assert.equal(f.requests.length,0); });