Files
glchat/web/tests/messageTypes.test.tsx
T

270 lines
11 KiB
TypeScript
Raw Normal View History

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,
}),
resolveWebhook: () => ({
id: '',
name: 'вебхук',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
loaded: true,
};
/** Сообщение без автора-пользователя: так приходят сообщения вебхуков. */
function authorlessMessage(overrides: Partial<Message> & { id: string }): Message {
const base = message(overrides);
delete base.author_id;
return base;
}
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();
});
});
describe('сообщения вебхуков', () => {
it('показывает имя и аватар вебхука, а не «неизвестного автора»', () => {
const webhookAuthors: AuthorDirectory = {
...authors,
resolveWebhook: (message) => ({
id: message.webhook_id ?? '',
name: message.webhook_name ?? 'вебхук',
avatarFileId: undefined,
avatarUrl: message.webhook_avatar,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
}),
};
render(
<MessageList
channelId="c-1"
channelName="общий"
messages={[
authorlessMessage({
id: 'w-1',
type: 'webhook',
content: 'сборка прошла',
webhook_id: 'hook-1',
webhook_name: 'Деплой',
webhook_avatar: '/files/777',
}),
]}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={webhookAuthors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={vi.fn()}
/>,
);
const body = article('w-1');
expect(body).toHaveAttribute('data-message-type', 'webhook');
// Имя, значок и аватар рисует шапка группы (само сообщение — только текст).
const group = screen.getByTestId('message-group');
expect(group).toHaveAttribute('data-message-type', 'webhook');
expect(within(group).getByText('Деплой')).toBeVisible();
expect(within(group).getByTestId('webhook-badge')).toHaveTextContent('вебхук');
expect(group.querySelector('img')?.getAttribute('src')).toBe('/files/777');
expect(within(group).queryByText('неизвестный')).toBeNull();
});
});