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(); // ID пользователя в интерфейсе не показываем и не копируем. expect(within(menu).queryByRole('menuitem', { name: 'Скопировать ID' })).toBeNull(); expect(menu).not.toHaveTextContent(user.id); }); 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(); }); });