[SPA][P10] Issue #187: Player reconnect/loading/error states #192
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { createApiClient } from '../../../../../src/api/client';
|
||||
@@ -12,6 +12,8 @@ interface SessionDetail {
|
||||
players: Array<{ id: number; nickname: string; score: number }>;
|
||||
}
|
||||
|
||||
type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
|
||||
@Component({
|
||||
selector: 'app-player-shell',
|
||||
standalone: true,
|
||||
@@ -26,6 +28,15 @@ interface SessionDetail {
|
||||
<button (click)="joinSession()" [disabled]="loading">Join</button>
|
||||
</div>
|
||||
|
||||
<p *ngIf="connectionState === 'reconnecting'" class="error">
|
||||
Reconnecting… trying to refresh session state.
|
||||
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
||||
</p>
|
||||
<p *ngIf="connectionState === 'offline'" class="error">
|
||||
You are offline. Reconnect to continue gameplay.
|
||||
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
||||
</p>
|
||||
|
||||
<div class="panel" *ngIf="session">
|
||||
<p><strong>Status:</strong> {{ session.session.status }}</p>
|
||||
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
|
||||
@@ -61,7 +72,7 @@ interface SessionDetail {
|
||||
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
|
||||
`,
|
||||
})
|
||||
export class PlayerShellComponent {
|
||||
export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
sessionCode = '';
|
||||
nickname = '';
|
||||
playerId = 0;
|
||||
@@ -73,14 +84,122 @@ export class PlayerShellComponent {
|
||||
submitError: { kind: 'lie' | 'guess'; message: string } | null = null;
|
||||
session: SessionDetail | null = null;
|
||||
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
|
||||
connectionState: ConnectionState = 'online';
|
||||
|
||||
private readonly sessionContextStore = createSessionContextStore();
|
||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
this.connectionState = 'offline';
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline);
|
||||
window.addEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
||||
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
||||
const codeFromRoute = match?.[1] ?? '';
|
||||
|
||||
const persistedContext = this.sessionContextStore.get();
|
||||
if (persistedContext) {
|
||||
this.playerId = persistedContext.playerId;
|
||||
this.sessionToken = persistedContext.token;
|
||||
}
|
||||
|
||||
const candidate = codeFromRoute || persistedContext?.sessionCode || '';
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionCode = this.normalizeCode(candidate);
|
||||
void this.refreshSession();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
this.clearReconnectTimer();
|
||||
}
|
||||
|
||||
private readonly handleOnline = (): void => {
|
||||
this.connectionState = 'reconnecting';
|
||||
this.retryReconnect();
|
||||
};
|
||||
|
||||
private readonly handleOffline = (): void => {
|
||||
this.connectionState = 'offline';
|
||||
this.clearReconnectTimer();
|
||||
};
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeCode(value: string): string {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private toMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
private markOnline(): void {
|
||||
this.connectionState = 'online';
|
||||
this.clearReconnectTimer();
|
||||
}
|
||||
|
||||
private markConnectionIssue(error: unknown): void {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
this.connectionState = 'offline';
|
||||
return;
|
||||
}
|
||||
|
||||
const message = this.toMessage(error).toLowerCase();
|
||||
if (
|
||||
message.includes('fetch') ||
|
||||
message.includes('network') ||
|
||||
message.includes('failed to') ||
|
||||
message.includes('could not load lobby status') ||
|
||||
message.includes('session refresh failed')
|
||||
) {
|
||||
this.connectionState = 'reconnecting';
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer || !this.sessionCode.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.retryReconnect();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async retryReconnect(): Promise<void> {
|
||||
if (!this.sessionCode.trim() || this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshSession();
|
||||
}
|
||||
|
||||
private syncFinalLeaderboard(): void {
|
||||
if (!this.session || this.session.session.status !== 'finished') {
|
||||
this.finalLeaderboard = [];
|
||||
@@ -128,8 +247,10 @@ export class PlayerShellComponent {
|
||||
this.selectedGuess = '';
|
||||
}
|
||||
this.syncFinalLeaderboard();
|
||||
this.markOnline();
|
||||
} catch (error) {
|
||||
this.error = `Session refresh failed: ${(error as Error).message}`;
|
||||
this.error = `Session refresh failed: ${this.toMessage(error)}`;
|
||||
this.markConnectionIssue(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -153,8 +274,10 @@ export class PlayerShellComponent {
|
||||
this.selectedGuess = '';
|
||||
}
|
||||
this.syncFinalLeaderboard();
|
||||
this.markOnline();
|
||||
} catch (error) {
|
||||
this.error = `Join failed: ${(error as Error).message}`;
|
||||
this.error = `Join failed: ${this.toMessage(error)}`;
|
||||
this.markConnectionIssue(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -177,8 +300,10 @@ export class PlayerShellComponent {
|
||||
}
|
||||
);
|
||||
await this.refreshSession();
|
||||
this.markOnline();
|
||||
} catch (error) {
|
||||
this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` };
|
||||
this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` };
|
||||
this.markConnectionIssue(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -201,8 +326,10 @@ export class PlayerShellComponent {
|
||||
}
|
||||
);
|
||||
await this.refreshSession();
|
||||
this.markOnline();
|
||||
} catch (error) {
|
||||
this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` };
|
||||
this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` };
|
||||
this.markConnectionIssue(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user