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 {{{ scoreboardPayload }}
+ {{ finalLeaderboardPayload }}
{{ error }}
@@ -64,6 +72,10 @@ 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); private readonly sessionContextStore = createSessionContextStore(); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); @@ -72,6 +84,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