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 } from '@/api/types'; import { Composer } from '@/components/chat/Composer'; import { MessageContent } from '@/components/chat/MessageContent'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { hasMarkers, parseInline, toMirrorLines } from '@/lib/markdown'; import { createTestQueryClient, findRequest, installFetch, json, makeMessage, resetStores, } from './helpers'; /** * Форматирование в поле ввода: зеркало под textarea показывает разметку, а * маркеры видны только у фрагмента, внутри которого стоит каретка. */ const channel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 }; const authors: AuthorDirectory = { resolve: () => ({ id: 'user-2', name: '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-2' ? 'Bob' : userId, isMe: false }), loaded: true, }; function renderComposer() { const fetchMock = installFetch([ { match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) }, { match: '/api/v1/channels/c-1/messages', method: 'POST', response: () => json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }), }, { match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) }, ]); const view = render( undefined} disabledReason={null} canAttach /> , ); return { ...view, fetchMock }; } /** Поле ввода (настоящий textbox с меткой). */ function field(): HTMLTextAreaElement { return screen.getByLabelText('Сообщение в комнату общий'); } /** Слой подсветки под полем ввода. */ function layer(): HTMLElement { return screen.getByTestId('composer-mirror'); } /** * Ставит каретку и сообщает об этом React. Нативный `select` React * синтезирует из `keyup`/`mouseup`/`selectionchange`, поэтому в тесте * отправляем и `select`, и `keyup` — как это делает браузер. */ function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void { input.setSelectionRange(start, end); fireEvent.select(input); fireEvent.keyUp(input); } /** Тексты приглушённых маркеров в зеркале. */ function markers(): string[] { return Array.from(layer().querySelectorAll('.text-fg-muted')).map( (node) => node.textContent ?? '', ); } beforeEach(() => { resetStores(); }); describe('композер: форматирование в поле ввода', () => { it('показывает выделение как mark, а маркеры прячет', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, 'важно: ==выделение== да'); // В поле ввода остаётся исходный текст с маркерами. expect(input).toHaveValue('важно: ==выделение== да'); // А в зеркале — уже отформатированный, без `==`. const mark = within(layer()).getByText('выделение'); expect(mark.tagName).toBe('MARK'); expect(layer().textContent).toBe('важно: выделение да'); expect(markers()).toEqual([]); }); it('показывает приглушённые маркеры, когда каретка внутри фрагмента', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '==выделение=='); // Каретка внутри содержимого. placeCaret(input, 5); expect(layer().textContent).toBe('==выделение=='); expect(markers()).toEqual(['==', '==']); // Позиция сразу после открывающего маркера и перед закрывающим — тоже внутри. placeCaret(input, 2); expect(layer().textContent).toBe('==выделение=='); placeCaret(input, 11); expect(layer().textContent).toBe('==выделение=='); // На самих маркерах (и за фрагментом) маркеры снова скрыты. placeCaret(input, 0); expect(layer().textContent).toBe('выделение'); expect(markers()).toEqual([]); placeCaret(input, 13); expect(layer().textContent).toBe('выделение'); }); it('форматирует жирный, курсив и зачёркнутый текст', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '**жирный** *курсив* ~~зачёркнутый~~'); expect(within(layer()).getByText('жирный').tagName).toBe('STRONG'); expect(within(layer()).getByText('курсив').tagName).toBe('EM'); expect(within(layer()).getByText('зачёркнутый').tagName).toBe('S'); expect(layer().textContent).toBe('жирный курсив зачёркнутый'); // Каретка внутри `**жирный**` — маркеры на месте. placeCaret(input, 4); expect(layer().textContent).toContain('**жирный**'); expect(markers()).toEqual(['**', '**']); // То же для курсива и зачёркнутого. placeCaret(input, 15); expect(layer().textContent).toContain('*курсив*'); expect(markers()).toEqual(['*', '*']); placeCaret(input, 25); expect(layer().textContent).toContain('~~зачёркнутый~~'); expect(markers()).toEqual(['~~', '~~']); }); it('оставляет незакрытые маркеры обычным текстом', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '==фыв'); expect(layer().querySelector('mark')).toBeNull(); expect(layer().textContent).toBe('==фыв'); }); it('не разбирает разметку внутри кода, а ограждения показывает по каретке', () => { renderComposer(); const input = field(); fireEvent.change(input, { target: { value: 'до\n```\n==не mark==\n```\nпосле' } }); // Каретка снаружи блока кода: ограждения скрыты, но строки остаются на месте. placeCaret(input, 0); expect(layer().querySelector('mark')).toBeNull(); expect(layer().textContent).toBe('до\n\n==не mark==\n\nпосле'); // Каретка внутри блока — ограждения видны. placeCaret(input, 10); expect(layer().querySelector('mark')).toBeNull(); expect(layer().textContent).toBe('до\n```\n==не mark==\n```\nпосле'); }); it('рендерит кодовый фрагмент без моноширинного шрифта — метрики важнее', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '`код`'); expect(layer().textContent).toBe('код'); // Смена шрифта в зеркале сдвинула бы подсветку относительно поля ввода. expect(layer().querySelector('.font-mono')).toBeNull(); }); it('отправляет исходный текст с маркерами, а не то, что видно в зеркале', async () => { const visitor = userEvent.setup(); const { fetchMock } = renderComposer(); const input = field(); await visitor.type(input, '==выделение== и **жирный**'); await visitor.keyboard('{Enter}'); await waitFor(() => { const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }); expect(request?.body).toMatchObject({ content: '==выделение== и **жирный**' }); }); await waitFor(() => { expect(input).toHaveValue(''); }); }); it('не ломает счётчик и лимит 4000 символов', () => { renderComposer(); const input = field(); const long = 'а'.repeat(4000); fireEvent.change(input, { target: { value: long } }); expect(input).toHaveAttribute('maxlength', '4000'); expect(input).toHaveValue(long); expect(screen.getByText('4000/4000')).toHaveClass('text-warning'); // Зеркало рисует весь текст: разбор мемоизирован по значению. expect(layer().textContent).toBe(long); }); it('зеркало скрыто от скринридера, а поле остаётся настоящим textbox', async () => { const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, '==выделение=='); expect(layer()).toHaveAttribute('aria-hidden', 'true'); expect(layer()).toHaveClass('pointer-events-none'); expect(screen.getAllByRole('textbox')).toHaveLength(1); expect(screen.getByRole('textbox')).toBe(input); }); it('синхронизирует прокрутку зеркала с полем ввода', () => { renderComposer(); const input = field(); const mirror = layer(); Object.defineProperty(input, 'scrollTop', { value: 40, writable: true, configurable: true }); Object.defineProperty(input, 'scrollLeft', { value: 5, writable: true, configurable: true }); let top = 0; let left = 0; Object.defineProperty(mirror, 'scrollTop', { get: () => top, set: (value: number) => { top = value; }, configurable: true, }); Object.defineProperty(mirror, 'scrollLeft', { get: () => left, set: (value: number) => { left = value; }, configurable: true, }); fireEvent.scroll(input); expect(top).toBe(40); expect(left).toBe(5); }); }); describe('композер: разбор совпадает с лентой сообщений', () => { it('зеркало и MessageContent показывают один и тот же текст', async () => { const content = '==выделение== **жирный** *курсив* ~~зачёркнутый~~ ||спойлер|| `код` https://example.com/a. <@user-2> конец'; const visitor = userEvent.setup(); renderComposer(); const input = field(); await visitor.type(input, content); const feed = render( , ); expect(layer().textContent).toBe(feed.container.textContent); }); it('диапазоны токенов указывают на маркеры и содержимое', () => { const [token] = parseInline('==выделение==', 0); expect(token).toBeDefined(); expect(token?.kind).toBe('mark'); expect(token?.start).toBe(0); expect(token?.end).toBe(13); expect(token?.contentStart).toBe(2); expect(token?.contentEnd).toBe(11); expect(hasMarkers('mark')).toBe(true); expect(hasMarkers('mention')).toBe(false); // Смещение базы: диапазоны остаются абсолютными. const [shifted] = parseInline('==a==', 10); expect(shifted?.start).toBe(10); expect(shifted?.end).toBe(15); expect(shifted?.contentStart).toBe(12); expect(shifted?.contentEnd).toBe(13); // Незакрытый маркер токеном не считается. expect(parseInline('==фыв', 0)).toEqual([]); }); it('строки зеркала повторяют исходные переносы', () => { const samples = [ '', 'одна строка', 'первая\nвторая\n', '```\nкод\n```', 'до\n```\nкод\n```\nпосле', 'незакрытый ```\nкод', '> цитата\n> ещё\nобычный', '```', ]; for (const sample of samples) { expect( toMirrorLines(sample) .map((line) => line.text) .join('\n'), ).toBe(sample); } }); });