import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'; import { CommandPalette } from '@/components/CommandPalette'; import { DesktopBridge } from '@/components/DesktopBridge'; import type { GatewaySnapshot } from '@/api/gateway'; import { useSessionStore } from '@/stores/session'; import { useUiStore } from '@/stores/ui'; import type { DesktopMessage } from '@/lib/platform'; import { installFetch, json, renderApp } from './helpers'; /** * Подменная платформа: сообщения обёртки раздаём вручную (`receive`), а вызовы * клиента только записываем. Настоящую реализацию здесь не проверить — она * выбирается при импорте модуля по наличию моста Tauri. */ const desktop = vi.hoisted(() => { const handlers = new Set<(message: unknown) => void>(); return { setDirty: vi.fn(), closeWindow: vi.fn(), onMessage: (handler: (message: unknown) => void) => { handlers.add(handler); return () => handlers.delete(handler); }, receive: (message: unknown) => { for (const handler of handlers) { handler(message); } }, }; }); vi.mock('@/lib/platform', () => ({ platform: { kind: 'tauri' as const, notify: () => {}, setBadge: () => {}, ready: () => {}, openSettings: () => {}, setDirty: desktop.setDirty, closeWindow: desktop.closeWindow, onMessage: desktop.onMessage, }, isTauri: () => true, routeFromDeepLink: () => null, })); /** Текущий путь роутера: по нему проверяются переходы палитры. */ function LocationProbe() { const location = useLocation(); return {location.pathname}; } /** Снапшот READY: два сервера, категория с комнатами и одна личная беседа. */ const snapshot: GatewaySnapshot = { user: { id: 'user-1', username: 'alice', display_name: 'Alice', is_instance_admin: false, badges: [], }, guilds: [ { id: 'g-1', name: 'Основной сервер', owner_id: 'user-1', is_main: true, channels: [ { id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 }, { id: 'c-general', name: 'общий', type: 'text', position: 1, parent_id: 'cat-1' }, { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 2 }, ], roles: [], emojis: [], sounds: [], voice_states: [], member_ids: ['user-1'], my_role_ids: [], }, { id: 'g-2', name: 'Второй сервер', owner_id: 'user-1', is_main: false, channels: [], roles: [], emojis: [], sounds: [], voice_states: [], member_ids: ['user-1'], my_role_ids: [], }, ], dm_channels: [{ id: 'dm-1', type: 'dm', name: 'Аня', position: 0, recipient_id: 'u-2' }], read_states: [], session_id: 'session-1', heartbeat_interval_ms: 45_000, }; /** Рендер палитры на заданном маршруте с подготовленным снапшотом. */ function renderPalette(path = '/app/friends') { useSessionStore.getState().reset(); useSessionStore.getState().applyReady(snapshot); useSessionStore.getState().selectGuild('g-1'); const router = createMemoryRouter( [ { path: '*', element: ( <> {/* Поле вне палитры: проверяем, что обычный ввод не перехватывается. */} ), }, ], { initialEntries: [path] }, ); render(); return { router }; } /** Открывает палитру шорткатом Ctrl+K и возвращает поле поиска. */ async function openWithShortcut(): Promise { await userEvent.keyboard('{Control>}k{/Control}'); return screen.findByTestId('palette-search'); } beforeEach(() => { desktop.setDirty.mockClear(); desktop.closeWindow.mockClear(); useUiStore.setState({ paletteOpen: false }); }); describe('палитра команд', () => { it('открывается по Ctrl+K и показывает серверы, комнаты, беседы и действия', async () => { renderPalette(); const search = await openWithShortcut(); expect(search).toHaveFocus(); expect(screen.getByTestId('palette-option-guild-g-1')).toHaveTextContent('Основной сервер'); expect(screen.getByTestId('palette-option-guild-g-2')).toHaveTextContent('Второй сервер'); expect(screen.getByTestId('palette-option-channel-c-general')).toHaveTextContent('общий'); expect(screen.getByTestId('palette-option-channel-c-voice')).toHaveTextContent('Голосовая'); expect(screen.getByTestId('palette-option-dm-dm-1')).toHaveTextContent('Аня'); expect(screen.getByTestId('palette-option-action-profile')).toHaveTextContent( 'Настройки профиля', ); expect(screen.getByTestId('palette-option-action-create-guild')).toBeVisible(); // Комнаты сгруппированы по категории сервера. expect(screen.getByRole('group', { name: 'Текстовые' })).toBeVisible(); }); it('открывается по Cmd+K из поля ввода, не перехватывая обычный ввод', async () => { renderPalette(); const field = screen.getByLabelText('Ввод'); await userEvent.click(field); await userEvent.type(field, 'привет'); expect(field).toHaveValue('привет'); expect(screen.queryByTestId('palette-search')).toBeNull(); await userEvent.keyboard('{Meta>}k{/Meta}'); expect(await screen.findByTestId('palette-search')).toBeVisible(); }); it('фильтрует пункты по подстроке без учёта регистра', async () => { renderPalette(); const search = await openWithShortcut(); await userEvent.type(search, 'ОБЩИЙ'); expect(screen.getByTestId('palette-option-channel-c-general')).toBeVisible(); expect(screen.queryByTestId('palette-option-guild-g-2')).toBeNull(); expect(screen.queryByTestId('palette-option-dm-dm-1')).toBeNull(); }); it('сообщает, что ничего не найдено', async () => { renderPalette(); const search = await openWithShortcut(); await userEvent.type(search, 'ззз'); expect(screen.getByTestId('palette-empty')).toHaveTextContent('Ничего не найдено'); }); it('переходит на комнату по Enter, стрелки двигают активный пункт', async () => { renderPalette(); const search = await openWithShortcut(); expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1'); await userEvent.keyboard('{ArrowDown}'); expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-2'); await userEvent.keyboard('{ArrowUp}'); expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1'); await userEvent.clear(search); await userEvent.type(search, 'общий'); await userEvent.keyboard('{Enter}'); await waitFor(() => { expect(screen.getByTestId('location')).toHaveTextContent('/app/g-1/c-general'); }); // После выполнения пункта палитра закрывается. await waitFor(() => { expect(screen.queryByTestId('palette-search')).toBeNull(); }); }); it('открывает сервер в его первой комнате', async () => { renderPalette(); const search = await openWithShortcut(); await userEvent.type(search, 'Второй'); await userEvent.keyboard('{Enter}'); await waitFor(() => { expect(screen.getByTestId('location')).toHaveTextContent('/app/g-2'); }); }); it('выполняет действия: «Создать сервер» и переход в друзья', async () => { renderPalette(); let search = await openWithShortcut(); await userEvent.type(search, 'Создать сервер'); await userEvent.keyboard('{Enter}'); expect(useUiStore.getState().guildCreateOpen).toBe(true); search = await openWithShortcut(); await userEvent.type(search, 'Друзья'); await userEvent.keyboard('{Enter}'); await waitFor(() => { expect(screen.getByTestId('location')).toHaveTextContent('/app/friends'); }); }); it('закрывается по Escape', async () => { renderPalette(); await openWithShortcut(); await userEvent.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByTestId('palette-search')).toBeNull(); }); }); it('открывается сообщением openPalette от обёртки', async () => { renderPalette(); render(); act(() => { desktop.receive({ type: 'openPalette' } satisfies DesktopMessage); }); expect(await screen.findByTestId('palette-search')).toBeVisible(); }); it('доступна на защищённом маршруте приложения', async () => { installFetch([ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user: { 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, }, }), }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]); // Палитра смонтирована корневой оболочкой роутера, а не тестовым стендом. renderApp('/app/empty'); await openWithShortcut(); expect(screen.getByTestId('palette-option-action-profile')).toBeVisible(); }); });