import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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, installGatewaySocket, json, makeReadySnapshot, makeUser, recordedRequests, renderApp, type FetchRoute, type RecordedRequest, } from './helpers'; import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes'; import type { InstanceMeta } from '@/api/meta'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; const ALL_PERMISSIONS = [ 'VIEW_GUILD', 'SEND_MESSAGES', 'CONNECT_VOICE', 'MUTE_MEMBERS', 'DEAFEN_MEMBERS', 'MOVE_MEMBERS', ]; const members = [ { user_id: 'user-1', username: 'alice', display_name: 'Alice', status: 'online', is_instance_admin: false, joined_at: '2026-09-19T09:00:00Z', role_ids: [], }, { user_id: 'user-2', username: 'bob', display_name: 'Bob', status: 'online', is_instance_admin: false, joined_at: '2026-09-19T09:00:00Z', role_ids: [], }, ]; const enabledMeta: InstanceMeta = { name: 'glchat', version: 'v0.1.0-test', commit: 'deadbee', build_date: '2026-09-19T00:00:00Z', api_version: 'v1', base_url: 'https://gl.localhost', files_url: 'https://files.gl.localhost', gateway_url: 'wss://gl.localhost/gateway', rtc_path: '/rtc', max_upload_size: 26_214_400, features: { registration_enabled: true, anti_bot_enabled: false, voice_enabled: true, web_push_enabled: false, passkeys_enabled: false, oauth_enabled: false, }, }; interface BootOptions extends VoiceRoutesOptions { path?: string; permissions?: string[]; /** Участники, которые уже сидят в комнатах (снапшот READY). */ seated?: Parameters[0][]; meta?: InstanceMeta; } /** Поднимает приложение, играет рукопожатие шлюза и мокает голосовые ручки. */ async function bootVoice(options: BootOptions = {}) { const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, my_permissions: options.permissions ?? ALL_PERMISSIONS, channels: [ { id: 'c-general', name: 'общий', position: 0 }, { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 1 }, { id: 'c-other', name: 'Вторая', type: 'voice', position: 2 }, ], voice_states: options.seated ?? [], }, ]); const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'], ...options, // READY и REST должны рассказывать про один и тот же состав комнат. states: (options.states ?? options.seated ?? []).map((state) => makeVoiceState(state)), }); const fetchMock = installFetch([ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/meta', response: () => json(options.meta ?? enabledMeta) }, ...voice.routes, ] as FetchRoute[]); const gateway = installGatewaySocket(snapshot); renderApp(options.path ?? '/app/g-1/c-general'); await screen.findByTestId('guild-rail'); await gateway.greet(); return { ...voice, fetchMock, gateway }; } /** Последний запрос по фильтру: так проверяются PATCH и leave. */ function lastRequest( fetchMock: ReturnType, predicate: { url: string; method?: string }, ): RecordedRequest | undefined { return recordedRequests(fetchMock) .filter( (request) => request.url.includes(predicate.url) && (predicate.method === undefined || request.method === predicate.method.toUpperCase()), ) .at(-1); } /** Клик по голосовой комнате в сайдбаре: вход и переход на её страницу. */ async function clickChannel(name: string): Promise { await userEvent.click(await screen.findByLabelText(`Открыть комнату ${name}`)); } async function waitConnected(): Promise { await waitFor(() => { expect(useVoiceConnectionStore.getState().status).toBe('connected'); }); } beforeEach(() => { FakeRoom.reset(); }); describe('голосовая комната: вход', () => { it('входит по клику на канал и рисует участников', async () => { const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] }); await clickChannel('Голосовая'); await waitConnected(); // Сервер записал состояние, комната SDK создана. expect(voice.joinCount()).toBe(1); expect(FakeRoom.latest().connectCalls).toHaveLength(1); expect(await screen.findByTestId('voice-tile-user-2')).toBeVisible(); expect(screen.getByTestId('voice-tile-user-1')).toBeVisible(); // Список участников под голосовой комнатой в сайдбаре. const list = screen.getByLabelText('Участники комнаты «Голосовая»'); expect(within(list).getByLabelText('Участник Bob')).toBeVisible(); }); it('подключается кнопкой в панели комнаты', async () => { await bootVoice({ path: '/app/g-1/c-voice' }); await userEvent.click(await screen.findByTestId('voice-join')); await waitConnected(); expect(FakeRoom.latest().connectCalls).toHaveLength(1); }); it('при выключенном голосе показывает подсказку вместо подключения', async () => { const voice = await bootVoice({ meta: { ...enabledMeta, features: { ...enabledMeta.features, voice_enabled: false } }, }); await clickChannel('Голосовая'); expect(await screen.findByTestId('voice-disabled')).toHaveTextContent( 'Голос не настроен на инстансе', ); expect(screen.queryByTestId('voice-join')).toBeNull(); expect(voice.joinCount()).toBe(0); }); it('переходит в другую комнату через выход из старой', async () => { const voice = await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); await clickChannel('Вторая'); await waitFor(() => { expect(useVoiceConnectionStore.getState().channelId).toBe('c-other'); }); const requests = recordedRequests(voice.fetchMock) .filter((request) => request.url.includes('/voice/')) .map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`); expect(requests).toEqual([ 'POST /channels/c-voice/voice/join', 'POST /channels/c-voice/voice/leave', 'POST /channels/c-other/voice/join', ]); }); it('показывает ошибку «в комнате нет свободных мест»', async () => { await bootVoice({ joinError: { code: 'voice.channel_full', status: 403 } }); await clickChannel('Голосовая'); // Ошибку показывают и сайдбар под комнатой, и панель комнаты. expect((await screen.findAllByText('В комнате нет свободных мест.')).length).toBeGreaterThan(0); expect(useVoiceConnectionStore.getState().status).toBe('error'); }); it('показывает ошибку нехватки прав', async () => { await bootVoice({ joinError: { code: 'perm.denied', status: 403 } }); await clickChannel('Голосовая'); expect( (await screen.findAllByText('Недостаточно прав для этого действия.')).length, ).toBeGreaterThan(0); }); }); describe('голосовая комната: панель пользователя', () => { it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => { const { fetchMock } = await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); const panel = screen.getByLabelText('Панель пользователя'); await userEvent.click(within(panel).getByLabelText('Выключить микрофон')); expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ method: 'setMicrophoneEnabled', value: false, }); await waitFor(() => { expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ self_mute: true, }); }); expect(within(panel).getByLabelText('Включить микрофон')).toHaveAttribute( 'aria-pressed', 'false', ); await userEvent.click(within(panel).getByLabelText('Включить камеру')); expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ method: 'setCameraEnabled', value: true, }); await waitFor(() => { expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ camera: true, }); }); await userEvent.click(within(panel).getByLabelText('Выключить звук')); await waitFor(() => { expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ self_deaf: true, }); }); }); it('серверный мьют блокирует кнопку микрофона с подсказкой', async () => { const { gateway } = await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); act(() => { gateway.dispatch( 'VOICE_STATE_UPDATE', makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }), ); }); const panel = screen.getByLabelText('Панель пользователя'); const mic = await within(panel).findByLabelText('Микрофон выключен модератором'); expect(mic).toBeDisabled(); expect(mic).toHaveAttribute('title', 'Микрофон выключен модератором'); expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); }); it('серверное глушение блокирует кнопку наушников с подсказкой', async () => { const { gateway } = await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); act(() => { gateway.dispatch( 'VOICE_STATE_UPDATE', makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }), ); }); const panel = screen.getByLabelText('Панель пользователя'); const sound = await within(panel).findByLabelText('Звук выключен модератором'); expect(sound).toBeDisabled(); }); it('смена устройства применяется к сессии', async () => { await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); await act(async () => { await useVoiceConnectionStore.getState().switchActiveDevice('audioinput', 'mic-2'); }); expect(FakeRoom.latest().deviceSwitches).toEqual([{ kind: 'audioinput', deviceId: 'mic-2' }]); }); }); describe('голосовая комната: события шлюза', () => { it('VOICE_STATE_UPDATE добавляет и убирает участника без перезагрузки', async () => { const { gateway } = await bootVoice(); expect(screen.queryByLabelText('Участник Bob')).toBeNull(); act(() => { gateway.dispatch( 'VOICE_STATE_UPDATE', makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }), ); }); expect(await screen.findByLabelText('Участник Bob')).toBeVisible(); act(() => { gateway.dispatch('VOICE_STATE_UPDATE', { guild_id: 'g-1', channel_id: 'c-voice', user_id: 'user-2', left: true, }); }); await waitFor(() => { expect(screen.queryByLabelText('Участник Bob')).toBeNull(); }); }); it('показывает индикацию речи участника', async () => { await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] }); await clickChannel('Голосовая'); await waitConnected(); act(() => { FakeRoom.latest().addRemote('user-2', 'Bob'); FakeRoom.latest().activeSpeakers(['user-2']); }); await waitFor(() => { expect(screen.getByTestId('voice-tile-user-2')).toHaveAttribute('data-speaking', 'true'); }); }); it('показывает и скрывает подсказку о речи при выключенном микрофоне', async () => { await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); await act(async () => { await useVoiceConnectionStore.getState().setMicEnabled(false); }); act(() => { FakeRoom.latest().activeSpeakers(['user-1']); }); const notice = await screen.findByTestId('voice-speaking-notice'); expect(notice).toHaveTextContent('Вы говорите с выключенным микрофоном'); await userEvent.click(within(notice).getByLabelText('Скрыть')); await waitFor(() => { expect(screen.queryByTestId('voice-speaking-notice')).toBeNull(); }); }); }); describe('голосовая комната: выход', () => { it('отключается по кнопке и очищает состояние', async () => { const { fetchMock } = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }], }); await clickChannel('Голосовая'); await waitConnected(); const tile = screen.getByTestId('voice-tile-user-1'); await userEvent.click(within(tile).getByLabelText('Отключиться')); await waitFor(() => { expect(useVoiceConnectionStore.getState().status).toBe('idle'); }); expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); expect(screen.queryByTestId('voice-tile-user-1')).toBeNull(); // Своя строка исчезла из сайдбара, а оставшийся участник — на месте. expect(screen.queryByLabelText('Участник Alice')).toBeNull(); expect(screen.getAllByLabelText('Участник Bob').length).toBeGreaterThan(0); }); it('отключается по двойному клику на комнату в сайдбаре', async () => { const { fetchMock } = await bootVoice(); await clickChannel('Голосовая'); await waitConnected(); await userEvent.dblClick(await screen.findByLabelText('Открыть комнату Голосовая')); await waitFor(() => { expect(useVoiceConnectionStore.getState().status).toBe('idle'); }); expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); }); }); describe('голосовая комната: модерация', () => { it('серверный мьют участника из меню', async () => { const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] }); await clickChannel('Голосовая'); await waitConnected(); await userEvent.click( within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'), ); await userEvent.click(await screen.findByRole('menuitem', { name: 'Выключить микрофон' })); await waitFor(() => { expect(voice.states.find((state) => state.user_id === 'user-2')?.server_mute).toBe(true); }); expect( lastRequest(voice.fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body, ).toEqual({ server_mute: true }); }); it('перемещение участника в другую комнату из меню', async () => { const voice = await bootVoice({ channelIds: ['c-voice', 'c-other'], seated: [{ user_id: 'user-2', channel_id: 'c-voice' }], }); await clickChannel('Голосовая'); await waitConnected(); await userEvent.click( within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'), ); await userEvent.click(await screen.findByRole('menuitem', { name: 'Переместить в «Вторая»' })); await waitFor(() => { expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other'); }); }); it('перемещает участника перетаскиванием на голосовую комнату', async () => { const voice = await bootVoice({ channelIds: ['c-voice', 'c-other'], seated: [{ user_id: 'user-2', channel_id: 'c-voice' }], }); const target = await screen.findByLabelText('Открыть комнату Вторая'); const dataTransfer = { types: ['application/x-glchat-voice-user'], getData: () => 'user-2', setData: vi.fn(), dropEffect: 'none', }; fireEvent.dragOver(target, { dataTransfer }); fireEvent.drop(target, { dataTransfer }); await waitFor(() => { expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other'); }); }); });