feat(spa): keep player in sync across next-round and document issue-180 flow

This commit is contained in:
2026-03-01 16:52:11 +00:00
committed by Asger Geel Weirsoee
parent 177574ae19
commit fd1fbbf5e7
3 changed files with 86 additions and 0 deletions

View File

@@ -198,6 +198,30 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.session?.session.status).toBe('scoreboard');
await vi.advanceTimersByTimeAsync(3100);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.session?.session.status).toBe('lobby');
component.ngOnDestroy();
});
it('enters reconnecting state when network request fails while online', async () => {
vi.stubGlobal('navigator', { onLine: true });

View File

@@ -107,6 +107,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage());
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -145,6 +146,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
window.removeEventListener('offline', this.handleOffline);
}
this.clearReconnectTimer();
this.clearStateSyncTimer();
}
private readonly handleOnline = (): void => {
@@ -155,6 +157,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly handleOffline = (): void => {
this.connectionState = 'offline';
this.clearReconnectTimer();
this.clearStateSyncTimer();
};
private clearReconnectTimer(): void {
@@ -164,6 +167,34 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
}
private clearStateSyncTimer(): void {
if (this.stateSyncTimer) {
clearTimeout(this.stateSyncTimer);
this.stateSyncTimer = null;
}
}
private scheduleStateSync(): void {
this.clearStateSyncTimer();
if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) {
return;
}
if (this.session.session.status === 'finished') {
return;
}
this.stateSyncTimer = setTimeout(() => {
this.stateSyncTimer = null;
if (this.loading || this.connectionState !== 'online') {
this.scheduleStateSync();
return;
}
void this.refreshSession();
}, 3000);
}
get loadingMessage(): string {
switch (this.loadingTransition) {
case 'join':
@@ -192,9 +223,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private markOnline(): void {
this.connectionState = 'online';
this.clearReconnectTimer();
this.scheduleStateSync();
}
private markConnectionIssue(error: unknown): void {
this.clearStateSyncTimer();
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
return;