Show fresh player capacity on Fleet cards
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { currentCapacity } from '../../src/lib/server-capacity.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
const now = Date.parse('2026-10-07T12:00:00Z');
|
||||
const iso = (offset = 0) => new Date(now + offset).toISOString();
|
||||
const unavailable = { capacity: null, capacity_observed_at: null };
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(now);
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
|
||||
async function setup(count = 1) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const database = db();
|
||||
const names = Array.from({ length: count }, (_, i) => `server-${i}`);
|
||||
for (const name of names) database.prepare(`INSERT INTO servers(name,created_at,game_port,container_state,
|
||||
container_checked_at,last_checked_at,last_seen_at,last_players) VALUES(?, ?, 28960, 'running', ?, ?, ?, 3)`)
|
||||
.run(name, iso(-60_000), iso(), iso(), iso());
|
||||
const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js');
|
||||
function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) {
|
||||
monitoringStatusWriter(name)({ observedAt: new Date().toISOString(), result: { ok: true, state: 'running' } },
|
||||
{ observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
status: { players: 3, capacity, map: 'zombie_theater', gametype: 'zom', roster: [] } }
|
||||
: { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } });
|
||||
}
|
||||
return { database, names, publish, ...await import('../../src/lib/server/fleet/player-capacity.js') };
|
||||
}
|
||||
|
||||
describe('current player capacity', () => {
|
||||
it('requires a fresh positive integer from after the latest lifecycle submission', () => {
|
||||
const known = { capacity: 4, capacity_observed_at: iso() };
|
||||
expect(currentCapacity(known, now)).toBe(4);
|
||||
expect(currentCapacity(known, now + 90_000)).toBe(4);
|
||||
expect(currentCapacity(known, now + 90_001)).toBeNull();
|
||||
expect(currentCapacity(known, now - 2000)).toBeNull();
|
||||
expect(currentCapacity({ ...known, telemetry_after: iso(-1) }, now)).toBe(4);
|
||||
for (const telemetry_after of [iso(), iso(1), 'invalid']) expect(currentCapacity({ ...known, telemetry_after }, now)).toBeNull();
|
||||
for (const capacity of [null, undefined, 0, -1, 4.5, NaN, Infinity]) expect(currentCapacity({ ...known, capacity }, now)).toBeNull();
|
||||
for (const capacity_observed_at of [null, undefined, 'invalid']) expect(currentCapacity({ ...known, capacity_observed_at }, now)).toBeNull();
|
||||
});
|
||||
|
||||
it('projects the accepted structured sample into REST and initial SSE without probing', async () => {
|
||||
const api = await setup();
|
||||
api.publish('server-0');
|
||||
const rcon = vi.spyOn(await import('../../src/lib/server/integrations/rcon.js'), 'rconStatusResult');
|
||||
const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js');
|
||||
expect(fleetSnapshot().servers[0]).toMatchObject({ capacity: 4, capacity_observed_at: iso() });
|
||||
const { GET } = await import('../../src/routes/api/events/servers/+server.js');
|
||||
const reader = (await GET()).body!.getReader();
|
||||
try {
|
||||
await reader.read(); // stream greeting
|
||||
const frame = new TextDecoder().decode((await reader.read()).value);
|
||||
const body = JSON.parse(frame.split('data: ')[1]);
|
||||
expect(body.entries[0]).toMatchObject({ capacity: 4, capacityObservedAt: iso() });
|
||||
} finally { await reader.cancel(); }
|
||||
expect(rcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears missing, invalid, failed and superseded evidence instead of reusing the previous limit', async () => {
|
||||
const api = await setup();
|
||||
const read = () => api.playerCapacityReader()('server-0');
|
||||
expect(read()).toEqual(unavailable);
|
||||
api.publish('server-0');
|
||||
expect(read()).toEqual({ capacity: 4, capacity_observed_at: iso() });
|
||||
for (const value of [null, 0, -1, 4.5, NaN]) {
|
||||
api.publish('server-0', value);
|
||||
expect(read()).toEqual(unavailable);
|
||||
}
|
||||
api.publish('server-0', 4, iso(), false);
|
||||
expect(read()).toEqual(unavailable);
|
||||
api.publish('server-0');
|
||||
api.database.prepare("UPDATE servers SET last_checked_at=?,last_error='timeout:newer failure'").run(iso(1));
|
||||
expect(read()).toEqual(unavailable);
|
||||
});
|
||||
|
||||
it('retains the original sample age even when the container is checked again', async () => {
|
||||
const api = await setup();
|
||||
api.publish('server-0');
|
||||
vi.setSystemTime(now + 90_001);
|
||||
api.publish('server-0', 4, iso());
|
||||
expect(api.playerCapacityReader()('server-0')).toEqual(unavailable);
|
||||
});
|
||||
|
||||
it.each(['generation', 'lifecycle', 'registration', 'offline'] as const)('rejects capacity after %s changes', async change => {
|
||||
const api = await setup();
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const host = hosts.createHost({ name: 'Capacity host' });
|
||||
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1 });
|
||||
api.database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('instance','server-0',?,'library','image',28960,?)`).run(host.id, iso());
|
||||
api.publish('server-0');
|
||||
expect(api.playerCapacityReader()('server-0').capacity).toBe(4);
|
||||
if (change === 'generation') api.database.prepare('UPDATE native_instances SET generation=2').run();
|
||||
if (change === 'lifecycle') api.database.prepare("INSERT INTO restart_rollout(server,requested_at) VALUES('server-0',?)").run(iso(1));
|
||||
if (change === 'registration') api.database.prepare('UPDATE servers SET created_at=?').run(iso(1));
|
||||
if (change === 'offline') api.database.prepare('UPDATE ec_hosts SET last_heartbeat=?').run(iso(-91_000));
|
||||
expect(api.playerCapacityReader()('server-0')).toEqual(unavailable);
|
||||
});
|
||||
|
||||
it('reads a fleet in one joined query and keeps server-detail reads scoped', async () => {
|
||||
const api = await setup(20);
|
||||
api.names.forEach(name => api.publish(name));
|
||||
// Initialize schema before measuring the snapshot's actual reads.
|
||||
api.playerCapacityReader();
|
||||
const prepare = vi.spyOn(api.database, 'prepare');
|
||||
const fleet = api.playerCapacityReader();
|
||||
expect(api.names.map(name => fleet(name).capacity)).toEqual(Array(20).fill(4));
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare.mock.calls[0][0]).toContain('WHERE s.destroyed_at IS NULL ORDER BY s.name');
|
||||
prepare.mockClear();
|
||||
const scoped = api.playerCapacityReader('server-0');
|
||||
expect(scoped('server-0').capacity).toBe(4);
|
||||
expect(scoped('server-1')).toEqual(unavailable);
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare.mock.calls[0][0]).toContain('WHERE s.name=? AND s.destroyed_at IS NULL');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,30 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('database fleet and container readiness', () => {
|
||||
it('publishes capacity from accepted monitoring samples and clears it on the next failed check', async () => {
|
||||
const { inspect, rcon, fanoutStatus } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const at = Date.parse('2026-10-07T12:00:00Z');
|
||||
vi.setSystemTime(at);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
status: { players: 3, capacity: 4, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
const events: unknown[] = [];
|
||||
const stop = subscribe(event => { if (event.type === 'server-status') events.push(event); });
|
||||
try {
|
||||
await fanoutStatus();
|
||||
expect(events.at(-1)).toMatchObject({ entries: [{ capacity: 4, capacityObservedAt: new Date(at).toISOString() }] });
|
||||
expect((await GET().json()).servers[0]).toMatchObject({ capacity: 4, capacity_observed_at: new Date(at).toISOString() });
|
||||
vi.setSystemTime(at + 30_000);
|
||||
rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' });
|
||||
await fanoutStatus();
|
||||
expect(events.at(-1)).toMatchObject({ entries: [{ capacity: null, capacityObservedAt: null }] });
|
||||
expect((await GET().json()).servers[0]).toMatchObject({ capacity: null, capacity_observed_at: null });
|
||||
} finally { stop(); }
|
||||
});
|
||||
|
||||
it('requires two distinct failed monitoring checks, preserves diagnostics, and resets on success', async () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
|
||||
@@ -86,6 +86,33 @@ function queueEvent(type: string, data: object) {
|
||||
}
|
||||
|
||||
describe('animation-frame stream batching', () => {
|
||||
it('replaces snapshot capacity with live values and honors explicit unavailable samples', async () => {
|
||||
const at = new Date(epoch).toISOString();
|
||||
snapshot.servers = [server(epoch, { capacity: 4, capacity_observed_at: at })];
|
||||
const { fleetServers } = await setup();
|
||||
expect(get(fleetServers)[0]).toMatchObject({ capacity: 4, capacity_observed_at: at });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
emit(epoch + 1000, [status(epoch + 1000, { capacity: 8, capacityObservedAt: new Date(epoch + 1000).toISOString() })]);
|
||||
expect(get(fleetServers)[0].capacity).toBe(8);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
emit(epoch + 2000, [status(epoch + 2000, { capacity: null, capacityObservedAt: null })]);
|
||||
expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null });
|
||||
});
|
||||
|
||||
it('expires capacity independently of fresh rosters and rejects the previous lifecycle limit', async () => {
|
||||
const at = (offset = 0) => new Date(epoch + offset).toISOString();
|
||||
snapshot.servers = [server(epoch, { capacity: 4, capacity_observed_at: at(-90_000),
|
||||
container_state: 'running', container_checked_at: at() })];
|
||||
const { fleetServers } = await setup();
|
||||
expect(get(fleetServers)[0].capacity).toBe(4);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
queueEvent('server-telemetry', { serverNow: epoch + 1000, entries: [{ ...gameEvent('heartbeat', 15, 3), receivedAt: at(1000) }] });
|
||||
flushFrame();
|
||||
expect(get(fleetServers)[0]).toMatchObject({ health: 'online', last_players: 3, capacity: null, capacity_observed_at: null });
|
||||
emit(epoch + 1000, [status(epoch + 1000, { capacity: 4, capacityObservedAt: at(), telemetryAfter: at(1) })]);
|
||||
expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null });
|
||||
});
|
||||
|
||||
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' })]);
|
||||
|
||||
Reference in New Issue
Block a user