Files
electric-cherry/tests/unit/ui-leaderboard.test.ts

204 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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);
});