feat(gameplay): expose canonical reveal payload in frontend contract
Some checks failed
CI / test-and-quality (push) Failing after 2m8s
Some checks failed
CI / test-and-quality (push) Failing after 2m8s
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
||||
HealthResponse,
|
||||
JoinSessionResponse,
|
||||
MixAnswersResponse,
|
||||
RevealPayload,
|
||||
ScoreboardResponse,
|
||||
SessionDetailResponse,
|
||||
ShowQuestionResponse,
|
||||
@@ -68,6 +69,57 @@ export function mapHealthResponse(payload: unknown): HealthResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function mapRevealPayload(payload: unknown, path: string): RevealPayload {
|
||||
const reveal = asRecord(payload, path);
|
||||
const liesRaw = reveal.lies;
|
||||
const guessesRaw = reveal.guesses;
|
||||
|
||||
if (!Array.isArray(liesRaw)) {
|
||||
throw new Error(`Invalid API contract: expected array at ${path}.lies`);
|
||||
}
|
||||
if (!Array.isArray(guessesRaw)) {
|
||||
throw new Error(`Invalid API contract: expected array at ${path}.guesses`);
|
||||
}
|
||||
|
||||
return {
|
||||
round_question_id: readNumber(reveal, 'round_question_id', path),
|
||||
round_number: readNumber(reveal, 'round_number', path),
|
||||
prompt: readString(reveal, 'prompt', path),
|
||||
correct_answer: readString(reveal, 'correct_answer', path),
|
||||
lies: liesRaw.map((lie, index) => {
|
||||
const liePath = `${path}.lies[${index}]`;
|
||||
const record = asRecord(lie, liePath);
|
||||
return {
|
||||
player_id: readNumber(record, 'player_id', liePath),
|
||||
nickname: readString(record, 'nickname', liePath),
|
||||
text: readString(record, 'text', liePath),
|
||||
created_at: readString(record, 'created_at', liePath)
|
||||
};
|
||||
}),
|
||||
guesses: guessesRaw.map((guess, index) => {
|
||||
const guessPath = `${path}.guesses[${index}]`;
|
||||
const record = asRecord(guess, guessPath);
|
||||
const fooledPlayerId = record.fooled_player_id;
|
||||
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
|
||||
throw new Error(`Invalid API contract: expected number|null at ${guessPath}.fooled_player_id`);
|
||||
}
|
||||
const fooledPlayerNickname = record.fooled_player_nickname;
|
||||
if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) {
|
||||
throw new Error(`Invalid API contract: expected string|undefined at ${guessPath}.fooled_player_nickname`);
|
||||
}
|
||||
return {
|
||||
player_id: readNumber(record, 'player_id', guessPath),
|
||||
nickname: readString(record, 'nickname', guessPath),
|
||||
selected_text: readString(record, 'selected_text', guessPath),
|
||||
is_correct: readBoolean(record, 'is_correct', guessPath),
|
||||
created_at: readString(record, 'created_at', guessPath),
|
||||
fooled_player_id: fooledPlayerId,
|
||||
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname })
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
||||
const root = asRecord(payload, 'session_detail');
|
||||
const session = asRecord(root.session, 'session_detail.session');
|
||||
@@ -97,6 +149,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
||||
};
|
||||
}
|
||||
|
||||
const revealRaw = root.reveal;
|
||||
const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model');
|
||||
const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints');
|
||||
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
|
||||
@@ -129,6 +182,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
||||
};
|
||||
}),
|
||||
round_question: roundQuestion,
|
||||
reveal: revealRaw === null || revealRaw === undefined ? null : mapRevealPayload(revealRaw, 'session_detail.reveal'),
|
||||
phase_view_model: {
|
||||
status: readString(phase, 'status', 'session_detail.phase_view_model'),
|
||||
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
||||
@@ -276,6 +330,7 @@ export function mapCalculateScoresResponse(payload: unknown): CalculateScoresRes
|
||||
round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question')
|
||||
},
|
||||
events_created: readNumber(root, 'events_created', 'calculate_scores'),
|
||||
reveal: mapRevealPayload(root.reveal, 'calculate_scores.reveal'),
|
||||
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,10 +57,38 @@ export interface PhaseViewModel {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RevealPlayerRef {
|
||||
player_id: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface RevealLie extends RevealPlayerRef {
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RevealGuess extends RevealPlayerRef {
|
||||
selected_text: string;
|
||||
is_correct: boolean;
|
||||
created_at: string;
|
||||
fooled_player_id: number | null;
|
||||
fooled_player_nickname?: 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;
|
||||
}
|
||||
|
||||
@@ -138,6 +166,7 @@ export interface CalculateScoresResponse {
|
||||
round_number: number;
|
||||
};
|
||||
events_created: number;
|
||||
reveal: RevealPayload;
|
||||
leaderboard: Array<{ id: number; nickname: string; score: number }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('createAngularApiClient', () => {
|
||||
{ id: 3, nickname: 'Bo', score: 0, is_connected: false }
|
||||
],
|
||||
round_question: null,
|
||||
reveal: null,
|
||||
phase_view_model: {
|
||||
status: 'lobby',
|
||||
round_number: 1,
|
||||
@@ -84,6 +85,7 @@ describe('createAngularApiClient', () => {
|
||||
if (session.ok) {
|
||||
expect(session.data.session.code).toBe('ABCD12');
|
||||
expect(session.data.session.host_id).toBe(1);
|
||||
expect(session.data.reveal).toBeNull();
|
||||
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
|
||||
}
|
||||
|
||||
@@ -119,6 +121,7 @@ describe('createAngularApiClient', () => {
|
||||
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
reveal: null,
|
||||
phase_view_model: {
|
||||
status: 'lobby',
|
||||
round_number: 1,
|
||||
@@ -189,6 +192,117 @@ describe('createAngularApiClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('maps canonical reveal payload from session detail in reveal phase', async () => {
|
||||
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||
if (url === '/lobby/sessions/ABCD12') {
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [
|
||||
{ id: 2, nickname: 'Maja', score: 4, is_connected: true },
|
||||
{ id: 3, nickname: 'Bo', score: 2, is_connected: true },
|
||||
{ id: 4, nickname: 'Ida', score: 5, is_connected: true }
|
||||
],
|
||||
round_question: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Hvem opfandt pæren?',
|
||||
shown_at: '2026-03-01T16:00:00Z',
|
||||
answers: [{ text: 'Edison' }, { text: 'Tesla' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Hvem opfandt pæren?',
|
||||
correct_answer: 'Edison',
|
||||
lies: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
text: 'Tesla',
|
||||
created_at: '2026-03-01T16:00:20Z'
|
||||
}
|
||||
],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 2,
|
||||
nickname: 'Maja',
|
||||
selected_text: 'Tesla',
|
||||
is_correct: false,
|
||||
fooled_player_id: 3,
|
||||
fooled_player_nickname: 'Bo',
|
||||
created_at: '2026-03-01T16:01:00Z'
|
||||
},
|
||||
{
|
||||
player_id: 4,
|
||||
nickname: 'Ida',
|
||||
selected_text: 'Edison',
|
||||
is_correct: true,
|
||||
fooled_player_id: null,
|
||||
created_at: '2026-03-01T16:01:02Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
round_number: 1,
|
||||
players_count: 3,
|
||||
constraints: {
|
||||
min_players_to_start: 2,
|
||||
max_players_mvp: 8,
|
||||
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 T;
|
||||
}
|
||||
|
||||
throw { status: 404, error: { error: 'Not found' } };
|
||||
});
|
||||
|
||||
const client = createAngularApiClient({ get, post: vi.fn() } as AngularHttpClientLike);
|
||||
const session = await client.getSession('abcd12');
|
||||
|
||||
expect(session.ok).toBe(true);
|
||||
if (session.ok) {
|
||||
expect(session.data.reveal?.correct_answer).toBe('Edison');
|
||||
expect(session.data.reveal?.lies[0]).toMatchObject({
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
text: 'Tesla'
|
||||
});
|
||||
expect(session.data.reveal?.guesses[0]).toMatchObject({
|
||||
player_id: 2,
|
||||
nickname: 'Maja',
|
||||
selected_text: 'Tesla',
|
||||
is_correct: false,
|
||||
fooled_player_id: 3,
|
||||
fooled_player_nickname: 'Bo'
|
||||
});
|
||||
expect(session.data.reveal?.guesses[1]).toMatchObject({
|
||||
player_id: 4,
|
||||
nickname: 'Ida',
|
||||
selected_text: 'Edison',
|
||||
is_correct: true,
|
||||
fooled_player_id: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns parse error when successful payload breaks typed contract', async () => {
|
||||
const http = {
|
||||
get: vi.fn<AngularHttpClientLike['get']>(async <T>() => ({ ok: true } as T)),
|
||||
@@ -248,6 +362,39 @@ describe('createAngularApiClient', () => {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
round_question: { id: 77, round_number: 1 },
|
||||
events_created: 3,
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Hvem opfandt pæren?',
|
||||
correct_answer: 'Edison',
|
||||
lies: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
text: 'Tesla',
|
||||
created_at: '2026-03-01T16:00:20Z'
|
||||
}
|
||||
],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 2,
|
||||
nickname: 'Maja',
|
||||
selected_text: 'Tesla',
|
||||
is_correct: false,
|
||||
fooled_player_id: 3,
|
||||
fooled_player_nickname: 'Bo',
|
||||
created_at: '2026-03-01T16:01:00Z'
|
||||
},
|
||||
{
|
||||
player_id: 4,
|
||||
nickname: 'Ida',
|
||||
selected_text: 'Edison',
|
||||
is_correct: true,
|
||||
fooled_player_id: null,
|
||||
created_at: '2026-03-01T16:01:02Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
|
||||
} as T;
|
||||
}
|
||||
@@ -305,6 +452,29 @@ describe('createAngularApiClient', () => {
|
||||
|
||||
const calculateScores = await client.calculateScores('abcd12', 77);
|
||||
expect(calculateScores.ok).toBe(true);
|
||||
if (calculateScores.ok) {
|
||||
expect(calculateScores.data.reveal.correct_answer).toBe('Edison');
|
||||
expect(calculateScores.data.reveal.lies[0]).toMatchObject({
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
text: 'Tesla'
|
||||
});
|
||||
expect(calculateScores.data.reveal.guesses[0]).toMatchObject({
|
||||
player_id: 2,
|
||||
nickname: 'Maja',
|
||||
selected_text: 'Tesla',
|
||||
is_correct: false,
|
||||
fooled_player_id: 3,
|
||||
fooled_player_nickname: 'Bo'
|
||||
});
|
||||
expect(calculateScores.data.reveal.guesses[1]).toMatchObject({
|
||||
player_id: 4,
|
||||
nickname: 'Ida',
|
||||
selected_text: 'Edison',
|
||||
is_correct: true,
|
||||
fooled_player_id: null
|
||||
});
|
||||
}
|
||||
|
||||
const scoreboard = await client.getScoreboard('abcd12');
|
||||
expect(scoreboard.ok).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user