import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; import { freshServer, matchesFleet, type FleetServer } from '../../src/lib/fleet-overview.js'; import type { ServerStatus } from '../../src/lib/ui/stores/servers.js'; import type { GameEvent, ServerTiming } from '../../src/lib/telemetry.js'; import { gameEvent } from '../helpers/telemetry.js'; vi.mock('$app/environment', () => ({ browser: true })); const epoch = Date.parse('2026-09-19T12:00:00Z'); let source: EventTarget & { close: () => void }; let stop: (() => void) | undefined; let snapshot: { serverNow: number; servers: FleetServer[]; integrations: object }; let frames: Map; function flushFrame() { const callbacks = [...frames.values()]; frames.clear(); for (const callback of callbacks) callback(performance.now()); } function server(timestamp = epoch, changes: Partial = {}): FleetServer { return { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_players: 0, last_map: 'zombie_theater', last_gametype: 'zom', last_seen_at: new Date(timestamp).toISOString(), last_error: null, roster: [], health: 'online', held: [], active_since: null, inactive_since: new Date(epoch).toISOString(), game_started_at: null, game_ended_at: null, ...changes }; } function status(timestamp: number, changes: Partial = {}): ServerStatus { return { server: 't5_zombie_theater_1', players: 0, map: 'zombie_theater', gametype: 'zom', roster: [], health: 'online', lastSeenAt: new Date(timestamp).toISOString(), lastError: null, endpoint: null, reason: null, replySnippet: null, activeSince: null, inactiveSince: new Date(epoch).toISOString(), gameStartedAt: null, gameEndedAt: null, ...changes }; } function emit(serverNow: number, entries: ServerStatus[]) { source.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({ serverNow, entries }) })); flushFrame(); } beforeEach(() => { vi.resetModules(); vi.useFakeTimers({ toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); vi.setSystemTime(epoch); frames = new Map(); let frameId = 0; vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; })); vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => { frames.delete(id); })); snapshot = { serverNow: epoch, servers: [server()], integrations: {} }; vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(JSON.stringify( url === '/api/servers' ? snapshot : { operations: [] } )))); vi.stubGlobal('EventSource', class extends EventTarget { constructor() { super(); source = this; } close() {} }); }); afterEach(() => { stop?.(); stop = undefined; vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function setup() { const clock = await import('../../src/lib/ui/stores/servers.js'); const fleet = await import('../../src/lib/ui/stores/fleet.js'); stop = clock.startServerStream(); await fleet.refreshFleet(); return { ...clock, ...fleet }; } function queueEvent(type: string, data: object) { source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) })); } describe('animation-frame stream batching', () => { it('publishes each changed store once for a burst and preserves lifecycle evidence through heartbeats', async () => { const stores = await setup(); emit(epoch, [status(epoch), status(epoch, { server: 'two' })]); const before = get(stores.statusByServer); await vi.advanceTimersByTimeAsync(1000); const watched = [stores.statusByServer, stores.statusReceivedAt, stores.telemetryByServer, stores.matchEventsByServer, stores.uiNow]; const changes = watched.map(() => vi.fn()); const unsubscribe = watched.map((store, index) => store.subscribe(changes[index])); changes.forEach(change => change.mockClear()); vi.mocked(requestAnimationFrame).mockClear(); try { for (let i = 0; i < 100; i++) { queueEvent('server-status', { delta: true, serverNow: epoch + 1000 + i, entries: [status(epoch + 1000, { players: i })] }); queueEvent('server-telemetry', { serverNow: epoch + 1000 + i, entries: [{ ...gameEvent(i === 0 ? 'game_start' : 'heartbeat', i, 1), receivedAt: new Date(epoch + 1000 + i).toISOString() }] }); } expect(requestAnimationFrame).toHaveBeenCalledTimes(1); changes.forEach(change => expect(change).not.toHaveBeenCalled()); expect(get(stores.statusByServer)).toBe(before); expect(before.get('t5_zombie_theater_1')?.players).toBe(0); flushFrame(); changes.forEach(change => expect(change).toHaveBeenCalledTimes(1)); expect(get(stores.statusByServer).size).toBe(2); expect(get(stores.statusByServer).get('t5_zombie_theater_1')?.players).toBe(99); expect(get(stores.telemetryByServer).get('t5_zombie_theater_1')).toMatchObject({ event: 'heartbeat', round: 99 }); expect(get(stores.matchEventsByServer).get('t5_zombie_theater_1')?.event).toBe('game_start'); queueEvent('server-telemetry', { entries: [{ ...gameEvent('heartbeat', 100, 1), receivedAt: new Date(epoch + 1100).toISOString() }] }); flushFrame(); expect(requestAnimationFrame).toHaveBeenCalledTimes(2); expect(changes[2]).toHaveBeenCalledTimes(2); expect(changes[3]).toHaveBeenCalledTimes(1); } finally { unsubscribe.forEach(unsubscribe => unsubscribe()); } }); it('applies snapshots and subsequent deltas in order, including empty snapshots', async () => { const { statusByServer, telemetryByServer, matchEventsByServer } = await setup(); const telemetry = (event: GameEvent['event'], name: string) => ({ ...gameEvent(event, 1, 1, name), receivedAt: new Date(epoch).toISOString() }); emit(epoch, [status(epoch, { server: 'old' })]); queueEvent('server-telemetry', { entries: [telemetry('game_start', 'old')] }); flushFrame(); queueEvent('server-status', { delta: true, entries: [status(epoch, { server: 'discarded' })] }); queueEvent('server-status', { entries: [status(epoch, { server: 'two' })] }); queueEvent('server-status', { delta: true, entries: [status(epoch, { server: 'three' })] }); queueEvent('server-telemetry', { entries: [telemetry('game_end', 'discarded')] }); queueEvent('server-telemetry', { snapshot: true, entries: [telemetry('heartbeat', 'two')] }); queueEvent('server-telemetry', { entries: [telemetry('game_end', 'three')] }); queueEvent('server-telemetry', { entries: [telemetry('heartbeat', 'three')] }); flushFrame(); expect([...get(statusByServer).keys()]).toEqual(['two', 'three']); expect([...get(telemetryByServer).keys()]).toEqual(['two', 'three']); expect([...get(matchEventsByServer).keys()]).toEqual(['three']); expect(get(matchEventsByServer).get('three')?.event).toBe('game_end'); expect(get(telemetryByServer).get('three')?.event).toBe('heartbeat'); queueEvent('server-status', { entries: [] }); queueEvent('server-telemetry', { snapshot: true, entries: [] }); flushFrame(); expect(get(statusByServer).size).toBe(0); expect(get(telemetryByServer).size).toBe(0); expect(get(matchEventsByServer).size).toBe(0); }); it('retains event arrival times so buffered status cannot overwrite a newer HTTP snapshot', async () => { const { fleetServers, refreshFleet, statusByServer, statusReceivedAt } = await setup(); await vi.advanceTimersByTimeAsync(1000); queueEvent('server-status', { delta: true, serverNow: epoch + 1000, entries: [status(epoch + 1000, { players: 1 })] }); await vi.advanceTimersByTimeAsync(1000); snapshot = { ...snapshot, serverNow: epoch + 2000, servers: [server(epoch + 2000, { last_players: 4 })] }; await refreshFleet(); queueEvent('server-status', { delta: true, serverNow: epoch + 2000, entries: [status(epoch + 2000, { server: 'two' })] }); flushFrame(); expect(get(statusByServer).get('t5_zombie_theater_1')?.receivedAt).toBe(epoch + 1000); expect(get(statusReceivedAt)).toBe(epoch + 2000); expect(get(fleetServers)[0].last_players).toBe(4); }); it('accounts for frame delay without rewinding the server clock', async () => { const { uiNow, statusByServer } = await setup(); vi.stubGlobal('document', { visibilityState: 'hidden' }); queueEvent('server-status', { serverNow: epoch + 5000, entries: [status(epoch + 5000)] }); await vi.advanceTimersByTimeAsync(2000); queueEvent('server-telemetry', { serverNow: epoch + 4000, entries: [] }); await vi.advanceTimersByTimeAsync(1000); expect(get(uiNow)).toBe(epoch); expect(get(statusByServer).size).toBe(0); expect(frames.size).toBe(1); flushFrame(); expect(get(uiNow)).toBe(epoch + 8000); expect(get(statusByServer).size).toBe(1); }); it('cancels queued frames and discards buffered events when stopping and restarting', async () => { const { statusByServer, telemetryByServer, startServerStream } = await setup(); const oldSource = source; const close = vi.spyOn(oldSource, 'close'); queueEvent('server-status', { entries: [status(epoch)] }); queueEvent('server-telemetry', { entries: [{ ...gameEvent('game_start'), receivedAt: new Date(epoch).toISOString() }] }); const callback = [...frames.values()][0]; const oldStop = stop!; oldStop(); expect(close).toHaveBeenCalledTimes(1); expect(cancelAnimationFrame).toHaveBeenCalledTimes(1); expect(frames.size).toBe(0); callback(0); expect(get(statusByServer).size).toBe(0); expect(get(telemetryByServer).size).toBe(0); stop = startServerStream(); oldStop(); oldSource.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({ entries: [status(epoch)] }) })); queueEvent('server-status', { delta: true, entries: [status(epoch, { server: 'new' })] }); flushFrame(); expect([...get(statusByServer).keys()]).toEqual(['new']); expect(get(telemetryByServer).size).toBe(0); }); }); describe('Fleet report clock', () => { it('accepts newer navigation snapshots while keeping stream evidence newer than the snapshot', async () => { const { fleetServers, hydrateFleet } = await setup(); await vi.advanceTimersByTimeAsync(2000); emit(epoch + 2000, [status(epoch + 2000, { players: 3 })]); const integrations = { rcon: true, komodo: false, rconTimeoutMs: 3000, defaultRconHost: '' }; hydrateFleet({ serverNow: epoch + 1000, servers: [server(epoch + 1000), server(epoch, { name: 'two' })], operations: [], integrations }); expect(get(fleetServers)).toHaveLength(2); expect(get(fleetServers)[0].last_players).toBe(3); hydrateFleet({ serverNow: epoch, servers: [], operations: [], integrations }); expect(get(fleetServers)).toHaveLength(2); }); it('merges partial events without dropping other servers or repainting unchanged elapsed ticks', async () => { snapshot.servers = [server(), server(epoch, { name: 'two' })]; const { fleetServers, statusByServer } = await setup(); emit(epoch, [status(epoch), status(epoch, { server: 'two' })]); await vi.advanceTimersByTimeAsync(1000); source.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({ delta: true, serverNow: epoch + 1000, entries: [status(epoch + 1000, { players: 2 })] }) })); flushFrame(); expect(get(statusByServer).size).toBe(2); expect(get(fleetServers).find(server => server.name === 't5_zombie_theater_1')?.last_players).toBe(2); const changes = vi.fn(); const unsubscribe = fleetServers.subscribe(changes); changes.mockClear(); await vi.advanceTimersByTimeAsync(5000); expect(changes).not.toHaveBeenCalled(); unsubscribe(); }); it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => { const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString(); snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: at(0) })]; const { fleetServers } = await setup(); const report = (event: GameEvent['event'], seconds: number, players: number, timing: ServerTiming) => { source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch + seconds * 1000, entries: [{ ...gameEvent(event, 2, players), receivedAt: at(seconds), timing: { matchId: 1, players: {}, server: timing } }] }) })); flushFrame(); return get(fleetServers)[0]; }; const active = { activeSince: at(1), inactiveSince: null, gameStartedAt: at(1), gameEndedAt: null }; report('game_start', 1, 1, active); expect(report('heartbeat', 20, 1, active)).toMatchObject({ active_since: at(1), inactive_since: null, game_started_at: at(1), game_ended_at: null }); const ended = { ...active, gameEndedAt: at(30) }; report('game_end', 30, 1, ended); expect(report('heartbeat', 31, 1, ended).game_ended_at).toBe(at(30)); const nextMatch = { ...active, gameStartedAt: at(32) }; report('game_start', 32, 1, nextMatch); expect(report('heartbeat', 33, 1, nextMatch)).toMatchObject({ active_since: at(1), game_started_at: at(32), game_ended_at: null }); expect(report('heartbeat', 34, 0, { activeSince: null, inactiveSince: at(34), gameStartedAt: null, gameEndedAt: null })) .toMatchObject({ active_since: null, inactive_since: at(34), game_started_at: null, game_ended_at: null }); expect(report('heartbeat', 35, 1, { activeSince: at(35), inactiveSince: null, gameStartedAt: null, gameEndedAt: null })) .toMatchObject({ active_since: at(35), inactive_since: null, game_started_at: null, game_ended_at: null }); }); it('restores timers from a telemetry snapshot even when the browser missed game_start', async () => { const startedAt = new Date(epoch - 19_000).toISOString(); snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: new Date(epoch).toISOString() })]; const { fleetServers } = await setup(); source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch, snapshot: true, entries: [{ ...gameEvent('heartbeat', 2, 1), receivedAt: new Date(epoch).toISOString(), timing: { matchId: 1, players: {}, server: { activeSince: startedAt, inactiveSince: null, gameStartedAt: startedAt, gameEndedAt: null } } }] }) })); flushFrame(); expect(get(fleetServers)[0]).toMatchObject({ active_since: startedAt, game_started_at: startedAt }); }); it('updates count and roster on the next frame and expires them together', async () => { snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: new Date(epoch).toISOString(), last_players: 4 })]; const { fleetServers, uiNow } = await setup(); await vi.advanceTimersByTimeAsync(1000); source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch + 1000, entries: [{ ...gameEvent('heartbeat', 15, 3), receivedAt: new Date(epoch + 1000).toISOString() }] }) })); flushFrame(); expect(get(fleetServers)[0]).toMatchObject({ health: 'online', last_players: 3, status_source: 'telemetry' }); expect(get(fleetServers)[0].roster).toHaveLength(3); expect(matchesFleet(get(fleetServers)[0], 'Player 3', 'occupied', '', [], [], get(uiNow))).toBe(true); await vi.advanceTimersByTimeAsync(46_000); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false); expect(matchesFleet(get(fleetServers)[0], '', 'occupied', '', [], [], get(uiNow))).toBe(false); }); it('applies game lifecycle events on the next frame and clears them after an empty report', async () => { const { fleetServers } = await setup(); const report = (event: string, time: number) => { source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: time, entries: [{ server: 't5_zombie_theater_1', event, receivedAt: new Date(time).toISOString(), players: [] }] }) })); flushFrame(); }; emit(epoch, [status(epoch, { players: 1 })]); report('game_end', epoch + 1000); expect(get(fleetServers)[0].game_ended_at).toBe(new Date(epoch + 1000).toISOString()); report('heartbeat', epoch + 2000); expect(get(fleetServers)[0].game_ended_at).not.toBeNull(); report('game_start', epoch + 3000); expect(get(fleetServers)[0].game_ended_at).toBeNull(); report('game_end', epoch + 4000); await vi.advanceTimersByTimeAsync(5000); emit(epoch + 5000, [status(epoch + 5000)]); expect(get(fleetServers)[0].game_ended_at).toBeNull(); emit(epoch + 6000, [status(epoch + 6000, { players: 1 })]); expect(get(fleetServers)[0].game_ended_at).toBeNull(); }); it.each([-2500, 60_000])('keeps fresh HTTP and streamed reports healthy with a browser clock offset of %i ms', async (offset) => { vi.setSystemTime(epoch + offset); const { fleetServers, uiNow, refreshFleet } = await setup(); const expectHealthy = () => { const current = get(fleetServers)[0]; expect(freshServer(current, get(uiNow))).toBe(true); expect(matchesFleet(current, '', 'empty', '', [], [], get(uiNow))).toBe(true); expect(matchesFleet(current, '', 'attention', '', [], [], get(uiNow))).toBe(false); }; expect(get(uiNow)).toBe(epoch); expectHealthy(); for (let cycle = 1; cycle <= 3; cycle++) { for (let second = 0; second < 30; second++) { await vi.advanceTimersByTimeAsync(1000); expectHealthy(); } const now = epoch + cycle * 30_000; emit(now, [status(now)]); expectHealthy(); snapshot = { ...snapshot, serverNow: now, servers: [server(now)] }; await refreshFleet(); expectHealthy(); } }); it('updates the clock before exposing a new status after a suspended UI ticker', async () => { const { fleetServers, uiNow } = await setup(); vi.spyOn(performance, 'now').mockReturnValue(30_000); vi.setSystemTime(epoch + 30_000); expect(get(uiNow)).toBe(epoch); emit(epoch + 30_000, [status(epoch + 30_000)]); expect(get(uiNow)).toBe(epoch + 30_000); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true); }); it('does not rewind the clock when an older HTTP snapshot follows a newer stream update', async () => { const { fleetServers, uiNow, refreshFleet } = await setup(); let resolveSnapshot!: (response: Response) => void; vi.mocked(fetch).mockImplementationOnce(() => new Promise(resolve => { resolveSnapshot = resolve; })); const refreshing = refreshFleet(); await vi.advanceTimersByTimeAsync(4000); emit(epoch + 4000, [status(epoch + 4000)]); resolveSnapshot(new Response(JSON.stringify(snapshot))); await refreshing; expect(get(uiNow)).toBe(epoch + 4000); expect(get(fleetServers)[0].last_seen_at).toBe(new Date(epoch + 4000).toISOString()); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true); }); it('keeps advancing through wall-clock changes and expires a report if updates stop', async () => { const { fleetServers, uiNow } = await setup(); vi.setSystemTime(epoch - 120_000); await vi.advanceTimersByTimeAsync(45_000); expect(get(uiNow)).toBe(epoch + 45_000); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true); await vi.advanceTimersByTimeAsync(1000); expect(get(uiNow)).toBe(epoch + 46_000); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false); expect(matchesFleet(get(fleetServers)[0], '', 'attention', '', [], [], get(uiNow))).toBe(true); }); it('preserves actual RCON failures and stopped-container health', async () => { const { fleetServers, uiNow } = await setup(); await vi.advanceTimersByTimeAsync(1000); emit(epoch + 1000, [status(epoch, { health: 'stale', lastError: 'timeout:no reply', reason: 'timeout' })]); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false); expect(matchesFleet(get(fleetServers)[0], '', 'attention', '', [], [], get(uiNow))).toBe(true); emit(epoch + 1000, [status(epoch, { health: 'offline' })]); expect(get(fleetServers)[0].health).toBe('offline'); expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false); }); it('anchors the initial telemetry snapshot even before Fleet finishes loading', async () => { vi.setSystemTime(epoch - 60_000); const { startServerStream, uiNow } = await import('../../src/lib/ui/stores/servers.js'); stop = startServerStream(); source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch, entries: [], snapshot: true }) })); flushFrame(); expect(get(uiNow)).toBe(epoch); await vi.advanceTimersByTimeAsync(1000); expect(get(uiNow)).toBe(epoch + 1000); }); });