import { afterEach, describe, expect, it, vi } from 'vitest'; import { PlayerShellComponent } from './player-shell.component'; type FetchMock = ReturnType; function jsonResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, status, json: vi.fn().mockResolvedValue(body), } 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, sessionDetailPayload('reveal', { answers: ['A'] })) ); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'abcd12'; component.selectedGuess = 'A'; await component.refreshSession(); expect(fetchMock).toHaveBeenCalledWith( '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }) ); expect(component.selectedGuess).toBe(''); }); it('surfaces lie submit error and allows retry success flow', async () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' })) .mockResolvedValueOnce(jsonResponse(201, { lie: { id: 1, player_id: 9, round_question_id: 11, text: 'my lie', created_at: '2026-01-01T00:00:01Z' }, window: { lie_deadline_at: '2026-01-01T00:00:45Z' } })) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; component.playerId = 9; component.sessionToken = 'token-1'; component.lieText = 'my lie'; component.session = { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [] }, players: [], }; await component.submitLie(); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/lobby/sessions/ABCD12/questions/11/lies/submit', expect.objectContaining({ method: 'POST', body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }), }) ); expect(component.submitError?.kind).toBe('lie'); expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage'); await component.submitLie(); expect(component.submitError).toBeNull(); expect(component.session?.session.status).toBe('guess'); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('builds final leaderboard in finished status without legacy page hop', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( jsonResponse( 200, sessionDetailPayload('finished', { roundQuestionId: 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() .mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' })) .mockResolvedValueOnce(jsonResponse(201, { guess: { id: 2, player_id: 9, round_question_id: 11, selected_text: 'B', is_correct: false, fooled_player_id: 3, created_at: '2026-01-01T00:00:10Z' }, window: { guess_deadline_at: '2026-01-01T00:01:30Z' } })) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = ' abcd12 '; component.playerId = 9; component.sessionToken = 'token-1'; component.selectedGuess = 'B'; 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(); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/lobby/sessions/ABCD12/questions/11/guesses/submit', expect.objectContaining({ method: 'POST', body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }), }) ); expect(component.submitError?.kind).toBe('guess'); expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy'); await component.submitGuess(); expect(component.submitError).toBeNull(); expect(component.session?.session.status).toBe('reveal'); expect(component.selectedGuess).toBe(''); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { vi.useFakeTimers(); const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); expect(component.session?.session.status).toBe('scoreboard'); await vi.advanceTimersByTimeAsync(3100); expect(fetchMock).toHaveBeenCalledTimes(2); expect(component.session?.session.status).toBe('lobby'); component.ngOnDestroy(); }); 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 === 'reconnecting' || component.connectionState === 'online').toBe(true); expect(component.error).toContain('Session refresh failed:'); }); 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'); }); it('tracks loading transition message for join action', async () => { let resolveJoin: ((value: Response) => void) | null = null; const fetchMock: FetchMock = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveJoin = resolve; }) ); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; component.nickname = 'Luna'; const joinPromise = component.joinSession(); expect(component.loading).toBe(true); expect(component.loadingMessage).toBe('Joining session… restoring your player state.'); resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null }))); await joinPromise; expect(component.loading).toBe(false); expect(component.loadingTransition).toBeNull(); }); it('returnToJoin clears persisted session context and transient state', () => { const values = new Map(); const localStorage = { getItem: vi.fn((key: string) => values.get(key) ?? null), setItem: vi.fn((key: string, value: string) => { values.set(key, value); }), removeItem: vi.fn((key: string) => { values.delete(key); }), }; vi.stubGlobal('window', { addEventListener: vi.fn(), removeEventListener: vi.fn(), localStorage, }); values.set('wpp.session-context', JSON.stringify({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-1' })); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; component.playerId = 9; component.sessionToken = 'tok-1'; component.error = 'Session refresh failed'; component.submitError = { kind: 'guess', message: 'Guess submit failed' }; component.session = { session: { code: 'ABCD12', status: 'guess', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, players: [], }; component.returnToJoin(); expect(component.playerId).toBe(0); expect(component.sessionToken).toBe(''); expect(component.session).toBeNull(); expect(component.error).toBe(''); expect(component.submitError).toBeNull(); expect(values.get('wpp.session-context')).toBeUndefined(); }); it('syncs player hash-route with latest phase during periodic state sync', async () => { vi.useFakeTimers(); const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); vi.stubGlobal('fetch', fetchMock); const replaceState = vi.fn(); const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }; vi.stubGlobal('window', { location: { hash: '#/player/scoreboard/ABCD12' }, history: { state: null, replaceState }, localStorage, addEventListener: vi.fn(), removeEventListener: vi.fn(), }); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); await vi.advanceTimersByTimeAsync(3100); expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/lobby/ABCD12'); component.ngOnDestroy(); }); it('installs secondary-device audio guard while player shell is mounted', async () => { const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); const mediaPrototype = { play: originalPlay }; vi.stubGlobal('window', { location: { hash: '', search: '' }, history: { state: null, replaceState: vi.fn() }, localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, HTMLMediaElement: { prototype: mediaPrototype }, addEventListener: vi.fn(), removeEventListener: vi.fn(), }); vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); const component = new PlayerShellComponent(); component.ngOnInit(); await expect(mediaPrototype.play()).resolves.toBeUndefined(); component.ngOnDestroy(); await expect(mediaPrototype.play()).rejects.toThrow('original play'); }); });