Files
glchat/web/tests/unsavedChanges.test.tsx
T
grendervill 085454d555 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` зелёные.
2026-09-22 23:17:32 +03:00

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});