feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+229
View File
@@ -0,0 +1,229 @@
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);
});
});