2026-09-19 23:36:16 +03:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
const user = makeUser({ bio: 'Пишу код и тесты' });
|
2026-09-19 23:36:16 +03:00
|
|
|
|
|
|
|
|
|
|
/** Базовые ответы для оболочки приложения без серверов. */
|
|
|
|
|
|
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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
/** Открывает мини-профиль кликом по аватару/имени. */
|
2026-09-19 23:36:16 +03:00
|
|
|
|
async function openMenu() {
|
|
|
|
|
|
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
|
|
|
|
|
await userEvent.click(trigger);
|
|
|
|
|
|
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
|
|
|
|
|
|
return { trigger, menu };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
/** Открывает пункт меню и возвращает окно настроек. */
|
|
|
|
|
|
async function openSettings(item: string) {
|
|
|
|
|
|
const { menu } = await openMenu();
|
|
|
|
|
|
await userEvent.click(within(menu).getByRole('menuitem', { name: item }));
|
|
|
|
|
|
return screen.findByRole('dialog', { name: 'Настройки' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
describe('меню пользователя', () => {
|
2026-09-20 01:32:11 +03:00
|
|
|
|
it('мини-профиль открывается по клику на аватар и показывает данные и статусы', async () => {
|
2026-09-19 23:36:16 +03:00
|
|
|
|
installFetch(baseRoutes());
|
|
|
|
|
|
|
|
|
|
|
|
renderApp('/app/empty');
|
|
|
|
|
|
const { menu } = await openMenu();
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
// Аватар, имя, логин и «О себе».
|
|
|
|
|
|
expect(within(menu).getByText('Alice')).toBeVisible();
|
|
|
|
|
|
expect(within(menu).getByText('@alice')).toBeVisible();
|
|
|
|
|
|
expect(within(menu).getByText('Пишу код и тесты')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Статусы с подписями и цветными точками.
|
2026-09-19 23:36:16 +03:00
|
|
|
|
expect(
|
|
|
|
|
|
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
|
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
|
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible();
|
2026-09-20 01:32:11 +03:00
|
|
|
|
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();
|
2026-09-19 23:36:16 +03:00
|
|
|
|
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
2026-09-20 11:24:09 +03:00
|
|
|
|
|
|
|
|
|
|
// ID пользователя в интерфейсе не показываем и не копируем.
|
|
|
|
|
|
expect(within(menu).queryByRole('menuitem', { name: 'Скопировать ID' })).toBeNull();
|
|
|
|
|
|
expect(menu).not.toHaveTextContent(user.id);
|
2026-09-19 23:36:16 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
it('шестерёнка открывает окно настроек', async () => {
|
2026-09-19 23:36:16 +03:00
|
|
|
|
installFetch(baseRoutes());
|
|
|
|
|
|
|
|
|
|
|
|
renderApp('/app/empty');
|
|
|
|
|
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
|
|
|
|
|
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
|
|
|
|
|
|
'aria-selected',
|
|
|
|
|
|
'true',
|
|
|
|
|
|
);
|
|
|
|
|
|
// Мини-профиль при этом не открывается.
|
|
|
|
|
|
expect(screen.queryByRole('menu', { name: 'Меню пользователя' })).toBeNull();
|
2026-09-19 23:36:16 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
it('пункт «Настройки аккаунта» открывает окно на «Профиль → Основной»', async () => {
|
2026-09-19 23:36:16 +03:00
|
|
|
|
installFetch(baseRoutes());
|
|
|
|
|
|
|
|
|
|
|
|
const { router } = renderApp('/app/empty');
|
2026-09-20 01:32:11 +03:00
|
|
|
|
const dialog = await openSettings('Настройки аккаунта');
|
2026-09-19 23:36:16 +03:00
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
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');
|
2026-09-19 23:36:16 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
it('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => {
|
2026-09-19 23:36:16 +03:00
|
|
|
|
installFetch(baseRoutes());
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
renderApp('/app/empty');
|
|
|
|
|
|
const dialog = await openSettings('Настройки серверов');
|
2026-09-19 23:36:16 +03:00
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
|
|
|
|
|
'aria-selected',
|
|
|
|
|
|
'true',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
|
|
|
|
|
|
).toBeVisible();
|
2026-09-19 23:36:16 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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}');
|
|
|
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
|
// Первый пункт мини-профиля — выбор статуса.
|
|
|
|
|
|
const first = await screen.findByRole('menuitemradio', { name: 'В сети' });
|
2026-09-19 23:36:16 +03:00
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(first).toHaveFocus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await userEvent.keyboard('{ArrowDown}');
|
2026-09-20 01:32:11 +03:00
|
|
|
|
expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus();
|
2026-09-19 23:36:16 +03:00
|
|
|
|
|
|
|
|
|
|
await userEvent.keyboard('{ArrowUp}');
|
2026-09-20 01:32:11 +03:00
|
|
|
|
expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus();
|
2026-09-19 23:36:16 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|