0f0060e8a4
- миграция 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)
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { createMemoryRouter, RouterProvider } from 'react-router';
|
|
import { vi } from 'vitest';
|
|
|
|
import { GatewayOp } from '@/api/gateway';
|
|
import type { Message } from '@/api/messages';
|
|
import { routes } from '@/router';
|
|
import { setQueryClient } from '@/lib/queryClient';
|
|
import { useFriendsStore } from '@/stores/friends';
|
|
import { useGatewayStore } from '@/stores/gateway';
|
|
import { useMessagesStore } from '@/stores/messages';
|
|
import { useSessionStore } from '@/stores/session';
|
|
import { useSoundboardStore } from '@/stores/soundboard';
|
|
import { useUiStore } from '@/stores/ui';
|
|
import { useVoiceStore } from '@/stores/voice';
|
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
|
import type { User } from '@/api/types';
|
|
|
|
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
|
export function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */
|
|
export function apiError(code: string, status = 400, message = 'error'): Response {
|
|
return json({ error: { code, message } }, status);
|
|
}
|
|
|
|
export interface FetchRoute {
|
|
/** Подстрока URL, по которой выбирается обработчик. */
|
|
match: string;
|
|
method?: string;
|
|
/** Ответ на запрос: обработчик видит url, метод и разобранное тело. */
|
|
response: (request: RecordedRequest) => Response | Promise<Response>;
|
|
}
|
|
|
|
/**
|
|
* Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET),
|
|
* URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше,
|
|
* поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`.
|
|
*/
|
|
export function installFetch(routes: FetchRoute[]) {
|
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input instanceof Request ? input.url : String(input);
|
|
const method = (
|
|
init?.method ?? (input instanceof Request ? input.method : 'GET')
|
|
).toUpperCase();
|
|
const ordered = [...routes].sort((left, right) => right.match.length - left.match.length);
|
|
for (const route of ordered) {
|
|
if ((route.method ?? 'GET').toUpperCase() !== method) {
|
|
continue;
|
|
}
|
|
if (!url.includes(route.match)) {
|
|
continue;
|
|
}
|
|
return Promise.resolve(route.response({ url, method, body: parseRequestBody(init?.body) }));
|
|
}
|
|
return Promise.resolve(apiError('not_found', 404));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
/** Тело запроса как JSON: у multipart и пустого тела его нет. */
|
|
function parseRequestBody(body: BodyInit | null | undefined): unknown {
|
|
if (typeof body !== 'string') {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(body) as unknown;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
|
|
export function installFailingFetch() {
|
|
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
export function createTestQueryClient(): QueryClient {
|
|
return new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
}
|
|
|
|
/** URL запроса независимо от того, строка это или Request. */
|
|
export function requestUrl(input: RequestInfo | URL): string {
|
|
return input instanceof Request ? input.url : String(input);
|
|
}
|
|
|
|
export interface RecordedRequest {
|
|
url: string;
|
|
method: string;
|
|
body: unknown;
|
|
}
|
|
|
|
/** Последний (или отфильтрованный) вызов fetch в удобном виде. */
|
|
export function recordedRequests(fetchMock: ReturnType<typeof installFetch>): RecordedRequest[] {
|
|
return fetchMock.mock.calls.map(([input, init]) => {
|
|
const request = input instanceof Request ? input : undefined;
|
|
const raw = init?.body;
|
|
return {
|
|
url: requestUrl(input),
|
|
method: (init?.method ?? request?.method ?? 'GET').toUpperCase(),
|
|
body: typeof raw === 'string' ? (JSON.parse(raw) as unknown) : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Тело запроса как JSON-объект (или undefined, если тела не было). */
|
|
export function bodyOf(request: RecordedRequest | undefined): unknown {
|
|
return request?.body;
|
|
}
|
|
|
|
/** Первый записанный запрос, подходящий под фильтр. */
|
|
export function findRequest(
|
|
fetchMock: ReturnType<typeof installFetch>,
|
|
predicate: { url: string; method?: string },
|
|
): RecordedRequest | undefined {
|
|
return recordedRequests(fetchMock).find(
|
|
(request) =>
|
|
request.url.includes(predicate.url) &&
|
|
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
|
|
);
|
|
}
|
|
|
|
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
|
|
/**
|
|
* Сброс UI-стора к начальному состоянию: окно настроек закрыто, список друзей
|
|
* свёрнут. Нужен и внутри `renderApp`, и в тестах, которые меняют настройки
|
|
* вида без перерисовки приложения.
|
|
*/
|
|
export function resetUiStore(): void {
|
|
useUiStore.setState({
|
|
guildCreateOpen: false,
|
|
settingsOpen: false,
|
|
settingsSection: 'profile',
|
|
settingsProfileTab: 'main',
|
|
friendsListOpen: false,
|
|
});
|
|
}
|
|
|
|
export function resetStores(): void {
|
|
useSessionStore.getState().reset();
|
|
useMessagesStore.getState().reset();
|
|
useVoiceStore.getState().reset();
|
|
// Голосовая сессия могла остаться от прошлого теста: гасим её без запросов.
|
|
useVoiceConnectionStore.getState().reset();
|
|
// Саундборд: подпись и очередь звуков не должны переживать тест.
|
|
useSoundboardStore.getState().reset();
|
|
useFriendsStore.getState().reset();
|
|
resetUiStore();
|
|
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
|
// (переподключение, статус) меняли бы стор уже следующего.
|
|
useGatewayStore.getState().reset();
|
|
}
|
|
|
|
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
|
export function renderApp(initialPath = '/') {
|
|
const queryClient = createTestQueryClient();
|
|
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
|
// поэтому подменяем его на тестовый.
|
|
setQueryClient(queryClient);
|
|
resetStores();
|
|
|
|
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
|
|
const view = render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...view, router, queryClient };
|
|
}
|
|
|
|
/**
|
|
* Ждёт, пока отрисуется ленивая страница: react-router подгружает её
|
|
* асинхронно, поэтому сразу после render DOM ещё пуст.
|
|
*/
|
|
export async function waitForPage(anchor = 'glchat') {
|
|
await waitFor(() => {
|
|
expect(document.body.textContent ?? '').toContain(anchor);
|
|
});
|
|
}
|
|
|
|
/** Ждёт появления поля по label и возвращает его. */
|
|
export async function findField(label: string): Promise<HTMLElement> {
|
|
return screen.findByLabelText(label);
|
|
}
|
|
|
|
/** Полный профиль пользователя для мока `GET /users/@me`. */
|
|
export function makeUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
bio: '',
|
|
status: 'online',
|
|
custom_status: '',
|
|
custom_status_emoji: '',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
locale: 'ru',
|
|
onboarding_completed: true,
|
|
totp_enabled: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface SnapshotGuildInput {
|
|
id: string;
|
|
name: string;
|
|
is_main?: boolean;
|
|
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
|
|
owner_id?: string;
|
|
icon_file_id?: string;
|
|
/** Оформление сервера (AGENT.md 7.4): баннер, splash и акцентный цвет. */
|
|
banner_file_id?: string;
|
|
splash_file_id?: string;
|
|
accent_color?: number;
|
|
my_permissions?: string[];
|
|
my_nickname?: string;
|
|
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
|
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
|
|
/** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
|
|
sounds?: {
|
|
id: string;
|
|
name: string;
|
|
file_id: string;
|
|
kind?: 'soundboard' | 'ui';
|
|
event?: string;
|
|
emoji?: string;
|
|
}[];
|
|
/** Кто сидит в голосовых комнатах (AGENT.md 7.14). */
|
|
voice_states?: {
|
|
user_id: string;
|
|
channel_id: string;
|
|
self_mute?: boolean;
|
|
self_deaf?: boolean;
|
|
server_mute?: boolean;
|
|
server_deaf?: boolean;
|
|
camera?: boolean;
|
|
screen?: boolean;
|
|
joined_at?: string;
|
|
}[];
|
|
channels?: {
|
|
id: string;
|
|
name: string;
|
|
type?: 'text' | 'voice' | 'category';
|
|
position?: number;
|
|
parent_id?: string;
|
|
can_send?: boolean;
|
|
can_view?: boolean;
|
|
/** Фон комнаты: файл-картинка за лентой (AGENT.md 7.5). */
|
|
background_file_id?: string;
|
|
}[];
|
|
}
|
|
|
|
/** Личная беседа в снапшоте READY. */
|
|
export interface SnapshotDmInput {
|
|
id: string;
|
|
/** Имя собеседника (сервер отдаёт display_name в поле `name`). */
|
|
name: string;
|
|
recipient_id?: string;
|
|
icon_file_id?: string;
|
|
status?: string;
|
|
last_message_id?: string;
|
|
last_message_at?: string;
|
|
can_send?: boolean;
|
|
}
|
|
|
|
/** Снапшот READY в формате шлюза. */
|
|
export function makeReadySnapshot(
|
|
guilds: SnapshotGuildInput[] = [],
|
|
dmChannels: SnapshotDmInput[] = [],
|
|
) {
|
|
return {
|
|
user: {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
},
|
|
guilds: guilds.map((guild) => ({
|
|
id: guild.id,
|
|
name: guild.name,
|
|
owner_id: guild.owner_id ?? 'user-1',
|
|
is_main: guild.is_main ?? false,
|
|
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
|
...(guild.banner_file_id === undefined ? {} : { banner_file_id: guild.banner_file_id }),
|
|
...(guild.splash_file_id === undefined ? {} : { splash_file_id: guild.splash_file_id }),
|
|
...(guild.accent_color === undefined ? {} : { accent_color: guild.accent_color }),
|
|
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
|
|
channels: (guild.channels ?? []).map((channel, index) => ({
|
|
id: channel.id,
|
|
name: channel.name,
|
|
type: channel.type ?? 'text',
|
|
position: channel.position ?? index,
|
|
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
|
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
|
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
|
...(channel.background_file_id === undefined
|
|
? {}
|
|
: { background_file_id: channel.background_file_id }),
|
|
})),
|
|
roles: [],
|
|
member_ids: ['user-1'],
|
|
my_role_ids: [],
|
|
my_permissions: guild.my_permissions ?? [],
|
|
emojis: (guild.emojis ?? []).map((emoji) => ({
|
|
id: emoji.id,
|
|
name: emoji.name,
|
|
file_id: emoji.file_id,
|
|
animated: emoji.animated ?? false,
|
|
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
|
|
})),
|
|
sounds: (guild.sounds ?? []).map((sound) => ({
|
|
id: sound.id,
|
|
name: sound.name,
|
|
file_id: sound.file_id,
|
|
kind: sound.kind ?? 'soundboard',
|
|
...(sound.event === undefined ? {} : { event: sound.event }),
|
|
...(sound.emoji === undefined ? {} : { emoji: sound.emoji }),
|
|
})),
|
|
voice_states: (guild.voice_states ?? []).map((state, index) => ({
|
|
user_id: state.user_id,
|
|
channel_id: state.channel_id,
|
|
guild_id: guild.id,
|
|
self_mute: state.self_mute ?? false,
|
|
self_deaf: state.self_deaf ?? false,
|
|
server_mute: state.server_mute ?? false,
|
|
server_deaf: state.server_deaf ?? false,
|
|
camera: state.camera ?? false,
|
|
screen: state.screen ?? false,
|
|
joined_at: state.joined_at ?? `2026-09-19T10:0${String(index)}:00Z`,
|
|
})),
|
|
})),
|
|
dm_channels: dmChannels.map((channel) => ({
|
|
id: channel.id,
|
|
type: 'dm',
|
|
name: channel.name,
|
|
position: 0,
|
|
...(channel.recipient_id === undefined ? {} : { recipient_id: channel.recipient_id }),
|
|
...(channel.icon_file_id === undefined ? {} : { icon_file_id: channel.icon_file_id }),
|
|
...(channel.status === undefined ? {} : { status: channel.status }),
|
|
...(channel.last_message_id === undefined
|
|
? {}
|
|
: { last_message_id: channel.last_message_id }),
|
|
...(channel.last_message_at === undefined
|
|
? {}
|
|
: { last_message_at: channel.last_message_at }),
|
|
can_send: channel.can_send ?? true,
|
|
})),
|
|
read_states: [],
|
|
session_id: 'session-1',
|
|
heartbeat_interval_ms: 45_000,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Шлюз и сообщения Фазы 2 */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** Подставной WebSocket: рукопожатие и события под контролем теста. */
|
|
class FakeGatewaySocket {
|
|
static instances: FakeGatewaySocket[] = [];
|
|
|
|
readyState = 0;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
onclose: ((event: CloseEvent) => void) | null = null;
|
|
/** Кадры, отправленные клиентом (IDENTIFY, HEARTBEAT, RESUME). */
|
|
readonly sent: { op: number; d: unknown }[] = [];
|
|
|
|
constructor(readonly url: string) {
|
|
FakeGatewaySocket.instances.push(this);
|
|
}
|
|
|
|
send(raw: string): void {
|
|
this.sent.push(JSON.parse(raw) as { op: number; d: unknown });
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
open(): void {
|
|
this.readyState = 1;
|
|
this.onopen?.(new Event('open'));
|
|
}
|
|
|
|
emit(packet: unknown): void {
|
|
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
|
}
|
|
|
|
dispatch(type: string, payload: unknown, seq = 1): void {
|
|
this.emit({ op: GatewayOp.DISPATCH, t: type, s: seq, d: payload });
|
|
}
|
|
}
|
|
|
|
export interface GatewayHarness {
|
|
/** Открывает сокет, отвечает HELLO и присылает READY со снапшотом. */
|
|
greet: () => Promise<void>;
|
|
/** Событие DISPATCH от сервера. */
|
|
dispatch: (type: string, payload: unknown) => void;
|
|
/** Кадры, отправленные клиентом. */
|
|
sent: () => { op: number; d: unknown }[];
|
|
}
|
|
|
|
/**
|
|
* Подменяет WebSocket подставным и отдаёт управление рукопожатием.
|
|
* Снапшот READY уходит после `greet()`: до этого момента шлюз «подключается».
|
|
*/
|
|
export function installGatewaySocket(snapshot: unknown): GatewayHarness {
|
|
FakeGatewaySocket.instances = [];
|
|
vi.stubGlobal('WebSocket', FakeGatewaySocket);
|
|
|
|
const current = (): FakeGatewaySocket => {
|
|
const socket = FakeGatewaySocket.instances[0];
|
|
if (socket === undefined) {
|
|
throw new Error('шлюз не подключился');
|
|
}
|
|
return socket;
|
|
};
|
|
|
|
return {
|
|
async greet() {
|
|
await waitFor(() => {
|
|
expect(FakeGatewaySocket.instances.length).toBeGreaterThan(0);
|
|
});
|
|
const socket = current();
|
|
socket.open();
|
|
socket.emit({
|
|
op: GatewayOp.HELLO,
|
|
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
|
|
});
|
|
socket.dispatch('READY', snapshot);
|
|
},
|
|
dispatch(type, payload) {
|
|
current().dispatch(type, payload);
|
|
},
|
|
sent() {
|
|
return current().sent;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Отложенный ответ: тест сам решает, когда сервер «ответит». */
|
|
export interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
}
|
|
|
|
export function deferred<T>(): Deferred<T> {
|
|
let settle!: (value: T) => void;
|
|
const promise = new Promise<T>((resolve) => {
|
|
settle = resolve;
|
|
});
|
|
return { promise, resolve: (value) => settle(value) };
|
|
}
|
|
|
|
/** Сообщение для моков и тестов стора. */
|
|
export function makeMessage(
|
|
overrides: Partial<Message> & { id: string; channel_id: string },
|
|
): Message {
|
|
return {
|
|
content: 'привет',
|
|
type: 'default',
|
|
pinned: false,
|
|
attachments: [],
|
|
mentions: [],
|
|
reactions: [],
|
|
created_at: '2026-09-19T10:00:00Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Мок `GET /channels/{id}/messages` и других ручек комнаты. */
|
|
export function messagesRoutes(
|
|
channelId: string,
|
|
messages: Message[],
|
|
extra: FetchRoute[] = [],
|
|
): FetchRoute[] {
|
|
// Дополнительные маршруты идут первыми: сортировка в installFetch устойчива,
|
|
// поэтому при одинаковом шаблоне выигрывает маршрут теста.
|
|
return [
|
|
...extra,
|
|
{
|
|
match: `/api/v1/channels/${channelId}/messages/search`,
|
|
response: () => json({ messages: [] }),
|
|
},
|
|
{
|
|
match: `/api/v1/channels/${channelId}/pins`,
|
|
response: () => json({ messages: messages.filter((message) => message.pinned) }),
|
|
},
|
|
{
|
|
match: `/api/v1/channels/${channelId}/messages`,
|
|
response: () => json({ messages: [...messages].reverse() }),
|
|
},
|
|
{
|
|
match: `/api/v1/channels/${channelId}/ack`,
|
|
method: 'POST',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
{
|
|
match: `/api/v1/channels/${channelId}/typing`,
|
|
method: 'POST',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
];
|
|
}
|