Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+249
-90
@@ -3,12 +3,19 @@ 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(); });
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
describe('historical metrics', () => {
|
||||
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 { 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);
|
||||
@@ -19,11 +26,14 @@ describe('historical metrics', () => {
|
||||
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);
|
||||
expect(result.points.reduce((sum, p) => sum + p.observed, 0)).toBeCloseTo(
|
||||
result.summary.observed
|
||||
);
|
||||
});
|
||||
|
||||
it('only counts map saturation when every lobby has known capacity and is full', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
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);
|
||||
@@ -36,19 +46,25 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
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 { 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]);
|
||||
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);
|
||||
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 { 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 };
|
||||
@@ -59,29 +75,55 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
it('distinguishes retained history, an empty range, and current collection targets', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
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, servers: [], maps: [] });
|
||||
expect(readMetrics(1, at)).toMatchObject({
|
||||
firstRetainedSampleAt: null,
|
||||
latestSampleAt: null,
|
||||
registeredServerCount: 0,
|
||||
servers: [],
|
||||
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());
|
||||
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, servers: [], maps: [], summary: { observed: 0, unknown: 0 } });
|
||||
expect(emptyRange.points.every(point => point.observed === 0 && point.unknown === 0)).toBe(true);
|
||||
expect(emptyRange).toMatchObject({
|
||||
firstRetainedSampleAt: at,
|
||||
latestSampleAt: at,
|
||||
registeredServerCount: 1,
|
||||
servers: [],
|
||||
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 { 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);
|
||||
recordMetrics(
|
||||
[{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }],
|
||||
at
|
||||
);
|
||||
const initial = readMetrics(1, at);
|
||||
expect(initial.latestSampleAt).toBe(at);
|
||||
expect(initial.servers).toHaveLength(1);
|
||||
expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
|
||||
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);
|
||||
@@ -97,14 +139,19 @@ describe('historical metrics', () => {
|
||||
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(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);
|
||||
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 pressure in wall time', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
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' };
|
||||
@@ -117,21 +164,30 @@ describe('historical metrics', () => {
|
||||
expect(result.summary.active * 3600).toBeCloseTo(90);
|
||||
expect(result.summary.idle * 3600).toBeCloseTo(90);
|
||||
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
|
||||
expect(result.servers.find(server => server.server === 'two')!.observed * 3600).toBeCloseTo(30);
|
||||
const town = result.maps.find(map => map.game === 't6' && map.map === 'Town')!;
|
||||
expect(
|
||||
result.servers.find((server) => server.server === 'two')!.observed * 3600
|
||||
).toBeCloseTo(30);
|
||||
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
|
||||
expect(town.observed * 3600).toBeCloseTo(60);
|
||||
expect(town.capacityObserved * 3600).toBeCloseTo(30);
|
||||
expect(town.full * 3600).toBeCloseTo(30);
|
||||
expect(result.maps.find(map => map.game === 't5')!.capacityObserved).toBe(0);
|
||||
expect(result.maps.find((map) => map.game === 't5')!.capacityObserved).toBe(0);
|
||||
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.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo(result.summary[field]);
|
||||
expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(result.summary[field]);
|
||||
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
|
||||
result.summary[field]
|
||||
);
|
||||
expect(result.servers.reduce((sum, server) => sum + server[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 { 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);
|
||||
@@ -156,8 +212,14 @@ describe('historical metrics', () => {
|
||||
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']) {
|
||||
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);
|
||||
@@ -167,29 +229,48 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
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');
|
||||
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 { 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(
|
||||
[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 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);
|
||||
@@ -197,35 +278,54 @@ describe('historical metrics', () => {
|
||||
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.servers.map(server => server.server)).toEqual(['one']);
|
||||
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.servers.map((server) => server.server)).toEqual(['one']);
|
||||
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.pooledPressure.value).toBeCloseTo(100 / 3);
|
||||
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]);
|
||||
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.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
|
||||
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
|
||||
expect(missing).toMatchObject({ scopeFirstSampleAt: null, scopeLatestSampleAt: null,
|
||||
summary: { observed: 0, unknown: 0 }, maps: [], servers: [], pooledPressure: { value: null },
|
||||
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true } });
|
||||
expect(missing).toMatchObject({
|
||||
scopeFirstSampleAt: null,
|
||||
scopeLatestSampleAt: null,
|
||||
summary: { observed: 0, unknown: 0 },
|
||||
maps: [],
|
||||
servers: [],
|
||||
pooledPressure: { value: null },
|
||||
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 { 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);
|
||||
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);
|
||||
@@ -233,7 +333,8 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
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 { 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 };
|
||||
@@ -245,11 +346,16 @@ describe('historical metrics', () => {
|
||||
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 });
|
||||
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 { 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 };
|
||||
@@ -257,7 +363,11 @@ describe('historical metrics', () => {
|
||||
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).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);
|
||||
@@ -265,7 +375,8 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
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 { 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 };
|
||||
@@ -281,19 +392,33 @@ describe('historical metrics', () => {
|
||||
});
|
||||
|
||||
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
|
||||
recordMetrics([full, { ...full, server: 'two' }, { ...full, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }], at);
|
||||
recordMetrics(
|
||||
[
|
||||
full,
|
||||
{ ...full, server: 'two' },
|
||||
{ ...full, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }
|
||||
],
|
||||
at
|
||||
);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
|
||||
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
|
||||
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
|
||||
expect(result.summary.observed * 3600).toBeCloseTo(120);
|
||||
const empty = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })), at + 30_000);
|
||||
const empty = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(empty.pooledPressure.value).toBe(0);
|
||||
const missing = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })), at + 30_000);
|
||||
const missing = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(missing.pooledPressure.value).toBeNull();
|
||||
});
|
||||
|
||||
@@ -302,52 +427,86 @@ describe('historical metrics', () => {
|
||||
{ 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);
|
||||
])(
|
||||
'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
|
||||
);
|
||||
}
|
||||
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 { 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)!;
|
||||
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);
|
||||
expect(result.heatmap.cells.filter((cell) => cell.observed > 0)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('counts unknown heatmap evidence and distinguishes retained names from current registrations', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
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);
|
||||
recordMetrics(
|
||||
[sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }],
|
||||
at
|
||||
);
|
||||
// A newly registered name can link to the current server without asserting
|
||||
// that its earlier historical observations belong to this registration.
|
||||
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());
|
||||
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.servers.find(server => server.server === 'reused')!.currentRegistered).toBe(true);
|
||||
expect(result.servers.filter(server => server.server !== 'reused').every(server => !server.currentRegistered)).toBe(true);
|
||||
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600).toBeCloseTo(90);
|
||||
expect(result.servers.find((server) => server.server === 'reused')!.currentRegistered).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
result.servers
|
||||
.filter((server) => server.server !== 'reused')
|
||||
.every((server) => !server.currentRegistered)
|
||||
).toBe(true);
|
||||
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);
|
||||
expect(result.heatmap.cells.find((cell) => cell.unknown > 0)!.distinctDates).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user