302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
|
|
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 <span data-testid="location">{location.pathname}</span>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Снапшот 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: (
|
||
|
|
<>
|
||
|
|
<CommandPalette />
|
||
|
|
<LocationProbe />
|
||
|
|
{/* Поле вне палитры: проверяем, что обычный ввод не перехватывается. */}
|
||
|
|
<input aria-label="Ввод" />
|
||
|
|
</>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
{ initialEntries: [path] },
|
||
|
|
);
|
||
|
|
render(<RouterProvider router={router} />);
|
||
|
|
return { router };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Открывает палитру шорткатом Ctrl+K и возвращает поле поиска. */
|
||
|
|
async function openWithShortcut(): Promise<HTMLElement> {
|
||
|
|
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(<DesktopBridge />);
|
||
|
|
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
});
|