diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx new file mode 100644 index 0000000..5cad220 --- /dev/null +++ b/tests/hooks/useSettings.test.tsx @@ -0,0 +1,278 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useSettings } from "@/hooks/useSettings"; +import type { Settings } from "@/types"; + +const mutateAsyncMock = vi.fn(); +const useSettingsQueryMock = vi.fn(); +const setAppConfigDirOverrideMock = vi.fn(); +const applyClaudePluginConfigMock = vi.fn(); +const toastErrorMock = vi.fn(); +const toastSuccessMock = vi.fn(); + +let settingsFormMock: any; +let directorySettingsMock: any; +let metadataMock: any; +let serverSettings: Settings; + +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => toastErrorMock(...args), + success: (...args: unknown[]) => toastSuccessMock(...args), + }, +})); + +vi.mock("@/hooks/useSettingsForm", () => ({ + useSettingsForm: () => settingsFormMock, +})); + +vi.mock("@/hooks/useDirectorySettings", () => ({ + useDirectorySettings: () => directorySettingsMock, +})); + +vi.mock("@/hooks/useSettingsMetadata", () => ({ + useSettingsMetadata: () => metadataMock, +})); + +vi.mock("@/lib/query", () => ({ + useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args), + useSaveSettingsMutation: () => ({ + mutateAsync: mutateAsyncMock, + isPending: false, + }), +})); + +vi.mock("@/lib/api", () => ({ + settingsApi: { + setAppConfigDirOverride: (...args: unknown[]) => + setAppConfigDirOverrideMock(...args), + applyClaudePluginConfig: (...args: unknown[]) => + applyClaudePluginConfigMock(...args), + }, +})); + +const createSettingsFormMock = (overrides: Record = {}) => ({ + settings: { + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + claudeConfigDir: "/claude", + codexConfigDir: "/codex", + language: "zh", + }, + isLoading: false, + initialLanguage: "zh", + updateSettings: vi.fn(), + resetSettings: vi.fn(), + syncLanguage: vi.fn(), + ...overrides, +}); + +const createDirectorySettingsMock = (overrides: Record = {}) => ({ + appConfigDir: undefined, + resolvedDirs: { + appConfig: "/home/mock/.cc-switch", + claude: "/default/claude", + codex: "/default/codex", + }, + isLoading: false, + initialAppConfigDir: undefined, + updateDirectory: vi.fn(), + updateAppConfigDir: vi.fn(), + browseDirectory: vi.fn(), + browseAppConfigDir: vi.fn(), + resetDirectory: vi.fn(), + resetAppConfigDir: vi.fn(), + resetAllDirectories: vi.fn(), + ...overrides, +}); + +const createMetadataMock = (overrides: Record = {}) => ({ + isPortable: false, + requiresRestart: false, + isLoading: false, + acknowledgeRestart: vi.fn(), + setRequiresRestart: vi.fn(), + ...overrides, +}); + +describe("useSettings hook", () => { + beforeEach(() => { + mutateAsyncMock.mockReset(); + useSettingsQueryMock.mockReset(); + setAppConfigDirOverrideMock.mockReset(); + applyClaudePluginConfigMock.mockReset(); + toastErrorMock.mockReset(); + toastSuccessMock.mockReset(); + window.localStorage.clear(); + + serverSettings = { + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + claudeConfigDir: "/server/claude", + codexConfigDir: "/server/codex", + language: "zh", + }; + + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + language: "zh", + }, + }); + directorySettingsMock = createDirectorySettingsMock(); + metadataMock = createMetadataMock(); + + mutateAsyncMock.mockResolvedValue(true); + setAppConfigDirOverrideMock.mockResolvedValue(true); + applyClaudePluginConfigMock.mockResolvedValue(true); + }); + + it("saves settings and flags restart when app config directory changes", async () => { + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: true, + claudeConfigDir: "/server/claude", + codexConfigDir: undefined, + language: "en", + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + claudeConfigDir: " /custom/claude ", + codexConfigDir: " ", + language: "en", + enableClaudePluginIntegration: true, + }, + initialLanguage: "en", + }); + + directorySettingsMock = createDirectorySettingsMock({ + appConfigDir: " /override/app ", + initialAppConfigDir: "/previous/app", + }); + + const { result } = renderHook(() => useSettings()); + + let saveResult: { requiresRestart: boolean } | null = null; + await act(async () => { + saveResult = await result.current.saveSettings(); + }); + + expect(saveResult).toEqual({ requiresRestart: true }); + expect(mutateAsyncMock).toHaveBeenCalledTimes(1); + const payload = mutateAsyncMock.mock.calls[0][0] as Settings; + expect(payload.claudeConfigDir).toBe("/custom/claude"); + expect(payload.codexConfigDir).toBeUndefined(); + expect(payload.language).toBe("en"); + expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app"); + expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: false }); + expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); + expect(window.localStorage.getItem("language")).toBe("en"); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("saves settings without restart when directory unchanged", async () => { + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + enableClaudePluginIntegration: false, + language: "zh", + }, + initialLanguage: "zh", + }); + + directorySettingsMock = createDirectorySettingsMock({ + appConfigDir: undefined, + initialAppConfigDir: undefined, + }); + + const { result } = renderHook(() => useSettings()); + + let saveResult: { requiresRestart: boolean } | null = null; + await act(async () => { + saveResult = await result.current.saveSettings(); + }); + + expect(saveResult).toEqual({ requiresRestart: false }); + expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null); + expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true }); + expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); + }); + + it("shows toast when Claude plugin sync fails but continues flow", async () => { + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + enableClaudePluginIntegration: true, + language: "zh", + }, + }); + directorySettingsMock = createDirectorySettingsMock({ + appConfigDir: "/override/app", + initialAppConfigDir: "/prior/app", + }); + + applyClaudePluginConfigMock.mockRejectedValueOnce(new Error("sync failed")); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.saveSettings(); + }); + + expect(toastErrorMock).toHaveBeenCalled(); + const message = toastErrorMock.mock.calls.at(-1)?.[0] as string; + expect(message).toContain("同步 Claude 插件失败"); + expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); + }); + + it("resets form, language and directories using server data", () => { + serverSettings = { + ...serverSettings, + claudeConfigDir: " /server/claude ", + codexConfigDir: " ", + language: "zh", + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + language: "zh", + }, + initialLanguage: "zh", + }); + directorySettingsMock = createDirectorySettingsMock(); + + const { result } = renderHook(() => useSettings()); + + act(() => { + result.current.resetSettings(); + }); + + expect(settingsFormMock.resetSettings).toHaveBeenCalledWith(serverSettings); + expect(settingsFormMock.syncLanguage).toHaveBeenCalledWith( + settingsFormMock.initialLanguage, + ); + expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith( + "/server/claude", + undefined, + ); + expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); + }); +}); diff --git a/tests/integration/SettingsDialog.test.tsx b/tests/integration/SettingsDialog.test.tsx index cabe232..e4d8b62 100644 --- a/tests/integration/SettingsDialog.test.tsx +++ b/tests/integration/SettingsDialog.test.tsx @@ -2,8 +2,10 @@ import React, { Suspense } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { resetProviderState, getSettings, getAppConfigDirOverride } from "../msw/state"; +import { server } from "../msw/server"; const toastSuccessMock = vi.fn(); const toastErrorMock = vi.fn(); @@ -175,4 +177,78 @@ describe("SettingsDialog integration", () => { expect(getAppConfigDirOverride()).toBe("/custom/app"); }); + + it("allows browsing and resetting directories", async () => { + renderDialog(); + + await waitFor(() => expect(screen.getByText("language:zh")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("settings.tabAdvanced")); + + const browseButtons = screen.getAllByTitle("settings.browseDirectory"); + const resetButtons = screen.getAllByTitle("settings.resetDefault"); + + const appInput = (await screen.findByPlaceholderText( + "settings.browsePlaceholderApp", + )) as HTMLInputElement; + expect(appInput.value).toBe("/home/mock/.cc-switch"); + + fireEvent.click(browseButtons[0]); + await waitFor(() => + expect(appInput.value).toBe("/home/mock/.cc-switch/picked"), + ); + + fireEvent.click(resetButtons[0]); + await waitFor(() => expect(appInput.value).toBe("/home/mock/.cc-switch")); + + const claudeInput = (await screen.findByPlaceholderText( + "settings.browsePlaceholderClaude", + )) as HTMLInputElement; + fireEvent.change(claudeInput, { target: { value: "/custom/claude" } }); + await waitFor(() => expect(claudeInput.value).toBe("/custom/claude")); + + fireEvent.click(browseButtons[1]); + await waitFor(() => + expect(claudeInput.value).toBe("/custom/claude/picked"), + ); + + fireEvent.click(resetButtons[1]); + await waitFor(() => expect(claudeInput.value).toBe("/home/mock/.claude")); + }); + + it("notifies when export fails", async () => { + renderDialog(); + + await waitFor(() => expect(screen.getByText("language:zh")).toBeInTheDocument()); + fireEvent.click(screen.getByText("settings.tabAdvanced")); + + server.use( + http.post("http://tauri.local/save_file_dialog", () => + HttpResponse.json(null), + ), + ); + fireEvent.click(screen.getByText("settings.exportConfig")); + + await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); + const cancelMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string; + expect(cancelMessage).toMatch(/settings\.selectFileFailed|选择保存位置失败/); + + toastErrorMock.mockClear(); + + server.use( + http.post("http://tauri.local/save_file_dialog", () => + HttpResponse.json("/mock/export-settings.json"), + ), + http.post("http://tauri.local/export_config_to_file", () => + HttpResponse.json({ success: false, message: "disk-full" }), + ), + ); + + fireEvent.click(screen.getByText("settings.exportConfig")); + + await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); + const exportMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string; + expect(exportMessage).toContain("disk-full"); + expect(toastSuccessMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index bd052ce..6101f67 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -138,6 +138,11 @@ export const handlers = [ return success(default_path ? `${default_path}/picked` : "/mock/selected-dir"); }), + http.post(`${TAURI_ENDPOINT}/pick_directory`, async ({ request }) => { + const { default_path } = await withJson<{ default_path?: string }>(request); + return success(default_path ? `${default_path}/picked` : "/mock/selected-dir"); + }), + http.post(`${TAURI_ENDPOINT}/open_file_dialog`, () => success("/mock/import-settings.json"), ),