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 }], }; 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('см. #общий'); }); });