feat(web): оформление сервера в настройках, рейке и приглашении
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета. - секция «Оформление» в настройках сервера: загрузка и удаление иконки, баннера и splash с превью, проверка типа и размера на клиенте, пресеты акцентного цвета; - иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла нет), акцентный цвет переопределяет `--color-accent` внутри приложения и снимается при значении 0; - предпросмотр приглашения показывает splash фоном и иконку сервера; - ответ сервера обновляет карточку и список серверов — оформление видно без перезагрузки; - тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
bodyOf,
|
||||
installFetch,
|
||||
requestUrl,
|
||||
json,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
resetStores,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Оформление сервера в настройках (AGENT.md 7.4): иконка, баннер, splash и
|
||||
* акцентный цвет. Загрузка идёт multipart-запросом, сервер отвечает сервером —
|
||||
* карточка и список серверов обновляются без перезагрузки.
|
||||
*/
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
function guildDetail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
description: '',
|
||||
owner_id: 'user-1',
|
||||
is_main: false,
|
||||
member_count: 1,
|
||||
roles: [],
|
||||
my_role_ids: [],
|
||||
my_permissions: ['MANAGE_GUILD'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Маршруты оболочки и карточки сервера; оформление отвечает тем же сервером. */
|
||||
function routes(extra: FetchRoute[] = [], guild = guildDetail()): FetchRoute[] {
|
||||
return [
|
||||
...extra,
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/appearance/',
|
||||
method: 'POST',
|
||||
response: () => json({ guild: guildDetail({ icon_file_id: '900' }) }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/appearance/',
|
||||
method: 'DELETE',
|
||||
response: () => json({ guild: guildDetail() }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-1',
|
||||
method: 'PATCH',
|
||||
response: () => json({ guild: guildDetail({ accent_color: 0x5865f2 }) }),
|
||||
},
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guild] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ 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 }) },
|
||||
];
|
||||
}
|
||||
|
||||
async function openAppearance(guild = guildDetail()) {
|
||||
const fetchMock = installFetch(routes([], guild));
|
||||
renderApp('/settings/servers/g-1');
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
const section = await within(dialog).findByTestId('guild-appearance');
|
||||
return { dialog, section, fetchMock };
|
||||
}
|
||||
|
||||
/** Файл изображения нужного размера. */
|
||||
function imageFile(name = 'icon.png', type = 'image/png', size = 1024): File {
|
||||
const file = new File(['картинка'], name, { type });
|
||||
Object.defineProperty(file, 'size', { value: size });
|
||||
return file;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
// jsdom не умеет object URL: подменяем, иначе превью выбранного файла падает.
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe('настройки сервера: оформление', () => {
|
||||
it('показывает иконку, баннер и splash с превью', async () => {
|
||||
const { section } = await openAppearance(
|
||||
guildDetail({
|
||||
icon_file_id: '900',
|
||||
banner_file_id: '901',
|
||||
splash_file_id: '902',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
within(section).getByTestId('appearance-icon-preview').querySelector('img'),
|
||||
).toHaveAttribute('src', '/files/900');
|
||||
expect(
|
||||
within(section).getByTestId('appearance-banner-preview').querySelector('img'),
|
||||
).toHaveAttribute('src', '/files/901');
|
||||
expect(
|
||||
within(section).getByTestId('appearance-splash-preview').querySelector('img'),
|
||||
).toHaveAttribute('src', '/files/902');
|
||||
});
|
||||
|
||||
it('загружает выбранный файл multipart-запросом', async () => {
|
||||
const { section, fetchMock } = await openAppearance();
|
||||
|
||||
const input = within(section).getByTestId('appearance-icon-input');
|
||||
const file = imageFile();
|
||||
await userEvent.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls
|
||||
.filter(
|
||||
([input, init]) =>
|
||||
requestUrl(input).includes('/appearance/icon') &&
|
||||
(init?.method ?? 'GET').toUpperCase() === 'POST',
|
||||
)
|
||||
.at(-1);
|
||||
expect(call).toBeDefined();
|
||||
// Тело multipart не разбирается тестовым помощником: смотрим FormData.
|
||||
const body = call?.[1]?.body;
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect((body as FormData).get('file')).toBe(file);
|
||||
});
|
||||
// Превью обновляется из ответа сервера.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(section).getByTestId('appearance-icon-preview').querySelector('img'),
|
||||
).toHaveAttribute('src', '/files/900');
|
||||
});
|
||||
});
|
||||
|
||||
it('удаляет оформление и очищает превью', async () => {
|
||||
const { section, fetchMock } = await openAppearance(guildDetail({ banner_file_id: '901' }));
|
||||
|
||||
await userEvent.click(within(section).getByTestId('appearance-banner-remove'));
|
||||
|
||||
await waitFor(() => {
|
||||
const request = recordedRequests(fetchMock)
|
||||
.filter((item) => item.url.includes('/appearance/banner'))
|
||||
.at(-1);
|
||||
expect(request?.method).toBe('DELETE');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(section).getByTestId('appearance-banner-preview').querySelector('img'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('применяет акцентный цвет и снимает его', async () => {
|
||||
const { section, fetchMock } = await openAppearance();
|
||||
|
||||
await userEvent.click(within(section).getByTestId(`appearance-accent-${0x5865f2}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const request = recordedRequests(fetchMock)
|
||||
.filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1'))
|
||||
.at(-1);
|
||||
expect(bodyOf(request)).toEqual({ accent_color: 0x5865f2 });
|
||||
});
|
||||
|
||||
await userEvent.click(within(section).getByTestId('appearance-accent-0'));
|
||||
await waitFor(() => {
|
||||
const request = recordedRequests(fetchMock)
|
||||
.filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1'))
|
||||
.at(-1);
|
||||
expect(bodyOf(request)).toEqual({ accent_color: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('не отправляет файл, который не является изображением', async () => {
|
||||
const { section, fetchMock } = await openAppearance();
|
||||
|
||||
// applyAccept: false — проверяем серверную проверку типа, а не атрибут accept.
|
||||
await userEvent.upload(
|
||||
within(section).getByTestId('appearance-icon-input'),
|
||||
new File(['текст'], 'note.txt', { type: 'text/plain' }),
|
||||
{ applyAccept: false },
|
||||
);
|
||||
|
||||
expect(await within(section).findByTestId('appearance-error')).toHaveTextContent(
|
||||
'Нужен файл изображения',
|
||||
);
|
||||
expect(
|
||||
recordedRequests(fetchMock).filter((item) => item.url.includes('/appearance/icon')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('скрывает секцию без права MANAGE_GUILD', async () => {
|
||||
const guild = guildDetail({ owner_id: 'user-9', my_permissions: [] });
|
||||
installFetch(routes([], guild));
|
||||
renderApp('/settings/servers/g-1');
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
|
||||
await within(dialog).findByText('Общее');
|
||||
expect(within(dialog).queryByTestId('guild-appearance')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user