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 3c15591..c25f188 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 @@ -50,11 +50,11 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb host: { can_start_round: status === 'lobby', can_show_question: status === 'lie', - can_mix_answers: status === 'lie', + can_mix_answers: status === 'lie' || status === 'guess', can_calculate_scores: status === 'guess', can_reveal_scoreboard: status === 'reveal', - can_start_next_round: status === 'scoreboard', - can_finish_game: status === 'scoreboard', + can_start_next_round: status === 'reveal', + can_finish_game: status === 'reveal', }, player: { can_join: status === 'lobby', @@ -116,7 +116,7 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.loading).toBe(false); }); - it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => { + it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce( @@ -156,12 +156,16 @@ describe('HostShellComponent gameplay wiring', () => { component.sessionCode = ' abcd12 '; component.roundQuestionId = ' 77 '; + component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any; await component.showQuestion(); + + component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; await component.mixAnswers(); await component.calculateScores(); 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 () => { @@ -245,27 +249,44 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.finishError).toContain('Session code is required'); }); - it('blocks illegal host actions outside canonical reveal/scoreboard boundaries', async () => { + 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 ['lie', 'guess', 'reveal'] as const) { - component.session = sessionDetailPayload(status) as any; + 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(); await component.startNextRound(); await component.finishGame(); } - component.session = sessionDetailPayload('reveal') as any; + 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(true); + expect(component.canFinishGame).toBe(true); + + component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; + expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('scoreboard') as any; - await component.loadScoreboard(); - - expect(component.canLoadScoreboard).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); 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 89baba5..9e6c5da 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -29,9 +29,9 @@ type LeaderboardResponse = FinishGameResponse; - - - + + + @@ -123,6 +123,22 @@ export class HostShellComponent implements OnInit, OnDestroy { return isHostGameplayActionAllowed(this.session as any, 'startRound'); } + get canShowQuestion(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'showQuestion'); + } + + get canMixAnswers(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'mixAnswers'); + } + + get canCalculateScores(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'calculateScores'); + } + + get canLoadScoreboard(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard'); + } + get canStartNextRound(): boolean { return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); } @@ -131,14 +147,6 @@ export class HostShellComponent implements OnInit, OnDestroy { return isHostGameplayActionAllowed(this.session as any, 'finishGame'); } - get canUseLegacyMidRoundHostAction(): boolean { - return false; - } - - get canLoadScoreboard(): boolean { - return !this.session || this.gameplayPhase === 'reveal'; - } - copy(key: string): string { return t(key, this.locale); } @@ -219,7 +227,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async showQuestion(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canShowQuestion) { return; } @@ -231,7 +239,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async mixAnswers(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canMixAnswers) { return; } @@ -244,7 +252,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async calculateScores(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canCalculateScores) { return; } 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 9ebcf84..36cf287 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 @@ -109,9 +109,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.lieText = 'my lie'; component.session = { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, + ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [] }, - players: [], }; await component.submitLie(); @@ -173,9 +172,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.selectedGuess = 'B'; component.session = { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, + ...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], }; await component.submitGuess(); diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index c9b059c..257d220 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -1,7 +1,14 @@ import type { PhaseViewModel, SessionDetailResponse } from '../api/types'; export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; -export type HostGameplayAction = 'startRound' | 'startNextRound' | 'finishGame'; +export type HostGameplayAction = + | 'startRound' + | 'showQuestion' + | 'mixAnswers' + | 'calculateScores' + | 'loadScoreboard' + | 'startNextRound' + | 'finishGame'; export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; export type GameplayPhaseEvent = @@ -77,15 +84,22 @@ export function isHostGameplayActionAllowed(session: SessionDetailResponse | nul return true; } - const phase = deriveGameplayPhase(session); const host = session.phase_view_model?.host; switch (action) { case 'startRound': - return phase === null || Boolean(host?.can_start_round ?? phase === null); + return Boolean(host?.can_start_round ?? false); + case 'showQuestion': + return Boolean(host?.can_show_question ?? false); + case 'mixAnswers': + return Boolean(host?.can_mix_answers ?? false); + case 'calculateScores': + return Boolean(host?.can_calculate_scores ?? false); + case 'loadScoreboard': + return Boolean(host?.can_reveal_scoreboard ?? false); case 'startNextRound': - return phase === 'scoreboard' && Boolean(host?.can_start_next_round ?? true); + return Boolean(host?.can_start_next_round ?? false); case 'finishGame': - return phase === 'scoreboard' && Boolean(host?.can_finish_game ?? true); + return Boolean(host?.can_finish_game ?? false); } } @@ -94,16 +108,15 @@ export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | n return action === 'join'; } - const phase = deriveGameplayPhase(session); const player = session.phase_view_model?.player; switch (action) { case 'join': - return Boolean(player?.can_join ?? true); + return Boolean(player?.can_join ?? false); case 'submitLie': - return phase === 'lie' && Boolean(player?.can_submit_lie ?? true); + return Boolean(player?.can_submit_lie ?? false); case 'submitGuess': - return phase === 'guess' && Boolean(player?.can_submit_guess ?? true); + return Boolean(player?.can_submit_guess ?? false); case 'viewFinalResult': - return session.session.status === 'finished' && Boolean(player?.can_view_final_result ?? true); + return Boolean(player?.can_view_final_result ?? false); } } diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts index 2d71a7b..f6fb2b2 100644 --- a/frontend/tests/gameplay-phase-machine.test.ts +++ b/frontend/tests/gameplay-phase-machine.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { allowedGameplayEvents, deriveGameplayPhase, + isHostGameplayActionAllowed, + isPlayerGameplayActionAllowed, transitionGameplayPhase, type GameplayPhase } from '../src/spa/gameplay-phase-machine'; @@ -103,4 +105,44 @@ describe('gameplay phase machine skeleton', () => { }) ).toBe('scoreboard'); }); + + it('gates host and player actions from canonical phase_view_model permissions', () => { + const session = { + session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 }, + players: [], + round_question: { id: 77, prompt: 'Q?', answers: [] }, + phase_view_model: { + status: 'reveal', + round_number: 1, + players_count: 3, + constraints: { + min_players_to_start: 3, + max_players_mvp: 5, + 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: true, + can_start_next_round: true, + can_finish_game: true + }, + player: { + can_join: false, + can_submit_lie: false, + can_submit_guess: false, + can_view_final_result: false + } + } + } as const; + + expect(deriveGameplayPhase(session as any)).toBe('reveal'); + expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true); + expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true); + expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true); + expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false); + }); });