fix(frontend): prefer canonical phase for client action gating
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 3m1s

This commit is contained in:
2026-03-16 17:00:02 +00:00
parent 112a85a22d
commit d9c4cda966
7 changed files with 175 additions and 26 deletions
@@ -21,6 +21,7 @@ function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
@@ -81,6 +82,7 @@ function sessionDetailPayload(
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: 2,
constraints: {
@@ -89,14 +91,18 @@ function sessionDetailPayload(
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: status === 'lobby',
can_show_question: status === 'lie',
can_mix_answers: status === 'lie' || status === 'guess',
can_calculate_scores: status === 'guess',
can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'reveal',
can_finish_game: status === 'reveal',
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',
@@ -259,7 +265,7 @@ describe('HostShellComponent gameplay wiring', () => {
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
@@ -296,7 +302,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -320,7 +326,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' ';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
@@ -351,6 +357,10 @@ describe('HostShellComponent gameplay wiring', () => {
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();
}
@@ -361,16 +371,42 @@ describe('HostShellComponent gameplay wiring', () => {
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);
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(false);
expect(component.canFinishGame).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);
@@ -408,12 +444,12 @@ describe('HostShellComponent gameplay wiring', () => {
component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).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(false);
expect(component.canFinishGame).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
});
});