2026-09-21 01:28:48 +03:00
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
|
|
|
2026-09-21 18:08:21 +03:00
|
|
|
|
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
2026-09-21 01:28:48 +03:00
|
|
|
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
|
|
|
|
|
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
|
|
|
|
|
import {
|
|
|
|
|
|
bodyOf,
|
|
|
|
|
|
findRequest,
|
|
|
|
|
|
installFetch,
|
2026-09-21 18:08:21 +03:00
|
|
|
|
installGatewaySocket,
|
2026-09-21 01:28:48 +03:00
|
|
|
|
json,
|
2026-09-21 18:08:21 +03:00
|
|
|
|
makeReadySnapshot,
|
2026-09-21 01:28:48 +03:00
|
|
|
|
makeUser,
|
2026-09-21 18:08:21 +03:00
|
|
|
|
messagesRoutes,
|
2026-09-21 01:28:48 +03:00
|
|
|
|
recordedRequests,
|
|
|
|
|
|
renderApp,
|
|
|
|
|
|
resetStores,
|
2026-09-21 01:48:56 +03:00
|
|
|
|
waitForPage,
|
2026-09-21 01:28:48 +03:00
|
|
|
|
type FetchRoute,
|
|
|
|
|
|
} from './helpers';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Оформление профиля (AGENT.md 7.2, 7.4): отрисовка рамки, иконки, цвета и
|
|
|
|
|
|
* эффекта ника, редактор личного стиля и галерея оформления сервера.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const user = makeUser({ display_name: 'Аля' });
|
|
|
|
|
|
|
|
|
|
|
|
describe('отрисовка оформления', () => {
|
|
|
|
|
|
it('рисует рамку, иконку, цвет и эффект ника', () => {
|
|
|
|
|
|
render(
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<FramedAvatar
|
|
|
|
|
|
name="Аля"
|
|
|
|
|
|
seed="u-1"
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
cosmetics={{
|
|
|
|
|
|
frame_id: 'f-1',
|
|
|
|
|
|
badge_id: 'b-1',
|
|
|
|
|
|
nick_color: 0xe74c3c,
|
|
|
|
|
|
nick_effect: 'glow',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Nickname
|
|
|
|
|
|
name="Аля"
|
|
|
|
|
|
cosmetics={{ badge_id: 'b-1', nick_color: 0xe74c3c, nick_effect: 'glow' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const frame = screen.getByTestId('avatar-frame');
|
|
|
|
|
|
expect(frame.querySelector('img')?.getAttribute('src')).toBe('/files/f-1');
|
|
|
|
|
|
expect(screen.getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-1');
|
|
|
|
|
|
|
|
|
|
|
|
const nick = screen.getByTestId('nick-name');
|
|
|
|
|
|
expect(nick).toHaveTextContent('Аля');
|
|
|
|
|
|
expect(nick).toHaveClass('gl-nick', 'gl-nick-glow');
|
|
|
|
|
|
expect(nick).toHaveStyle({ color: '#e74c3c' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('без оформления показывает обычный аватар и ник', () => {
|
|
|
|
|
|
render(<Nickname name="Боб" />);
|
|
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByTestId('avatar-frame')).toBeNull();
|
|
|
|
|
|
expect(screen.queryByTestId('nick-badge')).toBeNull();
|
|
|
|
|
|
expect(screen.getByTestId('nick-name')).toHaveClass('gl-nick');
|
|
|
|
|
|
expect(screen.getByTestId('nick-name')).not.toHaveClass('gl-nick-glow');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('неизвестный эффект и нулевой цвет игнорируются', () => {
|
|
|
|
|
|
expect(nickEffectClass('unknown')).toBe('gl-nick');
|
|
|
|
|
|
expect(nickEffectClass(undefined)).toBe('gl-nick');
|
|
|
|
|
|
expect(nickEffectClass('fire')).toBe('gl-nick gl-nick-fire');
|
|
|
|
|
|
expect(nickColorStyle(0)).toBeUndefined();
|
|
|
|
|
|
expect(nickColorStyle(undefined)).toBeUndefined();
|
|
|
|
|
|
expect(nickColorStyle(0x2ecc71)).toEqual({ color: '#2ecc71' });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Маршруты окна настроек: профиль, стили и серверы. */
|
|
|
|
|
|
function settingsRoutes(styles: unknown[], cosmetics: unknown[] = []): FetchRoute[] {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ match: '/api/v1/users/@me/styles', response: () => json({ styles }) },
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/users/@me/guilds',
|
|
|
|
|
|
response: () =>
|
|
|
|
|
|
json({
|
|
|
|
|
|
guilds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
|
name: 'Сервер',
|
|
|
|
|
|
owner_id: 'user-1',
|
|
|
|
|
|
is_main: true,
|
|
|
|
|
|
member_count: 1,
|
|
|
|
|
|
my_role_ids: [],
|
|
|
|
|
|
my_permissions: ['MANAGE_ROLES'],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/cosmetics', response: () => json({ cosmetics }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds/g-1',
|
|
|
|
|
|
response: () =>
|
|
|
|
|
|
json({
|
|
|
|
|
|
guild: {
|
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
|
name: 'Сервер',
|
|
|
|
|
|
owner_id: 'user-1',
|
|
|
|
|
|
is_main: true,
|
|
|
|
|
|
member_count: 1,
|
|
|
|
|
|
my_role_ids: [],
|
|
|
|
|
|
my_permissions: ['MANAGE_ROLES', 'ADMINISTRATOR'],
|
|
|
|
|
|
roles: [],
|
|
|
|
|
|
channels: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stylesPayload = [
|
|
|
|
|
|
{
|
|
|
|
|
|
scope: 'global',
|
|
|
|
|
|
style: { frame_id: 'f-1' },
|
|
|
|
|
|
frame_ids: ['f-1', 'f-2'],
|
|
|
|
|
|
badge_ids: ['b-1'],
|
|
|
|
|
|
nick_colors: [0xe74c3c, 0x2ecc71],
|
|
|
|
|
|
effects: ['none', 'glow', 'gradient'],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
scope: 'g-1',
|
|
|
|
|
|
guild_id: 'g-1',
|
|
|
|
|
|
guild_name: 'Сервер',
|
|
|
|
|
|
style: {},
|
|
|
|
|
|
frame_ids: ['f-1'],
|
|
|
|
|
|
badge_ids: [],
|
|
|
|
|
|
nick_colors: [0xe74c3c],
|
|
|
|
|
|
effects: ['none', 'glow'],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
resetStores();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('редактор личного стиля', () => {
|
|
|
|
|
|
it('меняет рамку, иконку, цвет и эффект и сбрасывает стиль', async () => {
|
|
|
|
|
|
const fetchMock = installFetch(settingsRoutes(stylesPayload));
|
|
|
|
|
|
renderApp('/app');
|
|
|
|
|
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
|
|
|
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
|
|
|
|
|
|
const section = await screen.findByTestId('profile-style');
|
|
|
|
|
|
|
|
|
|
|
|
// Текущий стиль подставляется в предпросмотр.
|
|
|
|
|
|
expect(within(section).getByTestId('avatar-frame')).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
|
|
// Рамка: выбор другой рамки уходит на сервер.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('style-frame-f-2'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
bodyOf(findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'PUT' })),
|
|
|
|
|
|
).toMatchObject({ frame_id: 'f-2', nick_effect: 'none' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Иконка.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('style-badge-b-1'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
const calls = recordedRequests(fetchMock).filter(
|
|
|
|
|
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(calls.at(-1)?.body).toMatchObject({ badge_id: 'b-1' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Цвет из палитры.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('style-color-2ecc71'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
const calls = recordedRequests(fetchMock).filter(
|
|
|
|
|
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(calls.at(-1)?.body).toMatchObject({ nick_color: 0x2ecc71 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Эффект ника.
|
|
|
|
|
|
await userEvent.selectOptions(within(section).getByTestId('style-effect'), 'glow');
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
const calls = recordedRequests(fetchMock).filter(
|
|
|
|
|
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(calls.at(-1)?.body).toMatchObject({ nick_effect: 'glow' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Сброс стиля.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('style-reset'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'DELETE' }),
|
|
|
|
|
|
).toBeDefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('переключает область на пер-серверный стиль', async () => {
|
|
|
|
|
|
installFetch(settingsRoutes(stylesPayload));
|
|
|
|
|
|
renderApp('/app');
|
|
|
|
|
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
|
|
|
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
|
|
|
|
|
|
const section = await screen.findByTestId('profile-style');
|
|
|
|
|
|
|
|
|
|
|
|
expect(within(section).getByText('Для друзей (DM и глобальные места)')).toBeInTheDocument();
|
|
|
|
|
|
await userEvent.selectOptions(within(section).getByTestId('style-scope'), 'g-1');
|
|
|
|
|
|
|
|
|
|
|
|
// В пер-серверном стиле доступна только одна рамка и нет иконок.
|
|
|
|
|
|
expect(within(section).getByTestId('style-frame-f-1')).toBeInTheDocument();
|
|
|
|
|
|
expect(within(section).queryByTestId('style-frame-f-2')).toBeNull();
|
|
|
|
|
|
expect(within(section).getByText(/Роли пока не выдали ни одной иконки/u)).toBeInTheDocument();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('галерея оформления сервера', () => {
|
|
|
|
|
|
it('загружает элемент, переименовывает и удаляет его', async () => {
|
|
|
|
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
|
|
const cosmetic = {
|
|
|
|
|
|
id: 'c-1',
|
|
|
|
|
|
guild_id: 'g-1',
|
|
|
|
|
|
kind: 'frame',
|
|
|
|
|
|
name: 'Золотая',
|
|
|
|
|
|
file_id: 'file-1',
|
|
|
|
|
|
created_at: '2026-09-20T10:00:00Z',
|
|
|
|
|
|
};
|
|
|
|
|
|
const routes: FetchRoute[] = [
|
|
|
|
|
|
...settingsRoutes([], [cosmetic]),
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds/g-1/cosmetics',
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
response: () => json({ cosmetic }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds/g-1/cosmetics/c-1',
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
response: () => json({ cosmetic: { ...cosmetic, name: 'Новая' } }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds/g-1/cosmetics/c-1',
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
response: () => json({ ok: true }),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const fetchMock = installFetch(routes);
|
|
|
|
|
|
renderApp('/app');
|
|
|
|
|
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
|
|
|
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Сервер' }));
|
|
|
|
|
|
const section = await screen.findByTestId('guild-cosmetics');
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка файла: multipart-запрос с видом элемента.
|
|
|
|
|
|
const file = new File(['картинка'], 'frame.png', { type: 'image/png' });
|
|
|
|
|
|
await userEvent.upload(within(section).getByTestId('cosmetic-input-frame'), file);
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
recordedRequests(fetchMock).some(
|
|
|
|
|
|
(request) => request.method === 'POST' && request.url.includes('/cosmetics'),
|
|
|
|
|
|
),
|
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Переименование.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('cosmetic-rename-c-1'));
|
|
|
|
|
|
const nameInput = within(section).getByLabelText('Имя элемента');
|
|
|
|
|
|
await userEvent.clear(nameInput);
|
|
|
|
|
|
await userEvent.type(nameInput, 'Новая');
|
|
|
|
|
|
await userEvent.click(within(section).getByRole('button', { name: 'Сохранить' }));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
bodyOf(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'PATCH' })),
|
|
|
|
|
|
).toMatchObject({
|
|
|
|
|
|
name: 'Новая',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Удаление.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('cosmetic-delete-c-1'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'DELETE' })).toBeDefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-21 01:48:56 +03:00
|
|
|
|
|
|
|
|
|
|
describe('оформление «для друзей»', () => {
|
|
|
|
|
|
it('показывает рамку, иконку и эффект в списке друзей', async () => {
|
|
|
|
|
|
const friend = {
|
|
|
|
|
|
user_id: 'u-2',
|
|
|
|
|
|
username: 'bob',
|
|
|
|
|
|
display_name: 'Боб',
|
|
|
|
|
|
status: 'online',
|
|
|
|
|
|
visible_status: 'online',
|
|
|
|
|
|
is_instance_admin: false,
|
|
|
|
|
|
badges: [],
|
|
|
|
|
|
timezone: 'Europe/Moscow',
|
|
|
|
|
|
relationship: 'friend',
|
|
|
|
|
|
cosmetics: { frame_id: 'f-9', badge_id: 'b-9', nick_color: 0x2ecc71, nick_effect: 'glow' },
|
|
|
|
|
|
};
|
|
|
|
|
|
installFetch([
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/users/@me/relationships',
|
|
|
|
|
|
response: () => json({ friends: [friend], incoming: [], outgoing: [], blocked: [] }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) },
|
|
|
|
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
|
|
|
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
|
|
|
|
]);
|
|
|
|
|
|
renderApp('/app/friends');
|
|
|
|
|
|
await waitForPage('Друзья');
|
|
|
|
|
|
// Список друзей свёрнут по умолчанию: раскрываем его перед проверкой.
|
|
|
|
|
|
await userEvent.click(await screen.findByTestId('friends-list-toggle'));
|
|
|
|
|
|
|
|
|
|
|
|
const row = await screen.findByTestId('friend-name-u-2');
|
|
|
|
|
|
expect(within(row).getByTestId('avatar-frame')).toBeInTheDocument();
|
|
|
|
|
|
expect(within(row).getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-9');
|
|
|
|
|
|
expect(within(row).getByTestId('nick-name')).toHaveClass('gl-nick-glow');
|
|
|
|
|
|
expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-21 18:04:52 +03:00
|
|
|
|
|
|
|
|
|
|
describe('шаблоны серверов', () => {
|
|
|
|
|
|
it('создаёт сервер по выбранному шаблону', async () => {
|
|
|
|
|
|
const templates = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'empty',
|
|
|
|
|
|
name: 'Пустой',
|
|
|
|
|
|
description: 'Одна общая текстовая комната.',
|
|
|
|
|
|
roles: ['Администратор', 'Пользователь'],
|
|
|
|
|
|
text_channels: 1,
|
|
|
|
|
|
voice_channels: 0,
|
|
|
|
|
|
categories: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'gaming',
|
|
|
|
|
|
name: 'Игровой',
|
|
|
|
|
|
description: 'Текстовые комнаты и две голосовые.',
|
|
|
|
|
|
roles: ['Администратор', 'Пользователь', 'Игрок'],
|
|
|
|
|
|
text_channels: 2,
|
|
|
|
|
|
voice_channels: 2,
|
|
|
|
|
|
categories: ['Текстовые', 'Голосовые'],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const fetchMock = installFetch([
|
|
|
|
|
|
{ match: '/api/v1/guild-templates', response: () => json({ templates }) },
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds',
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
response: () =>
|
|
|
|
|
|
json({
|
|
|
|
|
|
guild: {
|
|
|
|
|
|
id: 'g-new',
|
|
|
|
|
|
name: 'Новый',
|
|
|
|
|
|
owner_id: 'user-1',
|
|
|
|
|
|
is_main: false,
|
|
|
|
|
|
member_count: 1,
|
|
|
|
|
|
my_role_ids: [],
|
|
|
|
|
|
my_permissions: ['ADMINISTRATOR'],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ 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('/app');
|
|
|
|
|
|
await userEvent.click(await screen.findByLabelText('Создать сервер'));
|
|
|
|
|
|
|
|
|
|
|
|
const picker = await screen.findByTestId('guild-templates');
|
|
|
|
|
|
await userEvent.click(within(picker).getByTestId('guild-template-gaming'));
|
|
|
|
|
|
await userEvent.type(screen.getByLabelText('Название сервера'), 'Новый');
|
|
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Создать' }));
|
|
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toMatchObject({
|
|
|
|
|
|
name: 'Новый',
|
|
|
|
|
|
template_id: 'gaming',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-21 18:08:21 +03:00
|
|
|
|
|
|
|
|
|
|
describe('фон комнаты', () => {
|
|
|
|
|
|
it('показывает фон в ленте и позволяет его сменить', async () => {
|
|
|
|
|
|
const channel = {
|
|
|
|
|
|
id: 'c-1',
|
|
|
|
|
|
name: 'общий',
|
|
|
|
|
|
type: 'text' as const,
|
|
|
|
|
|
position: 0,
|
|
|
|
|
|
background_file_id: 'bg-1',
|
|
|
|
|
|
};
|
|
|
|
|
|
const routes: FetchRoute[] = [
|
|
|
|
|
|
{ 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' } }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
|
|
|
|
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/guilds/g-1',
|
|
|
|
|
|
response: () =>
|
|
|
|
|
|
json({
|
|
|
|
|
|
guild: {
|
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
|
name: 'Сервер',
|
|
|
|
|
|
owner_id: 'user-1',
|
|
|
|
|
|
is_main: true,
|
|
|
|
|
|
member_count: 1,
|
|
|
|
|
|
my_role_ids: [],
|
|
|
|
|
|
my_permissions: ['MANAGE_CHANNEL_BACKGROUND', 'ADMINISTRATOR'],
|
|
|
|
|
|
roles: [],
|
|
|
|
|
|
channels: [channel],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ match: '/api/v1/channels/c-1/webhooks', response: () => json({ webhooks: [] }) },
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/channels/c-1/background',
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
response: () => json({ channel: { ...channel, background_file_id: 'bg-2' } }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
match: '/api/v1/channels/c-1/background',
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
response: () => json({ channel: { ...channel, background_file_id: undefined } }),
|
|
|
|
|
|
},
|
|
|
|
|
|
...messagesRoutes('c-1', []),
|
|
|
|
|
|
];
|
|
|
|
|
|
const fetchMock = installFetch(routes);
|
|
|
|
|
|
const snapshot = makeReadySnapshot([
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
|
name: 'Сервер',
|
|
|
|
|
|
is_main: true,
|
|
|
|
|
|
my_permissions: ['MANAGE_CHANNEL_BACKGROUND', 'ADMINISTRATOR'],
|
|
|
|
|
|
channels: [channel],
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
const gateway = installGatewaySocket(snapshot);
|
|
|
|
|
|
renderApp('/app/g-1/c-1');
|
|
|
|
|
|
await gateway.greet();
|
|
|
|
|
|
await screen.findByTestId('message-list');
|
|
|
|
|
|
|
|
|
|
|
|
// Фон рисуется за лентой вместе с затемнением.
|
|
|
|
|
|
const overlay = screen.getByTestId('channel-background-overlay');
|
|
|
|
|
|
const image = overlay.parentElement?.querySelector('img');
|
|
|
|
|
|
expect(image?.getAttribute('src')).toBe('/files/bg-1');
|
|
|
|
|
|
|
|
|
|
|
|
// Секция настроек комнаты: загрузка нового фона.
|
|
|
|
|
|
await userEvent.click(screen.getByTestId('channel-settings'));
|
|
|
|
|
|
const section = await screen.findByTestId('channel-background');
|
|
|
|
|
|
await userEvent.upload(
|
|
|
|
|
|
within(section).getByTestId('channel-background-input'),
|
|
|
|
|
|
new File(['фон'], 'bg.png', { type: 'image/png' }),
|
|
|
|
|
|
);
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
recordedRequests(fetchMock).some(
|
|
|
|
|
|
(request) => request.method === 'POST' && request.url.includes('/background'),
|
|
|
|
|
|
),
|
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Удаление фона.
|
|
|
|
|
|
await userEvent.click(within(section).getByTestId('channel-background-remove'));
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
recordedRequests(fetchMock).some(
|
|
|
|
|
|
(request) => request.method === 'DELETE' && request.url.includes('/background'),
|
|
|
|
|
|
),
|
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('при «уменьшить движение» подменяет анимацию первым кадром', () => {
|
|
|
|
|
|
const listeners: ((event: MediaQueryListEvent) => void)[] = [];
|
|
|
|
|
|
vi.stubGlobal('matchMedia', (query: string) => ({
|
|
|
|
|
|
matches: query.includes('reduced-motion'),
|
|
|
|
|
|
media: query,
|
|
|
|
|
|
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => {
|
|
|
|
|
|
listeners.push(listener);
|
|
|
|
|
|
},
|
|
|
|
|
|
removeEventListener: () => undefined,
|
|
|
|
|
|
}));
|
|
|
|
|
|
const { container } = render(<AnimatedImage src="/files/bg-1" />);
|
|
|
|
|
|
const image = container.querySelector('img');
|
|
|
|
|
|
// Первый кадр рисуется из картинки: пока она не загрузилась, показываем
|
|
|
|
|
|
// исходную ссылку и помечаем её как анимированную.
|
|
|
|
|
|
expect(image?.getAttribute('src')).toBe('/files/bg-1');
|
|
|
|
|
|
expect(image?.getAttribute('data-animated')).toBe('animated');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|