Files
electric-cherry/tests/unit/metrics-players.test.ts
T
2026-10-10 18:47:56 -04:00

213 lines
9.4 KiB
TypeScript

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();
});
});