merge: rebase canonical reveal flow onto main
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
CI / test-and-quality (pull_request) Successful in 3m2s

This commit is contained in:
root
2026-03-15 12:57:15 +00:00
34 changed files with 4040 additions and 199 deletions

View File

@@ -49,17 +49,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
},
host: {
can_start_round: true,
can_show_question: true,
can_mix_answers: true,
can_calculate_scores: true,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
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: true,
can_submit_lie: true,
can_submit_guess: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
@@ -125,6 +125,12 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
reveal: {
round_question_id: 77,
correct_answer: 'A',
lies: [],
guesses: []
},
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
@@ -134,7 +140,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({});
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T;
}
if (url === '/lobby/sessions/ABCD12/finish') {
@@ -188,8 +194,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
expect(session.data.reveal?.correct_answer).toBe('A');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
}

View File

@@ -340,5 +340,26 @@ describe('HostShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(false);
expect(component.canMixAnswers).toBe(false);
expect(component.canCalculateScores).toBe(true);
});
it('uses phase_view_model to keep host action surface phase-specific', async () => {
const component = new HostShellComponent();
expect(component.canStartRound).toBe(true);
expect(component.canShowQuestion).toBe(false);
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canMixAnswers).toBe(true);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canRevealScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
});
});

View File

@@ -21,18 +21,15 @@ type LeaderboardResponse = FinishGameResponse;
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">{{ copy('host.retry_finish') }}</button>
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button *ngIf="canShowQuestion" (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button *ngIf="canMixAnswers" (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button *ngIf="canCalculateScores" (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button *ngIf="canRevealScoreboard || scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy(scoreboardError ? 'host.retry_scoreboard' : 'host.load_scoreboard') }}</button>
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button>
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button>
</div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
@@ -132,6 +129,34 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null;
}
get canStartRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session);
}
get canShowQuestion(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_show_question);
}
get canMixAnswers(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_mix_answers);
}
get canCalculateScores(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores);
}
get canRevealScoreboard(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard);
}
get canStartNextRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_next_round);
}
get canFinishGame(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_finish_game);
}
copy(key: string): string {
return t(key, this.locale);
}

View File

@@ -613,4 +613,28 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.clientHasNoAudioOutput).toBe(false);
});
it('keeps phone client controls phase-specific and low-complexity', () => {
const component = new PlayerShellComponent();
expect(component.showJoinControls).toBe(true);
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(false);
component.session = sessionDetailPayload('lie') as any;
component.playerId = 9;
component.sessionToken = 'tok';
expect(component.showJoinControls).toBe(false);
expect(component.showLieControls).toBe(true);
expect(component.showGuessControls).toBe(false);
component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any;
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(true);
component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any;
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(true);
});
});

View File

@@ -46,9 +46,9 @@ function resolveLocalStorage(): Storage | undefined {
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<label *ngIf="showJoinControls">{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
<button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
@@ -68,24 +68,28 @@ function resolveLocalStorage(): Storage | undefined {
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
<ng-container *ngIf="showLieControls">
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label>
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
</ng-container>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
>
{{ answer.text }}
</button>
</div>
<ng-container *ngIf="showGuessControls">
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
@@ -110,7 +114,7 @@ function resolveLocalStorage(): Storage | undefined {
</div>
</div>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
@@ -320,6 +324,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}, 3000);
}
get showJoinControls(): boolean {
if (!this.session) {
return true;
}
return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken);
}
get showLieControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_lie);
}
get showGuessControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_guess);
}
get showFinalLeaderboard(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_view_final_result);
}
get loadingMessage(): string {
switch (this.loadingTransition) {
case 'join':

View File

@@ -4,42 +4,56 @@ import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
function stubShellGlobals(initialLocale: string) {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true });
}
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('resolves host/player copy in en and da from shared catalog', () => {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
it.each([
{
locale: 'en',
hostRefresh: 'Refresh',
hostStartRound: 'Start round',
playerSubmitGuess: 'Submit guess',
},
{
locale: 'da',
hostRefresh: 'Opdatér',
hostStartRound: 'Start runde',
playerSubmitGuess: 'Send gæt',
},
])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => {
stubShellGlobals(locale);
const host = new HostShellComponent();
const player = new PlayerShellComponent();
host.ngOnInit();
player.ngOnInit();
setPreferredLocale(locale);
expect(host.copy('game.host.start_round')).toBe('Start round');
expect(player.copy('game.player.submit_guess')).toBe('Submit guess');
setPreferredLocale('da');
expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('game.player.submit_guess')).toBe('Send gæt');
expect(host.copy('common.refresh')).toBe(hostRefresh);
expect(host.copy('game.host.start_round')).toBe(hostStartRound);
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('keeps audio routing policy primary-only (client has no audio output)', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback'));
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
@@ -57,7 +71,7 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
const host = new HostShellComponent();
host.ngOnInit();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
const player = new PlayerShellComponent();
player.ngOnInit();
@@ -66,7 +80,7 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
player.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
host.ngOnDestroy();
});