MatchMaster/tools/test-activity-schedule.cjs
2026-09-24 19:33:11 +08:00

248 lines
16 KiB
JavaScript

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