Files
electric-cherry/tests/unit/metrics.test.ts
T

988 lines
47 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { parseMetricsQuery } from '../../src/lib/metrics.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
async function seedMatches(
records: Array<{
id: number;
at: number;
players: number;
game?: string;
map?: string;
status?: 'active' | 'completed' | 'interrupted';
}>
) {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, server_deleted_at, game, map, map_key, map_name, started_at,
last_received_at, status, start_observed, start_players, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, 'zm_transit', 'shared-key', ?, ?, ?, ?, 0, 4, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, present, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 0, ?, ?)`);
for (const record of records) {
const at = new Date(record.at).toISOString();
match.run(
record.id,
at,
record.game ?? 't5',
record.map ?? 'Moon',
at,
at,
record.status ?? 'completed'
);
for (let index = 0; index < record.players; index++) {
player.run(record.id, `player-${index}`, at, at);
}
}
return handle;
}
describe('historical metrics', () => {
it.each([1, 7, 30] as const)(
'counts retained matches started within the inclusive %i-day window regardless of status or observed start',
async (days) => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const since = now - days * 86400_000;
const handle = await seedMatches([
{ id: 1, at: since - 1, players: 4 },
{ id: 2, at: since, players: 1, status: 'completed' },
{ id: 3, at: now, players: 2, status: 'active' },
{ id: 4, at: since + 1, players: 0, status: 'interrupted' },
{ id: 5, at: now + 1, players: 4, map: 'Future Map' }
]);
// A recent update does not bring an earlier match into the selected window.
handle
.prepare('UPDATE leaderboard_matches SET last_received_at=?, ended_at=? WHERE id=1')
.run(new Date(now).toISOString(), new Date(now).toISOString());
const result = readMetrics(days, now);
expect(result.matches).toEqual({
started: 3,
withRecordedPlayers: 2,
averageGroupSize: 1.5,
solo: 1,
soloShare: 50
});
expect(result.maps).toHaveLength(1);
expect(result.maps[0].matches).toEqual(result.matches);
expect(result.maps[0].running).toEqual({ average: null, peak: 0, capturedHours: 0 });
expect(result.registeredServerCount).toBe(0);
expect(result.sampledServerCount).toBe(0);
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
}
);
it('weights group sizes by matches and counts unique participants per match across repeated updates', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const handle = await seedMatches([
{ id: 1, at: now - 5000, players: 1 },
{ id: 2, at: now - 4000, players: 1 },
{ id: 3, at: now - 3000, players: 4 },
{ id: 4, at: now - 2000, players: 0 },
{ id: 5, at: now - 1000, players: 4, map: 'Town' }
]);
for (let update = 0; update < 3; update++) {
handle
.prepare(
`INSERT INTO leaderboard_match_players
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, 'player-0', ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET kills=kills+1, last_seen_at=excluded.last_seen_at`
)
.run(new Date(now).toISOString(), new Date(now).toISOString());
}
const result = readMetrics(1, now);
expect(result.matches).toEqual({
started: 5,
withRecordedPlayers: 4,
averageGroupSize: 2.5,
solo: 2,
soloShare: 50
});
const moon = result.maps.find((map) => map.map === 'Moon')!.matches;
expect(moon).toMatchObject({
started: 4,
withRecordedPlayers: 3,
averageGroupSize: 2,
solo: 2
});
expect(moon.soloShare).toBeCloseTo((2 / 3) * 100);
expect(result.maps.find((map) => map.map === 'Town')!.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 4,
solo: 0,
soloShare: 0
});
expect(result.firstRetainedSampleAt).toBeNull();
expect(result.maps.every((map) => map.observed === 0 && map.playerHours === 0)).toBe(true);
});
it('distinguishes no matches, unknown rosters, and occupancy-only maps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const empty = {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
};
expect(readMetrics(1, now).matches).toEqual(empty);
recordMetrics(
[{ server: 'sample-only', game: 't5', map: 'Moon', players: 1, capacity: 4 }],
now - 30_000
);
await seedMatches([{ id: 1, at: now - 60_000, players: 0, map: 'Town' }]);
const result = readMetrics(1, now);
expect(result.matches).toEqual({ ...empty, started: 1 });
expect(result.maps.find((map) => map.map === 'Moon')!.matches).toEqual(empty);
expect(result.maps.find((map) => map.map === 'Town')).toMatchObject({
matches: { ...empty, started: 1 },
observed: 0,
active: 0,
idle: 0,
unknown: 0,
playerHours: 0,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0
});
});
it('filters matches by game and exact recorded map label while retaining historical match scope options', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
await seedMatches([
{ id: 1, at: now - 1000, players: 1, game: 't6', map: 'Town Survival' },
{ id: 2, at: now - 2000, players: 2, game: 't6', map: 'Town Grief' },
{ id: 3, at: now - 3000, players: 4, game: 't5', map: 'Town Survival' },
{ id: 4, at: now - 2 * 86400_000, players: 3, game: 't6', map: 'Farm Survival' },
{ id: 5, at: now + 1, players: 4, game: 't6', map: 'Future Map' }
]);
const query = parseMetricsQuery(
new URLSearchParams({ days: '1', game: 't6', map: 'Town Survival' })
);
const selected = readMetrics(query, now);
expect(selected.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 1,
solo: 1,
soloShare: 100
});
expect(selected.maps.map((map) => [map.game, map.map])).toEqual([['t6', 'Town Survival']]);
expect(selected.scopeOptions).toEqual({
games: ['t5', 't6'],
maps: [
{ game: 't5', map: 'Town Survival' },
{ game: 't6', map: 'Farm Survival' },
{ game: 't6', map: 'Town Grief' },
{ game: 't6', map: 'Town Survival' }
]
});
const game = readMetrics({ ...query, map: null }, now);
expect(game.matches).toMatchObject({ started: 2, averageGroupSize: 1.5, soloShare: 50 });
expect(game.maps.map((map) => map.map).sort()).toEqual(['Town Grief', 'Town Survival']);
expect(readMetrics({ ...query, days: 7, map: null }, now).matches.started).toBe(3);
expect(readMetrics({ ...query, game: 't5' }, now).matches.averageGroupSize).toBe(4);
expect(readMetrics({ ...query, map: 'zm_transit' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'shared-key' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'Farm Survival' }, now)).toMatchObject({
matches: { started: 0, averageGroupSize: null, soloShare: null },
maps: []
});
});
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 0 }], at + 30_000);
recordMetrics([{ ...sample, players: null }], at + 60_000);
const result = readMetrics(1, at + 3600_000);
expect(result.summary.active * 3600).toBeCloseTo(30);
expect(result.summary.idle * 3600).toBeCloseTo(30);
expect(result.summary.unknown * 3600).toBeCloseTo(90);
expect(result.summary.playerHours * 3600).toBeCloseTo(60);
expect(result.points.reduce((sum, p) => sum + p.observed, 0)).toBeCloseTo(
result.summary.observed
);
});
it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
recordMetrics([sample, { ...sample, server: 'two' }], at);
recordMetrics(
[
{ ...sample, players: 1 },
{ ...sample, server: 'two', players: 2 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, capacity: null },
{ ...sample, server: 'two', capacity: null }
],
at + 60_000
);
recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000);
const result = readMetrics(1, at + 120_000);
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown).toBe(0);
});
it('treats an empty spare as available and all-unknown map observations as unknown', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: 0 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: null }
],
at + 60_000
);
const result = readMetrics(1, at + 90_000);
expect(result.maps[0].noEmpty).toBe(0);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
expect(result.pooledAvailability.value).toBe(0);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
});
it('clips the reporting window and splits observations across UTC chart boundaries', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const midnight = Date.UTC(2026, 0, 2);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([sample], midnight - 15_000);
let result = readMetrics(1, midnight + 15_000);
expect(result.points.filter((p) => p.active > 0).map((p) => p.active * 3600)).toEqual([
15, 15
]);
result = readMetrics(1, midnight + 86400_000);
expect(result.summary.active * 3600).toBeCloseTo(75);
expect(result.points.reduce((sum, p) => sum + p.active, 0)).toBeCloseTo(
result.summary.active
);
});
it('retains history without a live server and prunes observations older than 31 days', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 2);
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
recordMetrics([sample], at);
expect(readMetrics(1, at + 30_000)).toMatchObject({
sampledServerCount: 1,
registeredServerCount: 0,
maps: [{ game: 't6', map: 'Town', active: 30 / 3600 }]
});
recordMetrics([sample], at + 32 * 86400_000);
expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 });
});
it('distinguishes retained history, an empty range, and current collection targets', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 2);
expect(readMetrics(1, at)).toMatchObject({
firstRetainedSampleAt: null,
latestSampleAt: null,
registeredServerCount: 0,
sampledServerCount: 0,
maps: []
});
db().prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)').run('current', null);
db()
.prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)')
.run('removed', new Date(at).toISOString());
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
recordMetrics([sample], at);
const emptyRange = readMetrics(1, at + 2 * 86400_000);
expect(emptyRange).toMatchObject({
firstRetainedSampleAt: at,
latestSampleAt: at,
registeredServerCount: 1,
sampledServerCount: 0,
maps: [],
summary: { observed: 0, unknown: 0 }
});
expect(
emptyRange.points.every((point) => point.observed === 0 && point.unknown === 0)
).toBe(true);
});
it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics(
[{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }],
at
);
const initial = readMetrics(1, at);
expect(initial.latestSampleAt).toBe(at);
expect(initial.sampledServerCount).toBe(1);
expect(initial.summary).toEqual({
observed: 0,
active: 0,
idle: 0,
unknown: 0,
playerHours: 0
});
const later = readMetrics(1, at + 120_000);
expect(later.summary.observed).toBe(0);
expect(later.summary.unknown * 3600).toBeCloseTo(90);
expect(later.maps[0].availabilityObserved).toBe(0);
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
expect(later.pooledAvailability.value).toBeNull();
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
expect(initial.maps[0].running).toEqual(initial.running);
expect(later.maps[0].running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
});
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 8, 12, 30);
for (const days of [1, 7, 30] as const) {
const result = readMetrics(days, now);
expect(result.points[0].startAt).toBe(result.since);
expect(result.points.at(-1)?.endAt).toBe(now);
expect(result.points[0].partial).toBe(true);
expect(result.points.at(-1)?.partial).toBe(true);
expect(result.points.slice(1, -1).every((point) => !point.partial)).toBe(true);
expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe(
days * 86400_000
);
}
expect(
readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every((point) => !point.partial)
).toBe(true);
});
it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 };
const two = { ...one, server: 'two' };
recordMetrics([one, two], at);
// Removing a lobby ends its previous observation at the next complete collection.
recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000);
// A collection outage is excluded; an identically named map in another game remains separate.
recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 150_000);
const result = readMetrics(1, at + 180_000);
expect(result.summary.active * 3600).toBeCloseTo(90);
expect(result.summary.idle * 3600).toBeCloseTo(90);
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
expect(result.sampledServerCount).toBe(2);
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60);
expect(town.availabilityObserved * 3600).toBeCloseTo(30);
expect(town.noEmpty * 3600).toBeCloseTo(30);
expect(
result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600
).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field]
);
expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(
result.summary[field]
);
}
});
it('does not include future samples in collection metadata or aggregates', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 4 }], at + 60_000);
const result = readMetrics(1, at + 30_000);
expect(result.latestSampleAt).toBe(at);
expect(result.sampledServerCount).toBe(1);
expect(result.summary.playerHours * 3600).toBeCloseTo(30);
});
it('updates retention in page loads and refreshes without new samples or cache expiry', async () => {
const { fleetSettings, saveFleetSettings } =
await import('../../src/lib/server/settings.js');
const { GET } = await import('../../src/routes/api/metrics/+server.js');
const { load } = await import('../../src/routes/metrics/+page.server.js');
vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 9, 10, 12));
const url = new URL('http://localhost/metrics?days=7');
const pageData = () => load({ url } as Parameters<typeof load>[0]);
expect(await pageData()).toMatchObject({ metricsKeepDays: 31 });
for (const metricsKeepDays of [90, 0, 1]) {
saveFleetSettings({ ...fleetSettings(), metricsKeepDays });
const payload = await (await GET({ url })).json();
expect(payload.metricsKeepDays).toBe(metricsKeepDays);
expect(payload).toEqual(await pageData());
}
});
it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => {
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { GET } = await import('../../src/routes/api/metrics/+server.js');
const { load } = await import('../../src/routes/metrics/+page.server.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
const url = new URL(`http://localhost/metrics?days=${range}`);
const response = await GET({ url });
const payload = await response.json();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
}
expect(
(await (await GET({ url: new URL('http://localhost/api/metrics') })).json()).days
).toBe(7);
for (const search of [
'days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu',
'days=7&map=Moon',
'game=t6&timeZone=Invalid'
]) {
const url = new URL(`http://localhost/metrics?${search}`);
const payload = await (await GET({ url })).json();
const { days, ...scope } = parseMetricsQuery(url.searchParams);
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload).toMatchObject({ days, scope });
}
});
it('normalizes malformed scopes consistently and requires a game for a map selection', () => {
expect(
parseMetricsQuery(new URLSearchParams('days=365&map=Moon&timeZone=Invalid/Zone'))
).toEqual({ days: 7, game: null, map: null, timeZone: 'America/New_York' });
expect(
parseMetricsQuery(new URLSearchParams({ game: 't5; DROP TABLE', map: 'Moon' }))
).toMatchObject({ game: null, map: null });
expect(
parseMetricsQuery(
new URLSearchParams({
game: ' t5 ',
map: 'Moon',
timeZone: 'Asia/Kathmandu',
days: '30'
})
)
).toEqual({ days: 30, game: 't5', map: 'Moon', timeZone: 'Asia/Kathmandu' });
for (const map of ['', ' ', 'a'.repeat(201), 'Moon\nTown']) {
expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map })).map).toBeNull();
}
expect(
parseMetricsQuery(new URLSearchParams({ game: 't5', map: 'Not recorded yet' })).map
).toBe('Not recorded yet');
});
it('filters every derived value by exact game and map while preserving unfiltered collection boundaries', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const moon = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
const other = { ...moon, server: 'other', game: 't6', players: 2 };
recordMetrics([moon, other], since - 30_000);
recordMetrics([other], since);
recordMetrics(
[moon, other, { ...moon, server: 'town', map: 'Town', players: 0 }],
now - 60_000
);
recordMetrics([other], now - 45_000);
recordMetrics([{ ...moon, players: 0 }, other], now - 30_000);
const query = parseMetricsQuery(
new URLSearchParams({ days: '1', game: 't5', map: 'Moon', timeZone: 'UTC' })
);
const result = readMetrics(query, now);
expect(result.scope).toEqual({ game: 't5', map: 'Moon', timeZone: 'UTC' });
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(30);
expect(result.summary.playerHours * 3600).toBeCloseTo(60);
expect(result.previous.summary.active * 3600).toBeCloseTo(30);
expect(result.previous.summary.playerHours * 3600).toBeCloseTo(120);
expect(result.sampledServerCount).toBe(1);
expect(result.maps.map((map) => [map.game, map.map])).toEqual([['t5', 'Moon']]);
expect(result.scopeOptions).toEqual({
games: ['t5', 't6'],
maps: [
{ game: 't5', map: 'Moon' },
{ game: 't5', map: 'Town' },
{ game: 't6', map: 'Moon' }
]
});
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
expect(result.pooledAvailability.value).toBeCloseTo(100 / 3);
expect(result.running.average).toBe(1);
expect(result.running.peak).toBe(1);
expect(result.running.capturedHours * 3600).toBeCloseTo(45);
expect(result.previous.running.average).toBe(1);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field]
);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell[field], 0)).toBeCloseTo(
result.summary[field]
);
}
const gameOnly = readMetrics({ ...query, map: null }, now);
expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
expect(gameOnly.running.average).toBeCloseTo(4 / 3);
expect(gameOnly.running.peak).toBe(2);
expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45);
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
expect(missing).toMatchObject({
scopeFirstSampleAt: null,
scopeLatestSampleAt: null,
summary: { observed: 0, unknown: 0 },
maps: [],
sampledServerCount: 0,
pooledAvailability: { value: null },
running: { average: null, peak: 0, capturedHours: 0 },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
});
expect(missing.scopeOptions).toEqual(result.scopeOptions);
});
it('separates scoped evidence timestamps from global collection timestamps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([{ ...sample, game: 't6' }], at - 60_000);
recordMetrics([sample], at - 30_000);
recordMetrics([{ ...sample, game: 't6' }], at);
const result = readMetrics(
parseMetricsQuery(new URLSearchParams({ game: 't5' })),
at + 30_000
);
expect(result.firstRetainedSampleAt).toBe(at - 60_000);
expect(result.latestSampleAt).toBe(at);
expect(result.scopeFirstSampleAt).toBe(at - 30_000);
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
});
it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: 0 };
const three = { ...one, server: 'three', players: null };
const four = { ...one, server: 'four', players: null };
recordMetrics([one, two, three, four], since - 120_000);
recordMetrics([one, two, three], now - 180_000);
recordMetrics([three], now - 150_000);
recordMetrics([one, two], now - 30_000);
const result = readMetrics(1, now);
expect(result.running.average).toBeCloseTo(1.6);
expect(result.sampledServerCount).toBe(3);
expect(result.running.peak).toBe(3);
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
expect(result.maps[0].running).toEqual(result.running);
expect(result.previous.running.average).toBe(4);
expect(result.previous.running.peak).toBe(4);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo(
result.running.capturedHours
);
const point = result.points.find((point) => point.capturedHours > 0)!;
expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6);
expect(result.summary.observed + result.summary.unknown).toBeCloseTo(
result.running.average! * result.running.capturedHours
);
});
it('counts concurrent lobbies per recorded game/map across reused names, clipped intervals and collection gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const one = { server: 'one', game: 't5', map: 'Moon', players: 0, capacity: null };
const two = { ...one, server: 'two', players: null };
const otherGame = { ...one, server: 't6-one', game: 't6' };
recordMetrics([one, two, otherGame], since - 30_000);
recordMetrics([{ ...one, server: 'three', players: 2 }], since + 15_000);
recordMetrics(
[
otherGame,
{ ...otherGame, server: 't6-two' },
{ ...otherGame, server: 't6-three' },
{ ...one, map: 'Kino Der Toten' }
],
since + 45_000
);
// Both lobby names are reused after the map was absent from a collection.
recordMetrics([one, { ...two, players: 0 }], now - 30_000);
// A snapshot with no elapsed time must not inflate the captured peak.
recordMetrics([one, two, { ...one, server: 'three' }], now);
const result = readMetrics(1, now);
const moon = result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!;
expect(moon.running.average).toBeCloseTo(1.6);
expect(moon.running.peak).toBe(2);
expect(moon.running.capturedHours * 3600).toBeCloseTo(75);
expect(moon.idle * 3600).toBeCloseTo(75);
expect(moon.unknown * 3600).toBeCloseTo(15);
expect(moon.observed + moon.unknown).toBeCloseTo(
moon.running.average! * moon.running.capturedHours
);
const t6 = result.maps.find((map) => map.game === 't6')!;
expect(t6.running.average).toBeCloseTo(285 / 105);
expect(t6.running.peak).toBe(3);
expect(t6.running.capturedHours * 3600).toBeCloseTo(105);
const kino = result.maps.find((map) => map.map === 'Kino Der Toten')!;
expect(kino.running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
const scoped = readMetrics({ days: 1, game: 't5', map: 'Moon', timeZone: 'UTC' }, now);
expect(scoped.maps).toEqual([moon]);
expect(scoped.running).toEqual(moon.running);
});
it('matches current policy context through map aliases and prefers the durable owner', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
const insert = db().prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at)
VALUES (?,?,?,?,?,?,?,?)`
);
insert.run(
't5-all',
't5',
'*',
JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }),
'create-destroy',
null,
1,
'now'
);
insert.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'off',
'Operator paused.',
0,
'now'
);
insert.run(
'town',
't6',
'town',
JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }),
'create-destroy',
null,
1,
'now'
);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics(
[
sample,
{ ...sample, server: 'two', map: 'Kino Der Toten' },
{ ...sample, server: 'three', game: 't6', map: 'Town Survival' },
{ ...sample, server: 'four', game: 't6', map: 'zm_transit' },
{ ...sample, server: 'five', game: 't6', map: 'Moon' },
{ ...sample, server: 'six', map: 'zombie_cosmodrome' },
{ ...sample, server: 'seven', map: 'unknown-map' }
],
at
);
const result = readMetrics(1, at + 30_000);
expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({
id: 't5-all',
mode: 'create-destroy',
minimum: 1,
maximum: 4,
spares: 2,
pausedReason: null
});
expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all');
expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe(
't5-all'
);
expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town');
expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull();
expect(
result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy
).toBeNull();
expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull();
db().prepare('UPDATE autoscaling_policies SET owns_map=0').run();
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
const beforeGlobalPause = readMetrics(1, at + 30_000);
expect(beforeGlobalPause.autoscalingPaused).toBe(false);
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
const globallyPaused = readMetrics(1, at + 30_000);
expect(globallyPaused.autoscalingPaused).toBe(true);
expect(globallyPaused.maps).toEqual(beforeGlobalPause.maps);
expect(
globallyPaused.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy
?.pausedReason
).toBe('Operator paused.');
});
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
db()
.prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)`
)
.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'create-destroy',
1,
'now'
);
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: null };
recordMetrics([one], at);
recordMetrics([one, two], at + 30_000);
recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000);
recordMetrics([one, { ...two, players: 0 }], at + 90_000);
recordMetrics([{ ...one, players: null }, two], at + 120_000);
const result = readMetrics(1, at + 150_000);
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90);
expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60);
expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
db()
.prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)")
.run();
const revised = readMetrics(1, at + 150_000).maps[0];
expect(revised.noEmptyAtMaximum).toBe(0);
expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90);
});
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], since - 15_000);
recordMetrics([{ ...sample, players: 0 }], since + 15_000);
const result = readMetrics(1, now);
expect(result.previous.since).toBe(now - 2 * 86400_000);
expect(result.previous.until).toBe(result.since);
expect(result.previous.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(90);
expect(result.previous).toMatchObject({
firstSampleAt: since - 15_000,
lastSampleAt: since - 15_000,
partialHistory: true
});
});
it('returns the available prior evidence when a thirty-day comparison exceeds retained history', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 2, 3, 12);
const day = 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], now - 31 * day + 30_000);
recordMetrics([{ ...sample, players: 0 }], now - 30 * day + 30_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(30, now);
expect(result.previous).toMatchObject({
since: now - 60 * day,
until: now - 30 * day,
partialHistory: true
});
expect(result.previous.summary.active * 3600).toBeCloseTo(90);
expect(result.previous.firstSampleAt).toBe(now - 31 * day + 30_000);
expect(result.summary.active * 3600).toBeCloseTo(30);
expect(result.summary.idle * 3600).toBeCloseTo(90);
});
it('marks the retained start bound separately from gaps inside the previous window', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const priorStart = now - 2 * 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics([sample], priorStart - 15_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(1, now);
expect(result.previous.partialHistory).toBe(false);
expect(result.previous.summary.unknown * 3600).toBeCloseTo(75);
expect(result.previous.summary.observed).toBe(0);
expect(result.previous.firstSampleAt).toBe(priorStart - 15_000);
// Having the left edge says nothing about the uncaptured time after it.
expect(result.previous.summary.unknown).toBeLessThan(24);
});
it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics(
[
occupied,
{ ...occupied, server: 'two' },
{ ...occupied, server: 'three', map: 'Town', players: 0 },
{ ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null },
{ ...occupied, server: 'five', map: 'Unknown', players: null }
],
at
);
const result = readMetrics(1, at + 30_000);
expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 });
expect(result.pooledAvailability.value).toBeCloseTo(200 / 3);
expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60);
expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
expect(result.summary.observed * 3600).toBeCloseTo(120);
const empty = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
at + 30_000
);
expect(empty.pooledAvailability.value).toBe(0);
const missingCapacity = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
at + 30_000
);
expect(missingCapacity.pooledAvailability.value).toBe(100);
const unknown = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })),
at + 30_000
);
expect(unknown.pooledAvailability.value).toBeNull();
});
it.each([
{ zone: 'America/New_York', at: '2026-03-08T06:59:45Z', first: [6, 1], second: [6, 3] },
{ zone: 'Asia/Kolkata', at: '2026-01-01T18:29:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Asia/Kathmandu', at: '2026-01-01T18:14:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Australia/Lord_Howe', at: '2026-10-03T15:29:45Z', first: [6, 1], second: [6, 2] }
])(
'splits heatmap time across local boundaries in $zone',
async ({ zone, at: timestamp, first, second }) => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse(timestamp);
recordMetrics(
[{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }],
at
);
const result = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: zone })),
at + 30_000
);
expect(result.heatmap.timeZone).toBe(zone);
expect(result.heatmap.cells).toHaveLength(168);
for (const [weekday, hour] of [first, second]) {
const cell = result.heatmap.cells.find(
(cell) => cell.weekday === weekday && cell.hour === hour
)!;
expect(cell.observed * 3600).toBeCloseTo(15);
expect(cell.playerHours * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
}
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBeCloseTo(
result.summary.observed
);
}
);
it('weights repeated fall-back hours and counts their local date once', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse('2026-11-01T05:30:00Z');
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 0 }], at + 3600_000);
const result = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: 'America/New_York' })),
at + 3630_000
);
const cell = result.heatmap.cells.find((cell) => cell.weekday === 6 && cell.hour === 1)!;
expect(cell.observed * 3600).toBeCloseTo(120);
expect(cell.active * 3600).toBeCloseTo(90);
expect(cell.idle * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
expect(result.heatmap.cells.filter((cell) => cell.observed > 0)).toHaveLength(1);
});
it('counts unknown heatmap evidence and sampled names independently of current registrations', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
const sample = { server: 'reused', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics(
[sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }],
at
);
// Current registration counts stay separate from sampled names, including reuse.
db()
.prepare('INSERT INTO servers (name, created_at) VALUES (?, ?)')
.run('reused', new Date(at + 15_000).toISOString());
db()
.prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)')
.run('tombstone', new Date(at).toISOString());
const result = readMetrics(1, at + 30_000);
expect(result.sampledServerCount).toBe(3);
expect(result.registeredServerCount).toBe(1);
expect(result).not.toHaveProperty('servers');
expect(
result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600
).toBeCloseTo(90);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBe(0);
expect(result.heatmap.cells.find((cell) => cell.unknown > 0)!.distinctDates).toBe(1);
});
});