Files
MatchMaster/tools/test-activity-schedule.cjs
T
2026-10-16 15:02:14 +08:00

366 lines
24 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(), target = ts.ScriptTarget.ES2017) {
let now = 10000, environment = 'test/', storageFails = false;
const requests = [], events = new Map(); let resourceCycles = 0;
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) { if (!events.has(name)) events.set(name, []); events.get(name).push({ fn, target }); },
off(name, fn, target) { const kept = (events.get(name) || []).filter(e => e.fn !== fn || e.target !== target); if (kept.length) events.set(name, kept); else 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, {
'./ActivityBundleLoader': { default: { beginCycle() { resourceCycles++; } } }, '../Pay/Utils': {
default: {
apiBaseUrl: () => environment, POST(path, body, resolve) { requests.push({ path, body, resolve }); }
}
}
}, () => now, target);
return {
service, cc, storage, requests, events, get resourceCycles() { return resourceCycles; }, 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 => { for (const event of (events.get(name) || []).slice()) event.fn.call(event.target); }
};
}
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
test('ES5 static lifecycle callbacks work when invoked without a class receiver', async () => {
const f = fixture(new Map(), ts.ScriptTarget.ES5);
const login = f.service.afterLogin(); f.requests[0].resolve(response([])); await login;
const show = f.service.onForeground, hide = f.service.onBackground;
hide(); const first = show(), concurrent = show();
assert.equal(first, concurrent); assert.equal(f.requests.length, 2);
f.requests[1].resolve(response([])); await first;
hide(); const next = show(); assert.equal(f.requests.length, 3);
f.requests[2].resolve(response([])); await next;
assert.equal(f.resourceCycles, 3);
});
test('first Home reuses login in flight or completed; later Home and foreground refresh once with a shared resource cycle', async () => {
for (const completed of [false, true]) {
const f = fixture(), login = f.service.afterLogin();
assert.equal(f.requests.length, 1); assert.equal(f.resourceCycles, 1);
if (completed) { f.requests[0].resolve(response([period('pyramid', 9000, 90000)])); await login; }
const Home = f.loadHome(), home = new Home(); home.onEnable(); assert.equal(f.requests.length, 1);
if (!completed) f.requests[0].resolve(response([period('pyramid', 9000, 90000)]));
await login; await flush(); assert.equal(f.requests.length, 1); assert.equal(f.resourceCycles, 1);
f.emit('hide'); f.emit('show'); assert.equal(f.requests.length, 2); assert.equal(f.resourceCycles, 2);
f.requests[1].resolve(response([period('pyramid', 9000, 90000)])); await flush();
for (let n=0;n<100;n++) for (const tick of home.scheduled) tick();
await f.service.sync(false); assert.equal(f.requests.length, 2);
home.onDisable(); home.onEnable(); assert.equal(f.requests.length, 3); assert.equal(f.resourceCycles, 3);
f.requests[2].resolve(response([])); await flush(); home.onDisable();
f.emit('hide'); f.emit('show'); assert.equal(f.requests.length, 4, 'foreground also refreshes outside Home');
f.requests[3].resolve(response([])); await flush();
}
});
test('a new account cannot reuse a previous login result on its first Home', async () => {
const f=fixture(), first=f.service.afterLogin();f.requests[0].resolve(response());await first;await f.service.enterHome();
f.cc.fx.GameConfig.GM_INFO={userId:2002,token:'b'};const login=f.service.afterLogin(), home=f.service.enterHome();
assert.equal(f.requests.length,2);assert.equal(f.requests[1].body.uid,'2002');
f.requests[1].resolve(response([]));assert.equal(await login,true);assert.equal(await home,true);assert.equal(f.requests.length,2);
});
test('Pyramid daily popup matches GoldMiner daily scope and only records the current eligible period', async () => {
const f = fixture(); Object.assign(f.cc.fx.GameConfig.GM_INFO, { uid: 'player-a', level: 50 });
const start = 16 * 3600000 - 1000, end = start + 3 * 86400000;
let current = { ...period('pyramid', start, end), unlockLevel: 50 };
async function sync() { const task = f.service.sync(true); f.requests.at(-1).resolve(response([current], start)); await task; }
await sync(); const reminder = f.service.pyramidReminder(); assert(reminder);
f.service.markPyramidReminderShown(reminder); assert.equal(f.service.pyramidReminder(), null);
await sync(); assert.equal(f.service.pyramidReminder(), null);
f.setNow(11000); const tomorrow = f.service.pyramidReminder(); assert.equal(tomorrow.day, reminder.day + 1);
f.service.markPyramidReminderShown(reminder); assert(f.service.pyramidReminder());
current = { ...current, periodId: 'pyramid:new' }; await sync();
f.setNow(12000); f.service.markPyramidReminderShown(tomorrow); assert(f.service.pyramidReminder());
f.cc.fx.GameConfig.GM_INFO.level = 49; assert.equal(f.service.pyramidReminder(), null);
f.cc.fx.GameConfig.GM_INFO.level = 50;
f.cc.fx.GameConfig.GM_INFO.uid = 'player-b'; await sync(); assert.notEqual(f.service.pyramidReminder().key, reminder.key);
f.setEnv('production/'); await sync(); assert.notEqual(f.service.pyramidReminder().key, reminder.key);
f.setNow(12000 + end - start); assert.equal(f.service.pyramidReminder(), null);
});
test('CloudRise daily reminder uses server Beijing date and persists separately per account/environment', async () => {
const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100;
const serverNow = 16 * 3600000 - 1000;
async function sync() { const task = f.service.sync(true); f.requests.at(-1).resolve(response([period('cloudRise', 0, 3 * 86400000)], serverNow)); await task; }
assert.equal(f.service.cloudRiseReminder(), null); await sync();
const first = f.service.cloudRiseReminder(); assert.equal(first.day, 0);
f.service.markCloudRiseReminderShown(first); assert.equal(f.service.cloudRiseReminder(), null);
await sync(); assert.equal(f.service.cloudRiseReminder(), null, 'refresh does not repeat today');
const restarted = fixture(f.storage); restarted.cc.fx.GameConfig.GM_INFO.level = 100;
const task = restarted.service.sync(); restarted.requests[0].resolve(response([period('cloudRise', 0, 3 * 86400000)], serverNow)); await task;
assert.equal(restarted.service.cloudRiseReminder(), null, 'relogin keeps daily marker');
f.setNow(11000); assert.equal(f.service.cloudRiseReminder().day, 1, 'server midnight opens the next day');
f.cc.fx.GameConfig.GM_INFO.level = 99; assert.equal(f.service.cloudRiseReminder(), null);
f.cc.fx.GameConfig.GM_INFO.level = 100; f.cc.fx.GameConfig.GM_INFO.userId = 1002; await sync();
assert.notEqual(f.service.cloudRiseReminder().key, first.key);
f.service.markCloudRiseReminderShown(first); assert(f.service.cloudRiseReminder(), 'stale account cannot mark current account');
f.cc.fx.GameConfig.GM_INFO.userId = 1001; f.setEnv('production/'); await sync(); assert.notEqual(f.service.cloudRiseReminder().key, first.key);
f.setNow(4 * 86400000); assert.equal(f.service.cloudRiseReminder(), null, 'expired activity is not promoted');
});
test('pyramid schedule works without private detail fields and is account-scoped', async () => {
const f = fixture(), query = f.service.sync();
const reply = response([period('cloudRise', 9000), period('pyramid', 9000)]);
f.requests[0].resolve(reply); await query;
assert.ok(f.service.active('pyramid')); assert.ok(f.service.active('cloudRise'));
assert.equal(f.requests[0].body.pyramidPeriodId, undefined);
await f.service.sync(); assert.equal(f.requests.length, 1);
f.cc.fx.GameConfig.GM_INFO.uid = 'another-database-id';
assert.equal(f.service.active('pyramid'), null);
});
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': {},
'../Config/BattlePassSettlementModel': {}, '../Config/BattlePassSettlementCheck': { default: {} },
'../Config/ClientUpdate': { default: { blocked: false, handleResponse: value => value } },
'../Config/ServerEnvironment': { default: { current: () => ['开发版', '体验版'].includes(version) ? 'test' : 'production' } }
}, () => 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('local ticks never poll; explicit refresh discovers an event published after login', 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, 1);
assert.equal(await f.service.sync(false), true); assert.equal(f.requests.length, 1);
const refresh = f.service.sync(true);
f.requests[1].resolve(response([period('cloudRise', 35000, 90000)], 40000)); await refresh;
assert(f.service.active('cloudRise'));
f.setNow(69999); f.service.tick(); assert.equal(f.requests.length, 2);
});
test('refreshAt does not trigger requests; pyramid starts and ends using calibrated server time', async () => {
const f = fixture(); f.setNow(900000);
const task = f.service.sync(); f.requests[0].resolve(response([period('pyramid')], 10000, 11000)); await task;
f.setNow(901000); f.service.tick(); assert(f.service.active('pyramid'));
f.setNow(909999); f.service.tick(); assert(f.service.active('pyramid'));
f.setNow(910000); f.service.tick(); assert.equal(f.service.active('pyramid'), null);
f.setNow(999999999); f.service.tick(); assert.equal(f.requests.length, 1);
});
test('refresh and transient failures preserve valid entries; a successful empty list removes them', 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(), true); assert.equal(f.storage.has('activitySchedule:v2:test/:1001'), true);
f.requests.at(-1).resolve(res); assert.equal(await task, false);
assert(f.service.active('cloudRise')); assert.equal(f.storage.has('activitySchedule:v2:test/:1001'), true);
}
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 start ignores disk snapshots 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.service.hasSchedule(), 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 only checks time locally, 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([period('cloudRise', 11000, 200000)])); await flush();
f.setNow(50000); for (const tick of home.scheduled) tick();
assert.equal(f.requests.length, 1); assert(f.service.active('cloudRise'));
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(f.service.active('cloudRise'), 'foreground request preserves the valid entry');
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);
});