feat(spa): add next-round and final leaderboard flow in Angular shells
This commit is contained in:
28
docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md
Normal file
28
docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Issue #180 Evidence — SPA next-round + final leaderboard
|
||||
|
||||
## Flow log (Angular SPA)
|
||||
1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`).
|
||||
2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload.
|
||||
3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering final leaderboard payload in the same shell.
|
||||
4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route.
|
||||
5. Error/retry paths are implemented and covered:
|
||||
- scoreboard failure -> `scoreboardError` + retry button
|
||||
- next-round failure -> `nextRoundError` + retry button
|
||||
- finish-game/final-leaderboard failure -> `finishError` + retry button
|
||||
|
||||
## Test evidence
|
||||
### `frontend/angular` (Vitest)
|
||||
- `src/app/features/host/host-shell.component.spec.ts`
|
||||
- `runs next-round transition without reload and clears scoreboard payload`
|
||||
- `captures finish-game failure for retry and stores final leaderboard on success`
|
||||
- `src/app/features/player/player-shell.component.spec.ts`
|
||||
- `builds final leaderboard in finished status without legacy page hop`
|
||||
|
||||
Result:
|
||||
- Test Files: 2 passed
|
||||
- Tests: 9 passed
|
||||
|
||||
### `frontend` shared SPA tests (regression)
|
||||
Result:
|
||||
- Test Files: 5 passed
|
||||
- Tests: 24 passed
|
||||
@@ -117,4 +117,78 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
expect(component.error).toBe('');
|
||||
expect(component.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('runs next-round transition without reload and clears scoreboard payload', async () => {
|
||||
const fetchMock: FetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'lobby', current_round: 2 },
|
||||
round_question: null,
|
||||
players: [],
|
||||
})
|
||||
);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = ' abcd12 ';
|
||||
component.scoreboardPayload = '{"leaderboard":[]}';
|
||||
|
||||
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('lobby');
|
||||
expect(component.scoreboardPayload).toBe('');
|
||||
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: 1, nickname: 'Luna', score: 320 }],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
|
||||
round_question: null,
|
||||
players: [],
|
||||
})
|
||||
);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,12 @@ interface SessionDetail {
|
||||
players: Array<{ id: number; nickname: string; score: number }>;
|
||||
}
|
||||
|
||||
interface LeaderboardResponse {
|
||||
session: { code: string; status: string; current_round: number };
|
||||
leaderboard: Array<{ id: number; nickname: string; score: number }>;
|
||||
winner?: { id: number; nickname: string; score: number } | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-host-shell',
|
||||
standalone: true,
|
||||
@@ -27,11 +33,17 @@ interface SessionDetail {
|
||||
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">Mix answers → guess</button>
|
||||
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">Calculate scores → reveal</button>
|
||||
<button (click)="loadScoreboard()" [disabled]="loading">Load scoreboard</button>
|
||||
<button (click)="startNextRound()" [disabled]="loading">Start next round</button>
|
||||
<button (click)="finishGame()" [disabled]="loading">Finish game</button>
|
||||
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">Retry scoreboard</button>
|
||||
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">Retry next round</button>
|
||||
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">Retry finish game</button>
|
||||
</div>
|
||||
|
||||
<p *ngIf="error" class="error">{{ error }}</p>
|
||||
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
|
||||
<p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p>
|
||||
<p *ngIf="finishError" class="error">{{ finishError }}</p>
|
||||
|
||||
<div *ngIf="session" class="panel">
|
||||
<p><strong>Status:</strong> {{ session.session.status }} · round {{ session.session.current_round }}</p>
|
||||
@@ -41,6 +53,7 @@ interface SessionDetail {
|
||||
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
||||
</ul>
|
||||
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
||||
<pre *ngIf="finalLeaderboardPayload">{{ finalLeaderboardPayload }}</pre>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -51,7 +64,10 @@ export class HostShellComponent {
|
||||
loading = false;
|
||||
error = '';
|
||||
scoreboardError = '';
|
||||
nextRoundError = '';
|
||||
finishError = '';
|
||||
scoreboardPayload = '';
|
||||
finalLeaderboardPayload = '';
|
||||
session: SessionDetail | null = null;
|
||||
|
||||
private readonly controller = createVerticalSliceController(createApiClient());
|
||||
@@ -83,6 +99,8 @@ export class HostShellComponent {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.scoreboardError = '';
|
||||
this.nextRoundError = '';
|
||||
this.finishError = '';
|
||||
try {
|
||||
const state = await this.controller.hydrateLobby(this.sessionCode);
|
||||
if (!state.session || state.errorMessage) {
|
||||
@@ -91,6 +109,9 @@ export class HostShellComponent {
|
||||
this.session = state.session as SessionDetail;
|
||||
this.sessionCode = this.session.session.code;
|
||||
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
||||
if (this.session.session.status !== 'finished') {
|
||||
this.finalLeaderboardPayload = '';
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = `Session refresh failed: ${(error as Error).message}`;
|
||||
} finally {
|
||||
@@ -107,6 +128,8 @@ export class HostShellComponent {
|
||||
this.session = state.session as SessionDetail;
|
||||
this.sessionCode = this.session.session.code;
|
||||
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
||||
this.scoreboardPayload = '';
|
||||
this.finalLeaderboardPayload = '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,6 +175,39 @@ export class HostShellComponent {
|
||||
}
|
||||
}
|
||||
|
||||
async startNextRound(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.nextRoundError = '';
|
||||
this.error = '';
|
||||
try {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
|
||||
this.scoreboardPayload = '';
|
||||
this.finalLeaderboardPayload = '';
|
||||
await this.refreshSession();
|
||||
} catch (error) {
|
||||
this.nextRoundError = `Next round failed: ${(error as Error).message}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async finishGame(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.finishError = '';
|
||||
this.error = '';
|
||||
try {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
const payload = await this.request<LeaderboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {});
|
||||
this.finalLeaderboardPayload = JSON.stringify(payload, null, 2);
|
||||
await this.refreshSession();
|
||||
} catch (error) {
|
||||
this.finishError = `Finish game failed: ${(error as Error).message}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(action: () => Promise<void>): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
|
||||
players: [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -49,6 +50,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
||||
players: [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -84,6 +86,28 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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 },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new PlayerShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
|
||||
await component.refreshSession();
|
||||
|
||||
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
|
||||
});
|
||||
|
||||
it('surfaces guess submit error and retries with selected answer payload', async () => {
|
||||
const fetchMock: FetchMock = vi
|
||||
.fn()
|
||||
@@ -93,6 +117,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
||||
players: [],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createVerticalSliceController } from '../../../../../src/spa/vertical-s
|
||||
interface SessionDetail {
|
||||
session: { code: string; status: string; current_round: number };
|
||||
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
|
||||
players: Array<{ id: number; nickname: string; score: number }>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -47,6 +48,13 @@ interface SessionDetail {
|
||||
|
||||
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">Submit guess</button>
|
||||
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">Retry guess submit</button>
|
||||
|
||||
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
|
||||
<h3>Final leaderboard</h3>
|
||||
<ol>
|
||||
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p *ngIf="error" class="error">{{ error }}</p>
|
||||
@@ -64,6 +72,10 @@ export class PlayerShellComponent {
|
||||
error = '';
|
||||
submitError: { kind: 'lie' | 'guess'; message: string } | null = null;
|
||||
session: SessionDetail | null = null;
|
||||
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
|
||||
|
||||
private readonly sessionContextStore = createSessionContextStore();
|
||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||
|
||||
private readonly sessionContextStore = createSessionContextStore();
|
||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||
@@ -72,6 +84,20 @@ export class PlayerShellComponent {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private syncFinalLeaderboard(): void {
|
||||
if (!this.session || this.session.session.status !== 'finished') {
|
||||
this.finalLeaderboard = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.finalLeaderboard = [...this.session.players].sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.nickname.localeCompare(b.nickname);
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
@@ -104,6 +130,7 @@ export class PlayerShellComponent {
|
||||
if (this.session.session.status !== 'guess') {
|
||||
this.selectedGuess = '';
|
||||
}
|
||||
this.syncFinalLeaderboard();
|
||||
} catch (error) {
|
||||
this.error = `Session refresh failed: ${(error as Error).message}`;
|
||||
} finally {
|
||||
@@ -128,6 +155,7 @@ export class PlayerShellComponent {
|
||||
if (this.session.session.status !== 'guess') {
|
||||
this.selectedGuess = '';
|
||||
}
|
||||
this.syncFinalLeaderboard();
|
||||
} catch (error) {
|
||||
this.error = `Join failed: ${(error as Error).message}`;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user