2026-09-20 19:30:43 +03:00
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
|
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('livekit-client', async () => {
|
|
|
|
|
|
const fake = await import('./fakeLiveKit');
|
|
|
|
|
|
return {
|
|
|
|
|
|
Room: fake.FakeRoom,
|
|
|
|
|
|
RoomEvent: fake.RoomEvent,
|
|
|
|
|
|
ConnectionQuality: fake.ConnectionQuality,
|
|
|
|
|
|
Track: fake.Track,
|
|
|
|
|
|
VideoQuality: fake.VideoQuality,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
import { FakeRoom, VideoQuality } from './fakeLiveKit';
|
|
|
|
|
|
import {
|
|
|
|
|
|
createTestQueryClient,
|
|
|
|
|
|
installFetch,
|
|
|
|
|
|
json,
|
|
|
|
|
|
makeUser,
|
|
|
|
|
|
resetStores,
|
|
|
|
|
|
type FetchRoute,
|
|
|
|
|
|
} from './helpers';
|
|
|
|
|
|
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
|
|
|
|
|
|
import type { Channel } from '@/api/types';
|
|
|
|
|
|
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
|
|
|
|
|
import { setQueryClient } from '@/lib/queryClient';
|
|
|
|
|
|
import { resetSoundboardEngine } from '@/lib/soundboard';
|
|
|
|
|
|
import { useSessionStore } from '@/stores/session';
|
|
|
|
|
|
import { useVoiceStore } from '@/stores/voice';
|
|
|
|
|
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Статистика своей публикации, выбор качества видео и пресетов демонстрации
|
|
|
|
|
|
* экрана (AGENT.md 7.14). Значения берутся из отчёта WebRTC подставного SDK,
|
|
|
|
|
|
* опрос идёт раз в секунду и останавливается при закрытии попапа.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 };
|
|
|
|
|
|
|
|
|
|
|
|
/** Отчёт WebRTC: кодек, отправка видео, обратный канал (RTT, потери). */
|
|
|
|
|
|
function statsReport(bytesSent: number, timestamp: number): [string, unknown][] {
|
|
|
|
|
|
return [
|
|
|
|
|
|
['codec-1', { type: 'codec', id: 'codec-1', mimeType: 'video/VP8' }],
|
|
|
|
|
|
[
|
|
|
|
|
|
'out-1',
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'outbound-rtp',
|
|
|
|
|
|
kind: 'video',
|
|
|
|
|
|
frameWidth: 1920,
|
|
|
|
|
|
frameHeight: 1080,
|
|
|
|
|
|
framesPerSecond: 30,
|
|
|
|
|
|
bytesSent,
|
|
|
|
|
|
packetsSent: 197,
|
|
|
|
|
|
codecId: 'codec-1',
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
'in-1',
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'remote-inbound-rtp',
|
|
|
|
|
|
kind: 'video',
|
|
|
|
|
|
roundTripTime: 0.042,
|
|
|
|
|
|
jitter: 0.003,
|
|
|
|
|
|
packetsLost: 3,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Рендер панели комнаты с поднятой голосовой сессией и включённой камерой. */
|
|
|
|
|
|
async function bootPanel() {
|
|
|
|
|
|
const queryClient = createTestQueryClient();
|
|
|
|
|
|
setQueryClient(queryClient);
|
|
|
|
|
|
resetStores();
|
|
|
|
|
|
resetSoundboardEngine();
|
|
|
|
|
|
|
|
|
|
|
|
useSessionStore.setState({
|
|
|
|
|
|
user: {
|
|
|
|
|
|
id: 'user-1',
|
|
|
|
|
|
username: 'alice',
|
|
|
|
|
|
display_name: 'Alice',
|
|
|
|
|
|
is_instance_admin: false,
|
|
|
|
|
|
badges: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
guilds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
|
name: 'Сервер',
|
|
|
|
|
|
owner_id: 'user-1',
|
|
|
|
|
|
is_main: true,
|
|
|
|
|
|
member_count: 1,
|
|
|
|
|
|
my_role_ids: [],
|
|
|
|
|
|
my_permissions: ['VIEW_GUILD'],
|
|
|
|
|
|
channels: [voiceChannel],
|
|
|
|
|
|
emojis: [],
|
|
|
|
|
|
sounds: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const voice = voiceRoutes({
|
|
|
|
|
|
channelIds: ['c-voice'],
|
|
|
|
|
|
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
|
|
|
|
|
|
});
|
|
|
|
|
|
installFetch([
|
|
|
|
|
|
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
|
|
|
|
|
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|
|
|
|
|
...voice.routes,
|
|
|
|
|
|
] as FetchRoute[]);
|
|
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
|
|
|
|
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
|
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-20 22:10:17 +03:00
|
|
|
|
// Фоновая публикация пинга опрашивает SDK раз в секунду; тесты попапа
|
|
|
|
|
|
// считают обращения к SDK, поэтому выключаем её и проверяем отдельно.
|
|
|
|
|
|
useVoiceStore.setState({ latencySharingEnabled: false });
|
|
|
|
|
|
|
2026-09-20 19:30:43 +03:00
|
|
|
|
await act(async () => {
|
|
|
|
|
|
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
|
|
|
|
|
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { room: FakeRoom.latest() };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
FakeRoom.reset();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('статистика соединения', () => {
|
|
|
|
|
|
it('показывает данные SDK и обновляет их раз в секунду', async () => {
|
|
|
|
|
|
const { room } = await bootPanel();
|
|
|
|
|
|
room.setStats(statsReport(0, 1_000));
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-stats-button'));
|
|
|
|
|
|
const panel = await screen.findByTestId('voice-stats-panel');
|
|
|
|
|
|
|
|
|
|
|
|
// Разрешение, fps и кодек приходят из отчёта; битрейт — по двум замерам.
|
|
|
|
|
|
expect(panel).toHaveTextContent('1920×1080');
|
|
|
|
|
|
expect(panel).toHaveTextContent('30 fps');
|
|
|
|
|
|
expect(panel).toHaveTextContent('VP8');
|
|
|
|
|
|
expect(panel).toHaveTextContent('42 мс');
|
|
|
|
|
|
expect(panel).toHaveTextContent('3 мс');
|
|
|
|
|
|
expect(panel).toHaveTextContent('1.5%');
|
|
|
|
|
|
|
|
|
|
|
|
room.setStats(statsReport(125_000, 2_000));
|
|
|
|
|
|
await waitFor(
|
|
|
|
|
|
() => {
|
|
|
|
|
|
expect(screen.getByTestId('voice-stats-panel')).toHaveTextContent('1000 кбит/с');
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 2_500 },
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('показывает прочерк, когда SDK не отдал поле', async () => {
|
|
|
|
|
|
const { room } = await bootPanel();
|
|
|
|
|
|
room.setStats([]);
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-stats-button'));
|
|
|
|
|
|
const panel = await screen.findByTestId('voice-stats-panel');
|
|
|
|
|
|
|
|
|
|
|
|
expect(within(panel).getAllByText('—').length).toBeGreaterThanOrEqual(5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('останавливает опрос при закрытии попапа', async () => {
|
|
|
|
|
|
const { room } = await bootPanel();
|
|
|
|
|
|
room.setStats(statsReport(0, 1_000));
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-stats-button'));
|
|
|
|
|
|
await screen.findByTestId('voice-stats-panel');
|
|
|
|
|
|
const track = room.localParticipant.getTrackPublication('camera')?.track;
|
|
|
|
|
|
const before = track?.statsRequests ?? 0;
|
|
|
|
|
|
expect(before).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(screen.getByTestId('voice-stats-button'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(screen.queryByTestId('voice-stats-panel')).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Ждём больше периода опроса: счётчик обращений к SDK не должен расти.
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
|
|
|
|
|
expect(track?.statsRequests).toBe(before);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('передаёт SDK выбранное качество публикации', async () => {
|
|
|
|
|
|
const { room } = await bootPanel();
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-quality-button'));
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-publish-quality-low'));
|
|
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(useVoiceStore.getState().videoQuality).toBe('low');
|
|
|
|
|
|
});
|
|
|
|
|
|
const track = room.localParticipant.getTrackPublication('camera')?.track;
|
|
|
|
|
|
expect(track?.publishingQuality).toContain(VideoQuality.LOW);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('публикует экран с выбранным разрешением и fps', async () => {
|
|
|
|
|
|
const { room } = await bootPanel();
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-screen-button'));
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('voice-screen-preset-720p30'));
|
|
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(useVoiceConnectionStore.getState().screenEnabled).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(useVoiceStore.getState().screenSharePreset).toBe('720p30');
|
|
|
|
|
|
expect(room.localParticipant.screenOptions.at(-1)).toEqual({
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
options: { resolution: { width: 1280, height: 720, frameRate: 30 } },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|