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 { 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 { 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(); }); });