Rebuild Fleet around attention and compact live matches

This commit is contained in:
2026-10-07 18:52:26 -04:00
parent e35b84475d
commit 9999e80226
7 changed files with 614 additions and 512 deletions
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { get, writable } from 'svelte/store';
import type { ComponentProps } from 'svelte';
import ServerCard from '../../src/lib/ui/components/ServerCard.svelte';
import { telemetryByServer, uiNow } from '../../src/lib/ui/stores/servers.js';
import { gameEvent } from '../helpers/telemetry.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/?filter=occupied') }) }));
const name = 't5_zombie_theater_1';
const at = (age: number) => new Date(get(uiNow) - age).toISOString();
const telemetry = (round = 31, age = 1000) => ({ ...gameEvent('heartbeat', round, 4, name), receivedAt: at(age) });
function html(props: Partial<ComponentProps<typeof ServerCard>> = {}) {
return render(ServerCard, { props: { name, health: 'online', players: 4, capacity: 4,
telemetry: telemetry(), gameStartedAt: at(6_000_000), ...props } }).body.replaceAll(/<!--.*?-->/g, '');
}
afterEach(() => telemetryByServer.set(new Map()));
describe('compact server cards', () => {
it('leads with identity and three labelled metrics, without healthy freshness or duplicate activity clocks', () => {
const body = html({ gametype: 'zom', activeSince: at(6_120_000), lastSeenAt: at(1000), longestMatch: true });
expect(body).toContain('Kino Der Toten');
expect(body).toContain('Lobby 1');
expect(body).toContain('BO1');
expect(body).toMatch(/<dt[^>]*>Players<\/dt><dd[^>]*>4<span[^>]*>\/4<\/span><\/dd>/);
expect(body).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>31<\/dd>/);
expect(body).toMatch(/<dt[^>]*>Match time<\/dt><dd[^>]*>1h 40m<\/dd>/);
expect(body).toContain('Longest match');
expect(body).not.toMatch(/Last seen|Last reported|Zombies Classic|>Active</);
});
it('keeps capacity unknown when unavailable and never invents a four-player limit', () => {
const body = html({ players: 2, capacity: null });
expect(body).toContain('title="Capacity unavailable"');
expect(body).toMatch(/>2<span[^>]*>\/\?<\/span>/);
expect(body).not.toContain('/4');
});
it('uses the newest fresh report while respecting explicit round data and match boundaries', () => {
telemetryByServer.set(new Map([[name, telemetry(35, 1000)]]));
expect(html({ telemetry: telemetry(31, 10_000) })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>35<\/dd>/);
expect(html({ round: 40 })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>40<\/dd>/);
expect(html({ round: null })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
expect(html({ gameStartedAt: at(0) })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
telemetryByServer.set(new Map());
expect(html({ telemetry: telemetry(31, 100_000) })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
expect(html({ gameEndedAt: at(1000), round: 40 })).toContain('Match ended');
expect(html({ gameEndedAt: at(1000), round: 40 })).not.toMatch(/<dd[^>]*>40<\/dd>/);
});
it('gives the roster real column headers and formatted scores without repeating ping units', () => {
const body = html({ roster: [
{ num: 0, guid: 'one', name: 'Vexx', score: 99170, ping: 36 },
{ num: 1, guid: 'two', name: 'Connecting player', score: null, ping: 999 }
] });
expect(body).toContain('aria-label="Current players"');
expect(body).toMatch(/<th[^>]*scope="col"[^>]*>Player<\/th>/);
expect(body).toContain('99,170');
expect(body).toContain('Connecting');
expect(body).not.toContain('36ms');
});
it('places Pin outside the only link and retains its pressed state and server name', () => {
const body = html({ pinned: true, onPin: () => {} });
expect(body).toMatch(/<article\b/);
expect(body.match(/<a\b/g)).toHaveLength(1);
expect(body.match(/<a\b[\s\S]*?<\/a>/)?.[0]).not.toContain('<button');
expect(body).toContain('aria-pressed="true"');
expect(body).toContain('aria-label="Unpin Kino Der Toten · Lobby 1"');
});
it('collapses empty healthy lobbies to readiness and empty duration', () => {
const body = html({ players: 0, inactiveSince: at(9 * 60_000), roster: [], compactEmpty: true });
expect(body).toContain('Ready');
expect(body).toContain('Empty for 9m 00s');
expect(body).not.toContain('<dl');
expect(body).not.toContain('<table');
expect(body).not.toContain('Player roster unavailable');
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import type { FleetServer } from '../../src/lib/fleet-overview.js';
import { serverHealth } from '../../src/lib/ui/server-health.js';
const now = Date.parse('2026-10-07T12:00:00Z');
const iso = (offset = 0) => new Date(now + offset).toISOString();
const server: FleetServer = {
name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater',
last_gametype: 'zclassic', last_players: 4, last_seen_at: iso(-10_000), last_error: null,
container_state: 'running', container_checked_at: iso(-5_000), last_checked_at: iso(-5_000),
roster: [], health: 'online', held: [], active_since: iso(-120_000), inactive_since: null,
game_started_at: iso(-120_000), game_ended_at: null, host_id: 'host-1',
host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: iso(-1_000) }
};
describe('server health presentation', () => {
it('uses current game evidence for Online and expires that evidence with the UI clock', () => {
expect(serverHealth(server, now)).toMatchObject({ state: 'online', since: server.last_seen_at });
expect(serverHealth({ ...server, host: undefined, host_id: undefined }, now + 91_000)).toMatchObject({
state: 'not-responding', actionHref: `/servers/${server.name}?tab=diagnostics`, since: server.last_seen_at
});
});
it('identifies host loss without claiming the game stopped, including stale heartbeat snapshots', () => {
for (const host of [{ ...server.host!, connected: false, connectionState: 'offline' as const },
{ ...server.host!, lastHeartbeat: iso(-91_000) }]) {
expect(serverHealth({ ...server, host, health: 'offline', container_state: 'stopped' }, now)).toEqual({
state: 'host-offline', label: 'Host offline', reason: "Dallas 1's host agent is unavailable. The game may still be running.",
actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat
});
}
});
it.each(['stopped', 'exited', 'created', 'dead'])('only offers Start after a current %s container observation', container_state => {
expect(serverHealth({ ...server, health: 'offline', container_state }, now)).toMatchObject({
state: 'stopped', since: server.container_checked_at, actionLabel: 'Start server',
actionHref: `/servers/${server.name}?action=start`
});
});
it.each([
{ container_checked_at: iso(-91_000), last_checked_at: iso() },
{ container_checked_at: null, last_checked_at: iso() },
{ container_checked_at: 'invalid' },
{ container_checked_at: iso(30_000) },
{ telemetry_after: iso(-2_000) },
{ telemetry_after: 'invalid' },
{ last_seen_at: iso(-1_000) },
{ last_seen_at: iso(-5_000) },
{ host: null }
])('does not treat outdated or conflicting stop evidence as a safe Start action: %j', change => {
const result = serverHealth({ ...server, health: 'offline', container_state: 'stopped', ...change }, now);
expect(result.state).toBe('unknown');
expect(result.actionHref).not.toContain('action=start');
});
it('separates a running container with failed game checks from a stopped or missing container', () => {
expect(serverHealth({ ...server, health: 'offline', last_seen_at: iso(-300_000), last_error: 'timeout:Internal endpoint 10.0.0.1 timed out' }, now)).toEqual({
state: 'not-responding', label: 'Not responding', reason: 'The game did not answer the latest status check.',
actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000)
});
expect(serverHealth({ ...server, health: 'offline', container_state: 'missing', last_seen_at: null }, now).state).toBe('unknown');
});
it('uses generic copy for unrecognized failures without exposing internal paths or prefixes', () => {
const result = serverHealth({ ...server, health: 'offline', last_error: 'legacy-failure:/private/server/secret', container_checked_at: null, last_seen_at: null }, now);
expect(result).toMatchObject({ state: 'unknown', since: null, reason: 'There is no current evidence of the game or container state.' });
expect(result.reason).not.toContain('secret');
});
it('retains explicit startup phases without mistaking old stopped observations for current state', () => {
for (const health of ['starting', 'provisioning'] as const) {
expect(serverHealth({ ...server, health, container_state: 'stopped', telemetry_after: iso(-2_000) }, now)).toMatchObject({
state: health, actionLabel: 'View progress', since: iso(-2_000)
});
}
});
it('rejects old-process and stale-container evidence for Online', () => {
expect(serverHealth({ ...server, telemetry_after: iso(-2_000) }, now).state).toBe('unknown');
expect(serverHealth({ ...server, container_checked_at: iso(-91_000) }, now).state).not.toBe('online');
});
it('uses only valid evidence times and encodes navigation identities', () => {
expect(serverHealth({ ...server, name: 'server / 1', health: 'offline', container_checked_at: null, last_seen_at: iso(30_000), host: null, host_id: 'host / 1' }, now)).toMatchObject({
state: 'unknown', actionHref: '/hosts/host%20%2F%201', since: null
});
expect(serverHealth({ ...server, name: 'server / 1', health: 'offline', container_state: 'unknown', host: undefined, host_id: undefined }, now).actionHref)
.toBe('/servers/server%20%2F%201?tab=diagnostics');
});
});
+2 -2
View File
@@ -47,12 +47,12 @@ describe('status accessibility', () => {
roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }]
} }).body;
const card = body.match(/<a\b[^>]*>/)?.[0] ?? '';
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1];
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1]?.split(' ')[0];
expect(titleId).toBeTruthy();
expect(body).toContain(`id="${titleId}"`);
expect(card).not.toContain('aria-label=');
expect(body).toContain('Online');
expect(body).toContain('Round 30');
expect(body).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>30<\/dd>/);
expect(body).toContain('Player One');
expect(body).not.toMatch(/aria-live=|role="status"/);
});