feat(voice): журнал обработанных вебхуков LiveKit

This commit is contained in:
2026-09-20 18:45:14 +03:00
parent ecd7b3be10
commit 0db4c63023
19 changed files with 1578 additions and 59 deletions
+1 -2
View File
@@ -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;
@@ -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);
}
};
+3 -8
View File
@@ -94,17 +94,12 @@ export function VoiceFlags({
<VoiceFlagIcon
glyph="🎧"
off={deafened}
label={t(serverDeaf ? 'voice.serverDeafened' : deafened ? 'voice.deafened' : 'voice.soundOn')}
label={t(
serverDeaf ? 'voice.serverDeafened' : deafened ? 'voice.deafened' : 'voice.soundOn',
)}
/>
{camera ? <VoiceFlagIcon glyph="🎥" label={t('voice.cameraOn')} /> : null}
{screen ? <VoiceFlagIcon glyph="🖥" label={t('voice.screenOn')} /> : null}
</span>
);
}
/** Кольцо вокруг аватара, когда участник говорит. */
export function speakingRingClass(speaking: boolean): string {
return speaking
? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
: 'ring-1 ring-border/60';
}
@@ -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)}
/>
</li>
);
+13 -14
View File
@@ -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}
/>
<VoiceControlButton
glyph="⏻"
label={t('voice.disconnect')}
active
onClick={onLeave}
/>
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
</div>
);
}
@@ -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'
}`}
>
<span aria-hidden="true">{glyph}</span>
@@ -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) {
+2 -1
View File
@@ -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;
}
@@ -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) =>
+11
View File
@@ -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';
}
+2 -1
View File
@@ -187,7 +187,8 @@ export function canManageEmojis(
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages( permissions: readonly string[],
export function canSendMessages(
permissions: readonly string[],
isInstanceAdmin = false,
channelCanSend?: boolean,
): boolean {
+5 -5
View File
@@ -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),
+1
View File
@@ -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';
+9 -18
View File
@@ -76,9 +76,12 @@ export interface VoiceConnectionState {
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
setVolume: (userId: string, volume: number) => void;
setParticipantMuted: (userId: string, muted: boolean) => Promise<void>;
setParticipantDeafened: (userId: string, deafened: boolean) => Promise<void>;
moveParticipant: (userId: string, channelId: string) => Promise<void>;
/** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */
setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise<void>;
/** Серверное глушение участника: право `DEAFEN_MEMBERS`. */
setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise<void>;
/** Перемещение участника: право `MOVE_MEMBERS`. */
moveParticipant: (guildId: string, userId: string, channelId: string) => Promise<void>;
/** Переносит серверные флаги (server_mute/server_deaf) в локальную сессию. */
syncServerState: () => void;
dismissSpeakingWarning: () => void;
@@ -485,11 +488,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((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);
+4 -2
View File
@@ -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();
});
+287
View File
@@ -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<string, FakePublication>();
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<void> {
this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
return Promise.resolve();
}
setCameraEnabled(enabled: boolean): Promise<void> {
this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
return Promise.resolve();
}
setScreenShareEnabled(enabled: boolean): Promise<void> {
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<string, FakeParticipant>();
readonly connectCalls: { url: string; token: string }[] = [];
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
disconnectCalls = 0;
startAudioCalls = 0;
private readonly handlers = new Map<string, Set<Handler>>();
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<Handler>();
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<void> {
this.connectCalls.push({ url, token });
return Promise.resolve();
}
disconnect(): Promise<void> {
this.disconnectCalls += 1;
this.emit(RoomEvent.Disconnected);
return Promise.resolve();
}
startAudio(): Promise<void> {
this.startAudioCalls += 1;
return Promise.resolve();
}
switchActiveDevice(kind: string, deviceId: string): Promise<boolean> {
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);
}
}
}
+42 -2
View File
@@ -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<Response>;
/** Ответ на запрос: обработчик видит url, метод и разобранное тело. */
response: (request: RecordedRequest) => Response | Promise<Response>;
}
/**
@@ -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,
+488
View File
@@ -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<typeof makeVoiceState>[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<typeof installFetch>,
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<void> {
await userEvent.click(await screen.findByLabelText(`Открыть комнату ${name}`));
}
async function waitConnected(): Promise<void> {
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');
});
});
});
+505
View File
@@ -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<typeof makeVoiceState>[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<void> {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
}
}
/** Последний запрос, подходящий под фильтр. */
function lastRequest(
fetchMock: ReturnType<typeof installFetch>,
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<ReturnType<typeof installFetch>> {
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);
});
});
+192
View File
@@ -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<VoiceState> & Pick<VoiceState, 'user_id' | 'channel_id'>,
): 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<string, unknown>;
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<string, unknown>;
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<string, unknown>)['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 };
}