feat: expose canonical reveal payload in SPA refs #289 parent #287
Some checks failed
CI / test-and-quality (push) Failing after 2m6s
CI / test-and-quality (pull_request) Failing after 2m11s

This commit is contained in:
2026-03-15 12:29:14 +00:00
parent a80b1ee354
commit f0e87eb988
7 changed files with 323 additions and 14 deletions

View File

@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
shown_at: '2026-03-01T18:00:00Z', shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }] 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: { phase_view_model: {
status: 'lobby', status: 'lobby',
round_number: 1, 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.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); 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.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); expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);

View File

@@ -12,7 +12,26 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response; } 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; const roundQuestionId = options?.roundQuestionId ?? 41;
return { return {
@@ -37,6 +56,23 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
{ id: 1, nickname: 'Host', score: 0, is_connected: true }, { id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, 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: { phase_view_model: {
status, status,
round_number: 1, round_number: 1,
@@ -101,6 +137,48 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.loading).toBe(false); 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 () => { it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));

View File

@@ -3,15 +3,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; 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 { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { type SessionDetail = SessionDetailResponse;
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 LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse; type LeaderboardResponse = FinishGameResponse;
@@ -52,6 +48,28 @@ type LeaderboardResponse = FinishGameResponse;
<ul> <ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li> <li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul> </ul>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre> <pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length"> <div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3> <h3>{{ copy('host.final_leaderboard') }}</h3>

View File

@@ -13,7 +13,28 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response; } 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 answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11; const roundQuestionId = options?.roundQuestionId ?? 11;
@@ -39,6 +60,23 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
...player, ...player,
is_connected: true, 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: { phase_view_model: {
status, status,
round_number: 1, round_number: 1,
@@ -158,6 +196,63 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); 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 () => { it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi const fetchMock: FetchMock = vi
.fn() .fn()

View File

@@ -3,15 +3,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { SessionDetailResponse } from '../../../../../src/api/types';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { type SessionDetail = SessionDetailResponse;
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 ConnectionState = 'online' | 'reconnecting' | 'offline'; type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
@@ -90,6 +87,29 @@ function resolveLocalStorage(): Storage | undefined {
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button> <button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button> <button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length"> <div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3> <h3>{{ copy('player.final_leaderboard') }}</h3>
<ol> <ol>
@@ -261,7 +281,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
const activeElements = document.querySelectorAll('audio,video') as const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<GuardableMediaElement> | NodeListOf<HTMLMediaElement>
| GuardableMediaElement[] | GuardableMediaElement[]
| undefined; | undefined;

View File

@@ -102,6 +102,56 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); 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 { return {
session: { session: {
code: readString(session, 'code', 'session_detail.session'), code: readString(session, 'code', 'session_detail.session'),
@@ -129,6 +179,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
}; };
}), }),
round_question: roundQuestion, round_question: roundQuestion,
reveal,
phase_view_model: { phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'), status: readString(phase, 'status', 'session_detail.phase_view_model'),
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),

View File

@@ -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 { export interface SessionDetailResponse {
session: SessionSummary; session: SessionSummary;
players: SessionPlayer[]; players: SessionPlayer[];
round_question: SessionRoundQuestion | null; round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
phase_view_model: PhaseViewModel; phase_view_model: PhaseViewModel;
} }