feat(spa): keep player in sync across next-round and document issue-180 flow
This commit is contained in:
28
docs/issue-180-flow-log.md
Normal file
28
docs/issue-180-flow-log.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Issue #180 – SPA gameplay flow evidence
|
||||||
|
|
||||||
|
## Flow log (host + player, no page reload)
|
||||||
|
|
||||||
|
1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`).
|
||||||
|
2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`).
|
||||||
|
3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads.
|
||||||
|
4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload.
|
||||||
|
5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell.
|
||||||
|
6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score).
|
||||||
|
7. Error/retry paths available:
|
||||||
|
- Host: next-round and finish-game retry buttons with explicit error feedback.
|
||||||
|
- Player: reconnect + submit retry feedback.
|
||||||
|
|
||||||
|
## Test output snapshot
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/angular
|
||||||
|
npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- `host-shell.component.spec.ts`: 6 passed
|
||||||
|
- `player-shell.component.spec.ts`: 7 passed
|
||||||
|
- Total: 13 passed, 0 failed
|
||||||
@@ -198,6 +198,30 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
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 () => {
|
it('enters reconnecting state when network request fails while online', async () => {
|
||||||
vi.stubGlobal('navigator', { onLine: true });
|
vi.stubGlobal('navigator', { onLine: true });
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage());
|
private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage());
|
||||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
@@ -145,6 +146,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
window.removeEventListener('offline', this.handleOffline);
|
window.removeEventListener('offline', this.handleOffline);
|
||||||
}
|
}
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
this.clearStateSyncTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly handleOnline = (): void => {
|
private readonly handleOnline = (): void => {
|
||||||
@@ -155,6 +157,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
private readonly handleOffline = (): void => {
|
private readonly handleOffline = (): void => {
|
||||||
this.connectionState = 'offline';
|
this.connectionState = 'offline';
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
this.clearStateSyncTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
private clearReconnectTimer(): void {
|
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 {
|
get loadingMessage(): string {
|
||||||
switch (this.loadingTransition) {
|
switch (this.loadingTransition) {
|
||||||
case 'join':
|
case 'join':
|
||||||
@@ -192,9 +223,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
private markOnline(): void {
|
private markOnline(): void {
|
||||||
this.connectionState = 'online';
|
this.connectionState = 'online';
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
this.scheduleStateSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private markConnectionIssue(error: unknown): void {
|
private markConnectionIssue(error: unknown): void {
|
||||||
|
this.clearStateSyncTimer();
|
||||||
|
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
this.connectionState = 'offline';
|
this.connectionState = 'offline';
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user