import { afterEach, describe, expect, it, vi } from 'vitest'; import lobbyCatalog from '../../../../../../shared/i18n/lobby.json'; 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; reveal?: { correct_answer: string; prompt?: string; lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>; guesses?: Array<{ player_id: number; nickname: string; selected_text: string; is_correct: boolean; fooled_player_id: number | null; fooled_player_nickname?: string; created_at?: string; }>; } | 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, })), reveal: options?.reveal === undefined || options?.reveal === null ? null : { round_question_id: roundQuestionId, round_number: 1, prompt: options.reveal.prompt ?? 'Q?', correct_answer: options.reveal.correct_answer, lies: (options.reveal.lies ?? []).map((lie) => ({ ...lie, created_at: lie.created_at ?? '2026-01-01T00:00:05Z', })), guesses: (options.reveal.guesses ?? []).map((guess) => ({ ...guess, created_at: guess.created_at ?? '2026-01-01T00:00:10Z', })), }, 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 = { ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [] }, }; 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('hydrates canonical reveal payload after guess -> reveal', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( jsonResponse( 200, sessionDetailPayload('reveal', { answers: ['A', 'B'], reveal: { correct_answer: 'A', lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }], guesses: [ { player_id: 9, nickname: 'Detektiv', selected_text: 'B', is_correct: false, fooled_player_id: 3, fooled_player_nickname: 'Løgnhals', }, { player_id: 10, nickname: 'Sandhed', selected_text: 'A', is_correct: true, fooled_player_id: null, }, ], }, }) ) ); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); expect(component.session?.reveal?.correct_answer).toBe('A'); expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' }); expect(component.session?.reveal?.guesses[0]).toMatchObject({ player_id: 9, nickname: 'Detektiv', selected_text: 'B', is_correct: false, fooled_player_id: 3, fooled_player_nickname: 'Løgnhals', }); expect(component.session?.reveal?.guesses[1]).toMatchObject({ player_id: 10, nickname: 'Sandhed', selected_text: 'A', is_correct: true, fooled_player_id: null, }); }); 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 = { ...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, }; 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('blocks illegal player guess submission outside canonical guess phase', async () => { const fetchMock: FetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const component = new PlayerShellComponent(); component.sessionCode = 'ABCD12'; component.playerId = 9; component.sessionToken = 'token-1'; component.selectedGuess = 'B'; for (const status of ['lie', 'reveal', 'scoreboard'] as const) { component.session = { ...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any), round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, }; await component.submitGuess(); } expect(component.canSubmitGuess).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); 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('silences active media elements when secondary-device audio guard is installed', () => { const pauseAudio = vi.fn(); const pauseVideo = vi.fn(); const audioElement = { muted: false, pause: pauseAudio }; const videoElement = { muted: false, pause: pauseVideo }; const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]); vi.stubGlobal('document', { querySelectorAll }); 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: { play: vi.fn().mockResolvedValue(undefined) } }, addEventListener: vi.fn(), removeEventListener: vi.fn(), }); vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); const component = new PlayerShellComponent(); component.ngOnInit(); expect(querySelectorAll).toHaveBeenCalledWith('audio,video'); expect(audioElement.muted).toBe(true); expect(videoElement.muted).toBe(true); expect(pauseAudio).toHaveBeenCalledTimes(1); expect(pauseVideo).toHaveBeenCalledTimes(1); 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(); const pause = vi.fn(); const audioElement = { muted: false, defaultMuted: false, volume: 1, pause }; await expect(mediaPrototype.play.call(audioElement)).resolves.toBeUndefined(); expect(audioElement.muted).toBe(true); expect(audioElement.defaultMuted).toBe(true); expect(audioElement.volume).toBe(0); expect(pause).toHaveBeenCalledTimes(1); component.ngOnDestroy(); await expect(mediaPrototype.play()).rejects.toThrow('original play'); }); it('keeps audio guard active until the last mounted player shell is destroyed', 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 firstComponent = new PlayerShellComponent(); const secondComponent = new PlayerShellComponent(); firstComponent.ngOnInit(); secondComponent.ngOnInit(); await expect(mediaPrototype.play()).resolves.toBeUndefined(); firstComponent.ngOnDestroy(); await expect(mediaPrototype.play()).resolves.toBeUndefined(); secondComponent.ngOnDestroy(); await expect(mediaPrototype.play()).rejects.toThrow('original play'); }); it('does not trigger original media play during player-shell init path', () => { const originalPlay = vi.fn().mockResolvedValue(undefined); 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(); expect(originalPlay).not.toHaveBeenCalled(); component.ngOnDestroy(); }); it('keeps primary-device playback untouched when no-audio capability is disabled', async () => { const originalPlay = vi.fn().mockResolvedValue(undefined); 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 as any).clientHasNoAudioOutput = false; component.ngOnInit(); await expect(mediaPrototype.play()).resolves.toBeUndefined(); expect(mediaPrototype.play).toBe(originalPlay); expect((mediaPrototype as any).__wppSecondaryDeviceAudioGuard__).toBeUndefined(); component.ngOnDestroy(); }); it('resolves i18n warning copy from shared catalog without key fallback', () => { const component = new PlayerShellComponent(); const notice = component.copy('player.audio_policy_notice'); const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale]; expect(notice).toBe(expected); expect(notice).not.toBe('player.audio_policy_notice'); }); it('gates template warning notice on the no-audio-output capability flag', () => { const templateSource = String((PlayerShellComponent as any).ɵcmp?.template); expect(templateSource).toContain('clientHasNoAudioOutput'); const component = new PlayerShellComponent(); expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice'); expect(component.clientHasNoAudioOutput).toBe(true); (component as any).clientHasNoAudioOutput = false; expect(component.clientHasNoAudioOutput).toBe(false); }); it('keeps phone client controls phase-specific and low-complexity', () => { const component = new PlayerShellComponent(); expect(component.showJoinControls).toBe(true); expect(component.showLieControls).toBe(false); expect(component.showGuessControls).toBe(false); expect(component.showFinalLeaderboard).toBe(false); component.session = sessionDetailPayload('lie') as any; component.playerId = 9; component.sessionToken = 'tok'; expect(component.showJoinControls).toBe(false); expect(component.showLieControls).toBe(true); expect(component.showGuessControls).toBe(false); component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any; expect(component.showLieControls).toBe(false); expect(component.showGuessControls).toBe(true); component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any; expect(component.showGuessControls).toBe(false); expect(component.showFinalLeaderboard).toBe(true); }); });