Files
glchat/web/tests/composerFormat.test.tsx
T
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00

350 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,
}),
resolveWebhook: () => ({
id: '',
name: 'вебхук',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: 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(
<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);
}
});
});