Files
glchat/web/tests/voiceStats.test.tsx
T
grendervill 619bd91df4 feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.

- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
  play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
  наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
  попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
  подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
  участника, упоминание, сообщение, вход/выход из голосовой, свои
  микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
  счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
  удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
  комнату с выключенным микрофоном, удержание включает публикацию, отпускание
  и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
  и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
  публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
  `useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
  расширенный фейковый LiveKit SDK.
2026-09-20 19:30:43 +03:00

220 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>,
);
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 } },
});
});
});