Files
2026-10-17 17:52:03 -04:00

321 lines
13 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import AfkPage from '../../src/routes/afk/+page.svelte';
import { readAfkQuery, type AfkOverview } from '../../src/lib/afk.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({ url: new URL('http://localhost/afk'), data: { user: null } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
},
navigating: writable(null)
}));
vi.mock('$app/navigation', () => ({
goto: navigation.goto,
beforeNavigate: (fn: () => void) => navigation.before.push(fn),
afterNavigate: vi.fn()
}));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.goto.mockReset().mockResolvedValue(undefined);
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const guid = '18446744073709551615';
const server = 't5_zombie_theater_1';
const checkedAt = '2026-10-17T12:00:00.000Z';
function report(search = ''): AfkOverview {
const query = readAfkQuery(new URLSearchParams(search));
return {
query,
checkedAt,
capacitySeconds: 21600,
summary: { accounts: 1, savedSeconds: 7200, reservedSeconds: 600, unresolvedSessions: 1 },
servers: [server],
accounts: {
entries: [
{
guid,
name: 'Dempsey',
freeSeconds: 1200,
savedSeconds: 7200,
availableSeconds: 8400,
reservedSeconds: 600,
reservedSavedSeconds: 300,
unresolvedSessions: 1,
lastActivity: checkedAt,
freeRenewsAt: null
}
],
total: 1,
offset: query.offset
},
sessions: {
entries: [
{
guid,
name: 'Dempsey',
sessionId: 'crash-session',
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
state: 'corrected',
authorizedSeconds: 600,
consumedSeconds: null,
chargedSeconds: 45,
confirmedSeconds: 40,
endReason: 'crash',
createdAt: '2026-10-17T11:00:00.000Z',
startedAt: '2026-10-17T11:00:00.000Z',
endedAt: '2026-10-17T11:00:45.000Z'
}
],
total: 1,
offset: query.offset
},
ledger: {
entries: [
{
guid,
name: 'Dempsey',
id: 1,
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
kind: 'purchase',
freeDelta: 0,
savedDelta: 600,
freeSeconds: 1200,
savedSeconds: 7200,
operationId: 'purchase-1',
sessionId: null,
eventAt: checkedAt,
createdAt: checkedAt
}
],
total: 1,
offset: query.offset
}
};
}
function show(initial = report()) {
const data = writable({ report: initial, user: null, setupRequired: false });
const state = fromStore(data);
const updatePage = (next: AfkOverview) =>
currentPage.set({
url: new URL(
`http://localhost/afk?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
updatePage(initial);
component = mount(AfkPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: AfkOverview) => {
updatePage(next);
data.set({ report: next, user: null, setupRequired: false });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
const enter = (name: string, value: string) => {
const input = document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
};
const select = (name: string, value: string) => {
const node = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`)!;
// happy-dom's :checked selector only matches inputs, not selected options.
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
};
const submit = () =>
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('links the exact account identity to its profile, sessions and ledger', () => {
show(report('q=Dempsey&sort=recent&offset=25'));
const panel = document.querySelector('#afk-results')!;
const links = [...panel.querySelectorAll('a')];
const profile = links.find((link) => link.textContent === 'Dempsey')!;
const profileUrl = new URL(profile.getAttribute('href')!, 'http://localhost');
expect(profileUrl.pathname).toBe(`/leaderboard/players/${guid}`);
expect(profileUrl.searchParams.get('returnTo')).toContain('q=Dempsey');
expect(profileUrl.searchParams.get('returnTo')).toContain('offset=25');
for (const [label, view] of [
['Sessions', 'sessions'],
['Time ledger', 'ledger']
]) {
const history = links.find((link) => link.textContent === label)!;
const historyUrl = new URL(history.getAttribute('href')!, 'http://localhost');
expect(historyUrl.pathname).toBe('/afk');
expect(historyUrl.searchParams.get('view')).toBe(view);
expect(historyUrl.searchParams.get('player')).toBe(guid);
expect(historyUrl.searchParams.has('q')).toBe(false);
expect(historyUrl.searchParams.has('offset')).toBe(false);
}
// Held saved time still occupies account capacity.
expect(panel.querySelector('meter')!.getAttribute('value')).toBe('7500');
expect(panel.textContent).toContain('Unresolved session');
});
it('applies session filters without losing an exact player and resets pagination', () => {
show(report(`view=sessions&offset=25&player=${guid}`));
enter('q', 'Nikolai');
select('game', 't6');
select('state', 'unresolved');
enter('from', '2026-10-01');
enter('to', '2026-10-17');
submit();
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
expect(lastNavigation().searchParams.get('game')).toBe('t6');
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('from')).toBe('2026-10-01');
expect(lastNavigation().searchParams.get('to')).toBe('2026-10-17');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
button('Reset').click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
for (const key of ['q', 'game', 'state', 'from', 'to', 'player', 'offset'])
expect(lastNavigation().searchParams.has(key)).toBe(false);
});
it('keeps the exact player across tabs and pagination', () => {
const update = show(report(`q=Dempsey&player=${guid}&offset=25`));
document.querySelector<HTMLButtonElement>('#afk-tab-sessions')!.click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
const next = report(`view=sessions&player=${guid}&state=unresolved&game=t5`);
next.sessions.total = 30;
update(next);
button('Next').click();
expect(lastNavigation().searchParams.get('offset')).toBe('25');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('game')).toBe('t5');
});
it('opens unresolved sessions across the fleet instead of retaining list filters', () => {
show(report(`view=ledger&player=${guid}&q=Dempsey&game=t5&kind=purchase&offset=25`));
document.querySelector<HTMLButtonElement>('.summary button')!.click();
const destination = lastNavigation();
expect(destination.searchParams.get('view')).toBe('sessions');
expect(destination.searchParams.get('state')).toBe('unresolved');
for (const key of ['player', 'q', 'game', 'kind', 'offset'])
expect(destination.searchParams.has(key)).toBe(false);
});
it('keeps corrected charges distinct from confirmed time and avoids expired match links', () => {
const update = show(report('view=sessions'));
const panel = document.querySelector('#afk-results')!;
expect(panel.textContent).toContain('Unconfirmed duration');
expect(panel.textContent).toContain('Corrected charge: 45s');
expect(panel.textContent).toContain('Confirmed at least 40s');
expect(panel.textContent).not.toContain('45s used');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
update(report('view=ledger'));
expect(panel.textContent).toContain('purchase-1');
expect(panel.textContent).toContain('+10m 00s');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
});
it('retains the last snapshot and draft filters after a refresh failure and retries', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
vi.stubGlobal('fetch', fetch);
show();
enter('q', 'draft');
const before = document.querySelector('.summary')!.textContent;
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('AFK updates paused.'));
expect(document.querySelector('.summary')!.textContent).toBe(before);
button('Retry').click();
await vi.waitFor(() => expect(document.body.textContent).not.toContain('AFK updates paused.'));
expect(document.querySelector<HTMLInputElement>('input[name="q"]')!.value).toBe('draft');
expect(fetch.mock.calls[0][0]).toContain('/api/afk?');
});
it('discards an old refresh after navigation changes the AFK view', async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((done) => {
resolve = done;
})
)
);
const update = show();
button('Refresh').click();
navigation.before.forEach((fn) => fn());
const next = report('view=ledger');
next.summary.accounts = 42;
update(next);
resolve(new Response(JSON.stringify(report())));
await tick();
await tick();
expect(document.querySelector('.summary')?.textContent).toContain('42');
expect(document.querySelector('#afk-tab-ledger')?.getAttribute('aria-selected')).toBe('true');
});
it('polls after 15 seconds, pauses while hidden, and refreshes when visible again', async () => {
vi.useFakeTimers();
let visibility: DocumentVisibilityState = 'visible';
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility);
const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(report()))));
vi.stubGlobal('fetch', fetch);
show();
await vi.advanceTimersByTimeAsync(14_999);
expect(fetch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await tick();
expect(fetch).toHaveBeenCalledTimes(2);
});