feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками
Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в веб-слое — том же, что и в браузере: - **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от обёртки) открывает быстрый переход: серверы (в первую доступную комнату), комнаты выбранного сервера с группировкой по категориям, личные беседы из уже загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида, настройки сервера, создание сервера, друзья, микрофон и «не слышу» при активной голосовой сессии). Новых запросов палитра не делает; поиск по подстроке без учёта регистра, 8 пунктов на раздел, стрелки со `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`; - **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить / Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет подсветка рамки; в браузере вместо сообщения обёртки работает штатный `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»: профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки, лимиты инстанса, переименование сервера (автосохраняемые переключатели намеренно не помечаются); - Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его присылает обёртка при клике по уведомлению о личном сообщении. Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,15 @@ describe('routeFromDeepLink', () => {
|
||||
expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20');
|
||||
});
|
||||
|
||||
it('разбирает личную беседу', () => {
|
||||
expect(routeFromDeepLink('glchat://dm/77')).toBe('/app/friends/77');
|
||||
});
|
||||
|
||||
it('не принимает мусор в ссылке на беседу', () => {
|
||||
expect(routeFromDeepLink('glchat://dm/77/лишнее')).toBeNull();
|
||||
expect(routeFromDeepLink('glchat://dm')).toBeNull();
|
||||
});
|
||||
|
||||
it('игнорирует чужие схемы и неизвестные пути', () => {
|
||||
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
|
||||
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
/** Ставит в window фальшивый мост Tauri v2 и возвращает список вызовов. */
|
||||
function stubTauri(): Array<{ command: string; args: unknown }> {
|
||||
const calls: Array<{ command: string; args: unknown }> = [];
|
||||
vi.stubGlobal('__TAURI_INTERNALS__', {
|
||||
invoke: (command: string, args?: unknown) => {
|
||||
calls.push({ command, args });
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Несохранённые изменения (docs/client-tauri.md §5): платформа должна знать о
|
||||
* них, чтобы обёртка перехватила закрытие окна, а браузер — показал своё
|
||||
* подтверждение. Здесь проверяются сами вызовы платформы; поведение клиента —
|
||||
* в `unsavedChanges.test.tsx`.
|
||||
*/
|
||||
describe('setDirty в браузере', () => {
|
||||
it('включает и снимает штатное подтверждение закрытия вкладки', () => {
|
||||
const added: unknown[] = [];
|
||||
const removed: unknown[] = [];
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => {
|
||||
if (type === 'beforeunload') {
|
||||
added.push(handler);
|
||||
}
|
||||
});
|
||||
vi.spyOn(window, 'removeEventListener').mockImplementation((type, handler) => {
|
||||
if (type === 'beforeunload') {
|
||||
removed.push(handler);
|
||||
}
|
||||
});
|
||||
|
||||
platform.setDirty(true);
|
||||
// Повторный вызов не вешает второй обработчик: подтверждение одно на окно.
|
||||
platform.setDirty(true);
|
||||
expect(added).toHaveLength(1);
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
(added[0] as (event: Event) => void)(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
|
||||
platform.setDirty(false);
|
||||
expect(removed).toEqual(added);
|
||||
});
|
||||
});
|
||||
|
||||
describe('команды обёртке', () => {
|
||||
it('передаёт dirty и закрывает окно', async () => {
|
||||
const calls = stubTauri();
|
||||
vi.resetModules();
|
||||
const desktop = await import('@/lib/platform');
|
||||
|
||||
expect(desktop.platform.kind).toBe('tauri');
|
||||
desktop.platform.setDirty(true);
|
||||
desktop.platform.setDirty(false);
|
||||
desktop.platform.closeWindow();
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ command: 'desktop_set_dirty', args: { dirty: true } },
|
||||
{ command: 'desktop_set_dirty', args: { dirty: false } },
|
||||
{ command: 'desktop_close_window', args: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DesktopBridge } from '@/components/DesktopBridge';
|
||||
import { DirtyCloseGuard } from '@/components/DirtyCloseGuard';
|
||||
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||
import type { DesktopMessage } from '@/lib/platform';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useUnsavedStore } from '@/stores/unsaved';
|
||||
import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
|
||||
|
||||
/**
|
||||
* Подменная платформа: так же, как в тестах палитры, сообщения обёртки раздаём
|
||||
* вручную, а `setDirty`/`closeWindow` только записываем.
|
||||
*/
|
||||
const desktop = vi.hoisted(() => {
|
||||
const handlers = new Set<(message: unknown) => void>();
|
||||
return {
|
||||
calls: [] as string[],
|
||||
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,
|
||||
}));
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
/** Форма с черновиком: то же, что делают формы настроек с кнопкой «Сохранить». */
|
||||
function DraftForm({ save }: { save: () => Promise<void> }) {
|
||||
const [value, setValue] = useState('');
|
||||
useUnsavedChanges({ dirty: value !== '', save });
|
||||
return (
|
||||
<input aria-label="Черновик" value={value} onChange={(event) => setValue(event.target.value)} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Приложение с реестром несохранённых изменений и формой-черновиком. */
|
||||
function renderGuard(save: () => Promise<void>) {
|
||||
return render(
|
||||
<>
|
||||
<DesktopBridge />
|
||||
<DirtyCloseGuard>
|
||||
<DraftForm save={save} />
|
||||
</DirtyCloseGuard>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Правит форму так, чтобы она стала dirty. */
|
||||
async function editDraft(): Promise<void> {
|
||||
await userEvent.type(screen.getByLabelText('Черновик'), 'п');
|
||||
await waitFor(() => {
|
||||
expect(desktop.setDirty).toHaveBeenCalledWith(true);
|
||||
});
|
||||
}
|
||||
|
||||
/** Просит обёртку закрыть окно (сообщение Rust `closeRequested`). */
|
||||
async function requestClose(): Promise<void> {
|
||||
// Промис в `act`: сообщение обёртки меняет состояние стора вне React-события.
|
||||
await act(() => {
|
||||
desktop.receive({ type: 'closeRequested' } satisfies DesktopMessage);
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Ожидание диалога закрытия. */
|
||||
function closeDialog(): HTMLElement {
|
||||
return screen.getByRole('dialog', { name: 'Есть несохранённые изменения' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
desktop.calls.length = 0;
|
||||
desktop.setDirty.mockClear();
|
||||
desktop.closeWindow.mockClear();
|
||||
desktop.closeWindow.mockImplementation(() => {
|
||||
desktop.calls.push('close');
|
||||
});
|
||||
// Между тестами реестр должен быть пуст: записи снимает размонтирование.
|
||||
useUnsavedStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('реестр несохранённых изменений', () => {
|
||||
it('сообщает платформе о появлении и исчезновении правок ровно один раз', async () => {
|
||||
const { unmount } = renderGuard(() => Promise.resolve());
|
||||
|
||||
await editDraft();
|
||||
// Второй ввод не должен дёргать платформу повторно.
|
||||
await userEvent.type(screen.getByLabelText('Черновик'), 'ривет');
|
||||
expect(desktop.setDirty).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
expect(desktop.setDirty).toHaveBeenLastCalledWith(false);
|
||||
expect(desktop.setDirty).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('без правок не считает окно dirty', async () => {
|
||||
renderGuard(() => Promise.resolve());
|
||||
|
||||
await requestClose();
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
|
||||
// Окно отпускаем: сохранять нечего, иначе приложение не закрыть.
|
||||
expect(desktop.closeWindow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('закрытие окна при несохранённых изменениях', () => {
|
||||
it('показывает диалог и трясёт контент', async () => {
|
||||
renderGuard(() => Promise.resolve());
|
||||
await editDraft();
|
||||
|
||||
await requestClose();
|
||||
|
||||
expect(await screen.findByText('Сохранить изменения перед выходом?')).toBeVisible();
|
||||
expect(closeDialog()).toBeVisible();
|
||||
expect(screen.getByTestId('app-content')).toHaveClass('gl-attention-shake');
|
||||
});
|
||||
|
||||
it('«Сохранить» сохраняет формы и закрывает окно', async () => {
|
||||
const save = vi.fn(() => {
|
||||
desktop.calls.push('save');
|
||||
return Promise.resolve();
|
||||
});
|
||||
renderGuard(save);
|
||||
await editDraft();
|
||||
await requestClose();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktop.closeWindow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(desktop.calls).toEqual(['save', 'close']);
|
||||
// В браузере `window.close()` может не сработать: диалог закрываем сами.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('«Выйти без сохранения» закрывает окно, не сохраняя', async () => {
|
||||
const save = vi.fn(() => Promise.resolve());
|
||||
renderGuard(save);
|
||||
await editDraft();
|
||||
await requestClose();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Выйти без сохранения' }));
|
||||
|
||||
expect(desktop.closeWindow).toHaveBeenCalledTimes(1);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(desktop.calls).toEqual(['close']);
|
||||
});
|
||||
|
||||
it('«Отмена» закрывает диалог и оставляет приложение открытым', async () => {
|
||||
const save = vi.fn(() => Promise.resolve());
|
||||
renderGuard(save);
|
||||
await editDraft();
|
||||
await requestClose();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Отмена' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
|
||||
});
|
||||
expect(desktop.closeWindow).not.toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
// Правки на месте: палитра закрытия не сбрасывает черновик.
|
||||
expect(screen.getByLabelText('Черновик')).toHaveValue('п');
|
||||
});
|
||||
|
||||
it('Escape работает как «Отмена»', async () => {
|
||||
renderGuard(() => Promise.resolve());
|
||||
await editDraft();
|
||||
await requestClose();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
|
||||
});
|
||||
expect(desktop.closeWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('оставляет диалог открытым и показывает текст ошибки', async () => {
|
||||
const save = vi.fn(() => Promise.reject(new Error('Сервер отклонил правку')));
|
||||
renderGuard(save);
|
||||
await editDraft();
|
||||
await requestClose();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
expect(await screen.findByTestId('unsaved-error')).toHaveTextContent('Сервер отклонил правку');
|
||||
expect(closeDialog()).toBeVisible();
|
||||
expect(desktop.closeWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('при prefers-reduced-motion вместо тряски подсвечивает контент', async () => {
|
||||
// Системная настройка «уменьшить движение»: движение заменяем подсветкой.
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn(() => ({
|
||||
matches: true,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})),
|
||||
);
|
||||
renderGuard(() => Promise.resolve());
|
||||
await editDraft();
|
||||
|
||||
await requestClose();
|
||||
|
||||
const content = screen.getByTestId('app-content');
|
||||
expect(content).toHaveClass('gl-attention-flash');
|
||||
expect(content).not.toHaveClass('gl-attention-shake');
|
||||
});
|
||||
});
|
||||
|
||||
describe('формы настроек в реестре', () => {
|
||||
const appRoutes: 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' } }) },
|
||||
];
|
||||
|
||||
it('правка профиля помечает клиент dirty, сохранение снимает флаг', async () => {
|
||||
installFetch([
|
||||
...appRoutes,
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/app/empty');
|
||||
// Окно настроек открывается шестерёнкой в панели пользователя.
|
||||
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
|
||||
const field = within(dialog).getByLabelText('Отображаемое имя');
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, 'Алиса');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktop.setDirty).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUnsavedStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(desktop.setDirty).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Стор сессии и UI между тестами сбрасывает `renderApp`, здесь — только UI. */
|
||||
beforeEach(() => {
|
||||
useSessionStore.getState().reset();
|
||||
useUiStore.setState({ paletteOpen: false, settingsOpen: false });
|
||||
});
|
||||
Reference in New Issue
Block a user