696 lines
25 KiB
TypeScript
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);
|
|
});
|
|
|
|
});
|