This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import type { MetricsDays } from '../../src/lib/metrics.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
const hour = 3600_000;
|
||||
const day = 24 * hour;
|
||||
const now = Date.UTC(2026, 9, 10, 12, 30);
|
||||
const iso = (at: number) => new Date(at).toISOString();
|
||||
|
||||
async function match(start: number, end: number, guids: string[], game = 't5', map = 'Moon') {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
const { lastInsertRowid: id } = handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
||||
VALUES ('removed-server',?,?,?, ?,?,?,'completed','game_end','test')`
|
||||
)
|
||||
.run(game, map, `${game}:${map}`, map, iso(start), iso(end));
|
||||
for (const guid of guids) {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id,guid,first_seen_at,last_seen_at) VALUES(?,?,?,?)`
|
||||
)
|
||||
.run(id, guid, iso(start), iso(end));
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||
VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET last_seen_at=excluded.last_seen_at`
|
||||
)
|
||||
.run(game, guid, guid, iso(start), iso(end));
|
||||
}
|
||||
return Number(id);
|
||||
}
|
||||
|
||||
describe('metrics player history', () => {
|
||||
it.each<MetricsDays>([1, 7, 30])(
|
||||
'counts spans and repeated matches once per period and range for %i days',
|
||||
async (days) => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const bucket = days === 1 ? hour : day;
|
||||
const boundary = Math.floor((now - bucket) / bucket) * bucket;
|
||||
await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||
await match(boundary - 10_000, boundary + 10_000, ['regular', 'newcomer']);
|
||||
await match(boundary + 20_000, boundary + 30_000, ['regular', 'newcomer']);
|
||||
const result = readMetrics(days, now);
|
||||
const before = result.points.find((point) => point.at === boundary - bucket)!;
|
||||
const after = result.points.find((point) => point.at === boundary)!;
|
||||
expect(before).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||
expect(after).toMatchObject({ uniquePlayers: 2, newPlayers: 0 });
|
||||
expect(result.players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||
expect(result.points.reduce((sum, point) => sum + (point.uniquePlayers ?? 0), 0)).toBe(
|
||||
4
|
||||
);
|
||||
expect(result.summary.observed).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses participation rather than match start and includes both range endpoints', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const since = now - day;
|
||||
await match(since - 2 * hour, since - 1, ['ended-before']);
|
||||
await match(since - hour, since + hour, ['spanning-start']);
|
||||
await match(since, since, ['at-start']);
|
||||
await match(now - hour, now + hour, ['spanning-end']);
|
||||
await match(now, now, ['at-end']);
|
||||
await match(now + 1, now + 1, ['future']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toMatchObject({ uniquePlayers: 4, newPlayers: 3 });
|
||||
expect(result.points[0]).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true });
|
||||
expect(result.points.at(-1)).toMatchObject({
|
||||
uniquePlayers: 2,
|
||||
newPlayers: 1,
|
||||
partial: true
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns a single sighting on an internal boundary only to the following period', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
await match(now - 2 * day, now - 2 * day, []);
|
||||
const boundary = Date.UTC(2026, 9, 10, 11);
|
||||
await match(boundary, boundary, ['boundary']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.points.find((point) => point.at === boundary - hour)).toMatchObject({
|
||||
uniquePlayers: 0,
|
||||
newPlayers: 0
|
||||
});
|
||||
expect(result.points.find((point) => point.at === boundary)).toMatchObject({
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 1
|
||||
});
|
||||
expect(result.points.find((point) => point.at === boundary + hour)).toMatchObject({
|
||||
uniquePlayers: 0,
|
||||
newPlayers: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates identity across games and scopes new players to their original match', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
await match(now - 2 * day, now - 2 * day, ['regular']);
|
||||
await match(
|
||||
now - 4 * hour,
|
||||
now - 4 * hour,
|
||||
['both-games', 'regular'],
|
||||
't6',
|
||||
'Town Survival'
|
||||
);
|
||||
await match(now - 3 * hour, now - 3 * hour, ['both-games', 'regular', 'moon-new']);
|
||||
await match(
|
||||
now - 2 * hour,
|
||||
now - 2 * hour,
|
||||
['moon-new', 'kino-new'],
|
||||
't5',
|
||||
'Kino Der Toten'
|
||||
);
|
||||
await match(now - hour, now - hour, ['town-new'], 't6', 'Town Survival');
|
||||
await match(now - hour, now - hour, ['grief-new'], 't6', 'Town Grief');
|
||||
const read = (game: string | null, map: string | null = null) =>
|
||||
readMetrics({ days: 1, game, map, timeZone: 'UTC' }, now);
|
||||
expect(read(null).players).toMatchObject({ uniquePlayers: 6, newPlayers: 5 });
|
||||
expect(read('t5').players).toMatchObject({ uniquePlayers: 4, newPlayers: 2 });
|
||||
expect(read('t5', 'Moon').players).toMatchObject({ uniquePlayers: 3, newPlayers: 1 });
|
||||
expect(read('t5', 'Kino Der Toten').players).toMatchObject({
|
||||
uniquePlayers: 2,
|
||||
newPlayers: 1
|
||||
});
|
||||
expect(read('t6', 'Town Survival').players).toMatchObject({
|
||||
uniquePlayers: 3,
|
||||
newPlayers: 2
|
||||
});
|
||||
expect(read('t6', 'Town Grief').players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
|
||||
expect(read('t5', 'Missing').players).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||
expect(read('t5', 'Missing').points.every((point) => point.uniquePlayers === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves periods before recording as gaps and marks the first retained period partial', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const start = now - 2 * hour;
|
||||
await match(start, start + 10_000, ['first']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toEqual({ historyStartAt: start, uniquePlayers: 1, newPlayers: 1 });
|
||||
expect(
|
||||
result.points
|
||||
.filter((point) => point.endAt <= start)
|
||||
.every((point) => point.uniquePlayers === null && point.newPlayers === null)
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.points.find((point) => point.at === Math.floor(start / hour) * hour)
|
||||
).toMatchObject({
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 1,
|
||||
partial: false,
|
||||
playerHistoryPartial: true
|
||||
});
|
||||
expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||
});
|
||||
|
||||
it('moves the history bound after retention without making returning players new', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const firstMatch = await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||
await match(now - day, now - day + hour, ['regular'], 't6', 'Town Survival');
|
||||
db().prepare('DELETE FROM leaderboard_matches WHERE id=?').run(firstMatch);
|
||||
const result = readMetrics(
|
||||
{ days: 30, game: 't6', map: 'Town Survival', timeZone: 'UTC' },
|
||||
now
|
||||
);
|
||||
expect(result.players).toEqual({
|
||||
historyStartAt: now - day,
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 0
|
||||
});
|
||||
expect(result.points[0].uniquePlayers).toBeNull();
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM leaderboard_players').get()).toEqual({
|
||||
n: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps no history unavailable, including when only durable player identities remain', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const assertMissing = () => {
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toEqual({
|
||||
historyStartAt: null,
|
||||
uniquePlayers: null,
|
||||
newPlayers: null
|
||||
});
|
||||
expect(
|
||||
result.points.every(
|
||||
(point) => point.uniquePlayers === null && point.newPlayers === null
|
||||
)
|
||||
).toBe(true);
|
||||
};
|
||||
assertMissing();
|
||||
await match(now - day, now - day, ['old']);
|
||||
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||
await match(now + hour, now + hour, ['future']);
|
||||
assertMissing();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user