2026-09-20 01:32:11 +03:00
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
|
|
|
|
|
|
import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
|
|
|
|
|
import { useVoiceStore, VOICE_STORAGE_KEY } from '@/stores/voice';
|
|
|
|
|
|
|
|
|
|
/** Устройства, которые «видит» браузер в тестах. */
|
|
|
|
|
const devices = [
|
|
|
|
|
{ kind: 'audioinput', deviceId: 'mic-1', label: 'Микрофон USB' },
|
|
|
|
|
{ kind: 'audioinput', deviceId: 'mic-2', label: '' },
|
|
|
|
|
{ kind: 'videoinput', deviceId: 'cam-1', label: 'Веб-камера HD' },
|
|
|
|
|
{ kind: 'audiooutput', deviceId: 'out-1', label: 'Наушники' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** Подменяет `navigator.mediaDevices` (в jsdom его нет). */
|
|
|
|
|
function installMediaDevices(list = devices) {
|
|
|
|
|
const mediaDevices = {
|
|
|
|
|
enumerateDevices: vi.fn(() => Promise.resolve(list)),
|
|
|
|
|
addEventListener: vi.fn(),
|
|
|
|
|
removeEventListener: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: mediaDevices });
|
|
|
|
|
return mediaDevices;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
Reflect.deleteProperty(navigator, 'mediaDevices');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
|
|
|
|
// Дополнительные маршруты идут первыми: при одинаковом шаблоне побеждает
|
|
|
|
|
// первый зарегистрированный, поэтому тест может переопределить профиль.
|
|
|
|
|
return [
|
|
|
|
|
...extra,
|
|
|
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
|
|
|
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Панель пользователя после загрузки оболочки. */
|
|
|
|
|
async function bootPanel(extra: FetchRoute[] = []) {
|
|
|
|
|
installFetch(baseRoutes(extra));
|
|
|
|
|
renderApp('/app/empty');
|
|
|
|
|
return screen.findByLabelText('Панель пользователя');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function storedPrefs(): Record<string, unknown> {
|
|
|
|
|
const raw = window.localStorage.getItem(VOICE_STORAGE_KEY);
|
|
|
|
|
return raw === null ? {} : (JSON.parse(raw) as Record<string, unknown>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('панель пользователя: устройства', () => {
|
|
|
|
|
it('кнопки микрофона, камеры и наушников переключают состояние и сохраняют его', async () => {
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
const mic = within(panel).getByLabelText('Выключить микрофон');
|
|
|
|
|
expect(mic).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
|
|
|
|
|
await userEvent.click(mic);
|
|
|
|
|
|
|
|
|
|
// Иконка микрофона перечёркнута, а состояние ушло в localStorage.
|
|
|
|
|
const muted = within(panel).getByLabelText('Включить микрофон');
|
|
|
|
|
expect(muted).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
|
expect(storedPrefs()).toMatchObject({ micEnabled: false });
|
|
|
|
|
|
|
|
|
|
const camera = within(panel).getByLabelText('Включить камеру');
|
|
|
|
|
expect(camera).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
|
await userEvent.click(camera);
|
|
|
|
|
expect(within(panel).getByLabelText('Выключить камеру')).toHaveAttribute(
|
|
|
|
|
'aria-pressed',
|
|
|
|
|
'true',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const headphones = within(panel).getByLabelText('Выключить звук');
|
|
|
|
|
await userEvent.click(headphones);
|
|
|
|
|
expect(within(panel).getByLabelText('Включить звук')).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
|
expect(storedPrefs()).toMatchObject({
|
|
|
|
|
micEnabled: false,
|
|
|
|
|
cameraEnabled: true,
|
|
|
|
|
headphonesEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('стрелка микрофона открывает список устройств ввода и слайдер громкости', async () => {
|
|
|
|
|
installMediaDevices();
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
|
|
|
|
|
|
|
|
|
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
|
|
|
|
expect(await within(popover).findByRole('option', { name: 'Микрофон USB' })).toBeVisible();
|
|
|
|
|
// Устройство без подписи показываем как «Устройство 2».
|
|
|
|
|
expect(within(popover).getByRole('option', { name: 'Устройство 2' })).toBeVisible();
|
|
|
|
|
expect(
|
|
|
|
|
within(popover).getByRole('option', { name: 'Устройство по умолчанию' }),
|
|
|
|
|
).toHaveAttribute('aria-selected', 'true');
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(popover).getByRole('option', { name: 'Микрофон USB' }));
|
|
|
|
|
expect(storedPrefs()).toMatchObject({ micDeviceId: 'mic-1' });
|
|
|
|
|
|
|
|
|
|
const volume = within(popover).getByLabelText('Громкость микрофона');
|
|
|
|
|
fireEvent.change(volume, { target: { value: '42' } });
|
|
|
|
|
expect(storedPrefs()).toMatchObject({ micVolume: 42 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('стрелка камеры показывает устройства videoinput', async () => {
|
|
|
|
|
installMediaDevices();
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки камеры'));
|
|
|
|
|
|
|
|
|
|
const popover = await screen.findByRole('dialog', { name: 'Настройки камеры' });
|
|
|
|
|
expect(await within(popover).findByRole('option', { name: 'Веб-камера HD' })).toBeVisible();
|
|
|
|
|
expect(within(popover).queryByRole('option', { name: 'Микрофон USB' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('стрелка наушников показывает устройства вывода и громкость звука', async () => {
|
|
|
|
|
installMediaDevices();
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки звука'));
|
|
|
|
|
|
|
|
|
|
const popover = await screen.findByRole('dialog', { name: 'Настройки звука' });
|
|
|
|
|
await userEvent.click(await within(popover).findByRole('option', { name: 'Наушники' }));
|
|
|
|
|
expect(storedPrefs()).toMatchObject({ outputDeviceId: 'out-1' });
|
|
|
|
|
|
|
|
|
|
fireEvent.change(within(popover).getByLabelText('Громкость звука'), {
|
|
|
|
|
target: { value: '10' },
|
|
|
|
|
});
|
|
|
|
|
expect(storedPrefs()).toMatchObject({ outputVolume: 10 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('без navigator.mediaDevices показывает «Устройства недоступны»', async () => {
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
|
|
|
|
|
|
|
|
|
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
|
|
|
|
expect(await within(popover).findByText('Устройства недоступны')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ссылка «Аудио-видео настройки» открывает окно настроек на этом пункте', async () => {
|
|
|
|
|
installMediaDevices();
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
|
|
|
|
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
|
|
|
|
await userEvent.click(
|
|
|
|
|
await within(popover).findByRole('button', { name: 'Аудио-видео настройки' }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
|
|
|
|
expect(within(dialog).getByRole('button', { name: 'Аудио-видео' })).toHaveAttribute(
|
|
|
|
|
'aria-current',
|
|
|
|
|
'true',
|
|
|
|
|
);
|
|
|
|
|
// Поповер устройства закрылся.
|
|
|
|
|
expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('поповеры закрываются по Escape и не открываются одновременно', async () => {
|
|
|
|
|
installMediaDevices();
|
|
|
|
|
const panel = await bootPanel();
|
|
|
|
|
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
|
|
|
|
await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
|
|
|
|
|
|
|
|
|
// Открытие второй панели закрывает первую.
|
|
|
|
|
await userEvent.click(within(panel).getByLabelText('Настройки звука'));
|
|
|
|
|
await screen.findByRole('dialog', { name: 'Настройки звука' });
|
|
|
|
|
expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull();
|
|
|
|
|
|
|
|
|
|
await userEvent.keyboard('{Escape}');
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.queryByRole('dialog', { name: 'Настройки звука' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('панель пользователя: профиль', () => {
|
|
|
|
|
it('показывает имя, статус и цветную точку статуса', async () => {
|
|
|
|
|
installFetch(
|
|
|
|
|
baseRoutes([
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/users/@me',
|
|
|
|
|
response: () => json({ user: makeUser({ status: 'idle' }) }),
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
renderApp('/app/empty');
|
|
|
|
|
|
|
|
|
|
const panel = await screen.findByLabelText('Панель пользователя');
|
|
|
|
|
expect(within(panel).getByText('Alice')).toBeVisible();
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(within(panel).getByText('Неактивен')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
expect(within(panel).getByTestId('user-status-dot')).toHaveClass('bg-warning');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('сохраняет предпочтения устройств в localStorage', async () => {
|
|
|
|
|
await bootPanel();
|
|
|
|
|
|
|
|
|
|
useVoiceStore.getState().toggleMic();
|
|
|
|
|
useVoiceStore.getState().setOutputVolume(30);
|
|
|
|
|
|
|
|
|
|
expect(storedPrefs()).toMatchObject({
|
|
|
|
|
micEnabled: false,
|
|
|
|
|
outputVolume: 30,
|
|
|
|
|
headphonesEnabled: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('читает сохранённые предпочтения при загрузке модуля', async () => {
|
|
|
|
|
window.localStorage.setItem(
|
|
|
|
|
VOICE_STORAGE_KEY,
|
|
|
|
|
JSON.stringify({ micEnabled: false, micVolume: 20 }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
const fresh = await import('@/stores/voice');
|
|
|
|
|
|
|
|
|
|
expect(fresh.useVoiceStore.getState().micEnabled).toBe(false);
|
|
|
|
|
expect(fresh.useVoiceStore.getState().micVolume).toBe(20);
|
|
|
|
|
// Неизвестные поля берут значения по умолчанию.
|
|
|
|
|
expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-20 14:35:08 +03:00
|
|
|
|
|
|
|
|
describe('панель: кнопки устройств', () => {
|
|
|
|
|
it('стрелки — прямоугольники во всю высоту кнопки и открывают меню', async () => {
|
|
|
|
|
const panelRoot = await bootPanel();
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
|
|
|
|
|
// Стрелка микрофона доступна по подписи и лежит в одной группе с кнопкой.
|
|
|
|
|
const arrow = within(panelRoot).getByRole('button', { name: 'Настройки микрофона' });
|
|
|
|
|
const group = arrow.parentElement;
|
|
|
|
|
expect(group).not.toBeNull();
|
|
|
|
|
expect(group?.className).toContain('h-8');
|
|
|
|
|
expect(group?.className).toContain('w-9');
|
|
|
|
|
// Кнопка-переключатель — сосед стрелки в той же группе.
|
|
|
|
|
expect(group?.querySelectorAll('button')).toHaveLength(2);
|
|
|
|
|
|
|
|
|
|
await visitor.click(arrow);
|
|
|
|
|
const panel = await screen.findByTestId('popover-panel');
|
2026-09-20 14:36:32 +03:00
|
|
|
// Панель прижата к рабочей области: класс с fixed-позиционированием,
|
|
|
|
|
// ограничение высоты и признак готовой раскладки.
|
|
|
|
|
expect(panel.className).toContain('gl-anchored-panel');
|
|
|
|
|
expect(panel.dataset.positioned).toBe('true');
|
2026-09-20 14:35:08 +03:00
|
|
|
expect(panel.style.maxHeight).not.toBe('');
|
|
|
|
|
expect(panel.getAttribute('role')).toBe('dialog');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('попап настроек звука не выходит за пределы окна', async () => {
|
|
|
|
|
const panelRoot = await bootPanel();
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
|
|
|
|
|
await visitor.click(within(panelRoot).getByRole('button', { name: 'Настройки звука' }));
|
|
|
|
|
const panel = await screen.findByTestId('popover-panel');
|
|
|
|
|
const left = Number.parseInt(panel.style.left, 10);
|
|
|
|
|
const top = Number.parseInt(panel.style.top, 10);
|
|
|
|
|
expect(left).toBeGreaterThanOrEqual(0);
|
|
|
|
|
expect(top).toBeGreaterThanOrEqual(0);
|
|
|
|
|
expect(left).toBeLessThan(window.innerWidth);
|
|
|
|
|
expect(top).toBeLessThan(window.innerHeight);
|
|
|
|
|
});
|
|
|
|
|
});
|