Files
glchat/web/tests/autocomplete.test.tsx
T
grendervill ca34d05377 fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
2026-09-20 18:13:04 +03:00

494 lines
18 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 { 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(
<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('@Боб');
});
});