204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
import { beforeEach, expect, it, vi } from 'vitest';
|
||
import { render } from 'svelte/server';
|
||
import { writable, type Writable } from 'svelte/store';
|
||
import { page } from '$app/stores';
|
||
import Leaderboard from '../../src/routes/leaderboard/+page.svelte';
|
||
import {
|
||
createLeaderboardUpdates,
|
||
leaderboardPageSize
|
||
} from '../../src/lib/leaderboard-updates.js';
|
||
import type {
|
||
LeaderboardEntry,
|
||
LeaderboardResponse,
|
||
RecordEvidence
|
||
} from '../../src/lib/leaderboard.js';
|
||
|
||
vi.mock('$app/stores', () => ({
|
||
page: writable({ url: new URL('http://localhost/leaderboard') }),
|
||
navigating: writable(null)
|
||
}));
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
const evidence: RecordEvidence = {
|
||
matchId: 71,
|
||
game: 't5',
|
||
mapKey: 't5:theater',
|
||
mapName: 'Kino Der Toten',
|
||
server: 't5_zombie_theater_1',
|
||
startedAt: '2026-10-06T12:00:00Z',
|
||
endedAt: '2026-10-06T13:00:00Z',
|
||
startObserved: true,
|
||
presentAtStart: false,
|
||
firstObservedRound: 8,
|
||
value: 49
|
||
};
|
||
const entry: LeaderboardEntry = {
|
||
rank: 27,
|
||
guid: '12345',
|
||
name: 'Cherry',
|
||
highestRound: 49,
|
||
kills: 1000,
|
||
roundsSurvived: 60,
|
||
headshots: 100,
|
||
revives: 3,
|
||
downs: 5,
|
||
gamesPlayed: 6,
|
||
gamesCompleted: 5,
|
||
bestKills: 500,
|
||
bestScore: 4000,
|
||
lastSeen: evidence.endedAt!,
|
||
records: { highestRound: evidence, bestKills: evidence, bestScore: evidence },
|
||
headshotRate: 0.1,
|
||
killsPerDown: 200,
|
||
averageKills: 1000 / 6,
|
||
averageRounds: 10,
|
||
completionRate: 5 / 6
|
||
};
|
||
function report(): LeaderboardResponse {
|
||
return {
|
||
entries: [structuredClone(entry)],
|
||
maps: [{ key: 't5:theater', game: 't5', map: 'theater', name: 'Kino Der Toten' }],
|
||
total: 60,
|
||
limit: 25,
|
||
offset: 25,
|
||
metric: 'highestRound',
|
||
summary: {
|
||
players: 473,
|
||
matches: 411,
|
||
kills: 112899,
|
||
roundsSurvived: 7062,
|
||
highestRound: 49,
|
||
firstTrackedAt: evidence.startedAt,
|
||
updatedAt: evidence.endedAt
|
||
}
|
||
};
|
||
}
|
||
function scope(search = '') {
|
||
(page as unknown as Writable<{ url: URL }>).set({
|
||
url: new URL(`http://localhost/leaderboard${search}`)
|
||
});
|
||
}
|
||
function html(data = report()) {
|
||
return render(Leaderboard, {
|
||
props: { data: { leaderboard: data, user: null, setupRequired: false } }
|
||
}).body;
|
||
}
|
||
beforeEach(() => scope('?map='));
|
||
|
||
it('shows compact totals and core stat columns, preserving ranks without repeated row disclosures', () => {
|
||
const body = html();
|
||
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
|
||
expect(headers.match(/scope="col"/g)).toHaveLength(6);
|
||
expect(headers).toContain('Highest round');
|
||
expect(headers).toContain('Total kills');
|
||
expect(headers).toContain('Games played');
|
||
expect(headers).toContain('Total rounds');
|
||
for (const label of [
|
||
'Headshots',
|
||
'Revives',
|
||
'Downs',
|
||
'Games completed',
|
||
'Best game kills',
|
||
'Highest reported score'
|
||
])
|
||
expect(body).toContain(label);
|
||
expect(body).toContain('Show all stats');
|
||
expect(body).toMatch(/<span[^>]*>27<\/span>/);
|
||
expect(body.replace(/\s+/g, ' ')).toContain('26–26 of 60 players');
|
||
const overview = body.slice(body.indexOf('class="overview'), body.indexOf('class="rankings'));
|
||
expect(overview).toContain('All-time stats for All games & maps');
|
||
for (const value of ['49', '112,899', '7,062', '411', '473'])
|
||
expect(overview).toMatch(new RegExp(`<strong[^>]*>${value}</strong>`));
|
||
expect(overview).toContain('Fleet record');
|
||
expect(overview).not.toContain('<details');
|
||
expect(body).not.toMatch(/<details[^>]*open/);
|
||
expect(body).not.toContain('Copy GUID');
|
||
expect(body).not.toContain('Every round counts');
|
||
expect(body).not.toContain('Record details');
|
||
expect(body).not.toContain('Hide details');
|
||
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||
expect(rows.match(/<a\b/g)).toHaveLength(2);
|
||
expect(rows).not.toContain('<details');
|
||
expect(rows).toContain('Match #71');
|
||
expect(body).toContain('including players who joined mid-game');
|
||
expect(body).not.toContain('>Search</button>');
|
||
});
|
||
|
||
it('deduplicates the selected metric and preserves scope and pagination on player and match links', () => {
|
||
scope('?game=t5&map=t5%3Atheater&metric=kills&q=Cherry&offset=25');
|
||
const data = report();
|
||
data.metric = 'kills';
|
||
const body = html(data);
|
||
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
|
||
expect(headers.match(/scope="col"/g)).toHaveLength(5);
|
||
expect(body).toContain('offset%3D25');
|
||
expect(body).toContain('q%3DCherry');
|
||
scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25');
|
||
const recordBody = html();
|
||
expect(recordBody).toContain('/matches/71?returnTo=%2Fleaderboard');
|
||
expect(recordBody).toContain('map%3Dt5%253Atheater');
|
||
expect(recordBody).toContain('Selection record');
|
||
expect(recordBody).not.toContain('Fleet record');
|
||
});
|
||
|
||
it('provides separate mobile filter controls and preserves explicit all-map and squad context', () => {
|
||
scope('?map=&players=2&metric=highestRound&offset=25');
|
||
const body = html();
|
||
expect(body).toContain('Leaderboard filters');
|
||
expect(body).toContain('id="leaderboard-players-mobile"');
|
||
expect(body).toContain('value="2" selected=""');
|
||
expect(body).toContain('All squad sizes');
|
||
expect(body).toContain('Solo');
|
||
expect(body).toContain('Duo');
|
||
expect(body).toContain('Trio');
|
||
expect(body).toContain('Four players');
|
||
expect(body.replace(/\s+/g, ' ')).toContain(
|
||
'unknown starting counts appear only under All squad sizes'
|
||
);
|
||
expect(body).toContain('players%3D2');
|
||
expect(body).toContain('map%3D');
|
||
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
expect(body).not.toMatch(/>Refresh<\/span>/);
|
||
});
|
||
|
||
it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => {
|
||
scope('?metric=headshotRate');
|
||
const data = report();
|
||
data.metric = 'headshotRate';
|
||
let body = html(data);
|
||
expect(body).toContain('At least 5 ended games and 100 kills within the selected game and map');
|
||
expect(body).toContain('100 headshots / 1,000 kills · 6 games');
|
||
expect(body).toContain('10%');
|
||
data.entries[0].headshotRate = null;
|
||
body = html(data);
|
||
expect(body).toContain('>—');
|
||
});
|
||
|
||
it('holds changing ranks until explicitly applied, coalescing later polls even after focus leaves', () => {
|
||
const first = report();
|
||
const updates = createLeaderboardUpdates('all', first);
|
||
const second = structuredClone(first);
|
||
second.entries[0].rank = 28;
|
||
expect(updates.receive('all', second, true)).toEqual({ current: first, pending: second });
|
||
const third = structuredClone(second);
|
||
third.entries[0].rank = 30;
|
||
expect(updates.receive('all', third, false)).toEqual({ current: first, pending: third });
|
||
expect(updates.apply()).toEqual(third);
|
||
expect(updates.receive('all', third, true)?.pending).toBeNull();
|
||
});
|
||
|
||
it('ignores old scopes, clears a reverted pending update, and accepts unattended refreshes', () => {
|
||
const first = report();
|
||
const second = structuredClone(first);
|
||
second.total = 100;
|
||
const updates = createLeaderboardUpdates('all', first);
|
||
updates.receive('all', second, true);
|
||
expect(updates.receive('all', structuredClone(first), true)?.pending).toBeNull();
|
||
expect(updates.receive('all', second, false)?.current).toEqual(second);
|
||
updates.receive('all', first, true);
|
||
updates.reset('t5', first);
|
||
expect(updates.receive('all', second, false)).toBeNull();
|
||
expect(updates.apply()).toEqual(first);
|
||
expect(leaderboardPageSize).toBe(25);
|
||
});
|