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