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, isOwner: false, isMember: true, }), resolveWebhook: () => ({ id: '', name: 'вебхук', avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, isOwner: false, isMember: false, }), resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }), loaded: true, }; /** Сообщение без автора-пользователя: так приходят сообщения вебхуков. */ function authorlessMessage(overrides: Partial & { id: string }): Message { const base = message(overrides); delete base.author_id; return base; } function message(overrides: Partial & { 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( 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, isOwner: false, isMember: false, }), }; render( 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(); }); });