220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { flushSync, mount, unmount } from 'svelte';
|
|
import { fromStore, writable } from 'svelte/store';
|
|
import MatchPage from '../../src/routes/matches/[id]/+page.svelte';
|
|
import type { MatchDetail, MatchPlayer } from '../../src/lib/match-history.js';
|
|
|
|
vi.mock('$app/stores', () => ({
|
|
page: writable({ url: new URL('http://localhost/matches/1'), data: { user: null } })
|
|
}));
|
|
|
|
function player(overrides: Partial<MatchPlayer> = {}): MatchPlayer {
|
|
return {
|
|
guid: '101',
|
|
name: 'Cherry',
|
|
hasStats: true,
|
|
presentAtEnd: true,
|
|
canViewProfile: true,
|
|
firstSeenAt: '2026-10-07T00:00:00Z',
|
|
lastSeenAt: '2026-10-07T00:01:00Z',
|
|
highestRound: 4,
|
|
roundsSurvived: 3,
|
|
kills: 20,
|
|
headshots: 5,
|
|
revives: 1,
|
|
downs: 2,
|
|
bestScore: 2760,
|
|
scoreTotal: 8470,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function match(players: MatchPlayer[], id = 1): MatchDetail {
|
|
return {
|
|
id,
|
|
server: 't5_zombie_theater_1',
|
|
game: 't5',
|
|
map: 'Kino Der Toten',
|
|
observedRound: 4,
|
|
finalRound: 4,
|
|
status: 'completed',
|
|
reporterMatchKey: null,
|
|
missingReports: null,
|
|
startObserved: true,
|
|
startedAt: '2026-10-07T00:00:00Z',
|
|
lastReportedAt: '2026-10-07T00:01:00Z',
|
|
endedAt: '2026-10-07T00:01:00Z',
|
|
durationMs: 60_000,
|
|
startPlayers: players.length,
|
|
uniquePlayers: players.length,
|
|
endPlayers: players.filter((entry) => entry.presentAtEnd).length,
|
|
players,
|
|
chat: []
|
|
};
|
|
}
|
|
|
|
let component: ReturnType<typeof mount> | undefined;
|
|
afterEach(async () => {
|
|
if (component) await unmount(component);
|
|
component = undefined;
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
function show(initial: MatchDetail) {
|
|
const data = writable({ match: initial });
|
|
const state = fromStore(data);
|
|
component = mount(MatchPage, {
|
|
target: document.body,
|
|
props: {
|
|
get data() {
|
|
return state.current;
|
|
}
|
|
}
|
|
});
|
|
flushSync();
|
|
return (next: MatchDetail) => {
|
|
data.set({ match: next });
|
|
flushSync();
|
|
};
|
|
}
|
|
|
|
function expectScores(label: string, values: string[]) {
|
|
const headers = [...document.querySelectorAll('thead th')].map((cell) => cell.textContent);
|
|
expect(headers).toEqual([
|
|
'Player',
|
|
'Participation',
|
|
'Highest round',
|
|
'Kills',
|
|
'Headshots',
|
|
'Revives',
|
|
'Downs',
|
|
'Rounds survived',
|
|
label,
|
|
'Last seen'
|
|
]);
|
|
const scores = [...document.querySelectorAll('tbody tr')].map(
|
|
(row) => row.children[8].textContent
|
|
);
|
|
expect(scores).toEqual(values);
|
|
expect(document.body.textContent).not.toContain('NaN');
|
|
}
|
|
|
|
function expectExplanation(totals: boolean) {
|
|
const intro = document.querySelector('.results-heading')!.textContent!.replace(/\s+/g, ' ');
|
|
const about = document.querySelector('details.note')!.textContent!.replace(/\s+/g, ' ');
|
|
if (totals) {
|
|
expect(intro).toContain('Total score is the highest recorded cumulative points earned');
|
|
expect(about).toContain('Total score is the highest recorded cumulative points earned');
|
|
expect(about).toContain(
|
|
'Players absent from the final report retain their latest recorded total.'
|
|
);
|
|
expect(about).toContain(
|
|
'A dash in Total score means no total was recorded for that player.'
|
|
);
|
|
expect(document.body.textContent).not.toContain('Highest reported score');
|
|
expect(about).not.toContain('points balance');
|
|
} else {
|
|
expect(intro).toContain('Highest reported score is the highest recorded points balance.');
|
|
expect(about).toContain(
|
|
'Highest reported score is a points balance, not total points earned'
|
|
);
|
|
expect(document.body.textContent).not.toContain('Total score');
|
|
}
|
|
}
|
|
|
|
describe('match result score presentation', () => {
|
|
it('shows recorded totals instead of balances, including totals for departed players', () => {
|
|
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
|
|
expectScores('Total score', ['8,470', '6,500']);
|
|
expectExplanation(true);
|
|
expect(document.querySelector('tbody')!.textContent).not.toContain('2,760');
|
|
expect(document.querySelector('tbody')!.textContent).toContain('Not at match end');
|
|
});
|
|
|
|
it('preserves balances when every total is null or missing', () => {
|
|
show(
|
|
match([
|
|
player({ scoreTotal: null }),
|
|
player({ guid: '202', scoreTotal: undefined, bestScore: 500 })
|
|
])
|
|
);
|
|
expectScores('Highest reported score', ['2,760', '500']);
|
|
expectExplanation(false);
|
|
});
|
|
|
|
it('treats zero as a recorded total', () => {
|
|
show(match([player({ scoreTotal: 0 })]));
|
|
expectScores('Total score', ['0']);
|
|
expectExplanation(true);
|
|
});
|
|
|
|
it('uses dashes for null and missing totals even when balances are known', () => {
|
|
show(
|
|
match([
|
|
player({ scoreTotal: null }),
|
|
player({ guid: '202', scoreTotal: undefined }),
|
|
player({ guid: '303' })
|
|
])
|
|
);
|
|
expectScores('Total score', ['—', '—', '8,470']);
|
|
expectExplanation(true);
|
|
expect(document.querySelector('tbody')!.textContent).not.toContain('2,760');
|
|
});
|
|
|
|
it.each([null, 8470])(
|
|
'keeps participants without stats when the other total is %s',
|
|
(scoreTotal) => {
|
|
show(
|
|
match([
|
|
player({ scoreTotal }),
|
|
player({
|
|
guid: '202',
|
|
hasStats: false,
|
|
presentAtEnd: false,
|
|
highestRound: null,
|
|
roundsSurvived: null,
|
|
kills: null,
|
|
headshots: null,
|
|
revives: null,
|
|
downs: null,
|
|
bestScore: null,
|
|
scoreTotal: null
|
|
})
|
|
])
|
|
);
|
|
expectScores(scoreTotal === null ? 'Highest reported score' : 'Total score', [
|
|
scoreTotal === null ? '2,760' : '8,470',
|
|
'—'
|
|
]);
|
|
const row = document.querySelectorAll('tbody tr')[1];
|
|
expect(row.textContent).toContain('No stats recorded');
|
|
expect([...row.children].slice(2, 9).map((cell) => cell.textContent)).toEqual(
|
|
Array(7).fill('—')
|
|
);
|
|
}
|
|
);
|
|
|
|
it('updates the same mounted page when navigating in both directions and refreshing data', () => {
|
|
const legacy = match([player({ scoreTotal: null })]);
|
|
const current = match([player()], 2);
|
|
const update = show(legacy);
|
|
expectScores('Highest reported score', ['2,760']);
|
|
expectExplanation(false);
|
|
update(current);
|
|
expectScores('Total score', ['8,470']);
|
|
expectExplanation(true);
|
|
update(legacy);
|
|
expectScores('Highest reported score', ['2,760']);
|
|
expectExplanation(false);
|
|
update({ ...legacy, players: [player({ scoreTotal: 0 })] });
|
|
expectScores('Total score', ['0']);
|
|
expectExplanation(true);
|
|
});
|
|
|
|
it('renders an empty match without a score table', () => {
|
|
show(match([]));
|
|
expect(document.body.textContent).toContain('No player results recorded');
|
|
expect(document.querySelector('table')).toBeNull();
|
|
});
|
|
});
|