2026-09-20 11:32:50 +03:00
|
|
|
|
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,
|
|
|
|
|
|
isMember: true,
|
|
|
|
|
|
}),
|
2026-09-21 00:26:50 +03:00
|
|
|
|
resolveWebhook: () => ({
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
name: 'вебхук',
|
|
|
|
|
|
avatarFileId: undefined,
|
|
|
|
|
|
colorHex: null,
|
|
|
|
|
|
isInstanceAdmin: false,
|
|
|
|
|
|
isMember: false,
|
|
|
|
|
|
}),
|
2026-09-20 11:32:50 +03:00
|
|
|
|
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(
|
|
|
|
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
|
|
|
|
<Composer
|
|
|
|
|
|
channel={channel}
|
2026-09-20 12:06:22 +03:00
|
|
|
|
guildId={null}
|
2026-09-20 11:32:50 +03:00
|
|
|
|
authors={authors}
|
|
|
|
|
|
replyTo={null}
|
|
|
|
|
|
onCancelReply={() => undefined}
|
|
|
|
|
|
disabledReason={null}
|
|
|
|
|
|
canAttach
|
|
|
|
|
|
/>
|
|
|
|
|
|
</QueryClientProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
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(
|
|
|
|
|
|
<MessageContent content={content} resolveMention={authors.resolveMention} />,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|