245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
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',
|
||
lastReportedAt: '2026-10-06T19:59:59Z',
|
||
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.each([
|
||
['', '0', 'All time'],
|
||
['0', '0', 'All time'],
|
||
['7', '7', 'Last 7 days'],
|
||
['30', '30', 'Last 30 days'],
|
||
['90', '90', 'Last 90 days']
|
||
])(
|
||
'selects the current date range for matchDays=%s on desktop and mobile',
|
||
(days, value, label) => {
|
||
scope(days ? `?matchDays=${days}` : '');
|
||
const body = render(MatchHistory, { props: { initial: report() } }).body;
|
||
for (const suffix of ['', '-mobile']) {
|
||
const select = body.match(
|
||
new RegExp(`<select\\b[^>]*id="match-days${suffix}"[^>]*>([\\s\\S]*?)</select>`)
|
||
)?.[1];
|
||
expect(select).toBeDefined();
|
||
expect(select).toContain(`<option value="${value}" selected="">${label}</option>`);
|
||
expect(select?.match(/\bselected=""/g)).toHaveLength(1);
|
||
}
|
||
}
|
||
);
|
||
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(/<h1[ >]/g)).toHaveLength(1);
|
||
expect(body.match(/scope="col"/g)).toHaveLength(6);
|
||
expect(body).toContain('2 h ago');
|
||
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('Filters · 2');
|
||
});
|
||
it('keeps mobile filters and sorting in one labelled sheet with distinct controls and one named match link', () => {
|
||
const body = render(MatchHistory, { props: { initial: report() } }).body;
|
||
expect(body).toContain('Match filters');
|
||
expect(body).toContain('id="match-map-mobile"');
|
||
expect(body).toContain('id="match-sort-mobile"');
|
||
expect(body).toContain('id="match-order-mobile"');
|
||
expect(body).toContain('Show results');
|
||
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||
expect(rows.match(/<a\b/g)).toHaveLength(1);
|
||
expect(rows).toContain('Town Survival · Match #1');
|
||
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
expect(body).toContain('Updates every 10 seconds');
|
||
});
|
||
it('links in-progress matches to their live server with the current filters and page', () => {
|
||
scope('?matchGame=t6&matchPage=2&matchStatus=active');
|
||
const data = report();
|
||
data.matches = [
|
||
{ ...match, server: 'Weekend lobby #1', status: 'active', endedAt: null, durationMs: null }
|
||
];
|
||
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||
expect(rows.match(/<a\b/g)).toHaveLength(1);
|
||
expect(rows).toContain(
|
||
'href="/servers/Weekend%20lobby%20%231?returnTo=%2Fmatches%3FmatchGame%3Dt6%26matchPage%3D2%26matchStatus%3Dactive"'
|
||
);
|
||
expect(rows).toContain('aria-label="View live Town Survival match on Weekend lobby #1');
|
||
expect(rows).not.toContain('href="/matches/1');
|
||
|
||
data.matches[0].server_deleted_at = '2026-10-06T19:59:00Z';
|
||
const deleted = render(MatchHistory, { props: { initial: data } }).body;
|
||
expect(deleted).not.toContain('href="/servers/');
|
||
expect(deleted).toContain('Historical instance');
|
||
});
|
||
it.each([
|
||
['2026-10-06T19:59:48Z', '12s'],
|
||
['2026-10-06T19:36:28Z', '23m 32s'],
|
||
['2026-10-06T18:20:00Z', '1h 40m'],
|
||
['2026-10-06 19:36:28', '23m 32s'],
|
||
['2026-10-06T20:00:05Z', '0s']
|
||
])('shows running time from %s on desktop and mobile', (startedAt, duration) => {
|
||
const data = report();
|
||
data.matches = [{ ...match, status: 'active', startedAt, endedAt: null, durationMs: null }];
|
||
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
|
||
/<!--.*?-->/g,
|
||
''
|
||
);
|
||
expect(body).toContain(`In progress · ${duration}</small>`);
|
||
expect(body).toContain(`<span>${duration}</span>`);
|
||
expect(body).not.toContain('Duration was not recorded.');
|
||
});
|
||
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).not.toContain('href="/servers/');
|
||
expect(body).toContain('>—</span>');
|
||
expect(body).toContain('>0</td>');
|
||
});
|
||
it.each([
|
||
'2026-10-06T19:58:29Z',
|
||
'2026-10-05T18:00:00Z',
|
||
'2026-10-06 19:58:29',
|
||
'2026-10-06T20:00:02Z',
|
||
null,
|
||
'invalid'
|
||
])('shows a non-live match with its last report when %s is not current', (lastReportedAt) => {
|
||
const data = report();
|
||
data.matches = [{ ...match, status: 'active', lastReportedAt, durationMs: null }];
|
||
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
|
||
/<!--.*?-->/g,
|
||
''
|
||
);
|
||
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||
expect(rows.match(/Not reporting/g)).toHaveLength(2);
|
||
expect(rows.match(/Last report /g)).toHaveLength(2);
|
||
expect(rows).not.toContain('In progress');
|
||
expect(rows).not.toContain('View live');
|
||
expect(rows).not.toContain('<a');
|
||
expect(rows).not.toContain('2h 34m');
|
||
expect(rows).toContain('>—</span>');
|
||
if (lastReportedAt && lastReportedAt !== 'invalid') {
|
||
expect(rows).toMatch(/Last report <time [^>]*datetime=/);
|
||
} else {
|
||
expect(rows).toContain('Last report <span>Not recorded</span>');
|
||
}
|
||
});
|
||
it('expires live links and clocks at the shared freshness boundary and restores them on a new report', () => {
|
||
const data = report();
|
||
data.matches = [
|
||
{ ...match, status: 'active', lastReportedAt: '2026-10-06T19:58:30Z', durationMs: null }
|
||
];
|
||
const rows = () => {
|
||
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||
return body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||
};
|
||
expect(rows()).toContain('View live');
|
||
expect(rows()).toContain('2h 34m');
|
||
vi.advanceTimersByTime(1000);
|
||
expect(rows()).toContain('Not reporting');
|
||
expect(rows()).not.toContain('View live');
|
||
expect(rows()).not.toContain('2h 34m');
|
||
data.matches[0].lastReportedAt = '2026-10-06T20:00:01Z';
|
||
expect(rows()).toContain('View live');
|
||
expect(rows()).toContain('2h 34m');
|
||
expect(rows()).not.toContain('Not reporting');
|
||
});
|
||
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('21 h ago');
|
||
expect(
|
||
matchStartedLabel(
|
||
'2025-10-06T17:26:00Z',
|
||
'America/New_York',
|
||
new Date('2026-10-06T20:00:00Z')
|
||
)
|
||
).toBe('Oct 6, 2025, 1:26 PM EDT');
|
||
expect(
|
||
matchStartedLabel(
|
||
'2026-10-06 17:26:00',
|
||
'America/New_York',
|
||
new Date('2026-10-06T20:00:00Z')
|
||
)
|
||
).toBe('2 h ago');
|
||
});
|