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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user