feat(player): add reconnect/offline states in angular gameplay flow
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 2m29s

This commit is contained in:
2026-03-01 16:00:53 +00:00
parent 386ac5b7c1
commit f3ea19fcd7
2 changed files with 234 additions and 33 deletions
@@ -12,18 +12,70 @@ 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 }) {
const answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11;
return {
session: {
code: 'ABCD12',
status,
host_id: null,
current_round: 1,
players_count: (options?.players ?? []).length,
},
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
answers: answers.map((text) => ({ text })),
},
players: (options?.players ?? []).map((player) => ({
...player,
is_connected: true,
})),
phase_view_model: {
status,
round_number: 1,
players_count: (options?.players ?? []).length,
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: false,
can_start_next_round: false,
can_finish_game: false,
},
player: {
can_join: status === 'lobby',
can_submit_lie: status === 'lie',
can_submit_guess: status === 'guess',
can_view_final_result: status === 'finished',
},
},
};
}
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('clears selected guess when refreshed status is no longer guess', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
players: [],
})
jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A'] }))
);
vi.stubGlobal('fetch', fetchMock);
@@ -46,13 +98,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
.fn()
.mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
})
);
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })));
vi.stubGlobal('fetch', fetchMock);
@@ -64,6 +110,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
};
await component.submitLie();
@@ -88,14 +135,16 @@ describe('PlayerShellComponent gameplay wiring', () => {
it('builds final leaderboard in finished status without legacy page hop', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
round_question: null,
players: [
{ id: 2, nickname: 'Mads', score: 150 },
{ id: 1, nickname: 'Luna', score: 320 },
],
})
jsonResponse(
200,
sessionDetailPayload('finished', {
roundQuestionId: null,
players: [
{ id: 2, nickname: 'Mads', score: 150 },
{ id: 1, nickname: 'Luna', score: 320 },
],
})
)
);
vi.stubGlobal('fetch', fetchMock);
@@ -113,13 +162,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
.fn()
.mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
})
);
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] })));
vi.stubGlobal('fetch', fetchMock);
@@ -131,6 +174,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
};
await component.submitGuess();
@@ -153,4 +197,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.selectedGuess).toBe('');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('enters reconnecting state when network request fails while online', async () => {
vi.stubGlobal('navigator', { onLine: true });
const fetchMock: FetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.connectionState).toBe('reconnecting');
expect(component.error).toContain('Session refresh failed: Could not load lobby status.');
});
it('uses offline state when browser reports disconnected network', async () => {
vi.stubGlobal('navigator', { onLine: false });
const fetchMock: FetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.connectionState).toBe('offline');
expect(component.error).toContain('Session refresh failed');
});
});