Files
electric-cherry/tests/unit/server-observation.test.ts
2026-10-09 19:41:55 -04:00

452 lines
17 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
freshTelemetry,
selectServerObservation,
type ServerObservation
} from '../../src/lib/server-observation.js';
import { gameEvent } from '../helpers/telemetry.js';
const now = Date.parse('2026-09-27T12:00:00Z');
const at = (offset = 0) => new Date(now + offset).toISOString();
const report = (offset = 0, players = 3) => ({
...gameEvent('heartbeat', 15, players, 'managed'),
receivedAt: at(offset)
});
function server(changes: Partial<ServerObservation> = {}): ServerObservation {
return {
name: 'managed',
health: 'online',
container_state: 'running',
container_checked_at: at(),
last_players: 4,
last_map: 'zombie_theater',
last_gametype: 'zom',
last_seen_at: at(),
last_error: null,
roster: [],
...changes
};
}
describe('shared server observation', () => {
it('tolerates only the first transient failure while retaining diagnostics and expiring evidence', () => {
for (const reason of ['timeout', 'send-error']) {
const raw = { ...server(), last_error: `${reason}:no reply`, rcon_failures: 1 };
const selected = selectServerObservation(server({ rcon_status: raw }), undefined, now);
expect(selected).toMatchObject({
health: 'online',
last_error: null,
rcon_status: raw
});
expect(selectServerObservation(selected, undefined, now + 90_000).health).toBe(
'online'
);
expect(selectServerObservation(selected, undefined, now + 90_001).health).toBe('stale');
expect(selectServerObservation(selected, undefined, now + 120_001).health).toBe(
'offline'
);
expect(
selectServerObservation(
server({ rcon_status: { ...raw, rcon_failures: 2 } }),
undefined,
now
)
).toMatchObject({ health: 'stale', last_error: raw.last_error });
}
for (const reason of ['no-config', 'no-port', 'bad-reply', 'no-players']) {
expect(
selectServerObservation(
server({ last_error: `${reason}:failed`, rcon_failures: 1 }),
undefined,
now
).health
).toBe('stale');
}
expect(
selectServerObservation(
server({ last_error: 'timeout:legacy failure' }),
undefined,
now
).health
).toBe('stale');
});
it('accepts telemetry and container evidence through 90 seconds, then expires each independently', () => {
expect(selectServerObservation(server(), report(), now + 90_000)).toMatchObject({
health: 'online',
status_source: 'telemetry'
});
expect(selectServerObservation(server(), report(90_001), now + 90_001).health).toBe(
'stale'
);
const current = server({ container_checked_at: at(90_001) });
expect(selectServerObservation(current, report(), now + 90_001).health).toBe('stale');
expect(freshTelemetry(report(-90_000), now)).toBe(true);
expect(freshTelemetry(report(-90_001), now)).toBe(false);
});
it('selects the telemetry count and roster together and keeps raw RCON evidence', () => {
const raw = server({ last_error: 'timeout' });
const selected = selectServerObservation(server({ rcon_status: raw }), report(), now);
expect(selected).toMatchObject({
health: 'online',
status_source: 'telemetry',
last_players: 3,
last_error: null
});
expect(selected.roster).toHaveLength(3);
expect(selected.roster?.[0]).toMatchObject({ ping: null, score: 5000 });
expect(selected.rcon_status).toBe(raw);
});
it('supplements telemetry members with GUID-matched pings without changing scores or names', () => {
const telemetry = report();
const raw = server({
roster: [
{ ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' },
{ ...telemetry.players[0], num: 3, score: 20, ping: 999 },
{
...telemetry.players[2],
num: 4,
score: 30,
ping: 45,
guid: 'different-player',
name: 'Different player'
}
]
});
const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now);
expect(selected.roster).toEqual(
telemetry.players.map((player, i) => ({
name: player.name,
guid: player.guid,
num: null,
score: 5000,
ping: [999, 0, null][i]
}))
);
expect(selected.last_players).toBe(3);
expect(selected.rcon_status).toBe(raw);
// A new telemetry report must not renew the RCON sample's age.
const expired = selectServerObservation(
{ ...selected, container_checked_at: at(90_001) },
report(90_001),
now + 90_001
);
expect(expired.health).toBe('online');
expect(expired.roster?.map((player) => player.ping)).toEqual([null, null, null]);
});
it('prefers telemetry ping, including zero, and fills absent or null readings from RCON', () => {
const telemetry = report();
telemetry.players[0].ping = 0;
telemetry.players[1].ping = null;
const raw = server({
roster: telemetry.players.map((player) => ({ ...player, num: 0, score: 10, ping: 42 }))
});
expect(
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(
(player) => player.ping
)
).toEqual([0, 42, 42]);
});
it.each([
{ last_seen_at: at(-90_001) },
{ last_seen_at: at(2000) },
{ last_seen_at: null },
{ last_error: 'timeout', rcon_failures: 2 },
{ last_map: 'other-map' }
])('does not supplement pings from unusable RCON evidence: %j', (changes) => {
const telemetry = report();
const raw = server({
...changes,
roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }]
});
expect(
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
).toBeNull();
});
it('requires RCON samples after the latest lifecycle and tolerates one transient failure', () => {
const telemetry = report();
const raw = server({
last_seen_at: at(-90_000),
roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }]
});
expect(
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
).toBe(42);
expect(
selectServerObservation(
server({ rcon_status: raw, telemetry_after: at(-90_000) }),
telemetry,
now
).roster?.[0].ping
).toBeNull();
expect(
selectServerObservation(
server({ rcon_status: { ...raw, last_error: 'timeout', rcon_failures: 1 } }),
telemetry,
now
).roster?.[0].ping
).toBe(42);
});
it.each(['duplicate', 'null', 'negative', 'invalid'])('rejects %s ping matches', (mode) => {
const telemetry = report();
const player = {
...telemetry.players[0],
num: 0,
score: 100,
ping: 42 as number | null,
guid: telemetry.players[0].guid as string | null
};
if (mode === 'null') player.ping = null;
if (mode === 'negative') player.ping = -1;
if (mode === 'invalid') player.ping = NaN;
const raw = server({
roster: mode === 'duplicate' ? [player, { ...player, ping: 50 }] : [player]
});
expect(
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
).toBeNull();
});
it.each(['t5', 't6'])(
'does not match %s players by name when GUIDs differ or are missing',
(game) => {
const telemetry = report(0, 2);
telemetry.players[0].name = 'EyelessGreem';
telemetry.players[1].name = '_frxn.3';
const raw = server({
roster: [
{ num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' },
{ num: 1, score: 150, ping: 151, guid: null, name: '_frxn.3' }
]
});
const selected = selectServerObservation(
server({ game, rcon_status: raw }),
telemetry,
now
);
expect(selected.roster?.map((player) => player.ping)).toEqual([null, null]);
}
);
it.each([
['6C4C20', '7097376'],
['6c45dd', '7095773'],
['00006C4C20', '0007097376'],
['0x6C45DD', '7095773'],
['10', '16'],
['20000000000001', '9007199254740993']
])('matches T6 hex GUID %s to decimal %s despite different names', (hex, decimal) => {
const telemetry = report(0, 1);
telemetry.players[0].guid = decimal;
const raw = server({
roster: [{ num: 0, score: 100, ping: 91, guid: hex, name: 'Previous name' }]
});
const selected = selectServerObservation(
server({ game: 't6', rcon_status: raw }),
telemetry,
now
);
expect(selected.roster?.[0]).toMatchObject({
guid: decimal,
name: 'Player 1',
ping: 91,
score: 5000
});
expect(selected.rcon_status?.roster?.[0].guid).toBe(hex);
});
it.each(['t5', undefined])('keeps decimal-looking GUIDs literal for game %s', (game) => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({
roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Previous name' }]
});
expect(
selectServerObservation(server({ game, rcon_status: raw }), telemetry, now).roster?.map(
(player) => player.ping
)
).toEqual([91, null]);
});
it('treats digit-only T6 GUIDs as hexadecimal regardless of player names', () => {
const telemetry = report(0, 2);
telemetry.players[0].guid = '10';
telemetry.players[1].guid = '16';
const raw = server({
roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Player 1' }]
});
expect(
selectServerObservation(
server({ game: 't6', rcon_status: raw }),
telemetry,
now
).roster?.map((player) => player.ping)
).toEqual([null, 91]);
});
it('rejects duplicate RCON GUIDs after normalization', () => {
const telemetry = report(0, 1);
telemetry.players[0].guid = '7097376';
const raw = server({
roster: [
{ num: 0, score: 100, ping: 91, guid: '6C4C20', name: 'Player 1' },
{ num: 1, score: 100, ping: 42, guid: '006c4c20', name: 'Other player' }
]
});
expect(
selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now)
.roster?.[0].ping
).toBeNull();
});
it('matches GUIDs regardless of conflicting names', () => {
const telemetry = report(0, 2);
const raw = server({
roster: [
{
num: 0,
score: 100,
ping: 42,
guid: telemetry.players[1].guid,
name: telemetry.players[0].name
},
{
num: 1,
score: 100,
ping: 99,
guid: 'another-guid',
name: telemetry.players[1].name
}
]
});
expect(
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(
(player) => player.ping
)
).toEqual([null, 42]);
});
it('uses the latest HTTP or streamed telemetry and falls back without extending freshness', () => {
const original = server();
const selected = selectServerObservation(
server({ rcon_status: original, telemetry: report(0, 2) }),
report(-1000),
now
);
expect(selected.last_players).toBe(2);
const fallback = selectServerObservation(
{
...selected,
container_checked_at: at(91_000),
rcon_status: { ...original, last_seen_at: at(40_000) }
},
undefined,
now + 91_000
);
expect(fallback).toMatchObject({
status_source: 'rcon',
health: 'online',
last_players: 4,
last_seen_at: at(40_000)
});
});
it('displays telemetry pings and clears missing readings on subsequent reports', () => {
const telemetry = report(0, 4);
telemetry.players = telemetry.players.map((player, i) => ({
...player,
ping: [0, 42, 999, null][i]
}));
const selected = selectServerObservation(server(), telemetry, now);
expect(selected.roster?.map((player) => player.ping)).toEqual([0, 42, 999, null]);
expect(
selectServerObservation(selected, report(1, 4), now + 1).roster?.map(
(player) => player.ping
)
).toEqual([null, null, null, null]);
const start = {
...gameEvent('game_start', 0, 1, 'managed'),
event: 'game_start' as const,
receivedAt: at(),
players: [{ name: 'Player 1', guid: '123', ping: 25 }]
};
expect(selectServerObservation(server(), start, now).roster?.[0]).toMatchObject({
score: null,
ping: 25
});
});
it.each(['starting', 'restarting', 'unhealthy', 'exited'])(
'telemetry cannot override container state %s',
(state) => {
const selected = selectServerObservation(
server({
health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline',
container_state: state
}),
report(),
now
);
expect(selected.health).not.toBe('online');
expect(selected.last_players).toBeNull();
expect(selected.roster).toBeNull();
}
);
it('uses fresh RCON with unknown container state, but does not substitute telemetry or extend freshness', () => {
const raw = server({ container_state: 'unknown', container_checked_at: at(-60_000) });
expect(selectServerObservation(raw, report(), now)).toMatchObject({
health: 'online',
last_players: 4,
status_source: 'rcon'
});
expect(selectServerObservation(raw, report(91_000), now + 91_000).health).toBe('stale');
expect(selectServerObservation(raw, report(121_000), now + 121_000).health).toBe('offline');
expect(
selectServerObservation({ ...raw, telemetry_after: at() }, report(), now).health
).toBe('offline');
expect(selectServerObservation({ ...raw, last_seen_at: null }, report(), now).health).toBe(
'offline'
);
});
it('requires fresh container evidence and reports after the latest lifecycle submission', () => {
expect(
selectServerObservation(server({ container_checked_at: at(-91_000) }), report(), now)
.health
).not.toBe('online');
expect(
selectServerObservation(server({ health: 'provisioning' }), report(), now).health
).toBe('provisioning');
expect(
selectServerObservation(server({ telemetry_after: at() }), report(-1), now).health
).toBe('offline');
expect(freshTelemetry(report(), now, 'invalid')).toBe(false);
expect(freshTelemetry(report(2000), now)).toBe(false);
expect(freshTelemetry(report(-91_000), now)).toBe(false);
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
});
it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
const active = selectServerObservation(server(), start, now);
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
expect(active.game_started_at).toBe(at());
expect(
selectServerObservation({ ...active, game_ended_at: at() }, report(1, 0), now + 1)
).toMatchObject({
last_players: 0,
roster: [],
game_started_at: at(),
game_ended_at: at()
});
});
});