Files
glchat/web/tests/messageTypes.test.tsx
T
grendervill a126f50468 feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
2026-09-20 12:06:22 +03:00

199 lines
8.2 KiB
TypeScript

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();
});
});