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.
This commit is contained in:
2026-09-20 19:30:43 +03:00
parent 2538e07ec3
commit 619bd91df4
40 changed files with 4269 additions and 48 deletions
+1
View File
@@ -91,6 +91,7 @@ const guild: SessionGuild = {
{ id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
],
emojis: [],
sounds: [],
};
const authors: AuthorDirectory = {
+63
View File
@@ -0,0 +1,63 @@
import { vi } from 'vitest';
/**
* Подставной `Audio` для тестов саундборда: в jsdom `play()` не реализован,
* поэтому звук подменяется целиком. Экземпляры собираются в массив — по ним
* проверяют, что звук скачали, проиграли или остановили.
*/
export class FakeAudio {
src: string;
volume = 1;
currentTime = 0;
paused = true;
playCalls = 0;
pauseCalls = 0;
/** Считаем и нативные, и «событийные» подписки: `ended` глушит звук. */
readonly listeners = new Map<string, Set<() => void>>();
constructor(src = '') {
this.src = src;
}
addEventListener(type: string, handler: () => void): void {
const set = this.listeners.get(type) ?? new Set<() => void>();
set.add(handler);
this.listeners.set(type, set);
}
removeEventListener(type: string, handler: () => void): void {
this.listeners.get(type)?.delete(handler);
}
play(): Promise<void> {
this.playCalls += 1;
this.paused = false;
return Promise.resolve();
}
pause(): void {
this.pauseCalls += 1;
this.paused = true;
}
/** Сообщить о конце звука: движок снимает элемент с учёта. */
emit(type: string): void {
for (const handler of [...(this.listeners.get(type) ?? [])]) {
handler();
}
}
}
/** Ставит подставной `Audio` и возвращает список созданных элементов. */
export function installFakeAudio(): FakeAudio[] {
const instances: FakeAudio[] = [];
class StubAudio extends FakeAudio {
constructor(src = '') {
super(src);
instances.push(this);
}
}
vi.stubGlobal('Audio', StubAudio);
return instances;
}
+50 -9
View File
@@ -31,6 +31,13 @@ export const ConnectionQuality = {
Unknown: 'unknown',
} as const;
/** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */
export const VideoQuality = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
} as const;
export const Track = {
Source: {
Camera: 'camera',
@@ -44,9 +51,15 @@ export const Track = {
export class FakeTrack {
readonly kind: 'audio' | 'video';
readonly attached: HTMLMediaElement[] = [];
/** Качество публикации, выставленное `setPublishingQuality` (тесты ждут). */
readonly publishingQuality: number[] = [];
/** Сколько раз спросили отчёт WebRTC: по нему проверяют опрос статистики. */
statsRequests = 0;
private readonly report: () => [string, unknown][];
constructor(kind: 'audio' | 'video') {
constructor(kind: 'audio' | 'video', report: () => [string, unknown][] = () => []) {
this.kind = kind;
this.report = report;
}
attach(element: HTMLMediaElement): HTMLMediaElement {
@@ -61,6 +74,17 @@ export class FakeTrack {
}
return [];
}
/** Отчёт WebRTC: `Map`, как настоящий `RTCStatsReport`. */
getRTCStatsReport(): Promise<Map<string, unknown>> {
this.statsRequests += 1;
return Promise.resolve(new Map(this.report()));
}
/** Смена качества публикации: SDK-метод `LocalVideoTrack`. */
setPublishingQuality(quality: number): void {
this.publishingQuality.push(quality);
}
}
export class FakePublication {
@@ -82,6 +106,8 @@ export class FakeParticipant {
readonly isLocal: boolean;
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
readonly calls: { method: string; value: unknown }[] = [];
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
readonly screenOptions: { enabled: boolean; options: unknown }[] = [];
private readonly publications = new Map<string, FakePublication>();
constructor(
@@ -113,15 +139,18 @@ export class FakeParticipant {
/** Публикация дорожки: используется и для своих, и для чужих участников. */
setPublication(source: string, muted = false): FakePublication {
const publication = new FakePublication(
source,
new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
muted,
);
const publication = new FakePublication(source, this.newTrack(source), muted);
this.publications.set(source, publication);
return publication;
}
/** Дорожка знает про отчёт WebRTC комнаты: тесты задают его через `setStats`. */
private newTrack(source: string): FakeTrack {
return new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video', () =>
this.room.statsEntries(),
);
}
removePublication(source: string): void {
this.publications.delete(source);
this.room.emit(RoomEvent.TrackUnsubscribed);
@@ -139,8 +168,7 @@ export class FakeParticipant {
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');
const track = existing?.track ?? this.newTrack(source);
this.publications.set(source, new FakePublication(source, track, !enabled));
this.room.emit(RoomEvent.LocalTrackPublished);
}
@@ -155,7 +183,9 @@ export class FakeParticipant {
return Promise.resolve();
}
setScreenShareEnabled(enabled: boolean): Promise<void> {
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
this.screenOptions.push({ enabled, options });
this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
return Promise.resolve();
}
@@ -190,6 +220,8 @@ export class FakeRoom {
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
disconnectCalls = 0;
startAudioCalls = 0;
/** Отчёт WebRTC: пары «ключ → объект», как у `RTCStatsReport`. */
private stats: [string, unknown][] = [];
private readonly handlers = new Map<string, Set<Handler>>();
constructor(readonly options?: unknown) {
@@ -197,6 +229,15 @@ export class FakeRoom {
this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
}
/** Отчёт WebRTC, который отдаёт `getRTCStatsReport()` своих дорожек. */
statsEntries(): [string, unknown][] {
return this.stats;
}
setStats(entries: [string, unknown][]): void {
this.stats = entries;
}
on(event: string, handler: Handler): this {
const handlers = this.handlers.get(event) ?? new Set<Handler>();
handlers.add(handler);
+1
View File
@@ -61,6 +61,7 @@ function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): Sessi
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis,
sounds: [],
};
}
+20
View File
@@ -11,6 +11,7 @@ import { useFriendsStore } from '@/stores/friends';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useSoundboardStore } from '@/stores/soundboard';
import { useUiStore } from '@/stores/ui';
import { useVoiceStore } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
@@ -152,6 +153,8 @@ export function resetStores(): void {
useVoiceStore.getState().reset();
// Голосовая сессия могла остаться от прошлого теста: гасим её без запросов.
useVoiceConnectionStore.getState().reset();
// Саундборд: подпись и очередь звуков не должны переживать тест.
useSoundboardStore.getState().reset();
useFriendsStore.getState().reset();
resetUiStore();
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
@@ -221,6 +224,15 @@ interface SnapshotGuildInput {
my_nickname?: string;
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
/** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
sounds?: {
id: string;
name: string;
file_id: string;
kind?: 'soundboard' | 'ui';
event?: string;
emoji?: string;
}[];
/** Кто сидит в голосовых комнатах (AGENT.md 7.14). */
voice_states?: {
user_id: string;
@@ -297,6 +309,14 @@ export function makeReadySnapshot(
animated: emoji.animated ?? false,
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
})),
sounds: (guild.sounds ?? []).map((sound) => ({
id: sound.id,
name: sound.name,
file_id: sound.file_id,
kind: sound.kind ?? 'soundboard',
...(sound.event === undefined ? {} : { event: sound.event }),
...(sound.emoji === undefined ? {} : { emoji: sound.emoji }),
})),
voice_states: (guild.voice_states ?? []).map((state, index) => ({
user_id: state.user_id,
channel_id: state.channel_id,
+1
View File
@@ -138,6 +138,7 @@ describe('разметка: упоминания каналов', () => {
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis: [],
sounds: [],
};
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
+216
View File
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, renderHook } from '@testing-library/react';
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 } from './fakeLiveKit';
import { installFetch, makeUser, resetStores, type FetchRoute } from './helpers';
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
import { usePushToTalk } from '@/components/voice/usePushToTalk';
import { VOICE_STORAGE_KEY, useVoiceStore, type VoicePreferences } from '@/stores/voice';
import { useSessionStore } from '@/stores/session';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
* Push-to-talk (AGENT.md 7.14): клавиша включает и выключает микрофон,
* серверный мьют приоритетнее, ввод текста и повторы клавиши игнорируются,
* а режим и клавиша сохраняются в localStorage.
*/
/** Поднимает настоящую голосовую сессию на фейковом SDK. */
async function connectVoice(): Promise<void> {
const voice = voiceRoutes({
channelIds: ['c-voice'],
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
});
installFetch([
{ match: '/api/v1/users/@me', response: () => Response.json({ user: makeUser() }) },
...voice.routes,
] as FetchRoute[]);
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: [],
channels: [{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 }],
emojis: [],
sounds: [],
},
],
});
await act(async () => {
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
});
}
/** Микрофон в SDK: последний вызов `setMicrophoneEnabled`. */
function micCalls(): unknown[] {
return FakeRoom.latest()
.localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled')
.map((call) => call.value);
}
/** Сохранённые предпочтения из localStorage. */
function storedPreferences(): VoicePreferences {
return JSON.parse(window.localStorage.getItem(VOICE_STORAGE_KEY) ?? '{}') as VoicePreferences;
}
/** Дать React применить состояние после события клавиатуры. */
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
FakeRoom.reset();
resetStores();
// В jsdom окно «не в фокусе», пока что-нибудь не сфокусировано: для PTT
// считаем окно активным, а отдельный тест проверяет обратное поведение.
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
});
describe('push-to-talk', () => {
it('включает микрофон на удержании и выключает при отпускании', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
// В режиме PTT вход в комнату — с выключенным микрофоном.
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
expect(micCalls()).toContain(true);
fireEvent.keyUp(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
expect(micCalls().at(-1)).toBe(false);
});
it('игнорирует повтор клавиши и ввод текста', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ', repeat: true });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
// Фокус в поле ввода: пробел печатает текст, а не включает микрофон.
const input = document.createElement('input');
document.body.append(input);
input.focus();
fireEvent.keyDown(input, { code: 'Space', key: ' ' });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
input.remove();
});
it('серверный мьют приоритетнее: PTT не включает микрофон', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
act(() => {
useVoiceConnectionStore.setState({ serverMuted: true, micEnabled: false });
});
const before = micCalls().length;
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
expect(micCalls()).toHaveLength(before);
});
it('в режиме голосовой активации клавиша ничего не делает', async () => {
await connectVoice();
renderHook(() => usePushToTalk());
const before = micCalls().length;
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
expect(micCalls()).toHaveLength(before);
});
it('слушает выбранную клавишу', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
useVoiceStore.getState().setPttKey('KeyV');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
fireEvent.keyDown(document, { code: 'KeyV', key: 'v' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
fireEvent.keyUp(document, { code: 'KeyV', key: 'v' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
});
it('сохраняет режим и клавишу в localStorage', () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
useVoiceStore.getState().setPttKey('KeyB');
const stored = storedPreferences();
expect(stored.micActivation).toBe('push_to_talk');
expect(stored.pttKey).toBe('KeyB');
});
it('не реагирует, когда окно не в фокусе', async () => {
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
renderHook(() => usePushToTalk());
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
await flush();
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
});
it('возвращает микрофон по предпочтению при выходе из режима PTT', async () => {
useVoiceStore.getState().setMicActivation('push_to_talk');
await connectVoice();
const view = renderHook(() => usePushToTalk());
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
// Пользователь вернулся к голосовой активации — микрофон снова включён.
await act(async () => {
useVoiceStore.getState().setMicActivation('voice');
await Promise.resolve();
});
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
view.unmount();
});
});
+124
View File
@@ -0,0 +1,124 @@
import { apiError, json, type FetchRoute } from './helpers';
import type { GuildSound } from '@/api/sounds';
/**
* Маршруты ручек звуков для тестов: список, загрузка, переименование,
* удаление и просьба сыграть. Состояние живёт в замыкании, поэтому ответы
* согласованы между запросами (как у настоящего сервера).
*/
export interface SoundRoutesOptions {
guildId?: string;
/** Набор звуков «сервера»: его же проверяют тесты. */
sounds?: GuildSound[];
/** Ошибка на `POST .../play` (лимит 429, `voice.not_connected` и т.п.). */
playError?: { code: string; status: number; retryAfterMs?: number };
/** Ошибка на загрузку звука (`sound.name_taken`, `sounds.limit_reached`). */
uploadError?: { code: string; status: number };
}
export interface SoundRoutes {
routes: FetchRoute[];
sounds: GuildSound[];
/** Сколько раз просили сыграть звук. */
playCount: () => number;
}
export function soundRoutes(options: SoundRoutesOptions = {}): SoundRoutes {
const guildId = options.guildId ?? 'g-1';
const sounds = [...(options.sounds ?? [])];
let plays = 0;
const list = (kind: string | null): GuildSound[] =>
kind === null ? sounds : sounds.filter((sound) => sound.kind === kind);
const routes: FetchRoute[] = [
{
match: `/guilds/${guildId}/sounds/`,
method: 'POST',
response: ({ url }) => {
plays += 1;
if (options.playError !== undefined) {
const { code, status, retryAfterMs } = options.playError;
return json(
{
error: {
code,
message: 'error',
...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }),
},
},
status,
);
}
// `/guilds/{id}/sounds/{sound_id}/play`
const soundId = url.split('/sounds/')[1]?.split('/')[0] ?? '';
const known = sounds.find((sound) => sound.id === soundId);
return known === undefined ? apiError('sounds.not_found', 404) : json({ ok: true });
},
},
{
match: `/guilds/${guildId}/sounds`,
method: 'POST',
response: ({ url }) => {
if (options.uploadError !== undefined) {
return apiError(options.uploadError.code, options.uploadError.status);
}
const kind = url.includes('kind=ui') ? 'ui' : 'soundboard';
const created: GuildSound = {
id: `s-${sounds.length + 1}`,
name: 'uploaded',
file_id: '950',
kind,
...(kind === 'ui' ? { event: 'member_join' as const } : {}),
};
sounds.push(created);
return json({ sound: created });
},
},
{
match: `/guilds/${guildId}/sounds/`,
method: 'PATCH',
response: ({ url, body }) => {
const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? '';
const patch = (body ?? {}) as Record<string, unknown>;
const index = sounds.findIndex((sound) => sound.id === soundId);
const current = sounds[index];
if (current === undefined) {
return apiError('sounds.not_found', 404);
}
const next: GuildSound = {
...current,
...(typeof patch['name'] === 'string' ? { name: patch['name'] } : {}),
};
sounds[index] = next;
return json({ sound: next });
},
},
{
match: `/guilds/${guildId}/sounds/`,
method: 'DELETE',
response: ({ url }) => {
const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? '';
const index = sounds.findIndex((sound) => sound.id === soundId);
if (index >= 0) {
sounds.splice(index, 1);
}
return json({ ok: true });
},
},
{
match: `/guilds/${guildId}/sounds`,
response: ({ url }) => {
const kind = url.includes('kind=ui')
? 'ui'
: url.includes('kind=soundboard')
? 'soundboard'
: null;
return json({ sounds: list(kind) });
},
},
];
return { routes, sounds, playCount: () => plays };
}
+318
View File
@@ -0,0 +1,318 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
installFetch,
json,
makeUser,
renderApp,
requestUrl,
resetStores,
type FetchRoute,
} from './helpers';
import { soundRoutes } from './soundRoutes';
import type { GuildSound } from '@/api/sounds';
import { SOUND_LIMIT_PER_KIND } from '@/api/sounds';
/**
* Секция «Звуки» в настройках сервера (AGENT.md 7.13): список саундборда и
* палитры со счётчиками, загрузка с выбором события, переименование и
* удаление. Снапшот обновляется точечно, запросы инвалидируются — попап
* саундборда видит изменения сразу.
*/
const user = makeUser();
const airhorn: GuildSound = {
id: 's-1',
name: 'airhorn',
file_id: '900',
kind: 'soundboard',
emoji: '📣',
};
const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' };
const joinSound: GuildSound = {
id: 'u-1',
name: 'hello',
file_id: '910',
kind: 'ui',
event: 'member_join',
};
function guildDetail(permissions: string[]) {
return {
id: 'g-1',
name: 'Сервер',
description: '',
owner_id: 'user-9',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: permissions,
};
}
interface RoutesOptions {
permissions?: string[];
sounds?: GuildSound[];
extra?: FetchRoute[];
uploadError?: { code: string; status: number };
}
/** Маршруты оболочки, карточки сервера и ручек звуков. */
function routes(options: RoutesOptions = {}) {
const permissions = options.permissions ?? ['MANAGE_SOUNDS'];
const sounds = soundRoutes({
sounds: options.sounds ?? [airhorn, quack, joinSound],
...(options.uploadError === undefined ? {} : { uploadError: options.uploadError }),
});
return [
...(options.extra ?? []),
...sounds.routes,
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: [guildDetail(permissions)] }),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ 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/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) },
] satisfies FetchRoute[];
}
/** Открывает настройки сервера и дожидается окна. */
async function openSettings(options: RoutesOptions = {}) {
const fetchMock = installFetch(routes(options));
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
return { dialog, fetchMock };
}
/** Открывает настройки сервера и дожидается загруженной секции «Звуки». */
async function openSoundsSection(options: RoutesOptions = {}) {
const { dialog, fetchMock } = await openSettings(options);
const counter = await within(dialog).findByTestId('guild-sounds-counter');
// Список приходит запросом: дожидаемся его, иначе счётчик ещё нулевой.
await within(dialog).findByTestId('guild-sounds');
return { dialog, counter, fetchMock };
}
/** Тело multipart-запроса загрузки звука. */
function uploadForm(fetchMock: ReturnType<typeof installFetch>): FormData | undefined {
const call = fetchMock.mock.calls.find(([input, init]) => {
const method = (init?.method ?? 'GET').toUpperCase();
return method === 'POST' && requestUrl(input).includes('/guilds/g-1/sounds');
});
return call?.[1]?.body as FormData | undefined;
}
/** URL запроса загрузки: по нему проверяют `kind`. */
function uploadUrl(fetchMock: ReturnType<typeof installFetch>): string {
const call = fetchMock.mock.calls.find(([input, init]) => {
const method = (init?.method ?? 'GET').toUpperCase();
return method === 'POST' && requestUrl(input).includes('/guilds/g-1/sounds');
});
return call === undefined ? '' : requestUrl(call[0]);
}
/** Один звук нужного размера и формата. */
function soundFile(name = 'airhorn.mp3', type = 'audio/mpeg', size?: number): File {
const file = new File(['звук'], name, { type });
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size });
}
return file;
}
beforeEach(() => {
resetStores();
});
describe('настройки сервера: секция «Звуки»', () => {
it('показывает саундборд со счётчиком, переключается на палитру', async () => {
const { dialog, counter } = await openSoundsSection();
expect(counter).toHaveTextContent('2 из 30');
const list = await within(dialog).findByTestId('guild-sounds');
expect(within(list).getByTestId('guild-sound-s-1')).toHaveTextContent('airhorn');
expect(within(list).queryByTestId('guild-sound-u-1')).toBeNull();
await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui'));
expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('1 из 30');
const palette = within(dialog).getByTestId('guild-sounds');
expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent('hello');
// У звука палитры видно привязанное событие.
expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent(
'Участник присоединился',
);
});
it('скрыта без права MANAGE_SOUNDS', async () => {
const { dialog } = await openSettings({ permissions: ['VIEW_GUILD'] });
expect(within(dialog).queryByTestId('guild-sounds-counter')).toBeNull();
});
it('загружает звук палитры: kind в URL, событие и имя в multipart', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui'));
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'notify');
await userEvent.selectOptions(within(dialog).getByTestId('guild-sounds-event'), 'mention');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
expect(uploadUrl(fetchMock)).toContain('kind=ui');
const form = uploadForm(fetchMock);
expect(form?.get('name')).toBe('notify');
expect(form?.get('event')).toBe('mention');
expect(form?.get('file')).toBeInstanceOf(File);
});
it('загружает звук саундборда без события', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'horn2');
await userEvent.type(within(dialog).getByLabelText('Эмодзи'), '📢');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('second.ogg', 'audio/ogg')] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
expect(uploadUrl(fetchMock)).toContain('kind=soundboard');
expect(uploadForm(fetchMock)?.get('event')).toBeNull();
expect(uploadForm(fetchMock)?.get('emoji')).toBe('📢');
});
it('принимает файл перетаскиванием в область загрузки', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.drop(within(dialog).getByTestId('sound-dropzone'), {
dataTransfer: { files: [soundFile('drop.wav', 'audio/wav')] },
});
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'dropped');
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)?.get('name')).toBe('dropped');
});
});
it('не отправляет файл неподдерживаемого формата', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('sound.txt', 'text/plain')] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Поддерживаются MP3, OGG, WAV, WebM и M4A.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('не отправляет файл больше 512 КБ', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('big.mp3', 'audio/mpeg', 512 * 1024 + 1)] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Файл больше допустимого размера.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('проверяет имя по формату сервера', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'плохое имя');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Имя звука: 2–32 символа, латиница, цифры и «_».',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('показывает ошибку лимита саундборда', async () => {
const many: GuildSound[] = Array.from({ length: SOUND_LIMIT_PER_KIND }, (_value, index) => ({
id: `s-${String(index)}`,
name: `sound_${String(index)}`,
file_id: `${String(1000 + index)}`,
kind: 'soundboard',
}));
const { dialog } = await openSoundsSection({ sounds: many });
expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('30 из 30');
expect(dialog).toHaveTextContent('Достигнут лимит звуков этого вида.');
expect(within(dialog).getByRole('button', { name: 'Загрузить' })).toBeDisabled();
});
it('переименовывает звук через PATCH', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByLabelText('Переименовать звук airhorn'));
// В окне переименования своё поле имени: ищем его внутри модального окна.
const modal = await screen.findByRole('dialog', { name: 'Переименовать звук' });
const field = within(modal).getByLabelText('Имя звука');
await userEvent.clear(field);
await userEvent.type(field, 'airhorn2');
await userEvent.click(within(modal).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
const request = fetchMock.mock.calls.find(
([input, init]) =>
(init?.method ?? 'GET').toUpperCase() === 'PATCH' &&
requestUrl(input).includes('/sounds/s-1'),
);
expect(request).toBeDefined();
});
});
it('удаляет звук после подтверждения', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByLabelText('Удалить звук quack'));
await userEvent.click(await screen.findByRole('button', { name: 'Удалить' }));
await waitFor(() => {
const request = fetchMock.mock.calls.find(
([input, init]) =>
(init?.method ?? 'GET').toUpperCase() === 'DELETE' &&
requestUrl(input).includes('/sounds/s-2'),
);
expect(request).toBeDefined();
});
});
it('показывает ошибку сервера понятным текстом', async () => {
const { dialog } = await openSoundsSection({
uploadError: { code: 'sound.name_taken', status: 409 },
});
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'airhorn');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByText('Звук с таким именем уже есть.')).toBeVisible();
});
});
+482
View File
@@ -0,0 +1,482 @@
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 } from './fakeLiveKit';
import { installFakeAudio } from './fakeAudio';
import {
createTestQueryClient,
installFetch,
json,
makeUser,
resetStores,
type FetchRoute,
} from './helpers';
import { soundRoutes } from './soundRoutes';
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
import type { Channel } from '@/api/types';
import type { GuildSound } from '@/api/sounds';
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
import { SoundboardNotice } from '@/components/voice/SoundboardNotice';
import { setQueryClient } from '@/lib/queryClient';
import { resetSoundboardEngine } from '@/lib/soundboard';
import { useSessionStore } from '@/stores/session';
import { useSoundboardStore } from '@/stores/soundboard';
import { useVoiceStore } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { dispatchGatewayEvent } from '@/stores/gateway';
/**
* Саундборд (AGENT.md 7.13): попап панели комнаты, оптимистичное
* воспроизведение, защита от двойного звука по событию `SOUNDBOARD_PLAY`,
* лимит 429 и подсказка `voice.not_connected`. Плюс звуковая палитра:
* звук события играется при системном сообщении и упоминании.
*/
const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 };
const airhorn: GuildSound = {
id: 's-1',
name: 'airhorn',
file_id: '900',
kind: 'soundboard',
emoji: '📣',
};
const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' };
const joinSound: GuildSound = {
id: 'u-1',
name: 'join',
file_id: '777',
kind: 'ui',
event: 'member_join',
};
const mentionSound: GuildSound = {
id: 'u-2',
name: 'ping',
file_id: '778',
kind: 'ui',
event: 'mention',
};
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: [],
},
];
/** Снапшот сессии: сервер со звуками и собой в голосовой комнате. */
function seedSession(sounds: GuildSound[], permissions: string[]): void {
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: 2,
my_role_ids: [],
my_permissions: permissions,
channels: [voiceChannel],
emojis: [],
sounds,
},
],
voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] },
});
}
interface PanelOptions {
sounds?: GuildSound[];
permissions?: string[];
playError?: { code: string; status: number; retryAfterMs?: number };
}
/** Рендер панели комнаты с поднятой голосовой сессией (фейковый SDK). */
async function bootPanel(options: PanelOptions = {}) {
const queryClient = createTestQueryClient();
setQueryClient(queryClient);
resetStores();
resetSoundboardEngine();
const sounds = options.sounds ?? [airhorn, quack];
seedSession(sounds, options.permissions ?? ['VIEW_GUILD', 'USE_SOUNDBOARD']);
const sound = soundRoutes({
sounds,
...(options.playError ? { playError: options.playError } : {}),
});
const voice = voiceRoutes({
channelIds: ['c-voice'],
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
});
const fetchMock = 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: [] }) },
...sound.routes,
...voice.routes,
] as FetchRoute[]);
const audio = installFakeAudio();
render(
<QueryClientProvider client={queryClient}>
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
<SoundboardNotice />
</QueryClientProvider>,
);
await act(async () => {
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
});
return { sound, voice, fetchMock, audio };
}
/** Открыть попап саундборда. */
async function openSoundboard(): Promise<HTMLElement> {
await userEvent.click(await screen.findByTestId('voice-soundboard-button'));
return screen.findByTestId('voice-soundboard-panel');
}
beforeEach(() => {
FakeRoom.reset();
});
describe('саундборд: панель комнаты', () => {
it('кнопка появляется только в комнате и с правом USE_SOUNDBOARD', async () => {
// Без права кнопки нет, хотя сессия поднята.
await bootPanel({ permissions: ['VIEW_GUILD'] });
expect(screen.queryByTestId('voice-soundboard-button')).toBeNull();
});
it('проигрывает звук: POST на сервер и локальное воспроизведение', async () => {
const { sound, audio } = await bootPanel();
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
await waitFor(() => {
expect(sound.playCount()).toBe(1);
});
expect(audio).toHaveLength(1);
expect(audio[0]?.src).toBe('/files/900');
// Оптимистичная подпись «кто включил звук» появляется сразу.
expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent(
'Alice включил звук «airhorn»',
);
});
it('не проигрывает свой звук дважды по событию шлюза', async () => {
const { audio } = await bootPanel();
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
await waitFor(() => {
expect(audio).toHaveLength(1);
});
// Эхо своего же нажатия: сопоставление по sound_id + user_id + времени.
act(() => {
useSoundboardStore.getState().applyGatewayPlay({
guild_id: 'g-1',
channel_id: 'c-voice',
sound_id: 's-1',
file_id: '900',
name: 'airhorn',
user_id: 'user-1',
played_at: '2026-09-19T10:00:00Z',
});
});
expect(audio).toHaveLength(1);
});
it('играет звук другого участника по событию SOUNDBOARD_PLAY', async () => {
const { audio } = await bootPanel();
act(() => {
useSoundboardStore.getState().applyGatewayPlay({
guild_id: 'g-1',
channel_id: 'c-voice',
sound_id: 's-2',
file_id: '901',
name: 'quack',
user_id: 'user-2',
played_at: '2026-09-19T10:00:00Z',
});
});
await waitFor(() => {
expect(audio).toHaveLength(1);
});
expect(audio[0]?.src).toBe('/files/901');
expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent(
'Bob включил звук «quack»',
);
});
it('на 429 показывает обратный отсчёт', async () => {
await bootPanel({
playError: { code: 'rate_limited', status: 429, retryAfterMs: 5_000 },
});
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
expect(await screen.findByTestId('voice-soundboard-limit')).toHaveTextContent(
/Слишком часто: повторите через \d с\./u,
);
// Пока идёт лимит, кнопки проигрывания выключены.
expect(within(panel).getByTestId('voice-soundboard-sound-s-1')).toBeDisabled();
});
it('на voice.not_connected подсказывает войти в комнату', async () => {
await bootPanel({
playError: { code: 'voice.not_connected', status: 403 },
});
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
expect(await screen.findByTestId('voice-soundboard-error')).toHaveTextContent(
'Сначала войдите в голосовую комнату.',
);
});
it('фильтрует звуки по имени и показывает пустой набор', async () => {
await bootPanel();
const panel = await openSoundboard();
await userEvent.type(within(panel).getByTestId('voice-soundboard-search'), 'quack');
expect(within(panel).queryByTestId('voice-soundboard-sound-s-1')).toBeNull();
expect(within(panel).getByTestId('voice-soundboard-sound-s-2')).toBeVisible();
});
it('без звуков показывает понятный текст', async () => {
await bootPanel({ sounds: [] });
const panel = await openSoundboard();
expect(panel).toHaveTextContent('На сервере пока нет звуков саундборда.');
expect(screen.queryByTestId('voice-soundboard-sound-s-1')).toBeNull();
});
it('не играет звуки, когда саундборд выключен в настройках', async () => {
const { sound, audio } = await bootPanel();
useVoiceStore.getState().setSoundboardEnabled(false);
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
// Запрос на сервер уходит, но локально звук не играет: выключено в настройках.
await waitFor(() => {
expect(sound.playCount()).toBe(1);
});
expect(audio).toHaveLength(0);
});
it('не играет звуки при выключенном звуке приложения (дефен)', async () => {
const { sound, audio } = await bootPanel();
// Кнопка «Звук выключен» глушит и саундборд.
useVoiceStore.getState().setHeadphonesEnabled(false);
const panel = await openSoundboard();
await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1'));
await waitFor(() => {
expect(sound.playCount()).toBe(1);
});
expect(audio).toHaveLength(0);
});
it('обновляет набор звуков по событию GUILD_SOUNDS_UPDATE', async () => {
await bootPanel({ sounds: [airhorn] });
expect(await screen.findByTestId('voice-soundboard-button')).toBeVisible();
act(() => {
dispatchGatewayEvent({
op: 0,
t: 'GUILD_SOUNDS_UPDATE',
s: 1,
d: {
guild_id: 'g-1',
sounds: [
{ id: 's-1', name: 'airhorn', file_id: '900', kind: 'soundboard' },
{ id: 's-3', name: 'bell', file_id: '902', kind: 'soundboard' },
],
},
});
});
const panel = await openSoundboard();
await waitFor(() => {
expect(within(panel).getByTestId('voice-soundboard-sound-s-3')).toBeVisible();
});
});
it('играет звук mic_off при выключении своего микрофона', async () => {
const micOff: GuildSound = {
id: 'u-3',
name: 'click',
file_id: '779',
kind: 'ui',
event: 'mic_off',
};
const { audio } = await bootPanel({ sounds: [airhorn, micOff] });
// Кнопка микрофона в панели своей плитки.
const controls = screen.getByLabelText('Управление голосом');
await userEvent.click(within(controls).getByLabelText('Выключить микрофон'));
await waitFor(() => {
expect(audio).toHaveLength(1);
});
expect(audio[0]?.src).toBe('/files/779');
});
});
describe('звуковая палитра: события', () => {
interface MessageInput {
id: string;
content: string;
type?: string;
author_id?: string;
mentions?: string[];
}
/** Событие MESSAGE_CREATE так, как его присылает шлюз. */
function messageCreate(input: MessageInput): void {
dispatchGatewayEvent({
op: 0,
t: 'MESSAGE_CREATE',
s: 1,
d: {
id: input.id,
channel_id: 'c-voice',
content: input.content,
type: input.type ?? 'default',
pinned: false,
attachments: [],
mentions: input.mentions ?? [],
reactions: [],
created_at: '2026-09-19T10:00:00Z',
...(input.author_id === undefined ? {} : { author_id: input.author_id }),
},
});
}
it('играет звук member_join при системном сообщении', async () => {
resetStores();
resetSoundboardEngine();
seedSession([joinSound], ['VIEW_GUILD']);
const audio = installFakeAudio();
messageCreate({
id: 'm-1',
content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }),
type: 'system',
});
await waitFor(() => {
expect(audio).toHaveLength(1);
});
expect(audio[0]?.src).toBe('/files/777');
});
it('играет звук mention при упоминании и молчит на своё сообщение', async () => {
resetStores();
resetSoundboardEngine();
seedSession([mentionSound], ['VIEW_GUILD']);
const audio = installFakeAudio();
messageCreate({
id: 'm-2',
content: 'привет <@user-1>',
author_id: 'user-2',
mentions: ['user-1'],
});
await waitFor(() => {
expect(audio).toHaveLength(1);
});
// Своё сообщение звуком не сопровождается.
messageCreate({ id: 'm-3', content: 'моё', author_id: 'user-1' });
expect(audio).toHaveLength(1);
});
it('не играет звуки событий, когда они выключены в настройках', () => {
resetStores();
resetSoundboardEngine();
seedSession([joinSound], ['VIEW_GUILD']);
const audio = installFakeAudio();
useVoiceStore.getState().setEventSoundsEnabled(false);
messageCreate({
id: 'm-4',
content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }),
type: 'system',
});
expect(audio).toHaveLength(0);
});
it('играет звук voice_join, когда участник входит в мою комнату', async () => {
resetStores();
resetSoundboardEngine();
seedSession(
[{ id: 'u-4', name: 'whoosh', file_id: '780', kind: 'ui', event: 'voice_join' }],
['VIEW_GUILD'],
);
useVoiceConnectionStore.setState({ guildId: 'g-1', channelId: 'c-voice', status: 'connected' });
const audio = installFakeAudio();
act(() => {
dispatchGatewayEvent({
op: 0,
t: 'VOICE_STATE_UPDATE',
s: 1,
d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
});
});
await waitFor(() => {
expect(audio).toHaveLength(1);
});
expect(audio[0]?.src).toBe('/files/780');
});
});
+1
View File
@@ -9,6 +9,7 @@ vi.mock('livekit-client', async () => {
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
+1
View File
@@ -7,6 +7,7 @@ vi.mock('livekit-client', async () => {
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
+219
View File
@@ -0,0 +1,219 @@
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 } },
});
});
});