Files
weirsoe-party-protocol/frontend/angular/src/app/features/player/player-shell.component.spec.ts
DEV-bot d9c4cda966
All checks were successful
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 3m1s
fix(frontend): prefer canonical phase for client action gating
2026-03-16 17:00:02 +00:00

696 lines
25 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import lobbyCatalog from '../../../../../../shared/i18n/lobby.json';
import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
answers?: string[];
players?: Array<{ id: number; nickname: string; score: number }>;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
prompt?: string;
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
guesses?: Array<{
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at?: string;
}>;
} | null;
}
) {
const answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11;
return {
session: {
code: 'ABCD12',
status,
host_id: null,
current_round: 1,
players_count: (options?.players ?? []).length,
},
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
answers: answers.map((text) => ({ text })),
},
players: (options?.players ?? []).map((player) => ({
...player,
is_connected: true,
})),
reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
round_question_id: roundQuestionId,
round_number: 1,
prompt: options.reveal.prompt ?? 'Q?',
correct_answer: options.reveal.correct_answer,
lies: (options.reveal.lies ?? []).map((lie) => ({
...lie,
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
})),
guesses: (options.reveal.guesses ?? []).map((guess) => ({
...guess,
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: (options?.players ?? []).length,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: false,
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: (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
},
},
};
}
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('clears selected guess when refreshed status is no longer guess', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A'] }))
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'abcd12';
component.selectedGuess = 'A';
await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.selectedGuess).toBe('');
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' }))
.mockResolvedValueOnce(jsonResponse(201, { lie: { id: 1, player_id: 9, round_question_id: 11, text: 'my lie', created_at: '2026-01-01T00:00:01Z' }, window: { lie_deadline_at: '2026-01-01T00:00:45Z' } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/lies/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }),
})
);
expect(component.submitError?.kind).toBe('lie');
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
await component.submitLie();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('builds final leaderboard in finished status without legacy page hop', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('finished', {
roundQuestionId: 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('hydrates canonical reveal payload after guess -> reveal', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('reveal', {
answers: ['A', 'B'],
reveal: {
correct_answer: 'A',
lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }],
guesses: [
{
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
},
{
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
},
],
},
})
)
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.session?.reveal?.correct_answer).toBe('A');
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' });
expect(component.session?.reveal?.guesses[0]).toMatchObject({
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
});
expect(component.session?.reveal?.guesses[1]).toMatchObject({
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
});
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' }))
.mockResolvedValueOnce(jsonResponse(201, { guess: { id: 2, player_id: 9, round_question_id: 11, selected_text: 'B', is_correct: false, fooled_player_id: 3, created_at: '2026-01-01T00:00:10Z' }, window: { guess_deadline_at: '2026-01-01T00:01:30Z' } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = ' abcd12 ';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/guesses/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }),
})
);
expect(component.submitError?.kind).toBe('guess');
expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy');
await component.submitGuess();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('reveal');
expect(component.selectedGuess).toBe('');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('blocks illegal player guess submission outside canonical guess phase', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = {
...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
}
expect(component.canSubmitGuess).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
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 });
const fetchMock: FetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.connectionState === 'reconnecting' || component.connectionState === 'online').toBe(true);
expect(component.error).toContain('Session refresh failed:');
});
it('uses offline state when browser reports disconnected network', async () => {
vi.stubGlobal('navigator', { onLine: false });
const fetchMock: FetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.connectionState).toBe('offline');
expect(component.error).toContain('Session refresh failed');
});
it('tracks loading transition message for join action', async () => {
let resolveJoin: ((value: Response) => void) | null = null;
const fetchMock: FetchMock = vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveJoin = resolve;
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.nickname = 'Luna';
const joinPromise = component.joinSession();
expect(component.loading).toBe(true);
expect(component.loadingMessage).toBe('Joining session… restoring your player state.');
resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null })));
await joinPromise;
expect(component.loading).toBe(false);
expect(component.loadingTransition).toBeNull();
});
it('returnToJoin clears persisted session context and transient state', () => {
const values = new Map<string, string>();
const localStorage = {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
}),
removeItem: vi.fn((key: string) => {
values.delete(key);
}),
};
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
localStorage,
});
values.set('wpp.session-context', JSON.stringify({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-1' }));
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
component.error = 'Session refresh failed';
component.submitError = { kind: 'guess', message: 'Guess submit failed' };
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
players: [],
};
component.returnToJoin();
expect(component.playerId).toBe(0);
expect(component.sessionToken).toBe('');
expect(component.session).toBeNull();
expect(component.error).toBe('');
expect(component.submitError).toBeNull();
expect(values.get('wpp.session-context')).toBeUndefined();
});
it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
vi.stubGlobal('window', {
location: { hash: '#/player/reveal/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.showGuessControls).toBe(false);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12');
});
it('syncs player hash-route with latest phase during periodic state sync', 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 replaceState = vi.fn();
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
vi.stubGlobal('window', {
location: { hash: '#/player/scoreboard/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
await vi.advanceTimersByTimeAsync(3100);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/lobby/ABCD12');
component.ngOnDestroy();
});
it('silences active media elements when secondary-device audio guard is installed', () => {
const pauseAudio = vi.fn();
const pauseVideo = vi.fn();
const audioElement = { muted: false, pause: pauseAudio };
const videoElement = { muted: false, pause: pauseVideo };
const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]);
vi.stubGlobal('document', { querySelectorAll });
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() },
HTMLMediaElement: { prototype: { play: vi.fn().mockResolvedValue(undefined) } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(querySelectorAll).toHaveBeenCalledWith('audio,video');
expect(audioElement.muted).toBe(true);
expect(videoElement.muted).toBe(true);
expect(pauseAudio).toHaveBeenCalledTimes(1);
expect(pauseVideo).toHaveBeenCalledTimes(1);
component.ngOnDestroy();
});
it('installs secondary-device audio guard while player shell is mounted', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
const pause = vi.fn();
const audioElement = { muted: false, defaultMuted: false, volume: 1, pause };
await expect(mediaPrototype.play.call(audioElement)).resolves.toBeUndefined();
expect(audioElement.muted).toBe(true);
expect(audioElement.defaultMuted).toBe(true);
expect(audioElement.volume).toBe(0);
expect(pause).toHaveBeenCalledTimes(1);
component.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
it('keeps audio guard active until the last mounted player shell is destroyed', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const firstComponent = new PlayerShellComponent();
const secondComponent = new PlayerShellComponent();
firstComponent.ngOnInit();
secondComponent.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
firstComponent.ngOnDestroy();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
secondComponent.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
it('does not trigger original media play during player-shell init path', () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(originalPlay).not.toHaveBeenCalled();
component.ngOnDestroy();
});
it('keeps primary-device playback untouched when no-audio capability is disabled', async () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
(component as any).clientHasNoAudioOutput = false;
component.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
expect(mediaPrototype.play).toBe(originalPlay);
expect((mediaPrototype as any).__wppSecondaryDeviceAudioGuard__).toBeUndefined();
component.ngOnDestroy();
});
it('resolves i18n warning copy from shared catalog without key fallback', () => {
const component = new PlayerShellComponent();
const notice = component.copy('player.audio_policy_notice');
const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale];
expect(notice).toBe(expected);
expect(notice).not.toBe('player.audio_policy_notice');
});
it('gates template warning notice on the no-audio-output capability flag', () => {
const templateSource = String((PlayerShellComponent as any).ɵcmp?.template);
expect(templateSource).toContain('clientHasNoAudioOutput');
const component = new PlayerShellComponent();
expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice');
expect(component.clientHasNoAudioOutput).toBe(true);
(component as any).clientHasNoAudioOutput = false;
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);
});
});