217 lines
8.0 KiB
TypeScript
217 lines
8.0 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|