321 lines
13 KiB
TypeScript
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);
|
|
});
|