Files
glchat/web/tests/profileCosmetics.test.tsx
T
grendervill a36ee9527b feat(cosmetics): оформление «для друзей» в друзьях и личных беседах
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом
  на весь список (AGENT.md 7.2)
- клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных
  бесед — из профиля собеседника
- тесты: Go на список друзей, Vitest на отрисовку в друзьях
2026-09-21 01:48:56 +03:00

325 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
});
});