import { afterEach, describe, expect, it, vi } from 'vitest'; import { HostShellComponent } from './host-shell.component'; type FetchMock = ReturnType; type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise; function jsonResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, status, json: vi.fn().mockResolvedValue(body), } as unknown as Response; } function createFetchRouteMock(handler: FetchRouteHandler): FetchMock { return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init))); } function sessionDetailPayload( status: string, options?: { currentPhase?: string; 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 roundQuestionId = options?.roundQuestionId ?? 41; return { session: { code: 'ABCD12', status, host_id: 1, current_round: status === 'lobby' ? 2 : 1, players_count: 2, }, round_question: roundQuestionId === null ? null : { id: roundQuestionId, round_number: 1, prompt: 'Q?', shown_at: '2026-01-01T00:00:00Z', answers: [], }, players: [ { id: 1, nickname: 'Host', score: 0, is_connected: true }, { id: 2, nickname: 'Mads', score: 120, 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, current_phase: options?.currentPhase ?? status, round_number: 1, players_count: 2, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: true, max_players_allowed: true, }, readiness: { question_ready: (options?.currentPhase ?? status) !== 'lobby', scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard', }, host: { can_start_round: (options?.currentPhase ?? status) === 'lobby', can_show_question: (options?.currentPhase ?? status) === 'lie', can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess', can_calculate_scores: (options?.currentPhase ?? status) === 'guess', can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal', can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard', can_finish_game: (options?.currentPhase ?? status) === 'scoreboard', }, player: { can_join: status === 'lobby', can_submit_lie: status === 'lie', can_submit_guess: status === 'guess', can_view_final_result: status === 'finished', }, }, }; } describe('HostShellComponent gameplay wiring', () => { afterEach(() => { vi.restoreAllMocks(); }); it('runs startRound transition and refreshes session details', async () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse(201, { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } }, }) ) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie'))); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; component.categorySlug = ' history '; await component.startRound(); expect(fetchMock).toHaveBeenNthCalledWith( 1, '/lobby/sessions/ABCD12/rounds/start', expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) }) ); expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); expect(component.session?.session.status).toBe('lie'); expect(component.roundQuestionId).toBe('41'); expect(component.loading).toBe(false); }); it('hydrates canonical reveal payload in reveal phase', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( jsonResponse( 200, sessionDetailPayload('reveal', { roundQuestionId: 77, reveal: { correct_answer: 'Mercury', lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }], guesses: [ { player_id: 3, nickname: 'Luna', selected_text: 'Venus', is_correct: false, fooled_player_id: 2, fooled_player_nickname: 'Mads', }, ], }, }) ) ); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); expect(component.session?.reveal?.correct_answer).toBe('Mercury'); expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' }); expect(component.session?.reveal?.guesses[0]).toMatchObject({ player_id: 3, nickname: 'Luna', selected_text: 'Venus', fooled_player_id: 2, fooled_player_nickname: 'Mads', }); }); it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { let refreshCount = 0; const fetchMock = createFetchRouteMock((input, init) => { const url = String(input); const method = init?.method ?? 'GET'; if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') { return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); } if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') { return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } }); } if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } }); } if (method === 'GET' && url === '/lobby/sessions/ABCD12') { refreshCount += 1; if (refreshCount === 1) { return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); } if (refreshCount === 2) { return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })); } return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })); } throw new Error(`Unhandled fetch in test: ${method} ${url}`); }); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; component.roundQuestionId = ' 77 '; component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any; await component.showQuestion(); expect(component.session?.session.status).toBe('lie'); expect(component.roundQuestionId).toBe('99'); component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; await component.mixAnswers(); expect(component.session?.session.status).toBe('guess'); await component.calculateScores(); expect(component.session?.session.status).toBe('reveal'); expect(component.error).toBe(''); expect(component.loading).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(6); }); it('runs next-round transition without reload and clears scoreboard payload', async () => { const fetchMock = createFetchRouteMock((input, init) => { const url = String(input); const method = init?.method ?? 'GET'; if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') { return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); } if (method === 'GET' && url === '/lobby/sessions/ABCD12') { return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); } throw new Error(`Unhandled fetch in test: ${method} ${url}`); }); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; component.scoreboardPayload = '{"leaderboard":[]}'; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; 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('lie'); expect(component.roundQuestionId).toBe('99'); expect(component.finalLeaderboardPayload).toBe(''); expect(component.finalLeaderboard).toEqual([]); 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: 2, nickname: 'Mads', score: 120 }, { id: 1, nickname: 'Luna', score: 320 }, ], }) ) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('finished', { roundQuestionId: null }))); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; 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"'); expect(component.finalWinner?.nickname).toBe('Luna'); expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); }); it('guards next-round and finish actions when session code is missing', async () => { const fetchMock: FetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' '; component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; await component.startNextRound(); await component.finishGame(); expect(fetchMock).not.toHaveBeenCalled(); expect(component.nextRoundError).toContain('Session code is required'); expect(component.finishError).toContain('Session code is required'); }); it('blocks illegal host actions outside canonical phase permissions', async () => { const fetchMock: FetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; component.roundQuestionId = '77'; for (const status of ['guess', 'reveal', 'scoreboard'] as const) { component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; await component.showQuestion(); } for (const status of ['lie', 'reveal', 'scoreboard'] as const) { component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; await component.calculateScores(); } for (const status of ['lie', 'guess', 'scoreboard'] as const) { component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; await component.loadScoreboard(); } for (const status of ['lie', 'guess', 'reveal'] as const) { component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; await component.startNextRound(); await component.finishGame(); } component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; expect(component.canShowQuestion).toBe(false); component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; expect(component.canCalculateScores).toBe(false); expect(component.canLoadScoreboard).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(true); expect(component.canFinishGame).toBe(true); expect(fetchMock).not.toHaveBeenCalled(); }); it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } })) ); vi.stubGlobal('fetch', fetchMock); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { hash: '#/host/reveal/ABCD12' }, history: { state: null, replaceState }, sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() }, }); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); expect(component.gameplayPhase).toBe('scoreboard'); expect(component.showRevealPanel).toBe(true); expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(true); expect(component.canFinishGame).toBe(true); expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12'); }); it('syncs host hash-route with latest phase after refresh without page reload', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { hash: '#/host/lobby/ABCD12' }, history: { state: null, replaceState }, sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() }, }); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; await component.refreshSession(); expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); expect(component.canStartRound).toBe(false); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); }); it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => { const component = new HostShellComponent(); expect(component.canStartRound).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); component.session = sessionDetailPayload('lie') as any; expect(component.canStartRound).toBe(false); expect(component.canShowQuestion).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); component.session = sessionDetailPayload('reveal') as any; expect(component.canLoadScoreboard).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); component.session = sessionDetailPayload('scoreboard') as any; expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(true); expect(component.canFinishGame).toBe(true); }); });