Files
electric-cherry/tests/unit/ui-server-clock.test.ts
T

257 lines
14 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('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 })]
}) }));
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 } }] }) }));
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);
});
});