import { afterEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MessageContent } from '@/components/chat/MessageContent';
import { useSessionStore } from '@/stores/session';
import type { SessionGuild } from '@/stores/session';
afterEach(() => {
useSessionStore.getState().reset();
});
/** Разметка сообщения: React-узлы без dangerouslySetInnerHTML. */
function markup(content: string) {
return render();
}
describe('разметка: выделение и жирный курсив', () => {
it('==текст== превращается в mark', () => {
const { container } = markup('важно: ==выделение== да');
const mark = container.querySelector('mark');
expect(mark).toHaveTextContent('выделение');
expect(container.textContent).toBe('важно: выделение да');
});
it('__жирный__ работает как **жирный**', () => {
const { container } = markup('__жирный__ и **тоже**');
const strong = container.querySelectorAll('strong');
expect(strong).toHaveLength(2);
expect(strong[0]).toHaveTextContent('жирный');
expect(strong[1]).toHaveTextContent('тоже');
expect(container.textContent).toBe('жирный и тоже');
});
it('***оба*** — жирный курсив без лишних звёздочек', () => {
const { container } = markup('***оба***');
const strong = container.querySelector('strong');
expect(strong?.querySelector('em')).toHaveTextContent('оба');
expect(container.textContent).toBe('оба');
});
it('_курсив_ работает, но подчёркивания внутри слова — нет', () => {
const { container } = markup('_курсив_ и foo_bar_baz');
expect(container.querySelector('em')).toHaveTextContent('курсив');
expect(container.textContent).toBe('курсив и foo_bar_baz');
});
it('внутри кода маркеры не разбираются', () => {
const { container } = markup('`==не mark==` и блок:\n```\n**не жирный**\n```');
expect(container.querySelector('mark')).toBeNull();
expect(container.querySelector('strong')).toBeNull();
expect(container.querySelector('code')).toHaveTextContent('==не mark==');
expect(container.querySelector('pre')?.textContent).toContain('**не жирный**');
});
});
describe('разметка: незакрытые маркеры и безопасность', () => {
it('незакрытые маркеры остаются обычным текстом', () => {
const content = '==фыв **жирный *курсив ~~зач _подч ||спойлер `код';
const { container } = markup(content);
expect(container.textContent).toBe(content);
expect(container.querySelector('mark')).toBeNull();
expect(container.querySelector('strong')).toBeNull();
expect(container.querySelector('em')).toBeNull();
expect(container.querySelector('s')).toBeNull();
expect(container.querySelector('button')).toBeNull();
});
it('ссылка с опасным протоколом остаётся текстом', () => {
const { container } = markup('javascript:alert(1) и data:text/html,x');
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toContain('javascript:alert(1)');
expect(container.textContent).toContain('data:text/html,x');
});
it('HTML из сообщения не выполняется', () => {
const { container } = markup('
');
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('');
});
it('автоссылки http(s) открываются в новой вкладке', () => {
const { container } = markup('смотри https://example.com/a.');
const link = container.querySelector('a');
expect(link).toHaveAttribute('href', 'https://example.com/a');
expect(link).toHaveAttribute('rel', 'noreferrer noopener');
expect(link).toHaveAttribute('target', '_blank');
});
});
describe('разметка: спойлер', () => {
it('скрывает текст до клика и раскрывается', async () => {
const visitor = userEvent.setup();
const { container } = markup('секрет: ||тайна||');
const spoiler = container.querySelector('button');
expect(spoiler).toHaveTextContent('тайна');
expect(screen.getByRole('button', { name: 'Показать спойлер' })).toHaveAttribute(
'aria-pressed',
'false',
);
await visitor.click(screen.getByRole('button', { name: 'Показать спойлер' }));
expect(screen.getByRole('button', { name: 'Скрыть спойлер' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(container.textContent).toBe('секрет: тайна');
});
it('незакрытый спойлер остаётся текстом', () => {
const { container } = markup('||тайна');
expect(container.querySelector('button')).toBeNull();
expect(container.textContent).toBe('||тайна');
});
});
describe('разметка: упоминания каналов', () => {
const guild: SessionGuild = {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis: [],
sounds: [],
};
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = markup('см. #общий и #неттакого');
const spans = Array.from(container.querySelectorAll('span'));
const known = spans.filter((span) => span.textContent === '#общий');
expect(known).toHaveLength(1);
expect(known[0]).toHaveClass('bg-accent/15');
expect(spans.some((span) => span.textContent === '#неттакого')).toBe(false);
expect(container.textContent).toBe('см. #общий и #неттакого');
});
it('без снапшота #канал остаётся обычным текстом', () => {
const { container } = markup('см. #общий');
expect(container.querySelectorAll('span')).toHaveLength(0);
expect(container.textContent).toBe('см. #общий');
});
it('рендерит ссылку на комнату <#id> именем из снапшота, иначе #id', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = markup('см. <#c-1> и <#999>');
const spans = Array.from(container.querySelectorAll('span'));
expect(spans.some((span) => span.textContent === '#общий')).toBe(true);
expect(spans.some((span) => span.textContent === '#999')).toBe(true);
// Угловые скобки не остаются текстом.
expect(container.textContent).toBe('см. #общий и #999');
});
it('не ломает разметку рядом с упоминанием, ссылкой и кодом', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = render(
пишет в <#c-1>: https://example.com/#якорь\n`<#c-1>`'}
resolveMention={() => ({ name: 'Аля', isMe: false })}
/>,
);
expect(container.textContent).toBe('@Аля пишет в #общий: https://example.com/#якорь\n<#c-1>');
// Ссылка осталась ссылкой целиком, вместе с якорем.
expect(container.querySelector('a')).toHaveAttribute('href', 'https://example.com/#якорь');
// Внутри кода разметка не разбирается.
expect(container.querySelector('code')).toHaveTextContent('<#c-1>');
});
});