277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import type Database from 'better-sqlite3';
|
|
import type { LeaderboardMetric } from '../../src/lib/leaderboard.js';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([]);
|
|
});
|
|
afterEach(async () => fixture.cleanup());
|
|
|
|
interface Sample {
|
|
kills?: number;
|
|
headshots?: number;
|
|
downs?: number;
|
|
rounds?: number;
|
|
completed?: number;
|
|
game?: string;
|
|
map?: string;
|
|
status?: 'active' | 'completed' | 'interrupted';
|
|
}
|
|
function addSamples(handle: Database.Database, guid: string, samples: Sample[]) {
|
|
for (const sample of samples) {
|
|
const {
|
|
kills = 20,
|
|
headshots = 10,
|
|
downs = 1,
|
|
rounds = 2,
|
|
completed = 1,
|
|
game = 't5',
|
|
map = 'theater',
|
|
status = 'completed'
|
|
} = sample;
|
|
const inserted = handle
|
|
.prepare(
|
|
`INSERT INTO leaderboard_matches
|
|
(server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status,
|
|
highest_round, last_event, last_fingerprint)
|
|
VALUES ('removed-server', ?, ?, ?, ?, '2026-09-01', '2026-09-02', '2026-09-02', ?, 20, 'game_end', 'test')`
|
|
)
|
|
.run(game, map, `${game}:${map}`, map, status);
|
|
handle
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
|
VALUES (?, ?, ?, '2026-09-01', '2026-09-02')`
|
|
)
|
|
.run(game, guid, guid);
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, headshots,
|
|
downs, rounds_survived, completed, has_stats, first_seen_at, last_seen_at)
|
|
VALUES (?, ?, 20, ?, ?, ?, ?, ?, 1, '2026-09-01', '2026-09-02')`
|
|
)
|
|
.run(inserted.lastInsertRowid, guid, kills, headshots, downs, rounds, completed);
|
|
}
|
|
}
|
|
const samples = (count: number, sample: Sample = {}) =>
|
|
Array.from({ length: count }, () => ({ ...sample }));
|
|
|
|
it('calculates every efficiency measure from aggregate counts and preserves raw ratios', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(db(), 'weighted', [
|
|
{ kills: 1, headshots: 1, downs: 1, rounds: 1, completed: 0 },
|
|
{ kills: 99, headshots: 9, downs: 9, rounds: 9 },
|
|
...samples(3, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 })
|
|
]);
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const entry = getLeaderboard().entries[0];
|
|
expect(entry).toMatchObject({
|
|
kills: 100,
|
|
headshots: 10,
|
|
downs: 10,
|
|
roundsSurvived: 10,
|
|
gamesPlayed: 5,
|
|
gamesCompleted: 1,
|
|
headshotRate: 0.1,
|
|
killsPerDown: 10,
|
|
averageKills: 20,
|
|
averageRounds: 2,
|
|
completionRate: 0.2
|
|
});
|
|
const { efficiencyMetrics, efficiencyValue } =
|
|
await import('../../src/lib/leaderboard-efficiency.js');
|
|
for (const { key } of efficiencyMetrics) {
|
|
expect(entry[key]).toBe(efficiencyValue(entry, key));
|
|
expect(getLeaderboard({ metric: key }).entries).toEqual([entry]);
|
|
}
|
|
});
|
|
|
|
it('qualifies at five games, one hundred kills and five downs, with each metric enforcing its own sample', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(db(), 'exact', samples(5));
|
|
addSamples(db(), 'four-games', samples(4, { kills: 1000, headshots: 1000, downs: 5 }));
|
|
addSamples(db(), 'ninety-nine-kills', [{ kills: 19 }, ...samples(4)]);
|
|
addSamples(db(), 'four-downs', [{ downs: 0 }, ...samples(4)]);
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const ids = (metric: LeaderboardMetric) =>
|
|
getLeaderboard({ metric })
|
|
.entries.map((entry) => entry.guid)
|
|
.sort();
|
|
expect(ids('headshotRate')).toEqual(['exact', 'four-downs']);
|
|
expect(ids('killsPerDown')).toEqual(['exact', 'ninety-nine-kills']);
|
|
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
|
expect(ids(metric)).toEqual(['exact', 'four-downs', 'ninety-nine-kills']);
|
|
}
|
|
expect(getLeaderboard({ metric: 'kills' }).total).toBe(4);
|
|
expect(getLeaderboard({ metric: 'headshotRate' }).summary).toEqual(getLeaderboard().summary);
|
|
});
|
|
|
|
it('keeps undefined ratios null and unranked while ranking legitimate zero efficiencies', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(
|
|
db(),
|
|
'zero',
|
|
samples(5, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 })
|
|
);
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
expect(getLeaderboard().entries[0]).toMatchObject({
|
|
headshotRate: null,
|
|
killsPerDown: null,
|
|
averageKills: 0,
|
|
averageRounds: 0,
|
|
completionRate: 0
|
|
});
|
|
for (const metric of ['headshotRate', 'killsPerDown'] as const) {
|
|
expect(getLeaderboard({ metric })).toMatchObject({
|
|
entries: [],
|
|
total: 0,
|
|
summary: { players: 1, matches: 5, kills: 0 }
|
|
});
|
|
}
|
|
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
|
expect(getLeaderboard({ metric })).toMatchObject({
|
|
total: 1,
|
|
entries: [{ guid: 'zero', rank: 1 }]
|
|
});
|
|
}
|
|
addSamples(db(), 'no-kills', samples(5, { kills: 0, headshots: 0, downs: 1 }));
|
|
expect(getLeaderboard({ metric: 'killsPerDown' }).entries).toEqual([
|
|
expect.objectContaining({ guid: 'no-kills', killsPerDown: 0, rank: 1 })
|
|
]);
|
|
});
|
|
|
|
it('recomputes qualification within selected games and maps using completed matches only', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(db(), 'split', [...samples(3), ...samples(2, { game: 't6', map: 'town' })]);
|
|
addSamples(db(), 'unfinished', [
|
|
...samples(4),
|
|
{ status: 'active', kills: 10000 },
|
|
{ status: 'interrupted', kills: 10000 }
|
|
]);
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
expect(getLeaderboard({ metric: 'averageKills' })).toMatchObject({
|
|
total: 1,
|
|
entries: [{ guid: 'split', gamesPlayed: 5, averageKills: 20 }],
|
|
summary: { players: 2, matches: 9, kills: 180 }
|
|
});
|
|
expect(getLeaderboard({ metric: 'averageKills', game: 't5' })).toMatchObject({
|
|
total: 0,
|
|
entries: [],
|
|
summary: { players: 2, matches: 7, kills: 140 }
|
|
});
|
|
expect(getLeaderboard({ metric: 'averageKills', map: 't6:town' })).toMatchObject({
|
|
total: 0,
|
|
entries: [],
|
|
summary: { players: 1, matches: 2, kills: 40 }
|
|
});
|
|
addSamples(db(), 'split', samples(2));
|
|
expect(
|
|
getLeaderboard({ metric: 'averageKills', game: 't5', map: 't5:theater' }).entries
|
|
).toEqual([expect.objectContaining({ guid: 'split', gamesPlayed: 5, rank: 1 })]);
|
|
});
|
|
|
|
it('qualifies before competition ranking, then searches and paginates without changing scope summaries', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const handle = db();
|
|
addSamples(handle, 'Ace', samples(5, { kills: 50 }));
|
|
addSamples(handle, 'Blake', samples(5, { kills: 50 }));
|
|
addSamples(handle, 'Casey', samples(5, { kills: 10 }));
|
|
addSamples(handle, 'Small sample', samples(4, { kills: 1000 }));
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
|
VALUES ('t6', 'Casey', 'Current name', '2026-09-01', '2026-09-03')`
|
|
)
|
|
.run();
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const full = getLeaderboard({ metric: 'averageKills', game: 't5' });
|
|
expect(full.entries.map((entry) => [entry.name, entry.rank])).toEqual([
|
|
['Ace', 1],
|
|
['Blake', 1],
|
|
['Current name', 3]
|
|
]);
|
|
const searched = getLeaderboard({
|
|
metric: 'averageKills',
|
|
game: 't5',
|
|
q: 'current NAME',
|
|
limit: 1
|
|
});
|
|
expect(searched).toMatchObject({
|
|
total: 1,
|
|
entries: [{ guid: 'Casey', rank: 3 }],
|
|
summary: full.summary
|
|
});
|
|
expect(getLeaderboard({ metric: 'averageKills', game: 't5', q: 'Small' })).toMatchObject({
|
|
total: 0,
|
|
entries: [],
|
|
summary: full.summary
|
|
});
|
|
const page = getLeaderboard({ metric: 'averageKills', game: 't5', limit: 1, offset: 1 });
|
|
expect(page).toMatchObject({
|
|
total: 3,
|
|
entries: [{ guid: 'Blake', rank: 1 }],
|
|
summary: full.summary
|
|
});
|
|
expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({
|
|
total: 3,
|
|
entries: [],
|
|
summary: full.summary
|
|
});
|
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
|
const response = await GET({
|
|
url: new URL(
|
|
'http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1'
|
|
)
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({
|
|
metric: 'averageKills',
|
|
total: 1,
|
|
entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }],
|
|
summary: full.summary
|
|
});
|
|
});
|
|
|
|
it('accepts each efficiency metric through the API when the qualified group is empty', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(db(), 'small', samples(4));
|
|
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
|
for (const { key } of efficiencyMetrics) {
|
|
const response = await GET({
|
|
url: new URL(`http://localhost/api/leaderboard?metric=${key}`)
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({
|
|
metric: key,
|
|
entries: [],
|
|
total: 0,
|
|
summary: { players: 1, matches: 4, kills: 80 }
|
|
});
|
|
}
|
|
});
|
|
|
|
it('does not use same-round departures to qualify for efficiency rankings', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
addSamples(db(), 'short-visit', [...samples(4), { completed: 0 }]);
|
|
db()
|
|
.prepare(
|
|
`UPDATE leaderboard_match_players SET first_observed_round = highest_round
|
|
WHERE completed = 0`
|
|
)
|
|
.run();
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
expect(getLeaderboard().entries[0]).toMatchObject({
|
|
gamesPlayed: 4,
|
|
gamesCompleted: 4,
|
|
kills: 100,
|
|
averageKills: 25,
|
|
completionRate: 1
|
|
});
|
|
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
|
for (const { key } of efficiencyMetrics)
|
|
expect(getLeaderboard({ metric: key }).entries).toEqual([]);
|
|
});
|