From 0db4c63023c9af550747426ffe2af28f9ad0f162 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:45:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B2=D0=B5=D0=B1=D1=85=D1=83=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20LiveKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/api_voice_webhook.go | 3 + web/src/api/voice.ts | 3 +- web/src/components/voice/VoiceChannelItem.tsx | 2 +- web/src/components/voice/VoiceIndicators.tsx | 11 +- web/src/components/voice/VoiceOccupantRow.tsx | 9 +- web/src/components/voice/VoiceRoomPanel.tsx | 27 +- web/src/components/voice/useVoiceEnabled.ts | 3 +- web/src/components/voice/useVoiceOccupants.ts | 5 +- web/src/components/voice/voiceStyles.ts | 11 + web/src/lib/identity.ts | 3 +- web/src/lib/livekit.ts | 10 +- web/src/pages/app/GuildView.tsx | 1 + web/src/stores/voiceConnection.ts | 27 +- web/tests/chat.test.tsx | 6 +- web/tests/fakeLiveKit.ts | 287 ++++++++++ web/tests/helpers.tsx | 44 +- web/tests/voice.test.tsx | 488 +++++++++++++++++ web/tests/voiceConnection.test.ts | 505 ++++++++++++++++++ web/tests/voiceRoutes.ts | 192 +++++++ 19 files changed, 1578 insertions(+), 59 deletions(-) create mode 100644 web/src/components/voice/voiceStyles.ts create mode 100644 web/tests/fakeLiveKit.ts create mode 100644 web/tests/voice.test.tsx create mode 100644 web/tests/voiceConnection.test.ts create mode 100644 web/tests/voiceRoutes.ts diff --git a/internal/server/api_voice_webhook.go b/internal/server/api_voice_webhook.go index 6d9463a..8d9e1b6 100644 --- a/internal/server/api_voice_webhook.go +++ b/internal/server/api_voice_webhook.go @@ -70,6 +70,9 @@ func (s *Server) handleLiveKitWebhook(w http.ResponseWriter, r *http.Request) { return } s.applyLiveKitEvent(r.Context(), event) + // Пишем в лог: по нему видно, что SFU действительно доставляет события. + s.logger.InfoContext(r.Context(), "livekit webhook handled", + slog.String("event", event.Event), slog.String("room", event.Room.Name)) httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true}) } diff --git a/web/src/api/voice.ts b/web/src/api/voice.ts index 8196306..9fcdcac 100644 --- a/web/src/api/voice.ts +++ b/web/src/api/voice.ts @@ -47,8 +47,7 @@ export interface VoiceModerationPatch { /** Разобранное событие `VOICE_STATE_UPDATE`: состояние или уход из комнаты. */ export type VoiceStateEvent = - | { kind: 'state'; state: VoiceState } - | { kind: 'left'; guildId: string; userId: string }; + { kind: 'state'; state: VoiceState } | { kind: 'left'; guildId: string; userId: string }; export const guildVoiceStatesQueryKey = (guildId: string) => ['guilds', guildId, 'voice-states'] as const; diff --git a/web/src/components/voice/VoiceChannelItem.tsx b/web/src/components/voice/VoiceChannelItem.tsx index c5cbcc1..80b8c1b 100644 --- a/web/src/components/voice/VoiceChannelItem.tsx +++ b/web/src/components/voice/VoiceChannelItem.tsx @@ -59,7 +59,7 @@ export function VoiceChannelItem({ guildId, channel, selected }: VoiceChannelIte event.preventDefault(); const userId = draggedUserId(event); if (userId !== '') { - void moveParticipant(userId, channel.id); + void moveParticipant(guildId, userId, channel.id); } }; diff --git a/web/src/components/voice/VoiceIndicators.tsx b/web/src/components/voice/VoiceIndicators.tsx index 62553d0..7eecc18 100644 --- a/web/src/components/voice/VoiceIndicators.tsx +++ b/web/src/components/voice/VoiceIndicators.tsx @@ -94,17 +94,12 @@ export function VoiceFlags({ {camera ? : null} {screen ? : null} ); } - -/** Кольцо вокруг аватара, когда участник говорит. */ -export function speakingRingClass(speaking: boolean): string { - return speaking - ? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1' - : 'ring-1 ring-border/60'; -} diff --git a/web/src/components/voice/VoiceOccupantRow.tsx b/web/src/components/voice/VoiceOccupantRow.tsx index 1a3e296..86a2f1b 100644 --- a/web/src/components/voice/VoiceOccupantRow.tsx +++ b/web/src/components/voice/VoiceOccupantRow.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef, useState, type DragEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Avatar } from '@/components/ui/Avatar'; -import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators'; +import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators'; import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu'; import { useVoiceChannels, @@ -10,6 +10,7 @@ import { type VoiceOccupant, } from '@/components/voice/useVoiceOccupants'; import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag'; +import { speakingRingClass } from '@/components/voice/voiceStyles'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; /** @@ -91,9 +92,9 @@ export function VoiceOccupantRow({ onClose={close} permissions={permissions} moveTargets={moveTargets} - onMute={(userId, muted) => void setParticipantMuted(userId, muted)} - onDeafen={(userId, deafened) => void setParticipantDeafened(userId, deafened)} - onMove={(userId, targetId) => void moveParticipant(userId, targetId)} + onMute={(userId, muted) => void setParticipantMuted(guildId, userId, muted)} + onDeafen={(userId, deafened) => void setParticipantDeafened(guildId, userId, deafened)} + onMove={(userId, targetId) => void moveParticipant(guildId, userId, targetId)} /> ); diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx index 2d582da..50475df 100644 --- a/web/src/components/voice/VoiceRoomPanel.tsx +++ b/web/src/components/voice/VoiceRoomPanel.tsx @@ -5,7 +5,7 @@ import type { Channel } from '@/api/types'; import { Avatar } from '@/components/ui/Avatar'; import { Card, Button } from '@/components/ui/primitives'; import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio'; -import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators'; +import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators'; import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu'; import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled'; import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates'; @@ -16,6 +16,7 @@ import { type VoiceOccupant, } from '@/components/voice/useVoiceOccupants'; import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag'; +import { speakingRingClass } from '@/components/voice/voiceStyles'; import type { VoiceModerationPermissions } from '@/lib/identity'; import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; import { useVoiceStore } from '@/stores/voice'; @@ -195,12 +196,7 @@ function VoiceControlsRow({ active={screenEnabled} onClick={onScreen} /> - + ); } @@ -231,7 +227,9 @@ function VoiceControlButton({ disabled={disabled} onClick={onClick} className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${ - active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger' + active + ? 'border-border/60 bg-surface-2 text-fg' + : 'border-danger/60 bg-danger/10 text-danger' }`} > @@ -278,16 +276,17 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel: const failed = here && status === 'error'; const onMute = useCallback( - (userId: string, muted: boolean) => void setParticipantMuted(userId, muted), - [setParticipantMuted], + (userId: string, muted: boolean) => void setParticipantMuted(guildId, userId, muted), + [guildId, setParticipantMuted], ); const onDeafen = useCallback( - (userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext), - [setParticipantDeafened], + (userId: string, deafenedNext: boolean) => + void setParticipantDeafened(guildId, userId, deafenedNext), + [guildId, setParticipantDeafened], ); const onMove = useCallback( - (userId: string, targetId: string) => void moveParticipant(userId, targetId), - [moveParticipant], + (userId: string, targetId: string) => void moveParticipant(guildId, userId, targetId), + [guildId, moveParticipant], ); if (!voiceEnabled) { diff --git a/web/src/components/voice/useVoiceEnabled.ts b/web/src/components/voice/useVoiceEnabled.ts index 96fb429..b10f7b4 100644 --- a/web/src/components/voice/useVoiceEnabled.ts +++ b/web/src/components/voice/useVoiceEnabled.ts @@ -16,5 +16,6 @@ export function useVoiceEnabled(): boolean { staleTime: 300_000, retry: 0, }); - return meta.data?.features.voice_enabled !== false; + // Ответ может быть неполным (старый сервер, тестовый мок) — тогда голос включён. + return meta.data?.features?.voice_enabled !== false; } diff --git a/web/src/components/voice/useVoiceOccupants.ts b/web/src/components/voice/useVoiceOccupants.ts index 3f22428..9f1ce21 100644 --- a/web/src/components/voice/useVoiceOccupants.ts +++ b/web/src/components/voice/useVoiceOccupants.ts @@ -38,7 +38,10 @@ const NO_OCCUPANTS: VoiceOccupant[] = []; * Кто в комнате: список строится из `voice_states` (один источник правды), * имена и аватары — из участников сервера, речь и качество — из SDK. */ -export function useVoiceOccupants(guildId: string | null, channelId: string | null): VoiceOccupant[] { +export function useVoiceOccupants( + guildId: string | null, + channelId: string | null, +): VoiceOccupant[] { const voiceStates = useSessionStore((state) => state.voiceStates); const sessionUser = useSessionStore((state) => state.user); const mediaParticipants = useVoiceConnectionStore((state) => diff --git a/web/src/components/voice/voiceStyles.ts b/web/src/components/voice/voiceStyles.ts new file mode 100644 index 0000000..d10f736 --- /dev/null +++ b/web/src/components/voice/voiceStyles.ts @@ -0,0 +1,11 @@ +/** + * Классы состояний голосового интерфейса. Вынесены из компонентов, чтобы + * файлы с разметкой экспортировали только компоненты (fast refresh). + */ + +/** Кольцо вокруг аватара: зелёное, когда участник говорит. */ +export function speakingRingClass(speaking: boolean): string { + return speaking + ? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1' + : 'ring-1 ring-border/60'; +} diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index 4a1bf9a..b459325 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -187,7 +187,8 @@ export function canManageEmojis( } /** Может ли пользователь отправлять сообщения в комнату. */ -export function canSendMessages( permissions: readonly string[], +export function canSendMessages( + permissions: readonly string[], isInstanceAdmin = false, channelCanSend?: boolean, ): boolean { diff --git a/web/src/lib/livekit.ts b/web/src/lib/livekit.ts index c514dcd..2ebf2dc 100644 --- a/web/src/lib/livekit.ts +++ b/web/src/lib/livekit.ts @@ -81,10 +81,7 @@ function qualityOf(participant: Participant): VoiceQuality { } } -function handleOf( - track: Track | undefined, - kind: 'audio' | 'video', -): VoiceTrackHandle | undefined { +function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrackHandle | undefined { if (track === undefined) { return undefined; } @@ -111,7 +108,10 @@ function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipan const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track; const snapshot: VoiceParticipant = { userId: participant.identity, - name: participant.name === undefined || participant.name === '' ? participant.identity : participant.name, + name: + participant.name === undefined || participant.name === '' + ? participant.identity + : participant.name, isLocal, isSpeaking: participant.isSpeaking, micEnabled: isLive(participant, Track.Source.Microphone), diff --git a/web/src/pages/app/GuildView.tsx b/web/src/pages/app/GuildView.tsx index 7907e1f..16a8588 100644 --- a/web/src/pages/app/GuildView.tsx +++ b/web/src/pages/app/GuildView.tsx @@ -7,6 +7,7 @@ import { ChatView } from '@/components/chat/ChatView'; import { PinsPanel } from '@/components/chat/PinsPanel'; import { SearchPanel } from '@/components/chat/SearchPanel'; import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { Card } from '@/components/ui/primitives'; import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel'; import { canManageMessages, canSendMessages } from '@/lib/identity'; import { useMessagesStore } from '@/stores/messages'; diff --git a/web/src/stores/voiceConnection.ts b/web/src/stores/voiceConnection.ts index 8f3310f..a4de83a 100644 --- a/web/src/stores/voiceConnection.ts +++ b/web/src/stores/voiceConnection.ts @@ -76,9 +76,12 @@ export interface VoiceConnectionState { switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; /** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */ setVolume: (userId: string, volume: number) => void; - setParticipantMuted: (userId: string, muted: boolean) => Promise; - setParticipantDeafened: (userId: string, deafened: boolean) => Promise; - moveParticipant: (userId: string, channelId: string) => Promise; + /** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */ + setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise; + /** Серверное глушение участника: право `DEAFEN_MEMBERS`. */ + setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise; + /** Перемещение участника: право `MOVE_MEMBERS`. */ + moveParticipant: (guildId: string, userId: string, channelId: string) => Promise; /** Переносит серверные флаги (server_mute/server_deaf) в локальную сессию. */ syncServerState: () => void; dismissSpeakingWarning: () => void; @@ -485,11 +488,7 @@ export const useVoiceConnectionStore = create((set, get) = } }, - setParticipantMuted: async (userId, muted) => { - const guildId = get().guildId; - if (guildId === null) { - return; - } + setParticipantMuted: async (guildId, userId, muted) => { try { const states = await moderateVoiceState(guildId, userId, { server_mute: muted }); useSessionStore.getState().setGuildVoiceStates(guildId, states); @@ -499,11 +498,7 @@ export const useVoiceConnectionStore = create((set, get) = } }, - setParticipantDeafened: async (userId, deafened) => { - const guildId = get().guildId; - if (guildId === null) { - return; - } + setParticipantDeafened: async (guildId, userId, deafened) => { try { const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened }); useSessionStore.getState().setGuildVoiceStates(guildId, states); @@ -513,11 +508,7 @@ export const useVoiceConnectionStore = create((set, get) = } }, - moveParticipant: async (userId, channelId) => { - const guildId = get().guildId; - if (guildId === null) { - return; - } + moveParticipant: async (guildId, userId, channelId) => { try { const states = await moveVoiceMember(guildId, userId, channelId); useSessionStore.getState().setGuildVoiceStates(guildId, states); diff --git a/web/tests/chat.test.tsx b/web/tests/chat.test.tsx index 50ebab0..e323e01 100644 --- a/web/tests/chat.test.tsx +++ b/web/tests/chat.test.tsx @@ -206,7 +206,7 @@ describe('чат комнаты: лента', () => { }); }); - it('оставляет голосовую комнату с заглушкой', async () => { + it('открывает панель голосовой комнаты вместо чата', async () => { await boot({ path: '/app/g-1/c-voice', channels: [ @@ -215,7 +215,9 @@ describe('чат комнаты: лента', () => { ], }); - expect(await screen.findByText('Голосовая комната')).toBeVisible(); + // Вместо ленты сообщений — панель голоса с кнопкой подключения. + expect(await screen.findByTestId('voice-join')).toBeVisible(); + expect(screen.getByText('В комнате пока никого нет')).toBeVisible(); expect(screen.queryByTestId('message-list')).toBeNull(); expect(screen.queryByTestId('composer')).toBeNull(); }); diff --git a/web/tests/fakeLiveKit.ts b/web/tests/fakeLiveKit.ts new file mode 100644 index 0000000..ee2a4bd --- /dev/null +++ b/web/tests/fakeLiveKit.ts @@ -0,0 +1,287 @@ +/** + * Подставной `livekit-client` для тестов: комната, участники и события под + * управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому + * `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его + * целиком, а логика `lib/livekit.ts` и стора проверяется настоящая. + */ + +export const RoomEvent = { + ParticipantConnected: 'participantConnected', + ParticipantDisconnected: 'participantDisconnected', + TrackSubscribed: 'trackSubscribed', + TrackUnsubscribed: 'trackUnsubscribed', + TrackPublished: 'trackPublished', + TrackUnpublished: 'trackUnpublished', + TrackMuted: 'trackMuted', + TrackUnmuted: 'trackUnmuted', + LocalTrackPublished: 'localTrackPublished', + LocalTrackUnpublished: 'localTrackUnpublished', + ConnectionQualityChanged: 'connectionQualityChanged', + ActiveSpeakersChanged: 'activeSpeakersChanged', + Reconnecting: 'reconnecting', + Reconnected: 'reconnected', + Disconnected: 'disconnected', +} as const; + +export const ConnectionQuality = { + Excellent: 'excellent', + Good: 'good', + Poor: 'poor', + Lost: 'lost', + Unknown: 'unknown', +} as const; + +export const Track = { + Source: { + Camera: 'camera', + Microphone: 'microphone', + ScreenShare: 'screen_share', + ScreenShareAudio: 'screen_share_audio', + }, +} as const; + +/** Дорожка: помнит, к каким элементам её подключили. */ +export class FakeTrack { + readonly kind: 'audio' | 'video'; + readonly attached: HTMLMediaElement[] = []; + + constructor(kind: 'audio' | 'video') { + this.kind = kind; + } + + attach(element: HTMLMediaElement): HTMLMediaElement { + this.attached.push(element); + return element; + } + + detach(element: HTMLMediaElement): HTMLMediaElement[] { + const index = this.attached.indexOf(element); + if (index >= 0) { + this.attached.splice(index, 1); + } + return []; + } +} + +export class FakePublication { + constructor( + readonly source: string, + readonly track: FakeTrack, + public isMuted: boolean, + ) {} +} + +type Handler = (...args: never[]) => void; + +/** Участник комнаты: публикации, флаги и записанные вызовы SDK. */ +export class FakeParticipant { + isSpeaking = false; + connectionQuality: string = ConnectionQuality.Excellent; + identity: string; + name: string; + readonly isLocal: boolean; + /** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */ + readonly calls: { method: string; value: unknown }[] = []; + private readonly publications = new Map(); + + constructor( + private readonly room: FakeRoom, + identity: string, + name: string, + isLocal = false, + ) { + this.identity = identity; + this.name = name; + this.isLocal = isLocal; + } + + get isMicrophoneEnabled(): boolean { + return this.isLive(Track.Source.Microphone); + } + + get isCameraEnabled(): boolean { + return this.isLive(Track.Source.Camera); + } + + get isScreenShareEnabled(): boolean { + return this.isLive(Track.Source.ScreenShare); + } + + getTrackPublication(source: string): FakePublication | undefined { + return this.publications.get(source); + } + + /** Публикация дорожки: используется и для своих, и для чужих участников. */ + setPublication(source: string, muted = false): FakePublication { + const publication = new FakePublication( + source, + new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'), + muted, + ); + this.publications.set(source, publication); + return publication; + } + + removePublication(source: string): void { + this.publications.delete(source); + this.room.emit(RoomEvent.TrackUnsubscribed); + } + + setSpeaking(speaking: boolean): void { + this.isSpeaking = speaking; + } + + private isLive(source: string): boolean { + const publication = this.publications.get(source); + return publication !== undefined && !publication.isMuted; + } + + private setLocal(source: string, enabled: boolean, method: string): void { + this.calls.push({ method, value: enabled }); + const existing = this.publications.get(source); + const track = + existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'); + this.publications.set(source, new FakePublication(source, track, !enabled)); + this.room.emit(RoomEvent.LocalTrackPublished); + } + + setMicrophoneEnabled(enabled: boolean): Promise { + this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled'); + return Promise.resolve(); + } + + setCameraEnabled(enabled: boolean): Promise { + this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled'); + return Promise.resolve(); + } + + setScreenShareEnabled(enabled: boolean): Promise { + this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled'); + return Promise.resolve(); + } +} + +export interface RemoteOptions { + muted?: boolean; + camera?: boolean; + screen?: boolean; + quality?: string; +} + +/** Подставная комната: те же методы, что использует `lib/livekit.ts`. */ +export class FakeRoom { + static instances: FakeRoom[] = []; + + static reset(): void { + FakeRoom.instances = []; + } + + static latest(): FakeRoom { + const room = FakeRoom.instances.at(-1); + if (room === undefined) { + throw new Error('комната LiveKit ещё не создавалась'); + } + return room; + } + + readonly localParticipant: FakeParticipant; + readonly remoteParticipants = new Map(); + readonly connectCalls: { url: string; token: string }[] = []; + readonly deviceSwitches: { kind: string; deviceId: string }[] = []; + disconnectCalls = 0; + startAudioCalls = 0; + private readonly handlers = new Map>(); + + constructor(readonly options?: unknown) { + FakeRoom.instances.push(this); + this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true); + } + + on(event: string, handler: Handler): this { + const handlers = this.handlers.get(event) ?? new Set(); + handlers.add(handler); + this.handlers.set(event, handlers); + return this; + } + + off(event: string, handler: Handler): this { + this.handlers.get(event)?.delete(handler); + return this; + } + + removeAllListeners(): this { + this.handlers.clear(); + return this; + } + + connect(url: string, token: string): Promise { + this.connectCalls.push({ url, token }); + return Promise.resolve(); + } + + disconnect(): Promise { + this.disconnectCalls += 1; + this.emit(RoomEvent.Disconnected); + return Promise.resolve(); + } + + startAudio(): Promise { + this.startAudioCalls += 1; + return Promise.resolve(); + } + + switchActiveDevice(kind: string, deviceId: string): Promise { + this.deviceSwitches.push({ kind, deviceId }); + return Promise.resolve(true); + } + + emit(event: string, ...args: unknown[]): void { + for (const handler of [...(this.handlers.get(event) ?? [])]) { + (handler as (...values: unknown[]) => void)(...args); + } + } + + /** Подключает удалённого участника: как `ParticipantConnected` из SDK. */ + addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant { + const participant = new FakeParticipant(this, userId, name); + participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent; + participant.setPublication(Track.Source.Microphone, options.muted ?? false); + if (options.camera === true) { + participant.setPublication(Track.Source.Camera); + } + if (options.screen === true) { + participant.setPublication(Track.Source.ScreenShare); + } + this.remoteParticipants.set(userId, participant); + this.emit(RoomEvent.ParticipantConnected, participant); + this.emit(RoomEvent.TrackSubscribed); + return participant; + } + + removeRemote(userId: string): void { + const participant = this.remoteParticipants.get(userId); + if (participant === undefined) { + return; + } + this.remoteParticipants.delete(userId); + this.emit(RoomEvent.ParticipantDisconnected, participant); + } + + /** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */ + activeSpeakers(userIds: string[]): void { + const speakerIds = new Set(userIds); + this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity)); + for (const participant of this.remoteParticipants.values()) { + participant.setSpeaking(speakerIds.has(participant.identity)); + } + this.emit(RoomEvent.ActiveSpeakersChanged, []); + } + + setQuality(userId: string, quality: string): void { + const participant = this.remoteParticipants.get(userId); + if (participant !== undefined) { + participant.connectionQuality = quality; + this.emit(RoomEvent.ConnectionQualityChanged, participant); + } + } +} diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index 86f5d49..8c06d0c 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -13,6 +13,7 @@ import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; import { useUiStore } from '@/stores/ui'; import { useVoiceStore } from '@/stores/voice'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; import type { User } from '@/api/types'; /** Ответ-заглушка с JSON-телом (как у настоящего API). */ @@ -32,7 +33,8 @@ export interface FetchRoute { /** Подстрока URL, по которой выбирается обработчик. */ match: string; method?: string; - response: () => Response | Promise; + /** Ответ на запрос: обработчик видит url, метод и разобранное тело. */ + response: (request: RecordedRequest) => Response | Promise; } /** @@ -54,7 +56,7 @@ export function installFetch(routes: FetchRoute[]) { if (!url.includes(route.match)) { continue; } - return Promise.resolve(route.response()); + return Promise.resolve(route.response({ url, method, body: parseRequestBody(init?.body) })); } return Promise.resolve(apiError('not_found', 404)); }); @@ -62,6 +64,18 @@ export function installFetch(routes: FetchRoute[]) { return fetchMock; } +/** Тело запроса как JSON: у multipart и пустого тела его нет. */ +function parseRequestBody(body: BodyInit | null | undefined): unknown { + if (typeof body !== 'string') { + return undefined; + } + try { + return JSON.parse(body) as unknown; + } catch { + return undefined; + } +} + /** Мок fetch, который падает на уровне сети (сервер недоступен). */ export function installFailingFetch() { const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); @@ -136,6 +150,8 @@ export function resetStores(): void { useSessionStore.getState().reset(); useMessagesStore.getState().reset(); useVoiceStore.getState().reset(); + // Голосовая сессия могла остаться от прошлого теста: гасим её без запросов. + useVoiceConnectionStore.getState().reset(); useFriendsStore.getState().reset(); resetUiStore(); // `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста @@ -205,6 +221,18 @@ interface SnapshotGuildInput { my_nickname?: string; /** Кастомные эмодзи сервера (AGENT.md 7.12). */ emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[]; + /** Кто сидит в голосовых комнатах (AGENT.md 7.14). */ + voice_states?: { + user_id: string; + channel_id: string; + self_mute?: boolean; + self_deaf?: boolean; + server_mute?: boolean; + server_deaf?: boolean; + camera?: boolean; + screen?: boolean; + joined_at?: string; + }[]; channels?: { id: string; name: string; @@ -269,6 +297,18 @@ export function makeReadySnapshot( animated: emoji.animated ?? false, token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`, })), + voice_states: (guild.voice_states ?? []).map((state, index) => ({ + user_id: state.user_id, + channel_id: state.channel_id, + guild_id: guild.id, + self_mute: state.self_mute ?? false, + self_deaf: state.self_deaf ?? false, + server_mute: state.server_mute ?? false, + server_deaf: state.server_deaf ?? false, + camera: state.camera ?? false, + screen: state.screen ?? false, + joined_at: state.joined_at ?? `2026-09-19T10:0${String(index)}:00Z`, + })), })), dm_channels: dmChannels.map((channel) => ({ id: channel.id, diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx new file mode 100644 index 0000000..f2479ba --- /dev/null +++ b/web/tests/voice.test.tsx @@ -0,0 +1,488 @@ +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, + }; +}); + +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'); + }); + }); +}); diff --git a/web/tests/voiceConnection.test.ts b/web/tests/voiceConnection.test.ts new file mode 100644 index 0000000..cb85514 --- /dev/null +++ b/web/tests/voiceConnection.test.ts @@ -0,0 +1,505 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('livekit-client', async () => { + const fake = await import('./fakeLiveKit'); + return { + Room: fake.FakeRoom, + RoomEvent: fake.RoomEvent, + ConnectionQuality: fake.ConnectionQuality, + Track: fake.Track, + }; +}); + +import { FakeRoom, RoomEvent } from './fakeLiveKit'; +import { + installFetch, + makeReadySnapshot, + recordedRequests, + resetStores, + type RecordedRequest, +} from './helpers'; +import { fakeVoiceToken, makeVoiceState, voiceRoutes, type VoiceRoutes } from './voiceRoutes'; +import { parseGatewaySnapshot } from '@/api/gateway'; +import { dispatchGatewayEvent } from '@/stores/gateway'; +import { useSessionStore } from '@/stores/session'; +import { useVoiceStore } from '@/stores/voice'; +import { + SPEAKING_WARNING_INTERVAL_MS, + SPEAKING_WARNING_VISIBLE_MS, + TOKEN_REFRESH_MIN_DELAY_MS, + useVoiceConnectionStore, + VOICE_SESSION_EXPIRED_CODE, +} from '@/stores/voiceConnection'; + +const ALL_PERMISSIONS = [ + 'VIEW_GUILD', + 'CONNECT_VOICE', + 'MUTE_MEMBERS', + 'DEAFEN_MEMBERS', + 'MOVE_MEMBERS', +]; + +/** Сессия: пользователь и сервер с двумя голосовыми комнатами. */ +function seedSession(voiceStates: Parameters[0][] = []): void { + const snapshot = parseGatewaySnapshot( + makeReadySnapshot([ + { + id: 'g-1', + name: 'Сервер', + my_permissions: ALL_PERMISSIONS, + channels: [ + { id: 'c-1', name: 'общий' }, + { id: 'c-voice', name: 'Голосовая', type: 'voice' }, + { id: 'c-other', name: 'Вторая', type: 'voice' }, + ], + voice_states: voiceStates, + }, + ]), + ); + if (snapshot === null) { + throw new Error('снимок READY не разобрался'); + } + useSessionStore.getState().applyReady(snapshot); +} + +/** Ждёт выполнения цепочки промисов (в том числе под фейковыми таймерами). */ +async function flush(times = 12): Promise { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +} + +/** Последний запрос, подходящий под фильтр. */ +function lastRequest( + fetchMock: ReturnType, + predicate: { url: string; method?: string }, +): RecordedRequest | undefined { + const matching = recordedRequests(fetchMock).filter( + (request) => + request.url.includes(predicate.url) && + (predicate.method === undefined || request.method === predicate.method.toUpperCase()), + ); + return matching.at(-1); +} + +/** Вход в комнату «как есть»: с готовыми маршрутами и сессией. */ +async function joinRoom( + channelId = 'c-voice', + voice: VoiceRoutes = voiceRoutes(), +): Promise> { + const fetchMock = installFetch([...voice.routes]); + await useVoiceConnectionStore.getState().join('g-1', channelId); + return fetchMock; +} + +beforeEach(() => { + resetStores(); + FakeRoom.reset(); + seedSession(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('голосовая сессия: подключение', () => { + it('входит в комнату по токену и публикует микрофон', async () => { + const voice = voiceRoutes(); + const fetchMock = installFetch([...voice.routes]); + + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + + const state = useVoiceConnectionStore.getState(); + expect(state.status).toBe('connected'); + expect(state.guildId).toBe('g-1'); + expect(state.channelId).toBe('c-voice'); + expect(state.error).toBeNull(); + expect(state.participants.map((participant) => participant.userId)).toEqual(['user-1']); + + const [call] = FakeRoom.latest().connectCalls; + expect(call?.url).toBe('wss://gl.localhost/rtc'); + expect(call?.token).not.toBe(''); + expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ + method: 'setMicrophoneEnabled', + value: true, + }); + + // Сервер уже знает о нас: состояние легло в снапшот сессии. + expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined(); + expect( + useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-1'), + ).toBe(true); + }); + + it('показывает участников комнаты из SDK с речью и качеством связи', async () => { + await joinRoom(); + const room = FakeRoom.latest(); + room.addRemote('user-2', 'Bob', { quality: 'poor' }); + room.activeSpeakers(['user-2']); + + const state = useVoiceConnectionStore.getState(); + expect(state.participants.map((participant) => participant.userId)).toEqual([ + 'user-1', + 'user-2', + ]); + const bob = state.participants.find((participant) => participant.userId === 'user-2'); + expect(bob?.isSpeaking).toBe(true); + expect(bob?.quality).toBe('poor'); + expect(bob?.audio).toBeDefined(); + }); + + it('повторный вход в ту же комнату ничего не делает', async () => { + const voice = voiceRoutes(); + const fetchMock = await joinRoom('c-voice', voice); + + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + + expect(voice.joinCount()).toBe(1); + expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined(); + }); + + it('переходит в другую комнату через выход и вход', async () => { + const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] }); + const fetchMock = installFetch([...voice.routes]); + + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + await useVoiceConnectionStore.getState().join('g-1', 'c-other'); + + const voiceRequests = recordedRequests(fetchMock) + .filter((request) => request.url.includes('/voice/')) + .map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`); + expect(voiceRequests).toEqual([ + 'POST /channels/c-voice/voice/join', + 'POST /channels/c-voice/voice/leave', + 'POST /channels/c-other/voice/join', + ]); + expect(useVoiceConnectionStore.getState().channelId).toBe('c-other'); + }); + + it('выходит из комнаты и очищает состояние', async () => { + const fetchMock = await joinRoom(); + FakeRoom.latest().addRemote('user-2', 'Bob'); + + await useVoiceConnectionStore.getState().leave(); + + const state = useVoiceConnectionStore.getState(); + expect(state.status).toBe('idle'); + expect(state.channelId).toBeNull(); + expect(state.participants).toEqual([]); + expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); + expect(FakeRoom.latest().disconnectCalls).toBe(1); + }); +}); + +describe('голосовая сессия: флаги', () => { + it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => { + const fetchMock = await joinRoom(); + const store = useVoiceConnectionStore.getState(); + + await store.setMicEnabled(false); + expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ + method: 'setMicrophoneEnabled', + value: false, + }); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + self_mute: true, + }); + + await useVoiceConnectionStore.getState().setCameraEnabled(true); + expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ + method: 'setCameraEnabled', + value: true, + }); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + camera: true, + }); + + await useVoiceConnectionStore.getState().setDeafened(true); + expect(useVoiceConnectionStore.getState().deafened).toBe(true); + expect(useVoiceStore.getState().headphonesEnabled).toBe(false); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + self_deaf: true, + }); + + await useVoiceConnectionStore.getState().setScreenShareEnabled(true); + expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ + method: 'setScreenShareEnabled', + value: true, + }); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + screen: true, + }); + }); + + it('без подключения кнопки только сохраняют предпочтения', async () => { + const fetchMock = installFetch([]); + + await useVoiceConnectionStore.getState().setMicEnabled(false); + + expect(useVoiceStore.getState().micEnabled).toBe(false); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })).toBeUndefined(); + }); + + it('серверный мьют выключает микрофон и блокирует включение', async () => { + const fetchMock = await joinRoom(); + + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 1, + d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }), + }); + await flush(); + + const state = useVoiceConnectionStore.getState(); + expect(state.serverMuted).toBe(true); + expect(state.micEnabled).toBe(false); + expect(FakeRoom.latest().localParticipant.calls).toContainEqual({ + method: 'setMicrophoneEnabled', + value: false, + }); + + // Пользователь не может включить микрофон, пока модератор не снимет мьют. + await state.setMicEnabled(true); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + expect(useVoiceStore.getState().micEnabled).toBe(true); + + // Модератор снял мьют — кнопка снова доступна. + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 2, + d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: false }), + }); + await flush(); + expect(useVoiceConnectionStore.getState().serverMuted).toBe(false); + await useVoiceConnectionStore.getState().setMicEnabled(true); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(true); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + self_mute: false, + }); + }); + + it('серверное глушение блокирует кнопку наушников', async () => { + await joinRoom(); + + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 1, + d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }), + }); + await flush(); + + expect(useVoiceConnectionStore.getState().serverDeafened).toBe(true); + expect(useVoiceConnectionStore.getState().deafened).toBe(true); + }); + + it('личная громкость участника хранится локально', async () => { + await joinRoom(); + + useVoiceConnectionStore.getState().setVolume('user-2', 40); + + expect(useVoiceStore.getState().participantVolumes['user-2']).toBe(40); + }); + + it('перемещение и модерация уходят на сервер', async () => { + const voice = voiceRoutes({ + states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })], + }); + const fetchMock = await joinRoom('c-voice', voice); + + await useVoiceConnectionStore.getState().setParticipantMuted('g-1', 'user-2', true); + expect(voice.states.find((item) => item.user_id === 'user-2')?.server_mute).toBe(true); + expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({ + server_mute: true, + }); + + await useVoiceConnectionStore.getState().setParticipantDeafened('g-1', 'user-2', true); + expect(voice.states.find((item) => item.user_id === 'user-2')?.server_deaf).toBe(true); + expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({ + server_deaf: true, + }); + + await useVoiceConnectionStore.getState().moveParticipant('g-1', 'user-2', 'c-other'); + expect(voice.states.find((item) => item.user_id === 'user-2')?.channel_id).toBe('c-other'); + expect( + lastRequest(fetchMock, { url: '/voice-states/user-2/move', method: 'POST' })?.body, + ).toEqual({ channel_id: 'c-other' }); + }); +}); + +describe('голосовая сессия: синхронизация с сервером', () => { + it('VOICE_STATE_UPDATE добавляет и убирает участника', async () => { + await joinRoom(); + + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 1, + d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }), + }); + expect( + useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'), + ).toBe(true); + + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 2, + d: { guild_id: 'g-1', channel_id: 'c-voice', user_id: 'user-2', left: true }, + }); + expect( + useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'), + ).toBe(false); + }); + + it('переподключается в комнату, куда нас переместил модератор', async () => { + const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] }); + const fetchMock = installFetch([...voice.routes]); + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + const first = FakeRoom.latest(); + + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 1, + d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-other' }), + }); + await flush(24); + + expect(useVoiceConnectionStore.getState().channelId).toBe('c-other'); + // Старая комната закрыта, для новой создана своя сессия SDK. + expect(first.disconnectCalls).toBe(1); + expect(FakeRoom.instances).toHaveLength(2); + expect(FakeRoom.latest().connectCalls).toHaveLength(1); + expect( + lastRequest(fetchMock, { url: '/channels/c-other/voice/join', method: 'POST' }), + ).toBeDefined(); + }); + + it('окончательный разрыв соединения возвращает в idle и шлёт leave', async () => { + const fetchMock = await joinRoom(); + + FakeRoom.latest().emit(RoomEvent.Disconnected); + await flush(24); + + expect(useVoiceConnectionStore.getState().status).toBe('idle'); + expect(useVoiceConnectionStore.getState().participants).toEqual([]); + expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); + }); + + it('во время переподключения показывает статус reconnecting', async () => { + await joinRoom(); + + FakeRoom.latest().emit(RoomEvent.Reconnecting); + expect(useVoiceConnectionStore.getState().status).toBe('reconnecting'); + + FakeRoom.latest().emit(RoomEvent.Reconnected); + expect(useVoiceConnectionStore.getState().status).toBe('connected'); + }); +}); + +describe('голосовая сессия: продление токена', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-19T10:00:00Z')); + }); + + it('за минуту до истечения берёт новый токен и переподключается с флагами', async () => { + const first = fakeVoiceToken(30); + const second = fakeVoiceToken(3600); + const voice = voiceRoutes({ tokens: [first, second] }); + const fetchMock = await joinRoom('c-voice', voice); + + await useVoiceConnectionStore.getState().setMicEnabled(false); + await useVoiceConnectionStore.getState().setCameraEnabled(true); + + // exp через 30 секунд: продление планируется на минимальную задержку. + await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1); + await flush(24); + + const room = FakeRoom.latest(); + expect(room.connectCalls.map((call) => call.token)).toEqual([first, second]); + expect(room.disconnectCalls).toBe(1); + expect(voice.joinCount()).toBe(2); + // Флаги применились заново и подтверждены на сервере. + expect( + room.localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled'), + ).toEqual([ + { method: 'setMicrophoneEnabled', value: true }, + { method: 'setMicrophoneEnabled', value: false }, + { method: 'setMicrophoneEnabled', value: false }, + ]); + expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({ + self_mute: true, + self_deaf: false, + camera: true, + screen: false, + }); + expect(useVoiceConnectionStore.getState().status).toBe('connected'); + expect(useVoiceConnectionStore.getState().refreshing).toBe(false); + }); + + it('после трёх неудач выходит из комнаты и показывает ошибку', async () => { + const voice = voiceRoutes({ + tokens: [fakeVoiceToken(30)], + joinSuccesses: 1, + }); + const fetchMock = await joinRoom('c-voice', voice); + + await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1); + await flush(40); + + const state = useVoiceConnectionStore.getState(); + expect(voice.joinCount()).toBe(4); + expect(state.status).toBe('error'); + expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE }); + expect(state.participants).toEqual([]); + expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined(); + }); +}); + +describe('голосовая сессия: предупреждение о речи', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-19T10:00:00Z')); + }); + + it('показывает подсказку не чаще одного раза в 5 секунд', async () => { + await joinRoom(); + await useVoiceConnectionStore.getState().setMicEnabled(false); + const room = FakeRoom.latest(); + + room.activeSpeakers(['user-1']); + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true); + + // Подсказка сама исчезает, но раньше пяти секунд не повторяется. + await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_VISIBLE_MS + 1); + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false); + room.activeSpeakers(['user-1']); + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false); + + await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_INTERVAL_MS); + room.activeSpeakers(['user-1']); + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true); + }); + + it('молчит, когда микрофон включён', async () => { + await joinRoom(); + + FakeRoom.latest().activeSpeakers(['user-1']); + + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false); + }); + + it('подсказку можно скрыть вручную', async () => { + await joinRoom(); + await useVoiceConnectionStore.getState().setMicEnabled(false); + FakeRoom.latest().activeSpeakers(['user-1']); + + useVoiceConnectionStore.getState().dismissSpeakingWarning(); + + expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false); + }); +}); diff --git a/web/tests/voiceRoutes.ts b/web/tests/voiceRoutes.ts new file mode 100644 index 0000000..491fc9d --- /dev/null +++ b/web/tests/voiceRoutes.ts @@ -0,0 +1,192 @@ +import { apiError, json, type FetchRoute } from './helpers'; +import type { VoiceState } from '@/api/voice'; + +/** + * Маршруты голосовых ручек для тестов: вход/выход, свои флаги, модерация и + * перемещение. Состояние живёт в замыкании, поэтому ответы согласованы между + * запросами (как у настоящего сервера). + */ + +/** JWT с `exp` через `expiresInSeconds`: из него клиент считает время продления. */ +export function fakeVoiceToken(expiresInSeconds = 3600): string { + const exp = Math.floor(Date.now() / 1000) + expiresInSeconds; + const payload = btoa(JSON.stringify({ exp })) + .replace(/=+$/u, '') + .replace(/\+/gu, '-') + .replace(/\//gu, '_'); + return `header.${payload}.signature`; +} + +export function makeVoiceState( + overrides: Partial & Pick, +): VoiceState { + return { + guild_id: 'g-1', + self_mute: false, + self_deaf: false, + server_mute: false, + server_deaf: false, + camera: false, + screen: false, + joined_at: '2026-09-19T10:00:00Z', + ...overrides, + }; +} + +export interface VoiceRoutesOptions { + guildId?: string; + /** Комнаты, для которых работает вход (по умолчанию одна). */ + channelIds?: string[]; + /** Токены, которые выдаёт join по порядку; последний повторяется. */ + tokens?: string[]; + /** Стартовое состояние сервера: кто в комнатах. */ + states?: VoiceState[]; + /** Код ошибки входа (`voice.channel_full`, `perm.denied`, ...). */ + joinError?: { code: string; status: number }; + /** Сколько первых входов проходят успешно (для проверки продления токена). */ + joinSuccesses?: number; +} + +export interface VoiceRoutes { + routes: FetchRoute[]; + /** Текущее состояние «сервера»: его проверяют тесты. */ + states: VoiceState[]; + /** Сколько раз запросили вход. */ + joinCount: () => number; +} + +export function voiceRoutes(options: VoiceRoutesOptions = {}): VoiceRoutes { + const guildId = options.guildId ?? 'g-1'; + const channelIds = options.channelIds ?? ['c-voice']; + const tokens = options.tokens ?? [fakeVoiceToken()]; + const states = [...(options.states ?? [])]; + let issued = 0; + let joins = 0; + + const nextToken = (): string => { + const token = tokens[Math.min(issued, tokens.length - 1)]; + issued += 1; + return token ?? ''; + }; + + const list = (): VoiceState[] => states; + + const routes: FetchRoute[] = [ + { + match: '/voice/join', + method: 'POST', + response: ({ url }) => { + joins += 1; + if (options.joinError !== undefined) { + return apiError(options.joinError.code, options.joinError.status); + } + if (options.joinSuccesses !== undefined && joins > options.joinSuccesses) { + return apiError('internal.error', 500); + } + const channelId = + channelIds.find((id) => url.includes(`/channels/${id}/`)) ?? channelIds[0] ?? ''; + const state = makeVoiceState({ + user_id: 'user-1', + channel_id: channelId, + guild_id: guildId, + self_mute: false, + }); + const index = states.findIndex((item) => item.user_id === 'user-1'); + if (index === -1) { + states.push(state); + } else { + states[index] = state; + } + return json({ + token: nextToken(), + url: 'wss://gl.localhost/rtc', + room: `guild-${guildId}-${channelId}`, + channel_id: channelId, + guild_id: guildId, + state, + }); + }, + }, + { + match: '/voice/leave', + method: 'POST', + response: () => { + const index = states.findIndex((item) => item.user_id === 'user-1'); + if (index >= 0) { + states.splice(index, 1); + } + return json({ ok: true }); + }, + }, + { + match: `/guilds/${guildId}/voice-states/@me`, + method: 'PATCH', + response: ({ body }) => { + const patch = (body ?? {}) as Record; + const index = states.findIndex((item) => item.user_id === 'user-1'); + const current = + index === -1 ? makeVoiceState({ user_id: 'user-1', channel_id: '' }) : states[index]; + if (current !== undefined) { + const next: VoiceState = { ...current }; + if (typeof patch['self_mute'] === 'boolean') { + next.self_mute = patch['self_mute']; + } + if (typeof patch['self_deaf'] === 'boolean') { + next.self_deaf = patch['self_deaf']; + } + if (typeof patch['camera'] === 'boolean') { + next.camera = patch['camera']; + } + if (typeof patch['screen'] === 'boolean') { + next.screen = patch['screen']; + } + if (index === -1) { + states.push(next); + } else { + states[index] = next; + } + } + return json({ voice_states: list() }); + }, + }, + { + match: `/guilds/${guildId}/voice-states/`, + method: 'PATCH', + response: ({ url, body }) => { + const userId = url.split('/voice-states/')[1]?.split(/[/?]/u)[0] ?? ''; + const patch = (body ?? {}) as Record; + const index = states.findIndex((item) => item.user_id === userId); + const current = states[index]; + if (current !== undefined) { + if (typeof patch['server_mute'] === 'boolean') { + current.server_mute = patch['server_mute']; + } + if (typeof patch['server_deaf'] === 'boolean') { + current.server_deaf = patch['server_deaf']; + } + } + return json({ voice_states: list() }); + }, + }, + { + match: `/guilds/${guildId}/voice-states/`, + method: 'POST', + response: ({ url, body }) => { + const userId = url.split('/voice-states/')[1]?.split('/')[0] ?? ''; + const target = ((body ?? {}) as Record)['channel_id']; + const index = states.findIndex((item) => item.user_id === userId); + const current = states[index]; + if (current !== undefined && typeof target === 'string') { + current.channel_id = target; + } + return json({ voice_states: list() }); + }, + }, + { + match: `/guilds/${guildId}/voice-states`, + response: () => json({ voice_states: list() }), + }, + ]; + + return { routes, states, joinCount: () => joins }; +}