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
+53 -25
View File
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
import type { FetchRoute } from './helpers';
const user = makeUser();
const user = makeUser({ bio: 'Пишу код и тесты' });
/** Базовые ответы для оболочки приложения без серверов. */
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
@@ -17,7 +17,7 @@ function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
];
}
/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */
/** Открывает мини-профиль кликом по аватару/имени. */
async function openMenu() {
const trigger = await screen.findByLabelText('Открыть меню пользователя');
await userEvent.click(trigger);
@@ -25,55 +25,82 @@ async function openMenu() {
return { trigger, menu };
}
/** Открывает пункт меню и возвращает окно настроек. */
async function openSettings(item: string) {
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: item }));
return screen.findByRole('dialog', { name: 'Настройки' });
}
describe('меню пользователя', () => {
it('открывается по клику на аватар и содержит все пункты', async () => {
it('мини-профиль открывается по клику на аватар и показывает данные и статусы', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { menu } = await openMenu();
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
// Аватар, имя, логин и «О себе».
expect(within(menu).getByText('Alice')).toBeVisible();
expect(within(menu).getByText('@alice')).toBeVisible();
expect(within(menu).getByText('Пишу код и тесты')).toBeVisible();
// Статусы с подписями и цветными точками.
expect(
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
).toBeVisible();
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible();
expect(within(menu).getByRole('menuitemradio', { name: 'Неактивен' })).toBeVisible();
expect(within(menu).getByRole('menuitemradio', { name: 'Невидимый' })).toBeVisible();
expect(menu.querySelectorAll('[role="menuitemradio"] .rounded-full')).toHaveLength(4);
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
});
it('шестерёнка открывает то же меню', async () => {
it('шестерёнка открывает окно настроек', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
expect(await screen.findByRole('menu', { name: 'Меню пользователя' })).toBeVisible();
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
'aria-selected',
'true',
);
// Мини-профиль при этом не открывается.
expect(screen.queryByRole('menu', { name: 'Меню пользователя' })).toBeNull();
});
it('пункт «Настройки аккаунта» ведёт в /settings/account/profile', async () => {
it('пункт «Настройки аккаунта» открывает окно на «Профиль → Основной»', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' }));
const dialog = await openSettings('Настройки аккаунта');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/account/profile');
});
expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible();
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(within(dialog).getByRole('heading', { name: 'Профиль' })).toBeVisible();
// Из приложения никуда не уходим: настройки — модальное окно.
expect(router.state.location.pathname).toBe('/app/empty');
});
it('пункт «Настройки серверов» ведёт в /settings/servers', async () => {
it('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
renderApp('/app/empty');
const dialog = await openSettings('Настройки серверов');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible();
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
).toBeVisible();
});
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
@@ -159,15 +186,16 @@ describe('меню пользователя', () => {
trigger.focus();
await userEvent.keyboard('{Enter}');
const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' });
// Первый пункт мини-профиля — выбор статуса.
const first = await screen.findByRole('menuitemradio', { name: 'В сети' });
await waitFor(() => {
expect(first).toHaveFocus();
});
await userEvent.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus();
expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus();
expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus();
});
});