Simplify match history with filters and persistent pagination
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { readMatchQuery, matchHistoryHref, matchApiParams } from '../../src/lib/match-history-query.js';
|
||||
let fixture: Fixture;
|
||||
const now = Date.parse('2026-10-06T20:00:00Z');
|
||||
beforeEach(async () => {
|
||||
vi.resetModules(); fixture = await newFixture([]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status, start_observed, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', 'test')`);
|
||||
const player = handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)`);
|
||||
for (let id = 1; id <= 40; id++) {
|
||||
const start = new Date(now - id * 86400000).toISOString();
|
||||
const end = new Date(now - id * 86400000 + id * 60000).toISOString();
|
||||
const game = id % 2 ? 't5' : 't6';
|
||||
const name = game === 't5' ? 'Kino Der Toten' : 'Town Survival';
|
||||
const map = game === 't5' ? 'theater' : 'town';
|
||||
match.run(id, `${game}_zombie_${map}_1`, game, map, `${game}:${map}`, name, start, end, end,
|
||||
id === 2 ? 'active' : id === 4 ? 'interrupted' : 'completed', id === 3 ? 0 : 1, id + 1);
|
||||
for (let n = 0; n < id % 5; n++) player.run(id, String(n), start, end);
|
||||
}
|
||||
handle.prepare("UPDATE leaderboard_matches SET server_deleted_at = ? WHERE id = 1").run(new Date(now).toISOString());
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
const query = (value = '') => readMatchQuery(new URLSearchParams(value));
|
||||
it('sorts the whole history before paging, keeps ties deterministic, and clamps empty pages', async () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const first = serverMatchHistory(undefined, 25, query());
|
||||
const second = serverMatchHistory(undefined, 25, query('page=2'));
|
||||
expect(first.total).toBe(40); expect(first.matches.map(m => m.id)).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||
expect(second.matches.map(m => m.id)).toEqual(Array.from({ length: 15 }, (_, i) => i + 26));
|
||||
expect(serverMatchHistory(undefined, 25, query('page=999')).page).toBe(2);
|
||||
expect(serverMatchHistory(undefined, 25, query('sort=round')).matches[0].id).toBe(40);
|
||||
expect(serverMatchHistory(undefined, 25, query('sort=players')).matches.slice(0, 3).map(m => m.id)).toEqual([39, 34, 29]);
|
||||
});
|
||||
it('combines game, map, readable instance search, status and date filters before counting', async () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const data = serverMatchHistory(undefined, 25, query('game=t6&map=t6%3Atown&q=Town+Lobby+1&status=completed&days=7'), now);
|
||||
expect(data.total).toBe(1); expect(data.matches.map(m => m.id)).toEqual([6]);
|
||||
expect(data.maps).toEqual([{ key: 't6:town', name: 'Town Survival', game: 't6' }]);
|
||||
expect(serverMatchHistory(undefined, 25, query('q=%25')).total).toBe(0);
|
||||
expect(serverMatchHistory('t5_zombie_theater_1', 25, query()).total).toBe(20);
|
||||
const empty = serverMatchHistory(undefined, 25, query('q=missing&page=5'));
|
||||
expect(empty).toMatchObject({ total: 0, matches: [], page: 1 });
|
||||
});
|
||||
it('keeps unknown durations last in either direction and retains historical and unfinished semantics', async () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
for (const direction of ['asc', 'desc']) {
|
||||
const data = serverMatchHistory(undefined, 100, query(`sort=duration&direction=${direction}`));
|
||||
expect(data.matches.slice(-3).map(m => m.durationMs)).toEqual([null, null, null]);
|
||||
}
|
||||
const data = serverMatchHistory();
|
||||
expect(data.matches[0].server_deleted_at).toBeTruthy();
|
||||
expect(data.matches[1]).toMatchObject({ status: 'active', observedRound: 3, finalRound: null, durationMs: null });
|
||||
expect(data.matches[3]).toMatchObject({ status: 'interrupted', observedRound: 5, finalRound: null, durationMs: null });
|
||||
expect(data.matches.find(m => m.id === 5)?.uniquePlayers).toBe(0);
|
||||
});
|
||||
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
|
||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||
const { GET: instanceGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||
for (const value of ['page=-1', 'page=1.5', 'sort=sql', 'direction=sideways', 'game=t7', 'days=3', 'status=deleted', 'limit=0']) {
|
||||
const url = new URL(`http://localhost/api/matches?${value}`);
|
||||
expect(GET({ url }).status).toBe(400);
|
||||
expect(instanceGET({ url, params: { name: 'anything' } }).status).toBe(400);
|
||||
}
|
||||
const { load } = await import('../../src/routes/matches/+page.server.js');
|
||||
const url = new URL('http://localhost/matches?matchPage=2&matchSort=round');
|
||||
const loaded = load({ url }).history;
|
||||
const body = await GET({ url: new URL(`http://localhost/api/matches?${matchApiParams(url)}`) }).json();
|
||||
expect(loaded).toEqual(body); expect(body.matches[0].id).toBe(15);
|
||||
});
|
||||
it('keeps return context and sorting while resetting page for a filter change', () => {
|
||||
const url = new URL('http://localhost/servers/t5_zombie_theater_1?tab=matches&returnTo=%2Fhosts&matchSort=duration&matchDirection=asc&matchPage=3&matches=100');
|
||||
const href = matchHistoryHref(url, { status: 'completed' });
|
||||
expect(href).toContain('tab=matches'); expect(href).toContain('returnTo=%2Fhosts');
|
||||
expect(href).toContain('matchSort=duration'); expect(href).toContain('matchDirection=asc');
|
||||
expect(href).not.toContain('matchPage'); expect(href).not.toContain('matches=100');
|
||||
expect(matchHistoryHref(url, { page: '4' })).toContain('matchPage=4');
|
||||
});
|
||||
Reference in New Issue
Block a user