From ffda04f276306d6de07c60c9ad6722a8a9ef9051 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 12 Oct 2026 16:13:48 -0400 Subject: [PATCH] Show recorded match score totals with legacy fallback (Refs #147) --- docs/guides/leaderboard.md | 17 +- package-lock.json | 95 +++++++++++ package.json | 1 + src/routes/matches/[id]/+page.svelte | 36 +++-- tests/client/ui-match-results.test.ts | 219 ++++++++++++++++++++++++++ vite.config.ts | 12 +- 6 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 tests/client/ui-match-results.test.ts diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md index f11c7d0..b1048e3 100644 --- a/docs/guides/leaderboard.md +++ b/docs/guides/leaderboard.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 402ec73..c13c2eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3728d8d..73568d2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/matches/[id]/+page.svelte b/src/routes/matches/[id]/+page.svelte index d65820b..a242453 100644 --- a/src/routes/matches/[id]/+page.svelte +++ b/src/routes/matches/[id]/+page.svelte @@ -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); {match.map} · Match #{match.id} · Electric Cherry @@ -122,6 +126,11 @@

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}

{#if match.players.length === 0} @@ -161,7 +170,7 @@ >{#if !player.hasStats}No stats recorded{/if} {#each stats as stat}{player[stat.key] === null + >{player[stat.key] == null ? '—' : number.format(player[stat.key]!)}{/each} @@ -178,11 +187,20 @@
About these results -

- 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. -

+

Kills, headshots, revives, and downs retain each player's highest reported value.

+ {#if hasScoreTotals} +

+ 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. +

+ {:else} +

+ 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. +

+ {/if}

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 diff --git a/tests/client/ui-match-results.test.ts b/tests/client/ui-match-results.test.ts new file mode 100644 index 0000000..34b300a --- /dev/null +++ b/tests/client/ui-match-results.test.ts @@ -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 { + 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 | 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(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 966c4bd..cb471e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 } } },