feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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(<MessageContent content={content} />);
|
||||
}
|
||||
|
||||
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,<b>x</b>');
|
||||
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
expect(container.textContent).toContain('javascript:alert(1)');
|
||||
expect(container.textContent).toContain('data:text/html,<b>x</b>');
|
||||
});
|
||||
|
||||
it('HTML из сообщения не выполняется', () => {
|
||||
const { container } = markup('<img src=x onerror=alert(1)> <script>alert(2)</script>');
|
||||
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(container.textContent).toContain('<script>alert(2)</script>');
|
||||
});
|
||||
|
||||
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('см. #общий');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user