import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { fromStore, writable } from 'svelte/store'; import BankPage from '../../src/routes/bank/+page.svelte'; import { readBankQuery, type BankOverview } from '../../src/lib/bank.js'; const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] })); const currentPage = writable({ url: new URL('http://localhost/bank'), 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() })); vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} })); 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.unstubAllGlobals(); }); const account = { guid: '18446744073709551615', name: 'Dempsey', balance: 2000, available: 1500, pendingOperation: 'withdraw' as const, pendingAmount: 500, lastActivity: '2026-10-13T11:00:00.000Z', lastAmount: 2000 }; const transfer = { id: 1, guid: account.guid, name: account.name, amount: 2000, balance: 2000, server: 't5_zombie_theater_1', game: 't5', matchId: 17, matchAvailable: 1, createdAt: account.lastActivity }; function report(search = ''): BankOverview { const query = readBankQuery(new URLSearchParams(search)); return { query, maximum: 250000, checkedAt: '2026-10-13T12:00:00.000Z', summary: { totalBanked: 2000, fundedAccounts: 1, transfers24h: 1, pending: 1 }, servers: [transfer.server], accounts: { entries: [account], total: 1, offset: 0 }, transactions: { entries: [transfer], total: 1, offset: 0 }, pending: { entries: [{ ...transfer, requestId: 'pending-id', amount: 500, operation: 'withdraw' }], total: 1, offset: 0 }, detail: query.player ? { account, pending: null, history: { entries: [transfer], total: 30, offset: query.historyOffset } } : null }; } function show(initial = report()) { const data = writable({ report: initial, user: null, setupRequired: false }); const state = fromStore(data); currentPage.set({ url: new URL( `http://localhost/bank?${new URLSearchParams(Object.entries(initial.query).map(([key, value]) => [key, String(value)]))}` ), data: { user: null } }); component = mount(BankPage, { target: document.body, props: { get data() { return state.current; } } }); flushSync(); return (next: BankOverview) => { currentPage.set({ url: new URL( `http://localhost/bank?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}` ), data: { user: null } }); 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'); it('opens player details without losing list filters and restores the list when closed', async () => { const update = show(report('q=Dempsey&sort=recent&offset=25')); button('Dempsey').click(); expect(lastNavigation().searchParams.get('player')).toBe(account.guid); expect(lastNavigation().searchParams.get('q')).toBe('Dempsey'); expect(lastNavigation().searchParams.get('offset')).toBe('25'); update(report(`q=Dempsey&sort=recent&offset=25&player=${account.guid}`)); await tick(); expect(document.querySelector('dialog')?.open).toBe(true); expect(document.querySelector('dialog')?.textContent).toContain('1,500 pts'); const profile = [...document.querySelectorAll('dialog a')].find((link) => link.textContent?.includes('View player profile') )!; const target = new URL(profile.getAttribute('href')!, 'http://localhost'); expect(target.searchParams.get('returnTo')).toContain(`player=${account.guid}`); button('Next').click(); expect(lastNavigation().searchParams.get('historyOffset')).toBe('25'); expect(lastNavigation().searchParams.get('offset')).toBe('25'); button('Close').click(); expect(lastNavigation().searchParams.has('player')).toBe(false); expect(lastNavigation().searchParams.get('sort')).toBe('recent'); }); it('applies accessible transfer filters and resets pagination while preserving player detail', () => { show(report(`view=transactions&offset=25&player=${account.guid}`)); const search = document.querySelector('input[name="q"]')!; search.value = 'Nikolai'; search.dispatchEvent(new Event('input', { bubbles: true })); const game = document.querySelector('select[name="game"]')!; // happy-dom's :checked selector only matches inputs, not selected options. const selectQuery = game.querySelector.bind(game); game.querySelector = ((selector: string) => selector === ':checked' ? (game.selectedOptions[0] ?? null) : selectQuery(selector)) as typeof game.querySelector; game.value = 't6'; game.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); document .querySelector('form')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); expect(lastNavigation().searchParams.get('q')).toBe('Nikolai'); expect(lastNavigation().searchParams.get('game')).toBe('t6'); expect(lastNavigation().searchParams.has('offset')).toBe(false); expect(lastNavigation().searchParams.get('player')).toBe(account.guid); button('Reset').click(); expect(lastNavigation().searchParams.has('game')).toBe(false); }); it('shows pending reservation guidance, age and historical matches without dead links', () => { const initial = report('view=pending'); initial.pending.entries[0].matchAvailable = 0; show(initial); const panel = document.querySelector('#bank-results')!; expect(panel.textContent).toContain('Waiting over 5 minutes'); expect(panel.textContent).toContain('These points are reserved'); expect(panel.textContent).toContain('Match #17 ยท History expired'); expect(panel.querySelector('a[href*="matches/17"]')).toBeNull(); expect(panel.querySelector('details summary')?.textContent).toBe('Why pending?'); }); it('retains data on refresh errors and successfully retries without erasing draft filters', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(new Response(JSON.stringify(report()))); vi.stubGlobal('fetch', fetch); show(); const search = document.querySelector('input[name="q"]')!; search.value = 'draft'; search.dispatchEvent(new Event('input', { bubbles: true })); button('Refresh').click(); await vi.waitFor(() => expect(document.body.textContent).toContain('Bank updates paused.')); expect(document.querySelector('#bank-results')?.textContent).toContain('Dempsey'); button('Retry').click(); await vi.waitFor(() => expect(document.body.textContent).not.toContain('Bank updates paused.')); expect(search.value).toBe('draft'); }); it('discards an old refresh when navigation changes the bank 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=transactions'); next.summary.totalBanked = 9000; update(next); resolve(new Response(JSON.stringify(report()))); await tick(); await tick(); expect(document.querySelector('.summary')?.textContent).toContain('9,000'); expect(document.querySelector('#bank-tab-transactions')?.getAttribute('aria-selected')).toBe( 'true' ); expect(document.querySelector('#bank-results')?.textContent).toContain('Balance after'); });