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[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 | 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(`input[name="${name}"]`)!; input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); }; const select = (name: string, value: string) => { const node = document.querySelector(`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('#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('.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('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((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); });