Files
glchat/web/tests/soundboard.test.tsx
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

483 lines
16 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 } 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');
});
});