Show recorded match score totals with legacy fallback (Refs #147)
CI / checks (push) Successful in 5m22s
CI / checks (push) Successful in 5m22s
This commit is contained in:
@@ -75,9 +75,20 @@ Completed matches link to their own results page at `/matches/[id]` from global
|
||||
Match History, server history, and player profiles. The page shows the recorded
|
||||
map and server, final round, start/end times, duration when the start was observed,
|
||||
player counts, and the full saved roster. Players missing from the final report
|
||||
retain their earlier counters; players with no stats display dashes. Counters and
|
||||
score are the highest reported values within the match, not necessarily its final
|
||||
scoreboard. Active, interrupted, and **End report missing** matches remain listed
|
||||
retain their earlier counters; players with no stats display dashes. Counters are
|
||||
the highest reported values within the match, not necessarily its final scoreboard.
|
||||
|
||||
Each match shows one score column. If any player has a recorded total (including
|
||||
zero), **Total score** shows each player's highest recorded cumulative points
|
||||
earned in that match. Players absent from the final report retain their latest
|
||||
recorded total. A dash in this column means no total was recorded for that player;
|
||||
their points balance is never substituted. Historical matches where all totals
|
||||
are missing retain **Highest reported score**, the highest recorded points
|
||||
balance, not total points earned or necessarily the final score. This choice is
|
||||
based on recorded data, not the match date. It does not change leaderboard or
|
||||
profile score rankings, which still use balances.
|
||||
|
||||
Active, interrupted, and **End report missing** matches remain listed
|
||||
without results links. Matches can filter each status and show known gaps in the
|
||||
reporter's sequence; a delayed report can reduce that missing-report count.
|
||||
Match pages remain available after the original server is removed.
|
||||
|
||||
Generated
+95
@@ -24,6 +24,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.1",
|
||||
"globals": "^17.13.0",
|
||||
"happy-dom": "^21.0.1",
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"svelte": "^5.1.0",
|
||||
@@ -1531,6 +1532,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
|
||||
@@ -2079,6 +2097,19 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-image-size": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
|
||||
@@ -2271,6 +2302,19 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -2807,6 +2851,25 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "21.0.1",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.1.tgz",
|
||||
"integrity": "sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||
@@ -4328,6 +4391,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -4377,6 +4450,28 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.1",
|
||||
"globals": "^17.13.0",
|
||||
"happy-dom": "^21.0.1",
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"svelte": "^5.1.0",
|
||||
|
||||
@@ -24,15 +24,19 @@
|
||||
: match.game.toUpperCase()
|
||||
);
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const stats = [
|
||||
const hasScoreTotals = $derived(match.players.some((player) => player.scoreTotal != null));
|
||||
const stats = $derived([
|
||||
{ key: 'highestRound', label: 'Highest round' },
|
||||
{ key: 'kills', label: 'Kills' },
|
||||
{ key: 'headshots', label: 'Headshots' },
|
||||
{ key: 'revives', label: 'Revives' },
|
||||
{ key: 'downs', label: 'Downs' },
|
||||
{ key: 'roundsSurvived', label: 'Rounds survived' },
|
||||
{ key: 'bestScore', label: 'Highest reported score' }
|
||||
] as const;
|
||||
{
|
||||
key: hasScoreTotals ? 'scoreTotal' : 'bestScore',
|
||||
label: hasScoreTotals ? 'Total score' : 'Highest reported score'
|
||||
}
|
||||
] as const);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{match.map} · Match #{match.id} · Electric Cherry</title></svelte:head>
|
||||
@@ -122,6 +126,11 @@
|
||||
<p>
|
||||
Every recorded player, including those absent from the final report. Stats are the
|
||||
highest reported counters for this match.
|
||||
{#if hasScoreTotals}
|
||||
Total score is the highest recorded cumulative points earned in this match.
|
||||
{:else}
|
||||
Highest reported score is the highest recorded points balance.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if match.players.length === 0}
|
||||
@@ -161,7 +170,7 @@
|
||||
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
|
||||
>
|
||||
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
|
||||
>{player[stat.key] === null
|
||||
>{player[stat.key] == null
|
||||
? '—'
|
||||
: number.format(player[stat.key]!)}</td
|
||||
>{/each}
|
||||
@@ -178,11 +187,20 @@
|
||||
|
||||
<details class="note">
|
||||
<summary>About these results</summary>
|
||||
<p>
|
||||
Kills, headshots, revives, downs, and score retain each player's highest reported value.
|
||||
Highest reported score is a points balance, not total points earned or necessarily the
|
||||
final score.
|
||||
</p>
|
||||
<p>Kills, headshots, revives, and downs retain each player's highest reported value.</p>
|
||||
{#if hasScoreTotals}
|
||||
<p>
|
||||
Total score is the highest recorded cumulative points earned in this match. Players
|
||||
absent from the final report retain their latest recorded total. A dash in Total
|
||||
score means no total was recorded for that player.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
Highest reported score is a points balance, not total points earned or necessarily
|
||||
the final score. This match has no recorded totals, so each player's highest
|
||||
reported balance is shown.
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
Highest round includes late joins. Rounds survived counts only observed round
|
||||
progression while the player was present. “At match end” means the player appeared in
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
+11
-1
@@ -12,7 +12,17 @@ export default defineConfig({
|
||||
test: {
|
||||
name: 'application',
|
||||
include: ['tests/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: [...configDefaults.exclude, migrationMemoryTest],
|
||||
exclude: [...configDefaults.exclude, migrationMemoryTest, 'tests/client/**'],
|
||||
sequence: { groupOrder: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
resolve: { conditions: ['browser'] },
|
||||
test: {
|
||||
name: 'client',
|
||||
environment: 'happy-dom',
|
||||
include: ['tests/client/**/*.{test,spec}.{js,ts}'],
|
||||
sequence: { groupOrder: 0 }
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user