From 0b40e200f580b64f5735fd6edcd6f7e6c08bcc98 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 25 Oct 2025 21:39:21 +0800 Subject: [PATCH] test: add useDirectorySettings and useSettingsMetadata hook tests useDirectorySettings Tests: - Test directory initialization with overrides and remote defaults - Verify app config override with space trimming - Load Claude/Codex directories from remote API - Calculate resolvedDirs correctly - Test directory browsing functionality - Browse Claude/Codex config directories - Browse app config directory with proper default paths - Update settings callback when selection succeeds - Test error handling scenarios - User cancels directory selection (returns null) - Directory picker throws error (shows toast) - Verify settings not updated on failure - Test directory reset operations - Reset individual directories to computed defaults - Reset app config directory - Batch reset with provided server values - Use vi.hoisted() for proper mock initialization - Factory function for settings creation (reusability) useSettingsMetadata Tests: - Test portable mode flag loading - Verify initial loading state - Load portable flag from API - Handle async state transitions - Test error tolerance when API fails - Silent failure on network errors - Default to non-portable mode - Continue without blocking UI - Test restart flag management - Set restart required flag - Acknowledge restart to clear flag - State updates wrapped in act() All tests passing: 10/10 (7 useDirectorySettings + 3 useSettingsMetadata) --- tests/hooks/useDirectorySettings.test.tsx | 206 ++++++++++++++++++++++ tests/hooks/useSettingsMetadata.test.tsx | 70 ++++++++ 2 files changed, 276 insertions(+) create mode 100644 tests/hooks/useDirectorySettings.test.tsx create mode 100644 tests/hooks/useSettingsMetadata.test.tsx diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx new file mode 100644 index 0000000..dbbcaa4 --- /dev/null +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -0,0 +1,206 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useDirectorySettings } from "@/hooks/useDirectorySettings"; +import type { SettingsFormState } from "@/hooks/useSettingsForm"; + +const getAppConfigDirOverrideMock = vi.hoisted(() => vi.fn()); +const getConfigDirMock = vi.hoisted(() => vi.fn()); +const selectConfigDirectoryMock = vi.hoisted(() => vi.fn()); +const setAppConfigDirOverrideMock = vi.hoisted(() => vi.fn()); +const homeDirMock = vi.hoisted(() => vi.fn<[], Promise>()); +const joinMock = vi.hoisted(() => vi.fn(async (...segments: string[]) => segments.join("/"))); +const toastErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api", () => ({ + settingsApi: { + getAppConfigDirOverride: getAppConfigDirOverrideMock, + getConfigDir: getConfigDirMock, + selectConfigDirectory: selectConfigDirectoryMock, + setAppConfigDirOverride: setAppConfigDirOverrideMock, + }, +})); + +vi.mock("@tauri-apps/api/path", () => ({ + homeDir: homeDirMock, + join: joinMock, +})); + +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + (options?.defaultValue as string) ?? key, + }), +})); + +const createSettings = (overrides: Partial = {}): SettingsFormState => ({ + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + claudeConfigDir: "/claude/custom", + codexConfigDir: "/codex/custom", + language: "zh", + ...overrides, +}); + +describe("useDirectorySettings", () => { + const onUpdateSettings = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + homeDirMock.mockResolvedValue("/home/mock"); + joinMock.mockImplementation(async (...segments: string[]) => segments.join("/")); + + getAppConfigDirOverrideMock.mockResolvedValue(null); + getConfigDirMock.mockImplementation(async (app: string) => + app === "claude" ? "/remote/claude" : "/remote/codex", + ); + selectConfigDirectoryMock.mockReset(); + }); + + it("initializes directories using overrides and remote defaults", async () => { + getAppConfigDirOverrideMock.mockResolvedValue(" /override/app "); + + const { result } = renderHook(() => + useDirectorySettings({ settings: createSettings(), onUpdateSettings }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.appConfigDir).toBe("/override/app"); + expect(result.current.resolvedDirs).toEqual({ + appConfig: "/override/app", + claude: "/remote/claude", + codex: "/remote/codex", + }); + }); + + it("updates claude directory when browsing succeeds", async () => { + selectConfigDirectoryMock.mockResolvedValue("/picked/claude"); + + const { result } = renderHook(() => + useDirectorySettings({ + settings: createSettings({ claudeConfigDir: undefined }), + onUpdateSettings, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseDirectory("claude"); + }); + + expect(selectConfigDirectoryMock).toHaveBeenCalledWith("/remote/claude"); + expect(onUpdateSettings).toHaveBeenCalledWith({ claudeConfigDir: "/picked/claude" }); + expect(result.current.resolvedDirs.claude).toBe("/picked/claude"); + }); + + it("reports error when directory selection fails", async () => { + selectConfigDirectoryMock.mockResolvedValue(null); + + const { result } = renderHook(() => + useDirectorySettings({ settings: createSettings(), onUpdateSettings }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseDirectory("codex"); + }); + + expect(result.current.resolvedDirs.codex).toBe("/remote/codex"); + expect(onUpdateSettings).not.toHaveBeenCalledWith({ + codexConfigDir: expect.anything(), + }); + expect(selectConfigDirectoryMock).toHaveBeenCalled(); + + selectConfigDirectoryMock.mockRejectedValue(new Error("dialog failed")); + toastErrorMock.mockClear(); + + await act(async () => { + await result.current.browseDirectory("codex"); + }); + + expect(toastErrorMock).toHaveBeenCalled(); + }); + + it("warns when directory selection promise rejects", async () => { + selectConfigDirectoryMock.mockRejectedValue(new Error("dialog failed")); + + const { result } = renderHook(() => + useDirectorySettings({ settings: createSettings(), onUpdateSettings }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseDirectory("codex"); + }); + + expect(toastErrorMock).toHaveBeenCalled(); + expect(onUpdateSettings).not.toHaveBeenCalledWith({ codexConfigDir: expect.anything() }); + }); + + it("updates app config directory via browseAppConfigDir", async () => { + selectConfigDirectoryMock.mockResolvedValue(" /new/app "); + + const { result } = renderHook(() => + useDirectorySettings({ + settings: createSettings(), + onUpdateSettings, + }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseAppConfigDir(); + }); + + expect(result.current.appConfigDir).toBe("/new/app"); + expect(selectConfigDirectoryMock).toHaveBeenCalledWith("/home/mock/.cc-switch"); + }); + + it("resets directories to computed defaults", async () => { + const { result } = renderHook(() => + useDirectorySettings({ + settings: createSettings({ + claudeConfigDir: "/custom/claude", + codexConfigDir: "/custom/codex", + }), + onUpdateSettings, + }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.resetDirectory("claude"); + await result.current.resetDirectory("codex"); + await result.current.resetAppConfigDir(); + }); + + expect(onUpdateSettings).toHaveBeenCalledWith({ claudeConfigDir: undefined }); + expect(onUpdateSettings).toHaveBeenCalledWith({ codexConfigDir: undefined }); + expect(result.current.resolvedDirs.claude).toBe("/home/mock/.claude"); + expect(result.current.resolvedDirs.codex).toBe("/home/mock/.codex"); + expect(result.current.resolvedDirs.appConfig).toBe("/home/mock/.cc-switch"); + }); + + it("resetAllDirectories applies provided resolved values", async () => { + const { result } = renderHook(() => + useDirectorySettings({ settings: createSettings(), onUpdateSettings }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.resetAllDirectories("/server/claude", "/server/codex"); + }); + + expect(result.current.resolvedDirs.claude).toBe("/server/claude"); + expect(result.current.resolvedDirs.codex).toBe("/server/codex"); + }); +}); diff --git a/tests/hooks/useSettingsMetadata.test.tsx b/tests/hooks/useSettingsMetadata.test.tsx new file mode 100644 index 0000000..c30f4d7 --- /dev/null +++ b/tests/hooks/useSettingsMetadata.test.tsx @@ -0,0 +1,70 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useSettingsMetadata } from "@/hooks/useSettingsMetadata"; + +const isPortableMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api", () => ({ + settingsApi: { + isPortable: (...args: unknown[]) => isPortableMock(...args), + }, +})); + +describe("useSettingsMetadata", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads portable flag and handles success path", async () => { + isPortableMock.mockResolvedValue(true); + + const { result } = renderHook(() => useSettingsMetadata()); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isPortable).toBe(false); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isPortable).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it("handles errors from settingsApi and proceeds", async () => { + isPortableMock.mockRejectedValue(new Error("network failure")); + + const { result } = renderHook(() => useSettingsMetadata()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isPortable).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + + it("allows updating restart flag via setters", async () => { + isPortableMock.mockResolvedValue(false); + + const { result } = renderHook(() => useSettingsMetadata()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + result.current.setRequiresRestart(true); + await Promise.resolve(); + }); + + expect(result.current.requiresRestart).toBe(true); + + await act(async () => { + result.current.acknowledgeRestart(); + await Promise.resolve(); + }); + + expect(result.current.requiresRestart).toBe(false); + }); +});