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 ba0647c..708b5b9 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 @@ -12,18 +12,70 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } +function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) { + const answers = options?.answers ?? []; + const roundQuestionId = options?.roundQuestionId ?? 11; + + return { + session: { + code: 'ABCD12', + status, + host_id: null, + current_round: 1, + players_count: (options?.players ?? []).length, + }, + round_question: + roundQuestionId === null + ? null + : { + id: roundQuestionId, + round_number: 1, + prompt: 'Q?', + shown_at: '2026-01-01T00:00:00Z', + answers: answers.map((text) => ({ text })), + }, + players: (options?.players ?? []).map((player) => ({ + ...player, + is_connected: true, + })), + phase_view_model: { + status, + round_number: 1, + players_count: (options?.players ?? []).length, + constraints: { + min_players_to_start: 2, + max_players_mvp: 8, + min_players_reached: true, + max_players_allowed: true, + }, + host: { + can_start_round: false, + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: false, + can_start_next_round: false, + can_finish_game: false, + }, + player: { + can_join: status === 'lobby', + can_submit_lie: status === 'lie', + can_submit_guess: status === 'guess', + can_view_final_result: status === 'finished', + }, + }, + }; +} + describe('PlayerShellComponent gameplay wiring', () => { afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); it('clears selected guess when refreshed status is no longer guess', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, - players: [], - }) + jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A'] })) ); vi.stubGlobal('fetch', fetchMock); @@ -46,13 +98,7 @@ describe('PlayerShellComponent gameplay wiring', () => { .fn() .mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' })) .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); @@ -64,6 +110,7 @@ describe('PlayerShellComponent gameplay wiring', () => { component.session = { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [] }, + players: [], }; await component.submitLie(); @@ -88,14 +135,16 @@ describe('PlayerShellComponent gameplay wiring', () => { 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 }, - ], - }) + jsonResponse( + 200, + sessionDetailPayload('finished', { + roundQuestionId: null, + players: [ + { id: 2, nickname: 'Mads', score: 150 }, + { id: 1, nickname: 'Luna', score: 320 }, + ], + }) + ) ); vi.stubGlobal('fetch', fetchMock); @@ -113,13 +162,7 @@ describe('PlayerShellComponent gameplay wiring', () => { .fn() .mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' })) .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); @@ -131,6 +174,7 @@ describe('PlayerShellComponent gameplay wiring', () => { component.session = { session: { code: 'ABCD12', status: 'guess', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + players: [], }; await component.submitGuess(); @@ -153,4 +197,34 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.selectedGuess).toBe(''); expect(fetchMock).toHaveBeenCalledTimes(3); }); + + it('enters reconnecting state when network request fails while online', async () => { + vi.stubGlobal('navigator', { onLine: true }); + + const fetchMock: FetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.connectionState).toBe('reconnecting'); + expect(component.error).toContain('Session refresh failed: Could not load lobby status.'); + }); + + it('uses offline state when browser reports disconnected network', async () => { + vi.stubGlobal('navigator', { onLine: false }); + + const fetchMock: FetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.connectionState).toBe('offline'); + expect(component.error).toContain('Session refresh failed'); + }); }); 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 5e09248..d1f2b85 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; @@ -12,6 +12,8 @@ interface SessionDetail { players: Array<{ id: number; nickname: string; score: number }>; } +type ConnectionState = 'online' | 'reconnecting' | 'offline'; + @Component({ selector: 'app-player-shell', standalone: true, @@ -26,6 +28,15 @@ interface SessionDetail { +
+ Reconnecting… trying to refresh session state. + +
++ You are offline. Reconnect to continue gameplay. + +
+Status: {{ session.session.status }}
Prompt: {{ session.round_question.prompt }}
@@ -61,7 +72,7 @@ interface SessionDetail {{{ submitError.message }}
`, }) -export class PlayerShellComponent { +export class PlayerShellComponent implements OnInit, OnDestroy { sessionCode = ''; nickname = ''; playerId = 0; @@ -73,14 +84,122 @@ export class PlayerShellComponent { submitError: { kind: 'lie' | 'guess'; message: string } | null = null; session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; + connectionState: ConnectionState = 'online'; private readonly sessionContextStore = createSessionContextStore(); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); + private reconnectTimer: ReturnType