feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -517,6 +517,61 @@ describe('чат комнаты: реакции и ответы', () => {
|
||||
expect(screen.queryByText('Ответ Аля')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('двойной клик по сообщению ставит его в ответ', async () => {
|
||||
await boot({
|
||||
messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })],
|
||||
});
|
||||
|
||||
const article = await screen.findByTestId('message-m-1');
|
||||
fireEvent.doubleClick(article);
|
||||
|
||||
// Та же плашка, что и у пункта «Ответить» в меню.
|
||||
expect(await screen.findByText('Ответ Bob')).toBeVisible();
|
||||
});
|
||||
|
||||
it('двойной клик по ссылке не ставит ответ', async () => {
|
||||
await boot({
|
||||
messages: [
|
||||
message({ id: 'm-1', content: 'смотри https://example.com/page', author_id: 'user-2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const article = await screen.findByTestId('message-m-1');
|
||||
fireEvent.doubleClick(within(article).getByRole('link'));
|
||||
|
||||
expect(screen.queryByText('Ответ Bob')).toBeNull();
|
||||
});
|
||||
|
||||
it('двойной клик по выделенному тексту не ставит ответ', async () => {
|
||||
await boot({
|
||||
messages: [message({ id: 'm-1', content: 'выделенный текст', author_id: 'user-2' })],
|
||||
});
|
||||
|
||||
const article = await screen.findByTestId('message-m-1');
|
||||
// Пользователь выделил текст — двойной клик принадлежит выделению.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(article);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
fireEvent.doubleClick(article);
|
||||
|
||||
expect(screen.queryByText('Ответ Bob')).toBeNull();
|
||||
selection?.removeAllRanges();
|
||||
});
|
||||
|
||||
it('двойной клик по кнопке реакции не ставит ответ', async () => {
|
||||
await boot({
|
||||
messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })],
|
||||
});
|
||||
|
||||
const article = await screen.findByTestId('message-m-1');
|
||||
fireEvent.doubleClick(within(article).getByRole('button', { name: 'Поставить реакцию 🔥' }));
|
||||
|
||||
expect(screen.queryByText('Ответ Bob')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('чат комнаты: права и действия', () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { setQueryClient } from '@/lib/queryClient';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
||||
@@ -117,6 +119,13 @@ export function findRequest(
|
||||
export function resetStores(): void {
|
||||
useSessionStore.getState().reset();
|
||||
useMessagesStore.getState().reset();
|
||||
useVoiceStore.getState().reset();
|
||||
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
||||
useUiStore.setState({
|
||||
settingsOpen: false,
|
||||
settingsSection: 'profile',
|
||||
settingsProfileTab: 'main',
|
||||
});
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
|
||||
+99
-18
@@ -25,14 +25,44 @@ const instanceOpen = {
|
||||
guild_count: 1,
|
||||
};
|
||||
|
||||
const totpLabel = 'Код 2FA или резервный код';
|
||||
|
||||
/** Ответы для входа: логин и оболочка приложения. */
|
||||
function loginRoutes(extra: Parameters<typeof installFetch>[0] = []) {
|
||||
const user = makeUser();
|
||||
return installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
...extra,
|
||||
]);
|
||||
}
|
||||
|
||||
function loginBodies(fetchMock: ReturnType<typeof installFetch>) {
|
||||
return recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/auth/login') && request.method === 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
describe('страница входа', () => {
|
||||
it('успешный вход переводит в /app', async () => {
|
||||
const user = makeUser();
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
it('поле кода 2FA показано сразу и принимает резервные коды', async () => {
|
||||
installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]);
|
||||
|
||||
renderApp('/login');
|
||||
|
||||
const field = await screen.findByLabelText(totpLabel);
|
||||
expect(field).toBeVisible();
|
||||
expect(field).toHaveAttribute('autocomplete', 'one-time-code');
|
||||
expect(field).toHaveAttribute('inputmode', 'text');
|
||||
// Резервный код вида `a8eh-pshp-t8st` должен влезать целиком.
|
||||
expect(Number(field.getAttribute('maxlength'))).toBeGreaterThanOrEqual(20);
|
||||
expect(field).not.toHaveAttribute('pattern');
|
||||
expect(screen.getByText(/резервный код вида xxxx-xxxx-xxxx/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('успешный вход переводит в /app и уходит без totp_code при пустом поле', async () => {
|
||||
const fetchMock = loginRoutes([
|
||||
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
@@ -56,7 +86,59 @@ describe('страница входа', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => {
|
||||
it('код 2FA уходит вместе с логином в первом же запросе', async () => {
|
||||
const fetchMock = loginRoutes([
|
||||
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.type(screen.getByLabelText(totpLabel), '123456');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginBodies(fetchMock)).toHaveLength(1);
|
||||
});
|
||||
expect(loginBodies(fetchMock)[0]?.body).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('резервный код из 14 символов уходит на сервер целиком', async () => {
|
||||
const fetchMock = loginRoutes();
|
||||
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
|
||||
|
||||
// Поле не обрезает значение: 14 символов с дефисами видны целиком.
|
||||
expect(screen.getByLabelText(totpLabel)).toHaveValue('a8eh-pshp-t8st');
|
||||
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginBodies(fetchMock)[0]?.body).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: 'a8eh-pshp-t8st',
|
||||
});
|
||||
});
|
||||
// Вход не требует предварительной попытки без кода.
|
||||
expect(loginBodies(fetchMock)).toHaveLength(1);
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
});
|
||||
|
||||
it('при auth.2fa_required подсказывает про код и сохраняет логин с паролем', async () => {
|
||||
const user = makeUser();
|
||||
let loginAttempts = 0;
|
||||
const fetchMock = installFetch([
|
||||
@@ -80,24 +162,23 @@ describe('страница входа', () => {
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
const totpField = await screen.findByLabelText('Код 2FA');
|
||||
expect(totpField).toBeVisible();
|
||||
// Подсказка понятная, а введённые логин и пароль остались на месте.
|
||||
expect(await screen.findByText(/Введите код 2FA или резервный код/)).toBeVisible();
|
||||
expect(screen.getByLabelText('Почта')).toHaveValue('alice@example.com');
|
||||
expect(screen.getByLabelText('Пароль')).toHaveValue('super-secret-1');
|
||||
|
||||
await visitor.type(totpField, '123456');
|
||||
// Код, введённый после подсказки, не теряется при повторной отправке.
|
||||
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
|
||||
const logins = recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/auth/login') && request.method === 'POST',
|
||||
);
|
||||
expect(logins).toHaveLength(2);
|
||||
expect(logins[1]?.body).toEqual({
|
||||
expect(loginBodies(fetchMock)).toHaveLength(2);
|
||||
expect(loginBodies(fetchMock)[1]?.body).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: '123456',
|
||||
totp_code: 'a8eh-pshp-t8st',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('см. #общий');
|
||||
});
|
||||
});
|
||||
+117
-50
@@ -91,6 +91,9 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||
return [
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
|
||||
{ match: '/api/v1/guilds/g-2/channels', response: () => json({ channels: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) },
|
||||
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
||||
@@ -107,61 +110,70 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Первая кнопка «Сохранить» на странице (секция «Общее»). */
|
||||
function saveButton(index = 0): HTMLElement {
|
||||
const buttons = screen.getAllByRole('button', { name: 'Сохранить' });
|
||||
const button = buttons[index];
|
||||
if (button === undefined) {
|
||||
throw new Error('кнопка сохранения не найдена');
|
||||
/** Кнопка «Сохранить» внутри формы конкретного поля. */
|
||||
function saveNear(label: string): HTMLElement {
|
||||
const field = screen.getByLabelText(label);
|
||||
const form = field.closest('form');
|
||||
if (form === null) {
|
||||
throw new Error(`форма для «${label}» не найдена`);
|
||||
}
|
||||
return button;
|
||||
return within(form).getByRole('button', { name: 'Сохранить' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов».
|
||||
* Так же их открывает и обычный участник без прав администратора.
|
||||
*/
|
||||
async function openServerSettings(path = '/app/empty', guildName?: string) {
|
||||
const view = renderApp(path);
|
||||
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
||||
await userEvent.click(trigger);
|
||||
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
|
||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
|
||||
await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
|
||||
if (guildName !== undefined) {
|
||||
const picker = await openGuildPicker();
|
||||
await userEvent.click(within(picker).getByRole('menuitemradio', { name: guildName }));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */
|
||||
async function openGuildPicker(): Promise<HTMLElement> {
|
||||
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
|
||||
return screen.findByRole('menu', { name: 'Мои серверы' });
|
||||
}
|
||||
|
||||
describe('настройки серверов', () => {
|
||||
it('показывает список серверов с иконкой и именем и выбирает первый', async () => {
|
||||
it('показывает выпадающий список серверов с иконкой и именем и выбирает первый', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
const { router } = renderApp('/settings/servers');
|
||||
await openServerSettings();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
|
||||
});
|
||||
// Первый сервер выбран по умолчанию.
|
||||
expect(await screen.findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
||||
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||
|
||||
// Первый сервер подсвечен. Список запрашиваем заново: после выбора
|
||||
// страница перерисовывается, и прежний узел может быть уже отсоединён.
|
||||
await waitFor(() => {
|
||||
const picker = screen.getByTestId('guild-picker');
|
||||
expect(
|
||||
within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }),
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
const picker = screen.getByTestId('guild-picker');
|
||||
const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' });
|
||||
const picker = await openGuildPicker();
|
||||
const first = within(picker).getByRole('menuitemradio', { name: 'Альфа' });
|
||||
expect(first).toHaveAttribute('aria-checked', 'true');
|
||||
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
|
||||
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
|
||||
expect(
|
||||
within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||
expect(within(picker).getByRole('menuitemradio', { name: 'Бета' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('переключение сервера меняет отображаемые настройки и права', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
const { router } = renderApp('/settings/servers/g-1');
|
||||
await openServerSettings();
|
||||
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||
// Владелец может редактировать.
|
||||
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }),
|
||||
);
|
||||
const picker = await openGuildPicker();
|
||||
await userEvent.click(within(picker).getByRole('menuitemradio', { name: 'Бета' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/servers/g-2');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
|
||||
});
|
||||
@@ -181,7 +193,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/settings/servers/g-1');
|
||||
await openServerSettings();
|
||||
|
||||
const name = await screen.findByLabelText('Название сервера');
|
||||
await userEvent.clear(name);
|
||||
@@ -189,7 +201,7 @@ describe('настройки серверов', () => {
|
||||
const description = screen.getByLabelText('Описание');
|
||||
await userEvent.clear(description);
|
||||
await userEvent.type(description, 'Новое');
|
||||
await userEvent.click(saveButton());
|
||||
await userEvent.click(saveNear('Название сервера'));
|
||||
|
||||
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
|
||||
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
|
||||
@@ -209,7 +221,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/settings/servers/g-1');
|
||||
await openServerSettings();
|
||||
|
||||
const nickname = await screen.findByLabelText('Никнейм');
|
||||
await waitFor(() => {
|
||||
@@ -218,8 +230,7 @@ describe('настройки серверов', () => {
|
||||
|
||||
await userEvent.clear(nickname);
|
||||
await userEvent.type(nickname, 'Алиса');
|
||||
// Вторая кнопка «Сохранить» — секция «Мой профиль на сервере».
|
||||
await userEvent.click(saveButton(1));
|
||||
await userEvent.click(saveNear('Никнейм'));
|
||||
|
||||
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
|
||||
const patch = recordedRequests(fetchMock).find(
|
||||
@@ -228,10 +239,47 @@ describe('настройки серверов', () => {
|
||||
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
||||
});
|
||||
|
||||
it('применяет введённый ник ко всем серверам по очереди', async () => {
|
||||
const fetchMock = installFetch(
|
||||
serversRoutes([
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/members/user-1',
|
||||
method: 'PATCH',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-2/members/user-1',
|
||||
method: 'PATCH',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await openServerSettings();
|
||||
|
||||
const nickname = await screen.findByLabelText('Никнейм');
|
||||
await userEvent.clear(nickname);
|
||||
await userEvent.type(nickname, 'Алиса');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Применить ко всем серверам' }));
|
||||
|
||||
expect(await screen.findByText('Никнейм применён на всех серверах (2).')).toBeVisible();
|
||||
|
||||
const patches = recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
|
||||
);
|
||||
expect(patches.map((request) => request.url.split('/api/v1')[1])).toEqual([
|
||||
'/guilds/g-1/members/user-1',
|
||||
'/guilds/g-2/members/user-1',
|
||||
]);
|
||||
expect(
|
||||
patches.every((request) => JSON.stringify(request.body) === '{"nickname":"Алиса"}'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('показывает участников и роли сервера', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
renderApp('/settings/servers/g-1');
|
||||
await openServerSettings();
|
||||
|
||||
const list = await screen.findByTestId('guild-members');
|
||||
expect(within(list).getByText('Аля')).toBeVisible();
|
||||
@@ -246,7 +294,7 @@ describe('настройки серверов', () => {
|
||||
it('владелец не может покинуть сервер, но может его удалить', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
renderApp('/settings/servers/g-1');
|
||||
await openServerSettings();
|
||||
|
||||
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||
@@ -256,7 +304,7 @@ describe('настройки серверов', () => {
|
||||
it('участник без прав не видит удаление сервера', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
renderApp('/settings/servers/g-2');
|
||||
await openServerSettings('/app/empty', 'Бета');
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
||||
@@ -265,15 +313,32 @@ describe('настройки серверов', () => {
|
||||
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
renderApp('/settings/servers/g-2');
|
||||
await openServerSettings('/app/empty', 'Бета');
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
|
||||
});
|
||||
// Окно настроек при этом остаётся открытым.
|
||||
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Escape закрывает только верхнее окно подтверждения', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
await openServerSettings('/app/empty', 'Бета');
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||
await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
|
||||
@@ -283,7 +348,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const { router } = renderApp('/settings/servers/g-2');
|
||||
const { router } = await openServerSettings('/app/empty', 'Бета');
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
||||
@@ -303,7 +368,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const { router } = renderApp('/settings/servers/g-1');
|
||||
const { router } = await openServerSettings();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
|
||||
@@ -319,11 +384,13 @@ describe('настройки серверов', () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
]);
|
||||
|
||||
renderApp('/settings/servers');
|
||||
await openServerSettings();
|
||||
|
||||
expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible();
|
||||
expect(screen.getByText('У вас пока нет серверов.')).toBeVisible();
|
||||
const content = screen.getByTestId('settings-content');
|
||||
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
||||
expect(within(content).getByText('У вас пока нет серверов.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+319
-59
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
requestUrl,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
const user = makeUser();
|
||||
@@ -33,6 +35,321 @@ const sessions = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Ответы оболочки приложения: профиль, серверы, инстанс. */
|
||||
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
|
||||
return [
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
...extra,
|
||||
];
|
||||
}
|
||||
|
||||
/** Открывает окно настроек шестерёнкой в панели пользователя. */
|
||||
async function openSettings(): Promise<HTMLElement> {
|
||||
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||
await userEvent.click(gear);
|
||||
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||
}
|
||||
|
||||
/** Переходит к пункту настроек по его названию в левом меню. */
|
||||
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
|
||||
await userEvent.click(within(dialog).getByRole('button', { name }));
|
||||
}
|
||||
|
||||
/** Тело multipart-запроса аватара (или null, если запроса не было). */
|
||||
function avatarForm(fetchMock: ReturnType<typeof installFetch>, method: string): FormData | null {
|
||||
const call = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
requestUrl(input).includes('/users/@me/avatar') &&
|
||||
(init?.method ?? 'GET').toUpperCase() === method,
|
||||
);
|
||||
const body = call?.[1]?.body;
|
||||
return body instanceof FormData ? body : null;
|
||||
}
|
||||
|
||||
describe('настройки: окно и навигация', () => {
|
||||
it('открывается шестерёнкой, показывает пункты и закрывается по Escape, крестику и подложке', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
for (const item of [
|
||||
'Профиль',
|
||||
'Безопасность',
|
||||
'Внешний вид',
|
||||
'Уведомления',
|
||||
'Аудио-видео',
|
||||
'Язык и регион',
|
||||
]) {
|
||||
expect(within(dialog).getByRole('button', { name: item })).toBeVisible();
|
||||
}
|
||||
expect(within(dialog).getByRole('button', { name: 'Профиль' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
);
|
||||
|
||||
// Escape.
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
|
||||
});
|
||||
|
||||
// Крестик.
|
||||
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||
const reopened = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
await userEvent.click(within(reopened).getByRole('button', { name: 'Закрыть' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
|
||||
});
|
||||
|
||||
// Клик по подложке.
|
||||
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||
await userEvent.click(gear);
|
||||
await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
fireEvent.mouseDown(screen.getByTestId('modal-backdrop'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('переключает вкладки «Основной» и «Сервер» внутри профиля', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
const mainTab = within(dialog).getByRole('tab', { name: 'Основной' });
|
||||
const serverTab = within(dialog).getByRole('tab', { name: 'Сервер' });
|
||||
expect(mainTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
|
||||
|
||||
await userEvent.click(serverTab);
|
||||
expect(serverTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(
|
||||
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
|
||||
).toBeVisible();
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('tab', { name: 'Основной' }));
|
||||
expect(mainTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
|
||||
});
|
||||
|
||||
it('пункт «Инстанс» видят только администраторы', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
|
||||
});
|
||||
|
||||
it('«Уведомления» пока заглушка', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Уведомления');
|
||||
|
||||
expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Аудио-видео');
|
||||
|
||||
expect(within(dialog).getAllByText('Устройства недоступны').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getByLabelText('Громкость микрофона')).toBeVisible();
|
||||
expect(within(dialog).getByLabelText('Громкость звука')).toBeVisible();
|
||||
});
|
||||
|
||||
it('«Язык и регион» сохраняет часовой пояс через PATCH /users/@me', async () => {
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { ...user, timezone: 'Europe/Berlin' } }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Язык и регион');
|
||||
|
||||
await userEvent.selectOptions(within(dialog).getByLabelText('Часовой пояс'), 'Europe/Berlin');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
|
||||
timezone: 'Europe/Berlin',
|
||||
});
|
||||
});
|
||||
expect(await within(dialog).findByText('Настройки региона сохранены.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройки: старые адреса', () => {
|
||||
it.each([
|
||||
['/settings', 'Профиль', 'Основной'],
|
||||
['/settings/account/profile', 'Профиль', 'Основной'],
|
||||
['/settings/profile', 'Профиль', 'Основной'],
|
||||
])('%s открывает окно на «%s → %s»', async (from, section, tab) => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
const { router } = renderApp(from);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toContain('/app');
|
||||
});
|
||||
expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
);
|
||||
expect(within(dialog).getByRole('tab', { name: tab })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/settings/security', 'Безопасность'],
|
||||
['/settings/account/security', 'Безопасность'],
|
||||
['/settings/appearance', 'Внешний вид'],
|
||||
['/settings/account/appearance', 'Внешний вид'],
|
||||
])('%s открывает окно на пункте «%s»', async (from, section) => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp(from);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('/settings/servers открывает вкладку «Сервер»', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/settings/servers');
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройки: аватар', () => {
|
||||
it('загружает файл через POST /users/@me/avatar и удаляет его', async () => {
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me/avatar',
|
||||
method: 'POST',
|
||||
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/users/@me/avatar',
|
||||
method: 'DELETE',
|
||||
response: () => json({ user: { ...user } }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
await openSettings();
|
||||
|
||||
const input = await screen.findByLabelText('Выбрать файл аватара');
|
||||
const file = new File(['картинка'], 'avatar.png', { type: 'image/png' });
|
||||
await userEvent.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
|
||||
});
|
||||
// Multipart с полем `file`, как ждёт сервер.
|
||||
const form = avatarForm(fetchMock, 'POST');
|
||||
expect((form?.get('file') as File | null)?.name).toBe('avatar.png');
|
||||
|
||||
// Ответ сервера обновил профиль: аватар отдаётся файловым сервисом.
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('img[src="/files/file-9"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Удалить аватар' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
recordedRequests(fetchMock).some(
|
||||
(request) => request.url.includes('/users/@me/avatar') && request.method === 'DELETE',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('слишком большой файл не отправляется, показывается ошибка', async () => {
|
||||
const fetchMock = installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
await openSettings();
|
||||
|
||||
const input = await screen.findByLabelText('Выбрать файл аватара');
|
||||
const big = new File(['x'], 'big.png', { type: 'image/png' });
|
||||
Object.defineProperty(big, 'size', { value: 9 * 1024 * 1024 });
|
||||
await userEvent.upload(input, big);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше');
|
||||
expect(avatarForm(fetchMock, 'POST')).toBeNull();
|
||||
});
|
||||
|
||||
it('ошибка сервера file.too_large показывается понятным текстом', async () => {
|
||||
installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me/avatar',
|
||||
method: 'POST',
|
||||
response: () => apiError('file.too_large', 413),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
await openSettings();
|
||||
|
||||
const input = await screen.findByLabelText('Выбрать файл аватара');
|
||||
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
|
||||
});
|
||||
|
||||
it('часовой пояс уходит в PATCH вместе с профилем', async () => {
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
await openSettings();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({
|
||||
timezone: 'Asia/Tokyo',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройки: безопасность', () => {
|
||||
it('включение 2FA показывает секрет и коды восстановления', async () => {
|
||||
const fetchMock = installFetch([
|
||||
@@ -105,7 +422,7 @@ describe('настройки: безопасность', () => {
|
||||
|
||||
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
|
||||
let passwordAttempts = 0;
|
||||
const fetchMock = installFetch([
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
||||
{
|
||||
@@ -135,11 +452,6 @@ describe('настройки: безопасность', () => {
|
||||
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
|
||||
|
||||
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
|
||||
expect(
|
||||
recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/users/@me/password') && request.method === 'POST',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('профиль сохраняется через PATCH /users/@me', async () => {
|
||||
@@ -165,58 +477,6 @@ describe('настройки: безопасность', () => {
|
||||
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
|
||||
});
|
||||
|
||||
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
|
||||
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
|
||||
|
||||
renderApp('/settings/account/profile');
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
|
||||
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
|
||||
});
|
||||
|
||||
it('вверху настроек — две соседние кнопки-переключателя', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/settings/account/profile');
|
||||
const account = await screen.findByRole('link', { name: 'Настройки сервера' });
|
||||
const servers = screen.getByRole('link', { name: 'Мои серверы' });
|
||||
|
||||
expect(account).toHaveAttribute('aria-current', 'page');
|
||||
expect(servers).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0');
|
||||
|
||||
await userEvent.click(servers);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/servers');
|
||||
});
|
||||
expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
// Подложка переключателя уезжает ко второй кнопке без перезагрузки.
|
||||
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/settings', '/settings/account/profile'],
|
||||
['/settings/profile', '/settings/account/profile'],
|
||||
['/settings/security', '/settings/account/security'],
|
||||
['/settings/appearance', '/settings/account/appearance'],
|
||||
['/settings/instance', '/settings/account/instance'],
|
||||
])('старый путь %s редиректит на %s', async (from, to) => {
|
||||
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
|
||||
|
||||
const { router } = renderApp(from);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
it('администратор видит данные инстанса', async () => {
|
||||
const admin = makeUser({ is_instance_admin: true });
|
||||
installFetch([
|
||||
|
||||
@@ -115,13 +115,19 @@ describe('основной экран', () => {
|
||||
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
|
||||
|
||||
// Панель пользователя.
|
||||
// Панель пользователя: имя, статус, кнопки устройств и шестерёнка настроек.
|
||||
const panel = screen.getByLabelText('Панель пользователя');
|
||||
expect(within(panel).getByText('Alice')).toBeVisible();
|
||||
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
|
||||
// Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx).
|
||||
expect(within(panel).getByLabelText('Выключить микрофон')).toBeVisible();
|
||||
expect(within(panel).getByLabelText('Включить камеру')).toBeVisible();
|
||||
expect(within(panel).getByLabelText('Выключить звук')).toBeVisible();
|
||||
// Выход переехал в мини-профиль (проверяется в tests/userMenu.test.tsx).
|
||||
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
|
||||
|
||||
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
||||
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||
|
||||
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
||||
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
||||
@@ -148,7 +154,7 @@ describe('основной экран', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('шестерёнка настроек сервера видна владельцу и ведёт в настройки', async () => {
|
||||
it('шестерёнка настроек сервера видна владельцу и открывает окно настроек', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const visitor = userEvent.setup();
|
||||
installFetch([
|
||||
@@ -184,9 +190,14 @@ describe('основной экран', () => {
|
||||
const gear = await screen.findByLabelText('Настройки сервера');
|
||||
await visitor.click(gear);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
|
||||
});
|
||||
// Настройки — модальное окно: из комнаты никуда не уходим.
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(router.state.location.pathname).toBe('/app/g-1/c-general');
|
||||
expect(await within(dialog).findByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер');
|
||||
});
|
||||
|
||||
it('обычный участник не видит шестерёнку настроек сервера', async () => {
|
||||
|
||||
+53
-25
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
|
||||
import type { FetchRoute } from './helpers';
|
||||
|
||||
const user = makeUser();
|
||||
const user = makeUser({ bio: 'Пишу код и тесты' });
|
||||
|
||||
/** Базовые ответы для оболочки приложения без серверов. */
|
||||
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||
@@ -17,7 +17,7 @@ function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */
|
||||
/** Открывает мини-профиль кликом по аватару/имени. */
|
||||
async function openMenu() {
|
||||
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
||||
await userEvent.click(trigger);
|
||||
@@ -25,55 +25,82 @@ async function openMenu() {
|
||||
return { trigger, menu };
|
||||
}
|
||||
|
||||
/** Открывает пункт меню и возвращает окно настроек. */
|
||||
async function openSettings(item: string) {
|
||||
const { menu } = await openMenu();
|
||||
await userEvent.click(within(menu).getByRole('menuitem', { name: item }));
|
||||
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||
}
|
||||
|
||||
describe('меню пользователя', () => {
|
||||
it('открывается по клику на аватар и содержит все пункты', async () => {
|
||||
it('мини-профиль открывается по клику на аватар и показывает данные и статусы', async () => {
|
||||
installFetch(baseRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const { menu } = await openMenu();
|
||||
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
|
||||
// Аватар, имя, логин и «О себе».
|
||||
expect(within(menu).getByText('Alice')).toBeVisible();
|
||||
expect(within(menu).getByText('@alice')).toBeVisible();
|
||||
expect(within(menu).getByText('Пишу код и тесты')).toBeVisible();
|
||||
|
||||
// Статусы с подписями и цветными точками.
|
||||
expect(
|
||||
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
|
||||
).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitemradio', { name: 'Неактивен' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitemradio', { name: 'Невидимый' })).toBeVisible();
|
||||
expect(menu.querySelectorAll('[role="menuitemradio"] .rounded-full')).toHaveLength(4);
|
||||
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('шестерёнка открывает то же меню', async () => {
|
||||
it('шестерёнка открывает окно настроек', async () => {
|
||||
installFetch(baseRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||
|
||||
expect(await screen.findByRole('menu', { name: 'Меню пользователя' })).toBeVisible();
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
// Мини-профиль при этом не открывается.
|
||||
expect(screen.queryByRole('menu', { name: 'Меню пользователя' })).toBeNull();
|
||||
});
|
||||
|
||||
it('пункт «Настройки аккаунта» ведёт в /settings/account/profile', async () => {
|
||||
it('пункт «Настройки аккаунта» открывает окно на «Профиль → Основной»', async () => {
|
||||
installFetch(baseRoutes());
|
||||
|
||||
const { router } = renderApp('/app/empty');
|
||||
const { menu } = await openMenu();
|
||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' }));
|
||||
const dialog = await openSettings('Настройки аккаунта');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/account/profile');
|
||||
});
|
||||
expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible();
|
||||
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(within(dialog).getByRole('heading', { name: 'Профиль' })).toBeVisible();
|
||||
// Из приложения никуда не уходим: настройки — модальное окно.
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
|
||||
it('пункт «Настройки серверов» ведёт в /settings/servers', async () => {
|
||||
it('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => {
|
||||
installFetch(baseRoutes());
|
||||
|
||||
const { router } = renderApp('/app/empty');
|
||||
const { menu } = await openMenu();
|
||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings('Настройки серверов');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/settings/servers');
|
||||
});
|
||||
expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible();
|
||||
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
|
||||
@@ -159,15 +186,16 @@ describe('меню пользователя', () => {
|
||||
trigger.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' });
|
||||
// Первый пункт мини-профиля — выбор статуса.
|
||||
const first = await screen.findByRole('menuitemradio', { name: 'В сети' });
|
||||
await waitFor(() => {
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus();
|
||||
expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus();
|
||||
expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
|
||||
import { useVoiceStore, VOICE_STORAGE_KEY } from '@/stores/voice';
|
||||
|
||||
/** Устройства, которые «видит» браузер в тестах. */
|
||||
const devices = [
|
||||
{ kind: 'audioinput', deviceId: 'mic-1', label: 'Микрофон USB' },
|
||||
{ kind: 'audioinput', deviceId: 'mic-2', label: '' },
|
||||
{ kind: 'videoinput', deviceId: 'cam-1', label: 'Веб-камера HD' },
|
||||
{ kind: 'audiooutput', deviceId: 'out-1', label: 'Наушники' },
|
||||
];
|
||||
|
||||
/** Подменяет `navigator.mediaDevices` (в jsdom его нет). */
|
||||
function installMediaDevices(list = devices) {
|
||||
const mediaDevices = {
|
||||
enumerateDevices: vi.fn(() => Promise.resolve(list)),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: mediaDevices });
|
||||
return mediaDevices;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'mediaDevices');
|
||||
});
|
||||
|
||||
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||
// Дополнительные маршруты идут первыми: при одинаковом шаблоне побеждает
|
||||
// первый зарегистрированный, поэтому тест может переопределить профиль.
|
||||
return [
|
||||
...extra,
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Панель пользователя после загрузки оболочки. */
|
||||
async function bootPanel(extra: FetchRoute[] = []) {
|
||||
installFetch(baseRoutes(extra));
|
||||
renderApp('/app/empty');
|
||||
return screen.findByLabelText('Панель пользователя');
|
||||
}
|
||||
|
||||
function storedPrefs(): Record<string, unknown> {
|
||||
const raw = window.localStorage.getItem(VOICE_STORAGE_KEY);
|
||||
return raw === null ? {} : (JSON.parse(raw) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('панель пользователя: устройства', () => {
|
||||
it('кнопки микрофона, камеры и наушников переключают состояние и сохраняют его', async () => {
|
||||
const panel = await bootPanel();
|
||||
|
||||
const mic = within(panel).getByLabelText('Выключить микрофон');
|
||||
expect(mic).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await userEvent.click(mic);
|
||||
|
||||
// Иконка микрофона перечёркнута, а состояние ушло в localStorage.
|
||||
const muted = within(panel).getByLabelText('Включить микрофон');
|
||||
expect(muted).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(storedPrefs()).toMatchObject({ micEnabled: false });
|
||||
|
||||
const camera = within(panel).getByLabelText('Включить камеру');
|
||||
expect(camera).toHaveAttribute('aria-pressed', 'false');
|
||||
await userEvent.click(camera);
|
||||
expect(within(panel).getByLabelText('Выключить камеру')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
const headphones = within(panel).getByLabelText('Выключить звук');
|
||||
await userEvent.click(headphones);
|
||||
expect(within(panel).getByLabelText('Включить звук')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(storedPrefs()).toMatchObject({
|
||||
micEnabled: false,
|
||||
cameraEnabled: true,
|
||||
headphonesEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('стрелка микрофона открывает список устройств ввода и слайдер громкости', async () => {
|
||||
installMediaDevices();
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
||||
|
||||
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
||||
expect(await within(popover).findByRole('option', { name: 'Микрофон USB' })).toBeVisible();
|
||||
// Устройство без подписи показываем как «Устройство 2».
|
||||
expect(within(popover).getByRole('option', { name: 'Устройство 2' })).toBeVisible();
|
||||
expect(
|
||||
within(popover).getByRole('option', { name: 'Устройство по умолчанию' }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await userEvent.click(within(popover).getByRole('option', { name: 'Микрофон USB' }));
|
||||
expect(storedPrefs()).toMatchObject({ micDeviceId: 'mic-1' });
|
||||
|
||||
const volume = within(popover).getByLabelText('Громкость микрофона');
|
||||
fireEvent.change(volume, { target: { value: '42' } });
|
||||
expect(storedPrefs()).toMatchObject({ micVolume: 42 });
|
||||
});
|
||||
|
||||
it('стрелка камеры показывает устройства videoinput', async () => {
|
||||
installMediaDevices();
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки камеры'));
|
||||
|
||||
const popover = await screen.findByRole('dialog', { name: 'Настройки камеры' });
|
||||
expect(await within(popover).findByRole('option', { name: 'Веб-камера HD' })).toBeVisible();
|
||||
expect(within(popover).queryByRole('option', { name: 'Микрофон USB' })).toBeNull();
|
||||
});
|
||||
|
||||
it('стрелка наушников показывает устройства вывода и громкость звука', async () => {
|
||||
installMediaDevices();
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки звука'));
|
||||
|
||||
const popover = await screen.findByRole('dialog', { name: 'Настройки звука' });
|
||||
await userEvent.click(await within(popover).findByRole('option', { name: 'Наушники' }));
|
||||
expect(storedPrefs()).toMatchObject({ outputDeviceId: 'out-1' });
|
||||
|
||||
fireEvent.change(within(popover).getByLabelText('Громкость звука'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
expect(storedPrefs()).toMatchObject({ outputVolume: 10 });
|
||||
});
|
||||
|
||||
it('без navigator.mediaDevices показывает «Устройства недоступны»', async () => {
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
||||
|
||||
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
||||
expect(await within(popover).findByText('Устройства недоступны')).toBeVisible();
|
||||
});
|
||||
|
||||
it('ссылка «Аудио-видео настройки» открывает окно настроек на этом пункте', async () => {
|
||||
installMediaDevices();
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
||||
const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
||||
await userEvent.click(
|
||||
await within(popover).findByRole('button', { name: 'Аудио-видео настройки' }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Аудио-видео' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
);
|
||||
// Поповер устройства закрылся.
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull();
|
||||
});
|
||||
|
||||
it('поповеры закрываются по Escape и не открываются одновременно', async () => {
|
||||
installMediaDevices();
|
||||
const panel = await bootPanel();
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки микрофона'));
|
||||
await screen.findByRole('dialog', { name: 'Настройки микрофона' });
|
||||
|
||||
// Открытие второй панели закрывает первую.
|
||||
await userEvent.click(within(panel).getByLabelText('Настройки звука'));
|
||||
await screen.findByRole('dialog', { name: 'Настройки звука' });
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Настройки звука' })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('панель пользователя: профиль', () => {
|
||||
it('показывает имя, статус и цветную точку статуса', async () => {
|
||||
installFetch(
|
||||
baseRoutes([
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
response: () => json({ user: makeUser({ status: 'idle' }) }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
renderApp('/app/empty');
|
||||
|
||||
const panel = await screen.findByLabelText('Панель пользователя');
|
||||
expect(within(panel).getByText('Alice')).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(within(panel).getByText('Неактивен')).toBeVisible();
|
||||
});
|
||||
expect(within(panel).getByTestId('user-status-dot')).toHaveClass('bg-warning');
|
||||
});
|
||||
|
||||
it('сохраняет предпочтения устройств в localStorage', async () => {
|
||||
await bootPanel();
|
||||
|
||||
useVoiceStore.getState().toggleMic();
|
||||
useVoiceStore.getState().setOutputVolume(30);
|
||||
|
||||
expect(storedPrefs()).toMatchObject({
|
||||
micEnabled: false,
|
||||
outputVolume: 30,
|
||||
headphonesEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('читает сохранённые предпочтения при загрузке модуля', async () => {
|
||||
window.localStorage.setItem(
|
||||
VOICE_STORAGE_KEY,
|
||||
JSON.stringify({ micEnabled: false, micVolume: 20 }),
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const fresh = await import('@/stores/voice');
|
||||
|
||||
expect(fresh.useVoiceStore.getState().micEnabled).toBe(false);
|
||||
expect(fresh.useVoiceStore.getState().micVolume).toBe(20);
|
||||
// Неизвестные поля берут значения по умолчанию.
|
||||
expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user