290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
|
|
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 });
|
|||
|
|
});
|