Files
electric-cherry/tests/unit/ui-server-clock.test.ts
T
josh 2fa3792482 Fix active and match timer drift between status polls
Persist occupancy transitions with telemetry and carry timer anchors through live updates. Cover delayed polling, lobby rejoining, and lifecycle boundaries with regression tests.

Fixes #47
2026-09-27 23:29:04 -04:00

229 lines
12 KiB
TypeScript

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;
let stop: (() => void) | undefined;
let snapshot: { serverNow: number; servers: FleetServer[]; integrations: object };
function server(timestamp = epoch, changes: Partial<FleetServer> = {}): 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> = {}): 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 }) }));
}
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
vi.setSystemTime(epoch);
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 };
}
describe('Fleet report clock', () => {
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 } }] }) }));
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 } } }] }) }));
expect(get(fleetServers)[0]).toMatchObject({ active_since: startedAt, game_started_at: startedAt });
});
it('updates count and roster immediately from telemetry 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() }] }) }));
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 immediately 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: [] }] })
}));
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 })
}));
expect(get(uiNow)).toBe(epoch);
await vi.advanceTimersByTimeAsync(1000);
expect(get(uiNow)).toBe(epoch + 1000);
});
});