Files
glchat/web/tests/composerFormat.test.tsx
T
grendervill a126f50468 feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
2026-09-20 12:06:22 +03:00

342 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}),
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}
guildId={null}
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);
}
});
});