[SPA][P8] #180 Next-round + final leaderboard flow i Angular SPA #185

Merged
integrator-bot merged 2 commits from dev/issue-180-spa-next-round-final-leaderboard-v2 into main 2026-03-01 16:26:27 +01:00
5 changed files with 208 additions and 0 deletions

View 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

View File

@@ -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"');
});
});

View File

@@ -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 = '';

View File

@@ -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: [],
})
);

View File

@@ -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,7 @@ 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);
@@ -72,6 +81,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 +127,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 +152,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 {