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:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
+115
View File
@@ -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');
});
});