Files
splatoon2.ink/test/web/data.test.js
2026-09-07 17:52:50 -07:00

208 lines
5.4 KiB
JavaScript

import { afterEach, beforeEach, test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { setImmediate } from 'node:timers/promises';
import { actions } from '../../src/web/store/splatoon/data.js';
let timers;
let commits;
let page;
let originalDocument;
const context = {
rootGetters: { 'splatoon/languages/selectedLanguage': null },
dispatch(name) {
return actions[name](context);
},
commit(name, value) {
commits.push({ name, value });
},
};
beforeEach(() => {
originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
page = new EventTarget();
page.visibilityState = 'visible';
Object.defineProperty(globalThis, 'document', { configurable: true, value: page });
timers = new Map();
commits = [];
let id = 0;
mock.method(globalThis, 'setTimeout', (callback, ms) => {
timers.set(++id, { callback, ms });
return id;
});
mock.method(globalThis, 'clearTimeout', id => timers.delete(id));
});
afterEach(() => {
actions.stopUpdatingData();
mock.restoreAll();
if (originalDocument)
Object.defineProperty(globalThis, 'document', originalDocument);
else
delete globalThis.document;
});
test('a stalled body times out and leaves a future refresh scheduled', async () => {
mock.method(
globalThis,
'fetch',
async (url, { signal }) =>
new Response(
new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => controller.error(new Error('aborted')), {
once: true,
});
},
}),
),
);
let refreshing = actions.startUpdatingData(context);
assert.equal(timers.size, 5);
for (let timer of [...timers.values()]) {
assert.equal(timer.ms, 30_000);
timer.callback();
}
await refreshing;
assert.equal(commits.length, 0);
assert.equal(timers.size, 1);
assert.ok([...timers.values()][0].ms > 0);
});
test('a failed fetch retains old data and does not stop the refresh loop', async () => {
mock.method(globalThis, 'fetch', async () => new Response('down', { status: 503 }));
await actions.startUpdatingData(context);
assert.equal(commits.length, 0);
assert.equal(timers.size, 1);
actions.stopUpdatingData();
assert.equal(timers.size, 0);
});
test('stop and restart while an old refresh finishes creates only one timer', async () => {
let release;
let pending = new Promise(resolve => {
release = resolve;
});
mock.method(globalThis, 'fetch', async () => {
await pending;
return Response.json({ current: true });
});
let first = actions.startUpdatingData(context);
actions.stopUpdatingData();
let second = actions.startUpdatingData(context);
release();
await Promise.all([first, second]);
assert.equal(timers.size, 1);
await actions.startUpdatingData(context); // Already running: no extra fetch or timer
assert.equal(timers.size, 1);
});
function visibility(state) {
page.visibilityState = state;
page.dispatchEvent(new Event('visibilitychange'));
}
test('returning to an old tab refreshes immediately, with a cooldown and one timer', async () => {
let now = Date.parse('2026-09-07T10:10:00Z');
mock.method(Date, 'now', () => now);
const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true }));
await actions.startUpdatingData(context);
assert.equal(fetching.mock.callCount(), 5);
now += 60_000;
visibility('hidden');
await setImmediate();
assert.equal(fetching.mock.callCount(), 5);
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 1);
visibility('hidden');
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
actions.stopUpdatingData();
now += 60_000;
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 0);
});
test('activation does not overlap an in-flight refresh or leave a timer after stopping', async () => {
let now = Date.now();
mock.method(Date, 'now', () => now);
let release;
let pending = new Promise(resolve => {
release = resolve;
});
const fetching = mock.method(globalThis, 'fetch', async () => {
await pending;
return Response.json({ current: true });
});
let refreshing = actions.startUpdatingData(context);
now += 60_000;
visibility('visible');
assert.equal(fetching.mock.callCount(), 5);
actions.stopUpdatingData();
release();
await refreshing;
assert.equal(timers.size, 0);
});
test('activation cooldown does not suppress the scheduled top-of-hour refresh', async () => {
let now = Date.parse('2026-09-07T10:59:50Z');
mock.method(Date, 'now', () => now);
const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true }));
await actions.startUpdatingData(context);
const [id, scheduled] = [...timers.entries()][0];
timers.delete(id); // A real timeout is removed before its callback runs.
now += 40_000;
await scheduled.callback();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 1);
});