Files
glchat/web/tests/pushToTalk.test.tsx
T

217 lines
8.0 KiB
TypeScript
Raw Normal View History

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, renderHook } from '@testing-library/react';
vi.mock('livekit-client', async () => {
const fake = await import('./fakeLiveKit');
return {
Room: fake.FakeRoom,
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
import { FakeRoom } from './fakeLiveKit';
import { installFetch, makeUser, resetStores, type FetchRoute } from './helpers';
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
import { usePushToTalk } from '@/components/voice/usePushToTalk';
import { VOICE_STORAGE_KEY, useVoiceStore, type VoicePreferences } from '@/stores/voice';
import { useSessionStore } from '@/stores/session';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
* Push-to-talk (AGENT.md 7.14): клавиша включает и выключает микрофон,
* серверный мьют приоритетнее, ввод текста и повторы клавиши игнорируются,
* а режим и клавиша сохраняются в localStorage.
*/
/** Поднимает настоящую голосовую сессию на фейковом SDK. */
async function connectVoice(): Promise<void> {
const voice = voiceRoutes({
channelIds: ['c-voice'],
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
});
installFetch([
{ match: '/api/v1/users/@me', response: () => Response.json({ user: makeUser() }) },
...voice.routes,
] as FetchRoute[]);
useSessionStore.setState({
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 }],
emojis: [],
sounds: [],
},
],
});
await act(async () => {
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
});
}
/** Микрофон в SDK: последний вызов `setMicrophoneEnabled`. */
function micCalls(): unknown[] {
return FakeRoom.latest()
.localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled')
.map((call) => call.value);
}
/** Сохранённые предпочтения из localStorage. */
function storedPreferences(): VoicePreferences {
return JSON.parse(window.localStorage.getItem(VOICE_STORAGE_KEY) ?? '{}') as VoicePreferences;
}
/** Дать React применить состояние после события клавиатуры. */
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
FakeRoom.reset();
resetStores();
// В jsdom окно «не в фокусе», пока что-нибудь не сфокусировано: для PTT
// считаем окно активным, а отдельный тест проверяет обратное поведение.
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
});
describe('push-to-talk', () => {
it('включает микрофон на удержании и выключает при отпускании', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
// В режиме PTT вход в комнату — с выключенным микрофоном.
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
expect(micCalls()).toContain(true);
fireEvent.keyUp(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
expect(micCalls().at(-1)).toBe(false);
});
it('игнорирует повтор клавиши и ввод текста', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ', repeat: true });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
// Фокус в поле ввода: пробел печатает текст, а не включает микрофон.
const input = document.createElement('input');
document.body.append(input);
input.focus();
fireEvent.keyDown(input, { code: 'Space', key: ' ' });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
input.remove();
});
it('серверный мьют приоритетнее: PTT не включает микрофон', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
act(() => {
useVoiceConnectionStore.setState({ serverMuted: true, micEnabled: false });
});
const before = micCalls().length;
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
expect(micCalls()).toHaveLength(before);
});
it('в режиме голосовой активации клавиша ничего не делает', async () => {
await connectVoice();
renderHook(() => usePushToTalk());
const before = micCalls().length;
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
expect(micCalls()).toHaveLength(before);
});
it('слушает выбранную клавишу', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
useVoiceStore.getState().setPttKey('KeyV');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
fireEvent.keyDown(document, { code: 'KeyV', key: 'v' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
fireEvent.keyUp(document, { code: 'KeyV', key: 'v' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
});
it('сохраняет режим и клавишу в localStorage', () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
useVoiceStore.getState().setPttKey('KeyB');
const stored = storedPreferences();
expect(stored.micActivation).toBe('push_to_talk');
expect(stored.pttKey).toBe('KeyB');
});
it('не реагирует, когда окно не в фокусе', async () => {
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
});
it('возвращает микрофон по предпочтению при выходе из режима PTT', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
const view = renderHook(() => usePushToTalk());
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
// Пользователь вернулся к голосовой активации — микрофон снова включён.
await act(async () => {
useVoiceStore.getState().setMicActivation('voice');
await Promise.resolve();
});
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
view.unmount();
});
});