Simplify match history with filters and persistent pagination
This commit is contained in:
@@ -68,7 +68,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
||||
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
@@ -94,9 +94,9 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
|
||||
expect(serverMatchHistory()).toEqual(history);
|
||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
|
||||
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual({ ...history, limit: 1 });
|
||||
const { GET: serverGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual(history);
|
||||
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual({ ...history, limit: 1 });
|
||||
});
|
||||
|
||||
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
|
||||
@@ -108,11 +108,11 @@ it('records server match counts, unique rejoins, duration, and final round durab
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
closeDb();
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({
|
||||
expect(serverMatchHistory(server)).toMatchObject({ total: 1, matches: [expect.objectContaining({
|
||||
status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1,
|
||||
durationMs: 3000, finalRound: 7, endedAt: '2026-09-18T20:00:03.000Z'
|
||||
})] });
|
||||
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [] });
|
||||
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
||||
});
|
||||
|
||||
it('preserves unknown boundaries and distinguishes empty rosters from missing observations', async () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('completed match details', () => {
|
||||
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const match = getMatchDetail(1)!;
|
||||
const { game, startObserved, players, ...summary } = match;
|
||||
expect(summary).toEqual(serverMatchHistory(server).matches[0]);
|
||||
expect({ ...summary, game }).toEqual(serverMatchHistory(server).matches[0]);
|
||||
expect({ ...summary, game, startObserved }).toMatchObject({
|
||||
id: 1, game: 't5', startObserved: true, server, status: 'completed',
|
||||
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import MatchHistory from '../../src/lib/ui/components/MatchHistory.svelte';
|
||||
import MatchHistoryPage from '../../src/routes/matches/+page.svelte';
|
||||
import { matchStartedLabel, matchInstanceLabel } from '../../src/lib/ui/match-history.js';
|
||||
import type { MatchHistoryResponse, ServerMatch } from '../../src/lib/match-history.js';
|
||||
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/matches') }) }));
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
const match: ServerMatch = { id: 1, server: 't6_zombie_town_1', game: 't6', map: 'Town Survival', observedRound: 20, finalRound: 20, status: 'completed', startedAt: '2026-10-06T17:26:00Z', endedAt: '2026-10-06T18:04:00Z', durationMs: 2280000, startPlayers: 2, uniquePlayers: 4, endPlayers: 2 };
|
||||
const report = (): MatchHistoryResponse => ({ matches: [match], total: 40, page: 1, limit: 25, maps: [{ key: 't6:town', game: 't6', name: 'Town Survival' }] });
|
||||
function scope(search = '') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/matches${search}`) }); }
|
||||
beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-06T20:00:00Z')); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it('renders one page heading, six columns, a combined identity, and compact dates', () => {
|
||||
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
|
||||
expect(body.match(/<h[12][ >]/g)).toHaveLength(1);
|
||||
expect(body.match(/scope="col"/g)).toHaveLength(6);
|
||||
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
|
||||
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
|
||||
expect(body).toContain('matchPage=2');
|
||||
});
|
||||
it('preserves filters and page in match links and avoids linking deleted instances', () => {
|
||||
scope('?matchGame=t6&matchPage=2&matchSort=round&matchStatus=completed');
|
||||
const data = report(); data.page = 2; data.matches = [{ ...match, server_deleted_at: '2026-10-06' }];
|
||||
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||
expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2');
|
||||
expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance');
|
||||
expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<');
|
||||
expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('More filters (1)');
|
||||
});
|
||||
it('labels unfinished rounds and missing durations without inventing final results or detail links', () => {
|
||||
const data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];
|
||||
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||
expect(body).toContain('Interrupted'); expect(body).toContain('Highest observed');
|
||||
expect(body).not.toContain('href="/matches/1'); expect(body).toContain('>—</span>');
|
||||
expect(body).toContain('>0</td>');
|
||||
});
|
||||
it('offers filter reset for an empty selection and retains the scoped instance view', () => {
|
||||
scope('?matchQ=missing'); const data = report(); data.matches = []; data.total = 0;
|
||||
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||
expect(body).toContain('No matches found'); expect(body).toContain('Clear filters');
|
||||
const scoped = render(MatchHistory, { props: { initial: report(), server: match.server } }).body;
|
||||
expect(scoped).not.toContain('id="match-search"'); expect(scoped).not.toContain('Historical instance');
|
||||
});
|
||||
it('keeps custom instance names and calculates yesterday across DST and year boundaries', () => {
|
||||
expect(matchInstanceLabel({ ...match, server: 'Weekend lobby' })).toBe('Weekend lobby · Black Ops II');
|
||||
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('Yesterday, 1:30 AM');
|
||||
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM');
|
||||
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Today, 1:26 PM');
|
||||
});
|
||||
Reference in New Issue
Block a user