diff --git a/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md b/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md new file mode 100644 index 0000000..80758ee --- /dev/null +++ b/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md @@ -0,0 +1,28 @@ +# Issue #180 Evidence โ€” SPA next-round + final leaderboard + +## Flow log (Angular SPA) +1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`). +2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload. +3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering final leaderboard payload in the same shell. +4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route. +5. Error/retry paths are implemented and covered: + - scoreboard failure -> `scoreboardError` + retry button + - next-round failure -> `nextRoundError` + retry button + - finish-game/final-leaderboard failure -> `finishError` + retry button + +## Test evidence +### `frontend/angular` (Vitest) +- `src/app/features/host/host-shell.component.spec.ts` + - `runs next-round transition without reload and clears scoreboard payload` + - `captures finish-game failure for retry and stores final leaderboard on success` +- `src/app/features/player/player-shell.component.spec.ts` + - `builds final leaderboard in finished status without legacy page hop` + +Result: +- Test Files: 2 passed +- Tests: 9 passed + +### `frontend` shared SPA tests (regression) +Result: +- Test Files: 5 passed +- Tests: 24 passed diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 6151161..53fdaf6 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -117,4 +117,78 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.error).toBe(''); expect(component.loading).toBe(false); }); + + it('runs next-round transition without reload and clears scoreboard payload', async () => { + const fetchMock: FetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) + .mockResolvedValueOnce( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'lobby', current_round: 2 }, + round_question: null, + players: [], + }) + ); + + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = ' abcd12 '; + component.scoreboardPayload = '{"leaderboard":[]}'; + + await component.startNextRound(); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/lobby/sessions/ABCD12/rounds/next', + expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/lobby/sessions/ABCD12', + expect.objectContaining({ method: 'GET' }) + ); + expect(component.session?.session.status).toBe('lobby'); + expect(component.scoreboardPayload).toBe(''); + expect(component.nextRoundError).toBe(''); + }); + + it('captures finish-game failure for retry and stores final leaderboard on success', async () => { + const fetchMock: FetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(503, { error: 'Final leaderboard timeout' })) + .mockResolvedValueOnce( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'finished', current_round: 2 }, + winner: { id: 1, nickname: 'Luna', score: 320 }, + leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'finished', current_round: 2 }, + round_question: null, + players: [], + }) + ); + + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.finishGame(); + + expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); + + await component.finishGame(); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/lobby/sessions/ABCD12/finish', + expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) + ); + expect(component.finishError).toBe(''); + expect(component.finalLeaderboardPayload).toContain('"status": "finished"'); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 661d90c..b14efcd 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -11,6 +11,12 @@ interface SessionDetail { players: Array<{ id: number; nickname: string; score: number }>; } +interface LeaderboardResponse { + session: { code: string; status: string; current_round: number }; + leaderboard: Array<{ id: number; nickname: string; score: number }>; + winner?: { id: number; nickname: string; score: number } | null; +} + @Component({ selector: 'app-host-shell', standalone: true, @@ -27,11 +33,17 @@ interface SessionDetail { + + + +

{{ error }}

{{ scoreboardError }}

+

{{ nextRoundError }}

+

{{ finishError }}

Status: {{ session.session.status }} ยท round {{ session.session.current_round }}

@@ -41,6 +53,7 @@ interface SessionDetail {
  • {{ p.nickname }}: {{ p.score }}
  • {{ scoreboardPayload }}
    +
    {{ finalLeaderboardPayload }}
    `, }) @@ -51,7 +64,10 @@ export class HostShellComponent { loading = false; error = ''; scoreboardError = ''; + nextRoundError = ''; + finishError = ''; scoreboardPayload = ''; + finalLeaderboardPayload = ''; session: SessionDetail | null = null; private readonly controller = createVerticalSliceController(createApiClient()); @@ -83,6 +99,8 @@ export class HostShellComponent { this.loading = true; this.error = ''; this.scoreboardError = ''; + this.nextRoundError = ''; + this.finishError = ''; try { const state = await this.controller.hydrateLobby(this.sessionCode); if (!state.session || state.errorMessage) { @@ -91,6 +109,9 @@ export class HostShellComponent { this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; + if (this.session.session.status !== 'finished') { + this.finalLeaderboardPayload = ''; + } } catch (error) { this.error = `Session refresh failed: ${(error as Error).message}`; } finally { @@ -107,6 +128,8 @@ export class HostShellComponent { this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; + this.scoreboardPayload = ''; + this.finalLeaderboardPayload = ''; }); } @@ -152,6 +175,39 @@ export class HostShellComponent { } } + async startNextRound(): Promise { + this.loading = true; + this.nextRoundError = ''; + this.error = ''; + try { + const code = this.normalizeCode(this.sessionCode); + await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); + this.scoreboardPayload = ''; + this.finalLeaderboardPayload = ''; + await this.refreshSession(); + } catch (error) { + this.nextRoundError = `Next round failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + + async finishGame(): Promise { + this.loading = true; + this.finishError = ''; + this.error = ''; + try { + const code = this.normalizeCode(this.sessionCode); + const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {}); + this.finalLeaderboardPayload = JSON.stringify(payload, null, 2); + await this.refreshSession(); + } catch (error) { + this.finishError = `Finish game failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + private async runAction(action: () => Promise): Promise { this.loading = true; this.error = ''; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 6a0862b..ba0647c 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -22,6 +22,7 @@ describe('PlayerShellComponent gameplay wiring', () => { jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, + players: [], }) ); @@ -49,6 +50,7 @@ describe('PlayerShellComponent gameplay wiring', () => { jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + players: [], }) ); @@ -84,6 +86,28 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it('builds final leaderboard in finished status without legacy page hop', async () => { + const fetchMock: FetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'finished', current_round: 2 }, + round_question: null, + players: [ + { id: 2, nickname: 'Mads', score: 150 }, + { id: 1, nickname: 'Luna', score: 320 }, + ], + }) + ); + + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); + }); + it('surfaces guess submit error and retries with selected answer payload', async () => { const fetchMock: FetchMock = vi .fn() @@ -93,6 +117,7 @@ describe('PlayerShellComponent gameplay wiring', () => { jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + players: [], }) ); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index e1d3df1..5e09248 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -9,6 +9,7 @@ import { createVerticalSliceController } from '../../../../../src/spa/vertical-s interface SessionDetail { session: { code: string; status: string; current_round: number }; round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; + players: Array<{ id: number; nickname: string; score: number }>; } @Component({ @@ -47,6 +48,13 @@ interface SessionDetail { + +
    +

    Final leaderboard

    +
      +
    1. {{ entry.nickname }}: {{ entry.score }}
    2. +
    +

    {{ error }}

    @@ -64,6 +72,7 @@ export class PlayerShellComponent { error = ''; submitError: { kind: 'lie' | 'guess'; message: string } | null = null; session: SessionDetail | null = null; + finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; private readonly sessionContextStore = createSessionContextStore(); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); @@ -72,6 +81,20 @@ export class PlayerShellComponent { return value.trim().toUpperCase(); } + private syncFinalLeaderboard(): void { + if (!this.session || this.session.session.status !== 'finished') { + this.finalLeaderboard = []; + return; + } + + this.finalLeaderboard = [...this.session.players].sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; + } + return a.nickname.localeCompare(b.nickname); + }); + } + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { const response = await fetch(path, { method, @@ -104,6 +127,7 @@ export class PlayerShellComponent { if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } + this.syncFinalLeaderboard(); } catch (error) { this.error = `Session refresh failed: ${(error as Error).message}`; } finally { @@ -128,6 +152,7 @@ export class PlayerShellComponent { if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } + this.syncFinalLeaderboard(); } catch (error) { this.error = `Join failed: ${(error as Error).message}`; } finally {