2026-09-20 18:45:14 +03:00
|
|
|
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' }),
|
|
|
|
|
});
|
2026-09-20 18:51:31 +03:00
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
|
|
|
|
|
});
|
2026-09-20 18:45:14 +03:00
|
|
|
|
|
|
|
|
// Старая комната закрыта, для новой создана своя сессия 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);
|
2026-09-20 18:51:31 +03:00
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(useVoiceConnectionStore.getState().status).toBe('idle');
|
|
|
|
|
});
|
2026-09-20 18:45:14 +03:00
|
|
|
|
|
|
|
|
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([]);
|
2026-09-20 18:51:31 +03:00
|
|
|
// Комната остаётся в состоянии ошибки: подсказка видна в интерфейсе.
|
|
|
|
|
expect(state.channelId).toBe('c-voice');
|
2026-09-20 18:45:14 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|