import { describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; import { GatewayOp } from '@/api/gateway'; import { findRequest, installFetch, json, makeReadySnapshot, makeUser, messagesRoutes, recordedRequests, renderApp, } from './helpers'; /** Подставной WebSocket: сразу отвечает HELLO и присылает READY. */ class FakeSocket { static instances: FakeSocket[] = []; 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; constructor(readonly url: string) { FakeSocket.instances.push(this); } send(): void { // IDENTIFY/HEARTBEAT от клиента в этом тесте не проверяются. } 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); } } function installGatewaySocket(snapshot: unknown) { FakeSocket.instances = []; vi.stubGlobal('WebSocket', FakeSocket); return { /** Ждёт, пока GatewayBridge откроет сокет, и проигрывает рукопожатие. */ async greet() { await waitFor(() => { expect(FakeSocket.instances.length).toBeGreaterThan(0); }); const socket = FakeSocket.instances[0]; if (socket === undefined) { throw new Error('шлюз не подключился'); } socket.open(); socket.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' }, }); socket.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: snapshot }); }, }; } const user = makeUser(); const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Основной сервер', is_main: true, channels: [ { id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 }, { id: 'c-general', name: 'общий', position: 1, parent_id: 'cat-1' }, { id: 'c-readonly', name: 'анонсы', position: 2, parent_id: 'cat-1', can_send: false }, { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 3 }, ], }, { id: 'g-2', name: 'Второй сервер' }, ]); describe('основной экран', () => { it('рендерит серверы и комнаты из снапшота READY', async () => { installFetch([ { 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: [] }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, ...messagesRoutes('c-general', []), ]); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-general'); // Ждём, пока AuthGuard отдаст профиль и смонтируется оболочка. await screen.findByTestId('guild-rail'); await gateway.greet(); // Серверы в рейке. const rail = screen.getByTestId('guild-rail'); await waitFor(() => { expect(within(rail).getAllByRole('link')).toHaveLength(2); }); expect(within(rail).getByLabelText('Открыть сервер Второй сервер')).toBeVisible(); // Комнаты: категория, текстовая, «только чтение» (can_send=false) и голосовая. expect(await screen.findByLabelText('Открыть комнату общий')).toBeVisible(); expect(screen.getByLabelText('Открыть комнату анонсы')).toBeVisible(); expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible(); expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible(); // Панель пользователя: имя, статус, кнопки устройств и шестерёнка настроек. const panel = screen.getByLabelText('Панель пользователя'); expect(within(panel).getByText('Alice')).toBeVisible(); expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible(); expect(within(panel).getByLabelText('Выключить микрофон')).toBeVisible(); expect(within(panel).getByLabelText('Включить камеру')).toBeVisible(); expect(within(panel).getByLabelText('Выключить звук')).toBeVisible(); // Выход переехал в мини-профиль (проверяется в tests/userMenu.test.tsx). expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible(); // «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне». expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull(); // Фаза 2: вместо заглушки в области комнаты открывается чат. expect(await screen.findByTestId('composer')).toBeVisible(); expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий'); expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible(); }); it('анимационные классы не мешают рендеру оболочки', async () => { installFetch([ { 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: [] }) }, ]); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-general'); await screen.findByTestId('guild-rail'); await gateway.greet(); // Каскад списков и анимация появления страницы — обычные CSS-классы. expect(screen.getByTestId('guild-rail')).toHaveClass('gl-stagger'); expect(document.querySelector('.gl-page')).not.toBeNull(); await waitFor(() => { expect(screen.getByLabelText('Открыть комнату общий')).toHaveClass('gl-indicator'); }); }); it('шестерёнка настроек сервера видна владельцу и открывает окно настроек', async () => { const { default: userEvent } = await import('@testing-library/user-event'); const visitor = userEvent.setup(); installFetch([ { 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: [] }) }, { 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: 'Основной сервер', description: 'главный', owner_id: 'user-1', is_main: true, member_count: 1, roles: [], my_role_ids: [], my_permissions: [], }, }), }, ]); const gateway = installGatewaySocket(snapshot); const { router } = renderApp('/app/g-1/c-general'); await screen.findByTestId('guild-rail'); await gateway.greet(); const gear = await screen.findByLabelText('Настройки сервера'); await visitor.click(gear); // Настройки — модальное окно: из комнаты никуда не уходим. const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(router.state.location.pathname).toBe('/app/g-1/c-general'); expect(await within(dialog).findByRole('tab', { name: 'Сервер' })).toHaveAttribute( 'aria-selected', 'true', ); expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер'); }); it('обычный участник не видит шестерёнку настроек сервера', async () => { const memberSnapshot = makeReadySnapshot([ { id: 'g-9', name: 'Чужой сервер', owner_id: 'user-2', my_permissions: [], channels: [{ id: 'c-1', name: 'общий', position: 0 }], }, ]); installFetch([ { 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: [] }) }, { match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) }, ]); const gateway = installGatewaySocket(memberSnapshot); renderApp('/app/g-9/c-1'); await screen.findByTestId('guild-rail'); await gateway.greet(); await screen.findByLabelText('Открыть комнату общий'); expect(screen.queryByLabelText('Настройки сервера')).toBeNull(); expect(screen.queryByLabelText('Создать комнату')).toBeNull(); }); it('пустой список серверов предлагает создать свой и вступить по приглашению', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test', registration_enabled: true, allow_guild_creation: true, max_guilds_per_user: 5, main_guild_id: 'g-main', }, }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) }, ]); renderApp('/app/empty'); expect(await screen.findByText('У вас пока нет серверов')).toBeVisible(); expect(screen.getByLabelText('Создать сервер')).toBeVisible(); expect(await screen.findByTestId('invite-join')).toBeVisible(); // Автовступления в «главный сервер» больше нет: только по приглашению. expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined(); }); it('друзья — первая иконка рейки, выше серверов', async () => { installFetch([ { 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: [] }) }, ]); const gateway = installGatewaySocket(snapshot); renderApp('/app/g-1/c-general'); await screen.findByTestId('guild-rail'); await gateway.greet(); // Друзья идут выше списка серверов, между ними — разделитель. const rail = screen.getByTestId('guild-rail'); expect(screen.getByTestId('friends-rail-link').compareDocumentPosition(rail)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); expect(within(rail).getAllByRole('link')).toHaveLength(2); expect(screen.getByRole('separator')).toBeVisible(); // Личные беседы в рейку не попадают: там только раздел друзей и серверы. expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('href', '/app/friends'); }); it('создаёт сервер через модальное окно', async () => { const { default: userEvent } = await import('@testing-library/user-event'); const visitor = userEvent.setup(); const created = { id: 'g-new', name: 'Новый', owner_id: 'user-1', is_main: false, member_count: 1, my_role_ids: [], my_permissions: [], }; const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test', registration_enabled: true, allow_guild_creation: true, max_guilds_per_user: 5, }, }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: created }) }, { match: '/api/v1/guilds/g-new/channels', response: () => json({ channels: [] }) }, { match: '/api/v1/guilds/g-new', response: () => json({ guild: { ...created, roles: [] } }) }, ]); const { router } = renderApp('/app/empty'); await visitor.click(await screen.findByLabelText('Создать сервер')); await visitor.type(await screen.findByLabelText('Название сервера'), 'Новый'); await visitor.click(screen.getByRole('button', { name: 'Создать' })); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/g-new'); }); const createCall = recordedRequests(fetchMock).find( (request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST', ); expect(createCall?.body).toEqual({ name: 'Новый' }); }); });