Files
MatchMaster/tools/test-online-task-buffer.cjs
T
2026-09-09 09:53:59 +08:00

169 lines
8.4 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { stripTypeScriptTypes } = require('node:module');
const root = path.resolve(__dirname, '..');
const baseline = process.env.ONLINE_BUFFER_BASELINE;
const trackerSource = fs.readFileSync(baseline ? path.join(baseline, 'DailyTaskProgressTracker.ts')
: path.join(root, 'assets/Script/module/Task/DailyTaskProgressTracker.ts'), 'utf8');
// Inject server snapshots directly to isolate buffered-time recovery. The actual
// cache, request queue and GameTool integration are tested in test-daily-task-cache.cjs.
const code = stripTypeScriptTypes(trackerSource.replace('export type ', 'type ')
.replace('export default class ', 'class ') + `
const GameTool = {getDailyQuestsInfo(callback) {
Utils.getDailyQuestInfo(response => {
if (response && response.code === 1 && response.data && response.data.task)
DailyTaskProgressTracker.setTaskData(response.data.task);
callback(response);
});
}};
globalThis.Tracker = DailyTaskProgressTracker; globalThis.GameTool = GameTool;`);
function taskData(value = 0, extra = {}) {
return { version: 2, dayKey: '2026-09-08', dailyTasks: [
{ taskId: 'online_5', metric: 'online_seconds', target: 300, value, state: 0 },
], ...extra };
}
function setup() {
let now = 1000, nextId = 1;
const timers = new Map(), reads = [], saves = [];
const info = { otherLevel: 0, tasks: null };
const addTimer = (callback, delay, interval = 0) => {
const id = nextId++;
timers.set(id, { callback, at: now + delay, interval });
return id;
};
const cc = { fx: { GameConfig: { GM_INFO: info }, GameTool: {
setDailyQuestInfo(callback) {
const data = structuredClone(info.tasks);
saves.push({ data, callback(response) {
// Match Utils: a successful save replaces global task data before invoking the callback.
if (response.code === 1) info.tasks = structuredClone(response.data?.task || data);
callback(response);
} });
},
shushu_Track() {},
} } };
const context = { cc, Date: { now: () => now }, Utils: { getDailyQuestInfo: callback => reads.push(callback) },
setInterval: (callback, delay) => addTimer(callback, delay, delay),
clearInterval: id => timers.delete(id),
setTimeout: (callback, delay) => addTimer(callback, delay),
clearTimeout: id => timers.delete(id),
};
vm.runInNewContext(code, context);
function advance(ms) {
const target = now + ms;
while (true) {
const next = [...timers].filter(([, timer]) => timer.at <= target).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
const [id, timer] = next;
now = timer.at;
if (timer.interval) timer.at += timer.interval;
else timers.delete(id);
timer.callback();
}
now = target;
}
function load(data, callback = () => {}) {
context.GameTool.getDailyQuestsInfo(callback);
reads.shift()({ code: 1, data: { task: data } });
}
const value = () => info.tasks.dailyTasks[0].value;
return { tracker: context.Tracker, gameTool: context.GameTool, info, saves, reads, advance, load, value };
}
test('settled time before loading is restored before a read callback renders tasks', () => {
const h = setup(); h.tracker.startOnline(); h.advance(65000);
assert.equal(h.saves.length, 0);
h.load(taskData(10), response => assert.equal(response.data.task.dailyTasks[0].value, 70));
assert.equal(h.value(), 70);
h.tracker.setTaskData(h.info.tasks); // JiaZai calls the setter again with the same response.
assert.equal(h.value(), 70);
h.advance(1000);
assert.equal(h.saves.length, 1); assert.equal(h.saves[0].data.dailyTasks[0].value, 70);
h.saves[0].callback({ code: 1 });
h.advance(24000); assert.equal(h.value(), 100); // Normal next 30-second settlement.
});
test('loading before the first settlement retains the existing 30-second cadence', () => {
const h = setup(); h.tracker.startOnline(); h.advance(10000); h.load(taskData(7));
assert.equal(h.value(), 7); assert.equal(h.saves.length, 0);
h.advance(20000); assert.equal(h.value(), 37);
});
test('hide before loading retains settled seconds and excludes time in the background', () => {
const h = setup(); h.tracker.startOnline(); h.advance(12500); h.tracker.stopOnline();
h.advance(300000); h.load(taskData()); assert.equal(h.value(), 12);
h.tracker.startOnline(); h.advance(15000); h.tracker.stopOnline();
assert.equal(h.value(), 27);
});
test('failed reads and invalid task data do not consume buffered time', () => {
const h = setup(); h.tracker.startOnline(); h.advance(30000);
h.gameTool.getDailyQuestsInfo(() => {}); h.reads.shift()({ code: 0 });
for (const data of [null, {version: 1, dailyTasks: []}, {version: 2, dailyTasks: null}]) h.tracker.setTaskData(data);
h.advance(30000); h.load(taskData()); assert.equal(h.value(), 60);
});
test('a stale second read before save cannot erase recovered progress', () => {
const h = setup(); h.tracker.startOnline(); h.advance(60000);
const server = taskData(5);
h.load(structuredClone(server)); h.load(structuredClone(server));
assert.equal(h.value(), 65);
h.advance(1000); assert.equal(h.saves.length, 1);
assert.equal(h.saves[0].data.dailyTasks[0].value, 65);
});
test('recovery is protected during a pending or failed save, and released after success', () => {
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.load(taskData());
h.advance(1000); h.load(taskData()); assert.equal(h.value(), 60);
h.saves[0].callback({ code: 0 }); h.load(taskData()); assert.equal(h.value(), 60);
h.advance(1000); h.saves[1].callback({ code: 1 });
h.load(taskData(3)); assert.equal(h.value(), 3); // An explicit later reset is authoritative.
});
test('a previous save callback cannot clear a newer recovery batch', () => {
const h = setup(); h.tracker.startOnline(); h.advance(30000); h.load(taskData()); h.advance(1000);
h.info.tasks = null; h.advance(29000); h.load(taskData(30)); assert.equal(h.value(), 60);
h.saves[0].callback({ code: 1 }); assert.equal(h.value(), 60);
h.load(taskData(30)); assert.equal(h.value(), 60);
h.advance(1000); assert.equal(h.saves[1].data.dailyTasks[0].value, 60);
});
test('recovery never crosses into another day or another task', () => {
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.load(taskData());
h.load(taskData(0, {dayKey: '2026-09-09'})); assert.equal(h.value(), 0);
const other = setup(); other.tracker.startOnline(); other.advance(60000); other.load(taskData());
const data = taskData(); data.dailyTasks[0].taskId = 'online_10'; other.load(data); assert.equal(other.value(), 0);
});
test('recovery respects targets and claimed tasks, and only updates online metrics', () => {
const h = setup(); h.tracker.startOnline(); h.advance(90000);
const data = taskData(280);
data.dailyTasks.push({taskId:'claimed',metric:'online_seconds',target:600,value:600,state:2});
data.dailyTasks.push({taskId:'coins',metric:'collect_coin',target:500,value:20,state:0});
h.load(data);
assert.equal(data.dailyTasks[0].value, 300); assert.equal(data.dailyTasks[0].state, 1);
assert.equal(data.dailyTasks[1].value, 600); assert.equal(data.dailyTasks[1].state, 2);
assert.equal(data.dailyTasks[2].value, 20);
const claimed = taskData(300); claimed.dailyTasks[0].state = 2; h.load(claimed);
assert.equal(claimed.dailyTasks[0].state, 2);
});
test('time is not carried forward to a future task when no online task was selected', () => {
const h = setup(); h.tracker.startOnline(); h.advance(60000);
h.load(taskData(0, {dailyTasks: [{taskId:'coins',metric:'collect_coin',target:500,value:0,state:0}]}));
assert.equal(h.saves.length, 0);
h.load(taskData()); assert.equal(h.value(), 0);
});
test('level initialization and temporary login snapshots do not prematurely consume the buffer', () => {
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.tracker.beginLevel();
h.info.tasks = taskData(2); // Raw login snapshot, before the task refresh API returns.
h.load(taskData()); assert.equal(h.value(), 60);
});