Files
electric-cherry/tests/unit/ui-match-history.test.ts
T
2026-10-12 00:58:05 -04:00

280 lines
11 KiB
TypeScript
Raw 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, 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',
reporterMatchKey: null,
missingReports: null,
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('labels and filters missing end reports and shows sequence gaps on desktop and mobile', () => {
scope('?matchStatus=end-report-missing');
const data = report();
data.matches = [
{
...match,
status: 'end-report-missing',
reporterMatchKey: 'game-a',
missingReports: 2,
finalRound: null,
durationMs: null
}
];
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
/<!--.*?-->/g,
''
);
for (const suffix of ['', '-mobile']) {
const select = body.match(
new RegExp(`<select\\b[^>]*id="match-status${suffix}"[^>]*>([\\s\\S]*?)</select>`)
)?.[1];
expect(select).toContain(
'<option value="end-report-missing" selected="">End report missing</option>'
);
}
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/End report missing/g)).toHaveLength(2);
expect(rows.match(/2 reports missing/g)).toHaveLength(2);
expect(rows).toContain('Highest observed');
expect(rows).not.toContain('<a');
expect(rows).not.toContain('In progress');
expect(rows).not.toContain('38m');
});
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');
});