2026-09-20 12:06:22 +03:00
|
|
|
|
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 },
|
|
|
|
|
|
],
|
2026-09-20 18:13:04 +03:00
|
|
|
|
emojis: [],
|
2026-09-20 19:30:43 +03:00
|
|
|
|
sounds: [],
|
2026-09-20 12:06:22 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const authors: AuthorDirectory = {
|
|
|
|
|
|
resolve: () => ({
|
|
|
|
|
|
id: '102',
|
|
|
|
|
|
name: 'Боб',
|
|
|
|
|
|
avatarFileId: undefined,
|
|
|
|
|
|
colorHex: null,
|
|
|
|
|
|
isInstanceAdmin: false,
|
|
|
|
|
|
isMember: true,
|
|
|
|
|
|
}),
|
2026-09-21 00:26:50 +03:00
|
|
|
|
resolveWebhook: () => ({
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
name: 'вебхук',
|
|
|
|
|
|
avatarFileId: undefined,
|
|
|
|
|
|
colorHex: null,
|
|
|
|
|
|
isInstanceAdmin: false,
|
|
|
|
|
|
isMember: false,
|
|
|
|
|
|
}),
|
2026-09-20 12:06:22 +03:00
|
|
|
|
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(
|
|
|
|
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
|
|
|
|
<Composer
|
|
|
|
|
|
channel={channel}
|
|
|
|
|
|
guildId={guildId}
|
|
|
|
|
|
authors={authors}
|
|
|
|
|
|
direct={channel.type === 'dm'}
|
|
|
|
|
|
replyTo={null}
|
|
|
|
|
|
onCancelReply={() => undefined}
|
|
|
|
|
|
disabledReason={null}
|
|
|
|
|
|
canAttach
|
|
|
|
|
|
/>
|
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
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('@Боб');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|