From f0e87eb98877601f4c4fa285ce1833258ac6e6d1 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 12:29:14 +0000 Subject: [PATCH] feat: expose canonical reveal payload in SPA refs #289 parent #287 --- .../src/app/api-contract-smoke.spec.ts | 20 ++++ .../host/host-shell.component.spec.ts | 80 ++++++++++++++- .../app/features/host/host-shell.component.ts | 30 ++++-- .../player/player-shell.component.spec.ts | 97 ++++++++++++++++++- .../features/player/player-shell.component.ts | 32 ++++-- frontend/src/api/mappers.ts | 51 ++++++++++ frontend/src/api/types.ts | 27 ++++++ 7 files changed, 323 insertions(+), 14 deletions(-) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 166c272..78a5fff 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { shown_at: '2026-03-01T18:00:00Z', answers: [{ text: 'A' }, { text: 'B' }] }, + reveal: { + round_question_id: 77, + round_number: 1, + prompt: 'Q?', + correct_answer: 'A', + lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], + guesses: [ + { + player_id: 3, + nickname: 'Bo', + selected_text: 'B', + is_correct: false, + fooled_player_id: 2, + fooled_player_nickname: 'Maja', + created_at: '2026-03-01T18:00:15Z' + } + ] + }, phase_view_model: { status: 'lobby', round_number: 1, @@ -172,6 +190,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { expect(session.data.session.code).toBe('ABCD12'); expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); expect(session.data.phase_view_model.player.can_submit_guess).toBe(true); + expect(session.data.reveal?.correct_answer).toBe('A'); + expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); } expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true); 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 e29ff47..b6f54b8 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 @@ -12,7 +12,26 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } -function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) { +function sessionDetailPayload( + status: string, + options?: { + 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 { @@ -37,6 +56,23 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb { 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, round_number: 1, @@ -101,6 +137,48 @@ describe('HostShellComponent gameplay wiring', () => { 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('captures scoreboard error for retry path', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); 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 4253051..36b431d 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,15 +3,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; +import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; -interface SessionDetail { - session: { code: string; status: string; current_round: number }; - round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; - players: Array<{ id: number; nickname: string; score: number }>; -} +type SessionDetail = SessionDetailResponse; type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; type LeaderboardResponse = FinishGameResponse; @@ -52,6 +48,28 @@ type LeaderboardResponse = FinishGameResponse; +
+

Reveal

+

Korrekt svar: {{ session.reveal.correct_answer }}

+

Spørgsmål: {{ session.reveal.prompt }}

+
+ Løgne +
    +
  • {{ lie.nickname }} løj: {{ lie.text }}
  • +
+
+
+ Gæt +
    +
  • + {{ guess.nickname }} valgte {{ guess.selected_text }} + · korrekt + · narret af {{ guess.fooled_player_nickname }} + · forkert +
  • +
+
+
{{ scoreboardPayload }}

{{ copy('host.final_leaderboard') }}

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 5bd1d1d..bbb092e 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 @@ -13,7 +13,28 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } -function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) { +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; @@ -39,6 +60,23 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl ...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, @@ -158,6 +196,63 @@ describe('PlayerShellComponent gameplay wiring', () => { 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() diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 393723a..d7f485e 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -3,15 +3,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; +import type { SessionDetailResponse } from '../../../../../src/api/types'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; -interface SessionDetail { - session: { code: string; status: string; current_round: number }; - round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; - players: Array<{ id: number; nickname: string; score: number }>; -} +type SessionDetail = SessionDetailResponse; type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; @@ -90,6 +87,29 @@ function resolveLocalStorage(): Storage | undefined { +
+

Reveal

+

Korrekt svar: {{ session.reveal.correct_answer }}

+

Spørgsmål: {{ session.reveal.prompt }}

+
+ Løgne +
    +
  • {{ lie.nickname }} løj: {{ lie.text }}
  • +
+
+
+ Gæt +
    +
  • + {{ guess.nickname }} valgte {{ guess.selected_text }} + · korrekt + · narret af {{ guess.fooled_player_nickname }} + · forkert +
  • +
+
+
+

{{ copy('player.final_leaderboard') }}

    @@ -261,7 +281,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } const activeElements = document.querySelectorAll('audio,video') as - | NodeListOf + | NodeListOf | GuardableMediaElement[] | undefined; diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 538a264..1944ec3 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -102,6 +102,56 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse { const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); + const revealRaw = root.reveal; + let reveal: SessionDetailResponse['reveal'] = null; + if (revealRaw !== null && revealRaw !== undefined) { + const revealRecord = asRecord(revealRaw, 'session_detail.reveal'); + const liesRaw = revealRecord.lies; + const guessesRaw = revealRecord.guesses; + if (!Array.isArray(liesRaw)) { + throw new Error('Invalid API contract: expected array at session_detail.reveal.lies'); + } + if (!Array.isArray(guessesRaw)) { + throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses'); + } + + reveal = { + round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'), + round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'), + prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'), + correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'), + lies: liesRaw.map((lie, index) => { + const record = asRecord(lie, `session_detail.reveal.lies[${index}]`); + return { + player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`), + nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`), + text: readString(record, 'text', `session_detail.reveal.lies[${index}]`), + created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`) + }; + }), + guesses: guessesRaw.map((guess, index) => { + const record = asRecord(guess, `session_detail.reveal.guesses[${index}]`); + const fooledPlayerId = record.fooled_player_id; + if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) { + throw new Error(`Invalid API contract: expected number|null at session_detail.reveal.guesses[${index}].fooled_player_id`); + } + const fooledPlayerNickname = record.fooled_player_nickname; + if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) { + throw new Error(`Invalid API contract: expected string at session_detail.reveal.guesses[${index}].fooled_player_nickname`); + } + return { + player_id: readNumber(record, 'player_id', `session_detail.reveal.guesses[${index}]`), + nickname: readString(record, 'nickname', `session_detail.reveal.guesses[${index}]`), + selected_text: readString(record, 'selected_text', `session_detail.reveal.guesses[${index}]`), + is_correct: readBoolean(record, 'is_correct', `session_detail.reveal.guesses[${index}]`), + fooled_player_id: fooledPlayerId, + ...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }), + created_at: readString(record, 'created_at', `session_detail.reveal.guesses[${index}]`) + }; + }) + }; + } + return { session: { code: readString(session, 'code', 'session_detail.session'), @@ -129,6 +179,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse { }; }), round_question: roundQuestion, + reveal, phase_view_model: { status: readString(phase, 'status', 'session_detail.phase_view_model'), round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 982928d..5a9a13a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -57,10 +57,37 @@ export interface PhaseViewModel { }; } +export interface RevealLie { + player_id: number; + nickname: string; + text: string; + created_at: string; +} + +export interface RevealGuess { + player_id: number; + nickname: string; + selected_text: string; + is_correct: boolean; + fooled_player_id: number | null; + fooled_player_nickname?: string; + created_at: string; +} + +export interface RevealPayload { + round_question_id: number; + round_number: number; + prompt: string; + correct_answer: string; + lies: RevealLie[]; + guesses: RevealGuess[]; +} + export interface SessionDetailResponse { session: SessionSummary; players: SessionPlayer[]; round_question: SessionRoundQuestion | null; + reveal: RevealPayload | null; phase_view_model: PhaseViewModel; }