This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user