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 { 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(
, ); 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(); 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(); const image = container.querySelector('img'); // Первый кадр рисуется из картинки: пока она не загрузилась, показываем // исходную ссылку и помечаем её как анимированную. expect(image?.getAttribute('src')).toBe('/files/bg-1'); expect(image?.getAttribute('data-animated')).toBe('animated'); }); });