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 }) { const [value, setValue] = useState(''); useUnsavedChanges({ dirty: value !== '', save }); return ( setValue(event.target.value)} /> ); } /** Приложение с реестром несохранённых изменений и формой-черновиком. */ function renderGuard(save: () => Promise) { return render( <> , ); } /** Правит форму так, чтобы она стала dirty. */ async function editDraft(): Promise { await userEvent.type(screen.getByLabelText('Черновик'), 'п'); await waitFor(() => { expect(desktop.setDirty).toHaveBeenCalledWith(true); }); } /** Просит обёртку закрыть окно (сообщение Rust `closeRequested`). */ async function requestClose(): Promise { // Промис в `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 }); });