import { beforeEach, describe, expect, it } from 'vitest'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClientProvider } from '@tanstack/react-query'; import type { Channel, GuildMember, UserSummary } from '@/api/types'; import { Composer } from '@/components/chat/Composer'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import type { SessionGuild } from '@/stores/session'; import { useSessionStore } from '@/stores/session'; import { createTestQueryClient, findRequest, installFetch, installGatewaySocket, json, makeMessage, makeReadySnapshot, makeUser, messagesRoutes, renderApp, resetStores, type FetchRoute, } from './helpers'; /** * Автокомплиты композера: `@` — участники, `#` — комнаты, `:` — эмодзи, * `/` — команды. Проверяем появление по триггеру, фильтр, вставку, навигацию * с клавиатуры и правила контекста (слово, код, начало сообщения). */ const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 }; const dmChannel: Channel = { id: 'dm-1', name: 'Боб', type: 'dm', position: 0, recipient_id: '102', }; const members: GuildMember[] = [ { user_id: '101', username: 'alice', display_name: 'Аля', status: 'online', is_instance_admin: false, joined_at: '2026-09-01T10:00:00Z', role_ids: [], }, { user_id: '102', username: 'bob', display_name: 'Боб', status: 'online', is_instance_admin: false, joined_at: '2026-09-02T10:00:00Z', role_ids: [], }, { user_id: '103', username: 'carol', display_name: 'Кэрол', status: 'online', is_instance_admin: false, joined_at: '2026-09-03T10:00:00Z', role_ids: [], }, ]; const recipient: UserSummary = { id: '102', username: 'bob', display_name: 'Боб', is_instance_admin: false, badges: [], }; const guild: SessionGuild = { id: 'g-1', name: 'Сервер', owner_id: 'user-1', is_main: false, member_count: 3, my_role_ids: [], my_permissions: [], channels: [ textChannel, { id: 'c-2', name: 'новости', type: 'text', position: 1 }, { id: 'c-3', name: 'Голосовая', type: 'voice', position: 2 }, { id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false }, ], emojis: [], }; const authors: AuthorDirectory = { resolve: () => ({ id: '102', name: 'Боб', avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, isMember: true, }), resolveMention: (userId) => ({ name: members.find((member) => member.user_id === userId)?.display_name ?? userId, isMe: false, }), loaded: true, }; function renderComposer(channel: Channel = textChannel, guildId: string | null = 'g-1') { const routes: FetchRoute[] = [ { match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, { match: '/api/v1/users/102', response: () => json({ user: recipient }) }, { match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) }, { match: '/api/v1/channels/c-1/messages', method: 'POST', response: () => json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }), }, ]; const fetchMock = installFetch(routes); const view = render( undefined} disabledReason={null} canAttach /> , ); return { ...view, fetchMock }; } /** Поле ввода: у комнаты подпись «Сообщение в комнату …», у беседы — «… для …». */ function field(name = 'Сообщение в комнату общий'): HTMLTextAreaElement { return screen.getByLabelText(name); } /** Слой подсветки: вставка обязана обновить и его. */ function layer(): HTMLElement { return screen.getByTestId('composer-mirror'); } /** Список подсказок (может ещё не появиться). */ function listbox(): HTMLElement | null { return screen.queryByTestId('composer-autocomplete'); } /** * Ставит каретку и сообщает об этом React — так же, как это делает браузер * (`select` синтезируется из `keyup`/`selectionchange`). */ function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void { input.setSelectionRange(start, end); fireEvent.select(input); fireEvent.keyUp(input); } /** Ввод значения без набора по символу: для проверок контекста. */ function setValue(input: HTMLTextAreaElement, value: string): void { fireEvent.change(input, { target: { value } }); placeCaret(input, value.length); } beforeEach(() => { resetStores(); useSessionStore.setState({ guilds: [guild] }); }); describe('автокомплит: участники (@)', () => { it('открывает список с именами и логинами, фильтрует и вставляет <@id>', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); expect(input).toHaveAttribute('aria-expanded', 'false'); await visitor.type(input, '@'); const list = await screen.findByTestId('composer-autocomplete'); const box = within(list).getByRole('listbox'); expect(box).toHaveAttribute('aria-label', 'Участники сервера'); // Список раскрывается над полем ввода и не перекрывает текст. expect(list).toHaveClass('bottom-full', 'gl-popover'); expect(input).toHaveAttribute('aria-expanded', 'true'); expect(input).toHaveAttribute('aria-controls', box.id); // Активный вариант помечен и связан с полем через aria-activedescendant. const active = list.querySelector('[aria-selected="true"]'); expect(active).not.toBeNull(); expect(input).toHaveAttribute('aria-activedescendant', active?.id ?? ''); // Показываем имя и @логин. expect(within(list).getByText('Боб')).toBeVisible(); expect(within(list).getByText('@bob')).toBeVisible(); expect(within(list).getByText('Кэрол')).toBeVisible(); // Фильтр по префиксу логина. await visitor.type(input, 'bo'); expect(within(list).getByText('Боб')).toBeVisible(); expect(within(list).queryByText('Кэрол')).toBeNull(); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('<@102>'); // Каретка сразу после вставленного фрагмента — подсветка не разъезжается. expect(input.selectionStart).toBe('<@102>'.length); expect(layer().textContent).toBe('@Боб'); expect(listbox()).toBeNull(); expect(input).toHaveAttribute('aria-expanded', 'false'); }); it('фильтрует по отображаемому имени и не срабатывает внутри слова', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '@кэр'); const list = await screen.findByTestId('composer-autocomplete'); expect(within(list).getByText('Кэрол')).toBeVisible(); expect(within(list).queryByText('Боб')).toBeNull(); await visitor.keyboard('{Escape}'); setValue(input, 'почта@пример'); expect(listbox()).toBeNull(); // Внутри кода упоминание — обычный текст. setValue(input, '`@bo`'); expect(listbox()).toBeNull(); }); it('вставляет выбранный стрелками вариант и закрывается по Escape', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '@'); const list = await screen.findByTestId('composer-autocomplete'); // ↓ — второй вариант, ↑ — возврат к первому. await visitor.keyboard('{ArrowDown}'); const second = list.querySelectorAll('[role="option"]')[1]; expect(second).toHaveAttribute('aria-selected', 'true'); expect(input).toHaveAttribute('aria-activedescendant', second?.id ?? ''); await visitor.keyboard('{ArrowUp}'); expect(list.querySelectorAll('[role="option"]')[0]).toHaveAttribute('aria-selected', 'true'); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('<@101>'); // Escape закрывает подсказку и она не всплывает снова на том же триггере. setValue(input, '@'); await screen.findByTestId('composer-autocomplete'); await visitor.keyboard('{Escape}'); expect(listbox()).toBeNull(); await visitor.type(input, 'bo'); expect(listbox()).toBeNull(); // Новый триггер после стёртого текста снова открывает список. await visitor.clear(input); await visitor.type(input, '@b'); expect(await screen.findByTestId('composer-autocomplete')).toBeVisible(); }); it('вставляет вариант по клику мышью', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '@bo'); const list = await screen.findByTestId('composer-autocomplete'); await visitor.click(within(list).getByText('@bob')); expect(input).toHaveValue('<@102>'); expect(listbox()).toBeNull(); }); it('Tab вставляет, а Enter с открытым списком не отправляет сообщение', async () => { const visitor = userEvent.setup(); const { fetchMock } = renderComposer(); const input = field(); await visitor.type(input, '@bo'); await screen.findByTestId('composer-autocomplete'); await visitor.keyboard('{Tab}'); expect(input).toHaveValue('<@102>'); expect( findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }), ).toBeUndefined(); }); it('в личной беседе предлагает собеседника', async () => { const visitor = userEvent.setup(); renderComposer(dmChannel, null); const input = field('Сообщение для Боб'); await visitor.type(input, '@'); expect(await screen.findByText('@bob')).toBeVisible(); expect(screen.queryByText('Кэрол')).toBeNull(); }); }); describe('автокомплит: комнаты (#)', () => { it('показывает видимые текстовые комнаты и вставляет <#id>', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '#'); const list = await screen.findByTestId('composer-autocomplete'); expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Комнаты сервера'); expect(within(list).getByText('#общий')).toBeVisible(); expect(within(list).getByText('#новости')).toBeVisible(); // Голосовая комната и скрытая в подсказках не участвуют. expect(within(list).queryByText('#Голосовая')).toBeNull(); expect(within(list).queryByText('#скрытая')).toBeNull(); await visitor.type(input, 'нов'); expect(within(list).queryByText('#общий')).toBeNull(); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('<#c-2>'); expect(input.selectionStart).toBe('<#c-2>'.length); // Слой подсветки показывает имя комнаты из снапшота. expect(layer().textContent).toBe('#новости'); }); it('не срабатывает внутри слова', () => { renderComposer(); const input = field(); setValue(input, 'задача#12'); expect(listbox()).toBeNull(); }); }); describe('автокомплит: эмодзи (:)', () => { it('показывает символ и код, вставляет эмодзи', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, ':fir'); const list = await screen.findByTestId('composer-autocomplete'); expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Эмодзи'); expect(within(list).getByText(':fire:')).toBeVisible(); expect(within(list).getByText('🔥')).toBeVisible(); await visitor.type(input, 'ezzz'); expect(listbox()).toBeNull(); setValue(input, ':fir'); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('🔥'); expect(input.selectionStart).toBe(2); }); it('не срабатывает внутри кода', () => { renderComposer(); const input = field(); setValue(input, '`код :fir`'); expect(listbox()).toBeNull(); setValue(input, 'текст\n```\n:fir\n```'); placeCaret(input, 'текст\n```\n:fir'.length); expect(listbox()).toBeNull(); // Вне кода подсказка снова работает. setValue(input, 'текст :fir'); expect(listbox()).not.toBeNull(); }); }); describe('автокомплит: команды (/)', () => { it('показывает команды с описанием и вставляет команду с пробелом', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '/'); const list = await screen.findByTestId('composer-autocomplete'); expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Команды'); expect(within(list).getByText('/me')).toBeVisible(); expect(within(list).getByText('действие: имя и текст курсивом')).toBeVisible(); await visitor.type(input, 'sc'); expect(within(list).queryByText('/me')).toBeNull(); expect(within(list).getByText('/scream')).toBeVisible(); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('/scream '); expect(input.selectionStart).toBe('/scream '.length); }); it('срабатывает только первым символом сообщения', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); setValue(input, 'путь /me'); expect(listbox()).toBeNull(); // `//` экранирует слэш — это обычный текст. setValue(input, '//me'); expect(listbox()).toBeNull(); await visitor.clear(input); await visitor.type(input, '/me'); expect(await screen.findByTestId('composer-autocomplete')).toBeVisible(); }); }); describe('автокомплит: закрытие по потере контекста', () => { it('пробел после запроса закрывает список', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '@bo'); await screen.findByTestId('composer-autocomplete'); await visitor.type(input, ' '); await waitFor(() => { expect(listbox()).toBeNull(); }); }); it('отправка закрывает список и очищает поле', async () => { const visitor = userEvent.setup(); const { fetchMock } = renderComposer(); const input = field(); await visitor.type(input, 'привет'); await visitor.keyboard('{Enter}'); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }), ).toBeDefined(); }); await waitFor(() => { expect(input).toHaveValue(''); }); expect(listbox()).toBeNull(); }); }); describe('автокомплит в приложении', () => { it('предлагает участников сервера в настоящем чате и вставляет упоминание', async () => { const visitor = userEvent.setup(); installFetch([ { 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' } }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) }, ...messagesRoutes('c-1', []), ]); const gateway = installGatewaySocket( makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, my_permissions: ['SEND_MESSAGES'], channels: [{ id: 'c-1', name: 'общий' }], }, ]), ); renderApp('/app/g-1/c-1'); await screen.findByTestId('guild-rail'); await gateway.greet(); const input = await screen.findByLabelText('Сообщение в комнату общий'); await visitor.type(input, '@б'); expect(await screen.findByTestId('composer-autocomplete')).toBeVisible(); await visitor.keyboard('{Enter}'); expect(input).toHaveValue('<@102>'); expect(screen.getByTestId('composer-mirror').textContent).toBe('@Боб'); }); });