feat(channels): фон комнаты и статичные превью при reduced-motion
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
@@ -2,14 +2,18 @@ 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,
|
||||
@@ -383,3 +387,114 @@ describe('шаблоны серверов', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user