Files
glchat/web/tests/userMenu.test.tsx
T
grendervill 20eed227ca feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
2026-09-20 01:32:11 +03:00

202 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
import type { FetchRoute } from './helpers';
const user = makeUser({ bio: 'Пишу код и тесты' });
/** Базовые ответы для оболочки приложения без серверов. */
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
...extra,
];
}
/** Открывает мини-профиль кликом по аватару/имени. */
async function openMenu() {
const trigger = await screen.findByLabelText('Открыть меню пользователя');
await userEvent.click(trigger);
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
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 () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { menu } = await openMenu();
// Аватар, имя, логин и «О себе».
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 () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
'aria-selected',
'true',
);
// Мини-профиль при этом не открывается.
expect(screen.queryByRole('menu', { name: 'Меню пользователя' })).toBeNull();
});
it('пункт «Настройки аккаунта» открывает окно на «Профиль → Основной»', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const dialog = await openSettings('Настройки аккаунта');
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('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const dialog = await openSettings('Настройки серверов');
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
).toBeVisible();
});
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
const fetchMock = installFetch(
baseRoutes([
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, status: 'invisible' } }),
},
]),
);
renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitemradio', { name: 'Невидимый' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
status: 'invisible',
});
});
// Меню закрывается, а статус в панели обновляется из ответа сервера.
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(screen.getByLabelText('Панель пользователя')).toHaveTextContent('Невидимый');
});
it('«Выйти» вызывает POST /auth/logout и уводит на /login', async () => {
const fetchMock = installFetch(
baseRoutes([
{ match: '/api/v1/auth/logout', method: 'POST', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Выйти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout') && request.method === 'POST',
),
).toBe(true);
});
it('закрывается по Escape и возвращает фокус на кнопку', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { trigger } = await openMenu();
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(trigger).toHaveFocus();
});
it('закрывается по клику вне меню', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await openMenu();
await userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
});
it('доступно с клавиатуры: фокус на первом пункте и стрелки', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const trigger = await screen.findByLabelText('Открыть меню пользователя');
trigger.focus();
await userEvent.keyboard('{Enter}');
// Первый пункт мини-профиля — выбор статуса.
const first = await screen.findByRole('menuitemradio', { name: 'В сети' });
await waitFor(() => {
expect(first).toHaveFocus();
});
await userEvent.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus();
});
});