Files
glchat/web/tests/profileCosmetics.test.tsx
T
grendervill b43452aa04 feat(profile): системные бейджи рядом с ником
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
  список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
  ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
2026-09-21 18:10:47 +03:00

530 lines
20 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 { AnimatedImage } from '@/components/ui/AnimatedImage';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges';
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
import {
bodyOf,
findRequest,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
messagesRoutes,
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' });
});
});
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',
});
});
});
});
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');
});
});
describe('системные бейджи', () => {
it('показывает щит администратора инстанса и корону владельца', () => {
render(<SystemBadges badges={['instance_admin', 'early_supporter']} isOwner isInstanceAdmin />);
expect(screen.getByTestId('system-badge-instance_admin')).toHaveTextContent('🛡');
expect(screen.getByTestId('system-badge-guild_owner')).toHaveTextContent('👑');
expect(screen.getByTestId('system-badge-early_supporter')).toHaveAttribute(
'title',
'Ранний сторонник',
);
// Администратор инстанса помечен даже без записи в badges_json.
expect(screen.getByLabelText('Администратор инстанса')).toBeInTheDocument();
});
it('неизвестный бейдж показывается нейтральной меткой, а не пропадает', () => {
render(<SystemBadges badges={['future_badge']} />);
const badge = screen.getByTestId('system-badge-future_badge');
expect(badge).toHaveTextContent('•');
expect(badge).toHaveAttribute('title', 'future_badge');
});
it('без бейджей ничего не рисует', () => {
const { container } = render(<SystemBadges />);
expect(container.querySelector('[data-testid="system-badges"]')).toBeNull();
});
});