456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { HostShellComponent } from './host-shell.component';
|
|
|
|
type FetchMock = ReturnType<typeof vi.fn>;
|
|
|
|
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
|
|
|
|
function jsonResponse(status: number, body: unknown) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: vi.fn().mockResolvedValue(body),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
|
|
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
|
|
}
|
|
|
|
function sessionDetailPayload(
|
|
status: string,
|
|
options?: {
|
|
currentPhase?: string;
|
|
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 {
|
|
session: {
|
|
code: 'ABCD12',
|
|
status,
|
|
host_id: 1,
|
|
current_round: status === 'lobby' ? 2 : 1,
|
|
players_count: 2,
|
|
},
|
|
round_question:
|
|
roundQuestionId === null
|
|
? null
|
|
: {
|
|
id: roundQuestionId,
|
|
round_number: 1,
|
|
prompt: 'Q?',
|
|
shown_at: '2026-01-01T00:00:00Z',
|
|
answers: [],
|
|
},
|
|
players: [
|
|
{ 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,
|
|
current_phase: options?.currentPhase ?? status,
|
|
round_number: 1,
|
|
players_count: 2,
|
|
constraints: {
|
|
min_players_to_start: 2,
|
|
max_players_mvp: 8,
|
|
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: (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',
|
|
can_submit_lie: status === 'lie',
|
|
can_submit_guess: status === 'guess',
|
|
can_view_final_result: status === 'finished',
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('HostShellComponent gameplay wiring', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('runs startRound transition and refreshes session details', async () => {
|
|
const fetchMock: FetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(201, {
|
|
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
|
round: { number: 1, category: { slug: 'history', name: 'History' } },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie')));
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const component = new HostShellComponent();
|
|
component.sessionCode = ' abcd12 ';
|
|
component.categorySlug = ' history ';
|
|
|
|
await component.startRound();
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/lobby/sessions/ABCD12/rounds/start',
|
|
expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) })
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
|
|
expect(component.session?.session.status).toBe('lie');
|
|
expect(component.roundQuestionId).toBe('41');
|
|
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('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
|
|
let refreshCount = 0;
|
|
const fetchMock = createFetchRouteMock((input, init) => {
|
|
const url = String(input);
|
|
const method = init?.method ?? 'GET';
|
|
|
|
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
|
|
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
|
|
}
|
|
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
|
|
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
|
|
}
|
|
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
|
|
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
|
|
}
|
|
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
|
|
refreshCount += 1;
|
|
if (refreshCount === 1) {
|
|
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
|
|
}
|
|
if (refreshCount === 2) {
|
|
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
|
|
}
|
|
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
|
|
}
|
|
|
|
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const component = new HostShellComponent();
|
|
component.sessionCode = ' abcd12 ';
|
|
component.roundQuestionId = ' 77 ';
|
|
|
|
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
|
|
await component.showQuestion();
|
|
expect(component.session?.session.status).toBe('lie');
|
|
expect(component.roundQuestionId).toBe('99');
|
|
|
|
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
|
|
await component.mixAnswers();
|
|
expect(component.session?.session.status).toBe('guess');
|
|
|
|
await component.calculateScores();
|
|
|
|
expect(component.session?.session.status).toBe('reveal');
|
|
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 () => {
|
|
const fetchMock = createFetchRouteMock((input, init) => {
|
|
const url = String(input);
|
|
const method = init?.method ?? 'GET';
|
|
|
|
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
|
|
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
|
|
}
|
|
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
|
|
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
|
|
}
|
|
|
|
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const component = new HostShellComponent();
|
|
component.sessionCode = ' abcd12 ';
|
|
component.scoreboardPayload = '{"leaderboard":[]}';
|
|
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
|
|
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
|
|
|
await component.startNextRound();
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/lobby/sessions/ABCD12/rounds/next',
|
|
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
|
|
expect(component.session?.session.status).toBe('lie');
|
|
expect(component.roundQuestionId).toBe('99');
|
|
expect(component.finalLeaderboardPayload).toBe('');
|
|
expect(component.finalLeaderboard).toEqual([]);
|
|
expect(component.nextRoundError).toBe('');
|
|
});
|
|
|
|
it('captures finish-game failure for retry and stores final leaderboard on success', async () => {
|
|
const fetchMock: FetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse(503, { error: 'Final leaderboard timeout' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
|
|
winner: { id: 1, nickname: 'Luna', score: 320 },
|
|
leaderboard: [
|
|
{ id: 2, nickname: 'Mads', score: 120 },
|
|
{ id: 1, nickname: 'Luna', score: 320 },
|
|
],
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('finished', { roundQuestionId: null })));
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const component = new HostShellComponent();
|
|
component.sessionCode = 'ABCD12';
|
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
|
|
|
await component.finishGame();
|
|
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
|
|
|
await component.finishGame();
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/lobby/sessions/ABCD12/finish',
|
|
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
|
);
|
|
expect(component.finishError).toBe('');
|
|
expect(component.finalLeaderboardPayload).toContain('"status": "finished"');
|
|
expect(component.finalWinner?.nickname).toBe('Luna');
|
|
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
|
|
});
|
|
|
|
it('guards next-round and finish actions when session code is missing', async () => {
|
|
const fetchMock: FetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const component = new HostShellComponent();
|
|
component.sessionCode = ' ';
|
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
|
|
|
await component.startNextRound();
|
|
await component.finishGame();
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(component.nextRoundError).toContain('Session code is required');
|
|
expect(component.finishError).toContain('Session code is required');
|
|
});
|
|
|
|
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 ['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();
|
|
}
|
|
|
|
for (const status of ['lie', 'guess', 'reveal'] as const) {
|
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
|
await component.startNextRound();
|
|
await component.finishGame();
|
|
}
|
|
|
|
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(false);
|
|
expect(component.canFinishGame).toBe(false);
|
|
|
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
|
expect(component.canLoadScoreboard).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);
|
|
|
|
const replaceState = vi.fn();
|
|
vi.stubGlobal('window', {
|
|
location: { hash: '#/host/lobby/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(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
|
|
expect(component.canStartRound).toBe(false);
|
|
expect(component.canStartNextRound).toBe(false);
|
|
expect(component.canFinishGame).toBe(false);
|
|
});
|
|
|
|
it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => {
|
|
const component = new HostShellComponent();
|
|
|
|
expect(component.canStartRound).toBe(true);
|
|
expect(component.canStartNextRound).toBe(false);
|
|
expect(component.canFinishGame).toBe(false);
|
|
|
|
component.session = sessionDetailPayload('lie') as any;
|
|
expect(component.canStartRound).toBe(false);
|
|
expect(component.canShowQuestion).toBe(true);
|
|
expect(component.canStartNextRound).toBe(false);
|
|
expect(component.canFinishGame).toBe(false);
|
|
|
|
component.session = sessionDetailPayload('reveal') as any;
|
|
expect(component.canLoadScoreboard).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(true);
|
|
expect(component.canFinishGame).toBe(true);
|
|
});
|
|
});
|