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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user