Files
glchat/web/tests/profileCosmetics.test.tsx
T

325 lines
12 KiB
TypeScript
Raw Normal View History

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
import {
bodyOf,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
resetStores,
waitForPage,
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();
});
});
});
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' });
});
});