Files
glchat/web/tests/userPanel.test.tsx
grendervill 389fdd5f9a fix(web): панели не уезжают за экран (правка после замечания линтера)
Позиционирование переведено на прямые стили и класс `gl-anchored-panel`:
React не управляет left/top/max-height, поэтому повторный рендер не сбрасывает
раскладку, а `data-placement` задаёт направление анимации (панель может
развернуться вниз, если сверху мало места). Линтер по хукам чистый, 289 тестов
зелёные.
2026-09-20 14:36:32 +03:00

269 lines
12 KiB
TypeScript

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);
});
});
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');
// Панель прижата к рабочей области: класс с fixed-позиционированием,
// ограничение высоты и признак готовой раскладки.
expect(panel.className).toContain('gl-anchored-panel');
expect(panel.dataset.positioned).toBe('true');
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);
});
});