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}, './HomeActivityEntryLayout': {default: {refresh(){}}}}, () => 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'); } }); const openingResponse=(overrides={},now=10000)=>{ const res=response([],now);res.data.openingRules=[{activityId:'cloudRise',enabled:true,unlockLevel:100, cooldownMs:43200000,lastEndedAt:null,hasOpenPeriod:false,...overrides}];return res; }; const openSuccess=(now=10000)=>({code:1,data:{serverNow:now,period:period('cloudRise',now,now+86400000)}}); test('Home checks opening at the unlock boundary and shows the entry only after a successful response',async()=>{ const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100; const first=f.service.checkCloudRiseOpening(()=>true),duplicate=f.service.checkCloudRiseOpening(()=>true); f.requests[0].resolve(openingResponse());await flush();assert.equal(f.requests.length,2); assert.equal(f.requests[1].path,'cloudRise/index');assert.equal(f.requests[1].body.action,'open_period'); assert.equal(f.requests[1].body.levelAmount,100);assert.equal(f.service.active('cloudRise'),null); f.requests[1].resolve(openSuccess());assert.equal(await first,true);assert.equal(await duplicate,true); assert.equal(f.service.active('cloudRise').startsAt,10000);assert.equal(f.requests.length,2); }); test('Home rejects locked, disabled, already opened and not-yet-cooled-down states',async()=>{ for(const [level,rule] of [[99,{}],[100,{enabled:false}],[100,{hasOpenPeriod:true}], [100,{lastEndedAt:10000-43200000}],[100,{lastEndedAt:9000}]]){ const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=level; const check=f.service.checkCloudRiseOpening(()=>true);f.requests[0].resolve(openingResponse(rule)); assert.equal(await check,false);assert.equal(f.requests.length,1); } const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100; const check=f.service.checkCloudRiseOpening(()=>true); f.requests[0].resolve(openingResponse({lastEndedAt:10000-43200000-1}));await flush(); assert.equal(f.requests.length,2);f.requests[1].resolve(openSuccess());assert.equal(await check,true); }); test('polling alone never opens periods; leaving Home during its list request cancels the opening decision',async()=>{ const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100; const query=f.service.sync();f.requests[0].resolve(openingResponse());await query; assert.equal(f.requests.length,1); let home=true;const check=f.service.checkCloudRiseOpening(()=>home);home=false; f.requests[1].resolve(openingResponse());assert.equal(await check,false);assert.equal(f.requests.length,2); }); test('opening failures never fabricate an entry and a later Home visit can retry',async()=>{ const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100; for(const reply of [null,{code:0},openSuccess()]){ const check=f.service.checkCloudRiseOpening(()=>true);f.requests.at(-1).resolve(openingResponse());await flush(); f.requests.at(-1).resolve(reply);assert.equal(await check,!!reply&&reply.code===1); assert.equal(!!f.service.active('cloudRise'),!!reply&&reply.code===1); } }); test('opening responses are isolated by account and cannot be erased by an older list',async()=>{ const f=fixture();f.cc.fx.GameConfig.GM_INFO.level=100; const check=f.service.checkCloudRiseOpening(()=>true);f.requests[0].resolve(openingResponse());await flush(); const stale=f.service.sync(true);f.requests[1].resolve(openSuccess());await flush(); f.requests[2].resolve(openingResponse());await stale;assert.equal(await check,true); assert.ok(f.service.active('cloudRise')); const second=f.service.checkCloudRiseOpening(()=>true);f.requests[3].resolve(openingResponse());await flush(); f.cc.fx.GameConfig.GM_INFO={userId:2002,token:'token-b',level:100}; f.requests[4].resolve(openSuccess());assert.equal(await second,false);assert.equal(f.service.active('cloudRise'),null); }); 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); });