feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты

- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 12:06:22 +03:00
parent 65d0bafe0e
commit a126f50468
22 changed files with 1826 additions and 116 deletions
+492
View File
@@ -0,0 +1,492 @@
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 },
],
};
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('@Боб');
});
});
+8
View File
@@ -57,6 +57,14 @@ describe('группировка сообщений', () => {
);
});
it('не присоединяет обычное сообщение к действию, шёпоту и личному', () => {
const next = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 1) });
for (const type of ['action', 'whisper', 'scream', 'private', 'system']) {
const previous = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0), type });
expect(canGroup(previous, next)).toBe(false);
}
});
it('строит строки ленты с разделителями дат', () => {
const rows = buildChatRows(
[
+1
View File
@@ -52,6 +52,7 @@ function renderComposer() {
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={channel}
guildId={null}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
+29
View File
@@ -158,4 +158,33 @@ describe('разметка: упоминания каналов', () => {
expect(container.querySelectorAll('span')).toHaveLength(0);
expect(container.textContent).toBe('см. #общий');
});
it('рендерит ссылку на комнату <#id> именем из снапшота, иначе #id', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = markup('см. <#c-1> и <#999>');
const spans = Array.from(container.querySelectorAll('span'));
expect(spans.some((span) => span.textContent === '#общий')).toBe(true);
expect(spans.some((span) => span.textContent === '#999')).toBe(true);
// Угловые скобки не остаются текстом.
expect(container.textContent).toBe('см. #общий и #999');
});
it('не ломает разметку рядом с упоминанием, ссылкой и кодом', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = render(
<MessageContent
content={'<@42> пишет в <#c-1>: https://example.com/#якорь\n`<#c-1>`'}
resolveMention={() => ({ name: 'Аля', isMe: false })}
/>,
);
expect(container.textContent).toBe('@Аля пишет в #общий: https://example.com/#якорь\n<#c-1>');
// Ссылка осталась ссылкой целиком, вместе с якорем.
expect(container.querySelector('a')).toHaveAttribute('href', 'https://example.com/#якорь');
// Внутри кода разметка не разбирается.
expect(container.querySelector('code')).toHaveTextContent('<#c-1>');
});
});
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Message } from '@/api/messages';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
/**
* Виды сообщений из контракта (AGENT.md §7.6): обычное, действие `/me`, шёпот,
* крик, личное `/ls` и системное. Лента оформляет их по-разному, но реакции,
* ответы и правка работают у всех, кроме системных.
*/
const authors: AuthorDirectory = {
resolve: (userId) => ({
id: userId ?? '',
name: userId === 'user-1' ? 'Аля' : 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
loaded: true,
};
function message(overrides: Partial<Message> & { id: string }): Message {
return {
channel_id: 'c-1',
author_id: 'user-2',
content: 'текст',
type: 'default',
pinned: false,
attachments: [],
mentions: [],
reactions: [],
created_at: '2026-09-19T10:00:00Z',
...overrides,
};
}
function renderList(messages: Message[], onReply = vi.fn()) {
const view = render(
<MessageList
channelId="c-1"
channelName="общий"
messages={messages}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={onReply}
/>,
);
return { ...view, onReply };
}
/** Сообщение ленты по id. */
function article(id: string): HTMLElement {
return screen.getByTestId(`message-${id}`);
}
/** Панель действий: реакции, ответ и меню. */
function expectActions(id: string): void {
const body = article(id);
expect(within(body).getByRole('button', { name: 'Поставить реакцию 👍' })).toBeVisible();
expect(within(body).getByRole('button', { name: 'Ответить' })).toBeVisible();
expect(within(body).getByRole('button', { name: 'Действия с сообщением' })).toBeVisible();
}
describe('виды сообщений: оформление', () => {
it('действие /me показывает имя акцентом, а текст курсивом', () => {
renderList([message({ id: 'm-1', type: 'action', content: 'танцует' })]);
const body = article('m-1');
expect(body).toHaveAttribute('data-message-type', 'action');
expect(body).toHaveTextContent('Bobтанцует');
// Имя автора — акцентным цветом, вся строка — курсивом.
expect(within(body).getByText('Bob')).toHaveClass('text-accent');
expect(body.querySelector('.italic')).not.toBeNull();
// Шапки группы нет: имя рисует само действие, а не заголовок.
expect(within(body).getAllByText('Bob')).toHaveLength(1);
expectActions('m-1');
});
it('шёпот приглушён, помечен подписью и остаётся обычным сообщением', () => {
renderList([message({ id: 'm-1', type: 'whisper', content: 'только между нами' })]);
const body = article('m-1');
expect(body).toHaveAttribute('data-message-type', 'whisper');
expect(within(body).getByText('шёпот')).toBeVisible();
// Приглушённый блок: полупрозрачный фон и курсив.
const block = body.querySelector('.bg-surface-2\\/50');
expect(block).not.toBeNull();
expect(block).toHaveTextContent('только между нами');
expect(body.querySelector('.italic')).not.toBeNull();
// Шапка автора на месте (она часть строки группы, а не самого сообщения).
expect(within(screen.getByTestId('message-group')).getByText('Bob')).toBeVisible();
expectActions('m-1');
});
it('крик крупнее обычного текста и помечен подписью «громко»', () => {
renderList([message({ id: 'm-1', type: 'scream', content: 'ОЧЕНЬ ГРОМКО' })]);
const body = article('m-1');
expect(body).toHaveAttribute('data-message-type', 'scream');
expect(within(body).getByText('громко')).toBeVisible();
const text = body.querySelector('.text-base');
expect(text).toHaveTextContent('ОЧЕНЬ ГРОМКО');
expect(text).toHaveClass('font-semibold', 'text-accent');
expectActions('m-1');
});
it('личное сообщение выделено фоном с акцентом, замком и подписью «лично»', () => {
renderList([message({ id: 'm-1', type: 'private', content: 'секрет для тебя' })]);
const body = article('m-1');
expect(body).toHaveAttribute('data-message-type', 'private');
expect(within(body).getByText('лично')).toBeVisible();
expect(body.textContent).toContain('🔒');
const block = body.querySelector('.bg-accent\\/10');
expect(block).not.toBeNull();
expect(block).toHaveTextContent('секрет для тебя');
expectActions('m-1');
});
it('системное сообщение центрировано, без аватара и панели действий', () => {
// У системного сообщения автора может не быть вовсе.
const system: Message = {
id: 'm-1',
channel_id: 'c-1',
content: 'Аля вошла',
type: 'system',
pinned: false,
attachments: [],
mentions: [],
reactions: [],
created_at: '2026-09-19T10:00:00Z',
};
renderList([system]);
const body = article('m-1');
expect(body).toHaveAttribute('data-message-type', 'system');
expect(body).toHaveClass('items-center', 'text-center');
expect(within(body).getByText('Системное сообщение')).toHaveClass('sr-only');
expect(body).toHaveTextContent('Аля вошла');
// Ни реакций, ни ответа, ни меню.
expect(within(body).queryByRole('button')).toBeNull();
// И ни аватара, ни шапки автора в строке группы.
const group = screen.getByTestId('message-group');
expect(group).toHaveAttribute('data-message-type', 'system');
expect(within(group).queryByText('Bob')).toBeNull();
expect(group.querySelector('.inline-flex.shrink-0')).toBeNull();
});
});
describe('виды сообщений: действия', () => {
it('ответ доступен у действия, шёпота, крика и личного сообщения', async () => {
const visitor = userEvent.setup();
const types = ['action', 'whisper', 'scream', 'private'] as const;
const { onReply } = renderList(
types.map((type, index) => message({ id: `m-${String(index)}`, type, content: 'текст' })),
);
for (const [index, type] of types.entries()) {
const id = `m-${String(index)}`;
expect(article(id)).toHaveAttribute('data-message-type', type);
await visitor.click(within(article(id)).getByRole('button', { name: 'Ответить' }));
expect(onReply).toHaveBeenLastCalledWith(expect.objectContaining({ id, type }));
}
});
it('обычное сообщение после системного не сливается с ним в группу', () => {
renderList([
message({ id: 'm-1', type: 'system', content: 'Аля вошла' }),
message({ id: 'm-2', type: 'default', content: 'привет' }),
]);
const groups = screen.getAllByTestId('message-group');
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveAttribute('data-message-type', 'system');
// Обычное сообщение снова с шапкой автора.
expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
});
});