feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID
Правки пользователя: - в «Профиль → Основной» сверху квадратный аватар слева, справа от него настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара — клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке; - убраны дубли: часовой пояс только в «Язык и регион», статус — только в мини-профиле панели; - панель пользователя переехала в нижний левый угол под сайдбар, подсказка о выходе из сервера — на её прежнее место; панель ужата под 240 px; - шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов, общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только для ника и «Применить ко всем серверам»; - окно настроек получило статический размер 70% рабочей области со скроллом; - ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора; - список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх; - 249 тестов Vitest (было 239), check/lint/test/build зелёные.
This commit is contained in:
@@ -109,6 +109,18 @@ async function dispatchEvent(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрывает список друзей: по умолчанию он свёрнут под кнопкой
|
||||
* «Друзья (N)» и разворачивается снизу вверх.
|
||||
*/
|
||||
async function expandFriends(): Promise<HTMLElement> {
|
||||
const toggle = await screen.findByTestId('friends-list-toggle');
|
||||
if (toggle.getAttribute('aria-expanded') !== 'true') {
|
||||
await userEvent.click(toggle);
|
||||
}
|
||||
return screen.findByTestId('friends-list');
|
||||
}
|
||||
|
||||
describe('раздел друзей', () => {
|
||||
it('первой иконкой рейки открывает раздел друзей', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
@@ -259,7 +271,8 @@ describe('раздел друзей', () => {
|
||||
expect(
|
||||
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
|
||||
).toBeDefined();
|
||||
expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол');
|
||||
await expandFriends();
|
||||
expect(screen.getByTestId('friends-list')).toHaveTextContent('Кэрол');
|
||||
|
||||
const outgoing = screen.getByTestId('friends-outgoing');
|
||||
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
|
||||
@@ -307,6 +320,84 @@ describe('раздел друзей', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('список друзей свёрнут по умолчанию и раскрывается кнопкой', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
const fetchMock = installFetch(
|
||||
baseRoutes({ ...empty, friends: [bob] }, [
|
||||
{
|
||||
match: '/api/v1/users/@me/channels',
|
||||
method: 'POST',
|
||||
response: () => json({ channel: directChannel('dm-1') }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const { router } = renderApp('/app/friends');
|
||||
|
||||
// Свёрнут: списка в DOM нет, кнопка сообщает состояние и число друзей.
|
||||
const toggle = await screen.findByTestId('friends-list-toggle');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(toggle).toHaveTextContent('Друзья (1)');
|
||||
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||
expect(screen.getByTestId('friends-list-region')).toHaveAttribute('data-state', 'closed');
|
||||
|
||||
// Развёрнутый список въезжает снизу: класс анимации и состояние open.
|
||||
await visitor.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
const region = screen.getByTestId('friends-list-region');
|
||||
expect(region).toHaveAttribute('data-state', 'open');
|
||||
expect(region).toHaveClass('gl-reveal-up');
|
||||
|
||||
// Развёрнутый список работает как раньше: переход в беседу по клику.
|
||||
const list = await screen.findByTestId('friends-list');
|
||||
await visitor.click(within(list).getByTestId('friend-name-user-2'));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
|
||||
});
|
||||
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
|
||||
recipient_id: 'user-2',
|
||||
});
|
||||
|
||||
// На странице беседы списка нет: состояние раскрытия переживает возврат.
|
||||
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('true');
|
||||
await visitor.click(screen.getByTestId('friends-rail-link'));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/friends');
|
||||
});
|
||||
|
||||
// Свёрнутое состояние тоже запоминается.
|
||||
await visitor.click(await screen.findByTestId('friends-list-toggle'));
|
||||
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('заявки в друзья остаются видимыми при свёрнутом списке', async () => {
|
||||
installFetch(
|
||||
baseRoutes({
|
||||
...empty,
|
||||
friends: [bob],
|
||||
incoming: [
|
||||
relationship({
|
||||
user_id: 'user-3',
|
||||
username: 'carol',
|
||||
display_name: 'Кэрол',
|
||||
relationship: 'incoming',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderApp('/app/friends');
|
||||
|
||||
expect(await screen.findByTestId('friends-incoming')).toHaveTextContent('Кэрол');
|
||||
expect(await screen.findByTestId('friends-list-toggle')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
// Бейджи непрочитанного в беседах живут в сайдбаре и от списка не зависят.
|
||||
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('рисует цветные точки статусов в списке друзей', async () => {
|
||||
installFetch(
|
||||
baseRoutes({
|
||||
@@ -348,7 +439,7 @@ describe('раздел друзей', () => {
|
||||
);
|
||||
|
||||
renderApp('/app/friends');
|
||||
const list = await screen.findByTestId('friends-list');
|
||||
const list = await expandFriends();
|
||||
|
||||
const dots: [string, string, string][] = [
|
||||
['user-2', 'online', 'bg-success'],
|
||||
@@ -393,7 +484,7 @@ describe('раздел друзей', () => {
|
||||
);
|
||||
|
||||
renderApp('/app/friends');
|
||||
await screen.findByTestId('friends-list');
|
||||
await expandFriends();
|
||||
|
||||
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
|
||||
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||
@@ -441,7 +532,7 @@ describe('раздел друзей', () => {
|
||||
);
|
||||
|
||||
renderApp('/app/friends');
|
||||
await screen.findByTestId('friends-list');
|
||||
await expandFriends();
|
||||
|
||||
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
|
||||
@@ -480,7 +571,7 @@ describe('раздел друзей', () => {
|
||||
);
|
||||
|
||||
const { router } = renderApp('/app/friends');
|
||||
const list = await screen.findByTestId('friends-list');
|
||||
const list = await expandFriends();
|
||||
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -664,7 +755,7 @@ describe('события шлюза о друзьях', () => {
|
||||
hasFriend = true;
|
||||
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
|
||||
|
||||
const list = await screen.findByTestId('friends-list');
|
||||
const list = await expandFriends();
|
||||
expect(list).toHaveTextContent('Боб');
|
||||
});
|
||||
|
||||
@@ -685,7 +776,7 @@ describe('события шлюза о друзьях', () => {
|
||||
);
|
||||
|
||||
renderApp('/app/friends');
|
||||
const list = await screen.findByTestId('friends-list');
|
||||
const list = await expandFriends();
|
||||
await gateway.greet();
|
||||
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
|
||||
'data-status',
|
||||
|
||||
+15
-6
@@ -117,18 +117,27 @@ export function findRequest(
|
||||
}
|
||||
|
||||
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
|
||||
export function resetStores(): void {
|
||||
useSessionStore.getState().reset();
|
||||
useMessagesStore.getState().reset();
|
||||
useVoiceStore.getState().reset();
|
||||
useFriendsStore.getState().reset();
|
||||
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
||||
/**
|
||||
* Сброс UI-стора к начальному состоянию: окно настроек закрыто, список друзей
|
||||
* свёрнут. Нужен и внутри `renderApp`, и в тестах, которые меняют настройки
|
||||
* вида без перерисовки приложения.
|
||||
*/
|
||||
export function resetUiStore(): void {
|
||||
useUiStore.setState({
|
||||
guildCreateOpen: false,
|
||||
settingsOpen: false,
|
||||
settingsSection: 'profile',
|
||||
settingsProfileTab: 'main',
|
||||
friendsListOpen: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStores(): void {
|
||||
useSessionStore.getState().reset();
|
||||
useMessagesStore.getState().reset();
|
||||
useVoiceStore.getState().reset();
|
||||
useFriendsStore.getState().reset();
|
||||
resetUiStore();
|
||||
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
||||
// (переподключение, статус) меняли бы стор уже следующего.
|
||||
useGatewayStore.getState().reset();
|
||||
|
||||
+29
-13
@@ -121,16 +121,15 @@ function saveNear(label: string): HTMLElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов».
|
||||
* Так же их открывает и обычный участник без прав администратора.
|
||||
* Открывает администрирование сервера: приложение → мини-профиль →
|
||||
* «Настройки серверов» → пункт «Сервер». Так же его открывает шестерёнка
|
||||
* в шапке сервера.
|
||||
*/
|
||||
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: 'Настройки' });
|
||||
const dialog = await openSettingsWindow();
|
||||
const nav = within(dialog).getByTestId('settings-nav');
|
||||
await userEvent.click(await within(nav).findByRole('button', { name: 'Сервер' }));
|
||||
|
||||
if (guildName !== undefined) {
|
||||
const picker = await openGuildPicker();
|
||||
@@ -139,7 +138,24 @@ async function openServerSettings(path = '/app/empty', guildName?: string) {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */
|
||||
/**
|
||||
* Открывает «Профиль → Сервер»: личный ник и «Применить ко всем серверам».
|
||||
* Доступно и обычному участнику без прав администратора.
|
||||
*/
|
||||
async function openProfileServerTab(path = '/app/empty') {
|
||||
const view = renderApp(path);
|
||||
await openSettingsWindow();
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Сервер' }));
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Окно настроек открывается шестерёнкой в панели пользователя. */
|
||||
async function openSettingsWindow(): Promise<HTMLElement> {
|
||||
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||
}
|
||||
|
||||
/** Раскрывает выпадающий список серверов в настройках. */
|
||||
async function openGuildPicker(): Promise<HTMLElement> {
|
||||
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
|
||||
return screen.findByRole('menu', { name: 'Мои серверы' });
|
||||
@@ -210,7 +226,7 @@ describe('настройки серверов', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('сохраняет свой никнейм через PATCH /guilds/{id}/members/{me}', async () => {
|
||||
it('сохраняет свой никнейм в «Профиль → Сервер» через PATCH /guilds/{id}/members/{me}', async () => {
|
||||
const fetchMock = installFetch(
|
||||
serversRoutes([
|
||||
{
|
||||
@@ -221,7 +237,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
await openServerSettings();
|
||||
await openProfileServerTab();
|
||||
|
||||
const nickname = await screen.findByLabelText('Никнейм');
|
||||
await waitFor(() => {
|
||||
@@ -239,7 +255,7 @@ describe('настройки серверов', () => {
|
||||
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
||||
});
|
||||
|
||||
it('применяет введённый ник ко всем серверам по очереди', async () => {
|
||||
it('применяет введённый ник ко всем серверам по очереди (вкладка профиля)', async () => {
|
||||
const fetchMock = installFetch(
|
||||
serversRoutes([
|
||||
{
|
||||
@@ -255,7 +271,7 @@ describe('настройки серверов', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
await openServerSettings();
|
||||
await openProfileServerTab();
|
||||
|
||||
const nickname = await screen.findByLabelText('Никнейм');
|
||||
await userEvent.clear(nickname);
|
||||
@@ -387,7 +403,7 @@ describe('настройки серверов', () => {
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
]);
|
||||
|
||||
await openServerSettings();
|
||||
await openProfileServerTab();
|
||||
|
||||
const content = screen.getByTestId('settings-content');
|
||||
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
||||
|
||||
+213
-16
@@ -35,13 +35,46 @@ const sessions = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Ответы оболочки приложения: профиль, серверы, инстанс. */
|
||||
/**
|
||||
* Ответы оболочки приложения: профиль, серверы, инстанс. Дополнительные
|
||||
* маршруты идут первыми — они должны побеждать при одинаковом шаблоне.
|
||||
*/
|
||||
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
|
||||
return [
|
||||
...extra,
|
||||
{ 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,
|
||||
];
|
||||
}
|
||||
|
||||
/** Сводка сервера для ответов `GET /guilds/{id}`. */
|
||||
function guildDetail(id: string, name: string, ownerId: string, permissions: string[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: '',
|
||||
owner_id: ownerId,
|
||||
is_main: false,
|
||||
member_count: 1,
|
||||
roles: [],
|
||||
my_role_ids: [],
|
||||
my_permissions: permissions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Серверы пользователя: «Альфа» своя, «Бета» с правом MANAGE_GUILD. */
|
||||
function guildRoute(): FetchRoute[] {
|
||||
const alpha = guildDetail('g-1', 'Альфа', 'user-1', []);
|
||||
const beta = guildDetail('g-2', 'Бета', 'user-9', ['MANAGE_GUILD']);
|
||||
return [
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
||||
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [] }) },
|
||||
{ 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', response: () => json({ guild: alpha }) },
|
||||
{ match: '/api/v1/guilds/g-2', response: () => json({ guild: beta }) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,6 +85,16 @@ async function openSettings(): Promise<HTMLElement> {
|
||||
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||
}
|
||||
|
||||
/** Список пунктов окна настроек. */
|
||||
function settingsNav(dialog: HTMLElement): HTMLElement {
|
||||
return within(dialog).getByTestId('settings-nav');
|
||||
}
|
||||
|
||||
/** Открывает пункт «Сервер» (администрирование) в левом меню настроек. */
|
||||
async function openServerSection(dialog: HTMLElement): Promise<void> {
|
||||
await userEvent.click(await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }));
|
||||
}
|
||||
|
||||
/** Переходит к пункту настроек по его названию в левом меню. */
|
||||
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
|
||||
await userEvent.click(within(dialog).getByRole('button', { name }));
|
||||
@@ -136,6 +179,41 @@ describe('настройки: окно и навигация', () => {
|
||||
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
|
||||
});
|
||||
|
||||
it('пункт «Сервер» видят только те, кто может управлять сервером', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
// Ни своего сервера, ни прав администратора инстанса — пункта нет.
|
||||
expect(within(settingsNav(dialog)).queryByRole('button', { name: 'Сервер' })).toBeNull();
|
||||
});
|
||||
|
||||
it('владелец сервера открывает администрирование этого сервера', async () => {
|
||||
installFetch(appRoutes(user, guildRoute()));
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
// Пункт появляется, когда снапшот сессии узнал серверы пользователя.
|
||||
await openServerSection(dialog);
|
||||
|
||||
// Выбран сервер пользователя: его общие настройки и «Опасная зона».
|
||||
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
||||
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||
expect(within(dialog).getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('администратор инстанса видит пункт «Сервер» без своих серверов', async () => {
|
||||
installFetch(appRoutes(makeUser({ is_instance_admin: true })));
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
expect(
|
||||
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('пункт «Инстанс» видят только администраторы', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
@@ -230,16 +308,27 @@ describe('настройки: старые адреса', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('/settings/servers открывает вкладку «Сервер»', async () => {
|
||||
installFetch(appRoutes());
|
||||
it('/settings/servers открывает пункт «Сервер» администрирования', async () => {
|
||||
installFetch(appRoutes(user, guildRoute()));
|
||||
|
||||
renderApp('/settings/servers');
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(
|
||||
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
|
||||
).toHaveAttribute('aria-current', 'true');
|
||||
// Открылся первый сервер списка.
|
||||
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
||||
});
|
||||
|
||||
it('/settings/servers/g-2 открывает администрирование выбранного сервера', async () => {
|
||||
installFetch(appRoutes(user, guildRoute()));
|
||||
|
||||
renderApp('/settings/servers/g-2');
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Бета');
|
||||
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Бета');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,29 +413,137 @@ describe('настройки: аватар', () => {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
|
||||
});
|
||||
|
||||
it('часовой пояс уходит в PATCH вместе с профилем', async () => {
|
||||
it('профиль сохраняется без часового пояса и статуса: у них свои места', async () => {
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }),
|
||||
response: () => json({ user }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
await openSettings();
|
||||
const dialog = await openSettings();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('Статус присутствия')).toBeNull();
|
||||
// ID не показываем и не копируем в пользовательских настройках.
|
||||
expect(within(dialog).queryByText('ID пользователя')).toBeNull();
|
||||
expect(within(dialog).queryByRole('button', { name: 'Скопировать ID' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('Отображаемое имя')).toHaveValue('Alice');
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({
|
||||
timezone: 'Asia/Tokyo',
|
||||
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
|
||||
display_name: 'Alice',
|
||||
bio: '',
|
||||
custom_status: '',
|
||||
custom_status_emoji: '',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('часовой пояс есть ровно в одном пункте — «Язык и регион»', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
for (const item of ['Профиль', 'Внешний вид', 'Уведомления', 'Аудио-видео']) {
|
||||
await goToSection(dialog, item);
|
||||
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
|
||||
}
|
||||
|
||||
await goToSection(dialog, 'Язык и регион');
|
||||
expect(within(dialog).getAllByLabelText('Часовой пояс')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('аватар стоит слева от полей, клик по нему открывает выбор файла', async () => {
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(user, [
|
||||
{
|
||||
match: '/api/v1/users/@me/avatar',
|
||||
method: 'POST',
|
||||
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
// Аватар — слева: кнопка смены идёт в DOM раньше поля имени.
|
||||
const avatar = within(dialog).getByTestId('profile-avatar');
|
||||
const displayName = within(dialog).getByLabelText('Отображаемое имя');
|
||||
expect(avatar.compareDocumentPosition(displayName)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
// Подсказка о форматах — в title и доступном имени кнопки, а не текстом.
|
||||
const change = within(dialog).getByRole('button', { name: /Сменить аватар/ });
|
||||
expect(change).toHaveAttribute('title', expect.stringContaining('JPEG'));
|
||||
expect(within(dialog).queryByText(/PNG, JPEG, WebP/)).toBeNull();
|
||||
|
||||
// Клик по аватару открывает выбор файла: он же отправляет multipart.
|
||||
const input = within(dialog).getByLabelText('Выбрать файл аватара');
|
||||
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
|
||||
expect(change).toBeVisible();
|
||||
expect(document.querySelector('input[type="file"]')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('размер окна настроек фиксирован и не зависит от содержимого', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
|
||||
const panel = dialog;
|
||||
const style = panel.getAttribute('style') ?? '';
|
||||
expect(style).toContain('clamp(20rem, 70vw, 72rem)');
|
||||
expect(style).toContain('clamp(22rem, 70vh, 52rem)');
|
||||
expect(panel).toHaveClass('overflow-hidden');
|
||||
expect(screen.getByTestId('settings-content')).toHaveClass('overflow-y-auto');
|
||||
|
||||
// Переключение пункта не меняет размеры окна.
|
||||
const before = panel.getAttribute('style');
|
||||
await goToSection(dialog, 'Безопасность');
|
||||
expect(panel.getAttribute('style')).toBe(before);
|
||||
});
|
||||
|
||||
it('ID пользователя в «Инстансе» виден администратору', async () => {
|
||||
installFetch(
|
||||
appRoutes(makeUser({ is_instance_admin: true }), [
|
||||
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||
{
|
||||
match: '/api/v1/instance/users',
|
||||
response: () =>
|
||||
json({
|
||||
users: [
|
||||
{
|
||||
id: 'u-42',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: true,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Инстанс');
|
||||
|
||||
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
||||
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@ describe('основной экран', () => {
|
||||
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
||||
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||
|
||||
// Панель пользователя стоит под сайдбаром в левой колонке, а подсказка о
|
||||
// выходе с сервера — внизу области контента.
|
||||
const column = screen.getByTestId('app-sidebar-column');
|
||||
const sidebar = screen.getByRole('complementary', { name: 'Комнаты' });
|
||||
expect(column).toContainElement(panel);
|
||||
expect(sidebar.compareDocumentPosition(panel)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(screen.getByTestId('guild-leave-hint')).toHaveTextContent('Опасная зона');
|
||||
|
||||
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
||||
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
||||
@@ -155,7 +163,7 @@ describe('основной экран', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('шестерёнка настроек сервера видна владельцу и открывает окно настроек', async () => {
|
||||
it('шестерёнка настроек сервера видна владельцу и открывает администрирование этого сервера', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const visitor = userEvent.setup();
|
||||
installFetch([
|
||||
@@ -194,10 +202,15 @@ describe('основной экран', () => {
|
||||
// Настройки — модальное окно: из комнаты никуда не уходим.
|
||||
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',
|
||||
// Открылся пункт «Сервер» (администрирование) с этим же сервером.
|
||||
const nav = within(dialog).getByTestId('settings-nav');
|
||||
expect(within(nav).getByRole('button', { name: 'Сервер' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
);
|
||||
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent(
|
||||
'Основной сервер',
|
||||
);
|
||||
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер');
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ describe('меню пользователя', () => {
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
||||
|
||||
// ID пользователя в интерфейсе не показываем и не копируем.
|
||||
expect(within(menu).queryByRole('menuitem', { name: 'Скопировать ID' })).toBeNull();
|
||||
expect(menu).not.toHaveTextContent(user.id);
|
||||
});
|
||||
|
||||
it('шестерёнка открывает окно настроек', async () => {
|
||||
|
||||
Reference in New Issue
Block a user