This commit is contained in:
@@ -12,7 +12,200 @@ afterEach(async () => {
|
||||
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.registeredServerCount).toBe(0);
|
||||
expect(result.servers).toEqual([]);
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user