Files
glchat/web/tests/voiceStats.test.tsx
T
grendervill 3fe634c9bc feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
  инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
  («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
  измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
  (`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
  недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
  выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
  публикация по служебной теме, выключенный пинг не дёргает SDK.
2026-09-20 22:10:17 +03:00

224 lines
7.9 KiB
TypeScript
Raw Permalink 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>,
);
// Фоновая публикация пинга опрашивает SDK раз в секунду; тесты попапа
// считают обращения к SDK, поэтому выключаем её и проверяем отдельно.
useVoiceStore.setState({ latencySharingEnabled: false });
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 } },
});
});
});