import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createContext, useContext } from "react"; import { SettingsDialog } from "@/components/settings/SettingsDialog"; const toastSuccessMock = vi.fn(); const toastErrorMock = vi.fn(); vi.mock("sonner", () => ({ toast: { success: (...args: unknown[]) => toastSuccessMock(...args), error: (...args: unknown[]) => toastErrorMock(...args), }, })); const tMock = vi.fn((key: string) => key); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: tMock }), })); interface SettingsMock { settings: any; isLoading: boolean; isSaving: boolean; isPortable: boolean; appConfigDir?: string; resolvedDirs: Record; requiresRestart: boolean; updateSettings: ReturnType; updateDirectory: ReturnType; updateAppConfigDir: ReturnType; browseDirectory: ReturnType; browseAppConfigDir: ReturnType; resetDirectory: ReturnType; resetAppConfigDir: ReturnType; saveSettings: ReturnType; resetSettings: ReturnType; acknowledgeRestart: ReturnType; } const createSettingsMock = (overrides: Partial = {}) => { const base: SettingsMock = { settings: { showInTray: true, minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, language: "zh", claudeConfigDir: "/claude", codexConfigDir: "/codex", }, isLoading: false, isSaving: false, isPortable: false, appConfigDir: "/app-config", resolvedDirs: { claude: "/claude", codex: "/codex", }, requiresRestart: false, updateSettings: vi.fn(), updateDirectory: vi.fn(), updateAppConfigDir: vi.fn(), browseDirectory: vi.fn(), browseAppConfigDir: vi.fn(), resetDirectory: vi.fn(), resetAppConfigDir: vi.fn(), saveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }), resetSettings: vi.fn(), acknowledgeRestart: vi.fn(), }; return { ...base, ...overrides }; }; interface ImportExportMock { selectedFile: string; status: string; errorMessage: string | null; backupId: string | null; isImporting: boolean; selectImportFile: ReturnType; importConfig: ReturnType; exportConfig: ReturnType; clearSelection: ReturnType; resetStatus: ReturnType; } const createImportExportMock = (overrides: Partial = {}) => { const base: ImportExportMock = { selectedFile: "", status: "idle", errorMessage: null, backupId: null, isImporting: false, selectImportFile: vi.fn(), importConfig: vi.fn(), exportConfig: vi.fn(), clearSelection: vi.fn(), resetStatus: vi.fn(), }; return { ...base, ...overrides }; }; let settingsMock = createSettingsMock(); let importExportMock = createImportExportMock(); const useImportExportSpy = vi.fn(); let lastUseImportExportOptions: Record | undefined; vi.mock("@/hooks/useSettings", () => ({ useSettings: () => settingsMock, })); vi.mock("@/hooks/useImportExport", () => ({ useImportExport: (options?: Record) => useImportExportSpy(options), })); vi.mock("@/lib/api", () => ({ settingsApi: { restart: vi.fn().mockResolvedValue(true), }, })); const TabsContext = createContext<{ value: string; onValueChange?: (value: string) => void }>({ value: "general", }); vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ open, children }: any) => (open ?
{children}
: null), DialogContent: ({ children }: any) =>
{children}
, DialogHeader: ({ children }: any) =>
{children}
, DialogFooter: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, })); vi.mock("@/components/ui/tabs", () => { return { Tabs: ({ value, onValueChange, children }: any) => (
{children}
), TabsList: ({ children }: any) =>
{children}
, TabsTrigger: ({ value, children }: any) => { const ctx = useContext(TabsContext); return ( ); }, TabsContent: ({ value, children }: any) => { const ctx = useContext(TabsContext); if (ctx.value !== value) return null; return
{children}
; }, }; }); vi.mock("@/components/settings/LanguageSettings", () => ({ LanguageSettings: ({ value, onChange }: any) => (
language:{value}
), })); vi.mock("@/components/settings/ThemeSettings", () => ({ ThemeSettings: () =>
theme-settings
, })); vi.mock("@/components/settings/WindowSettings", () => ({ WindowSettings: ({ onChange }: any) => ( ), })); vi.mock("@/components/settings/DirectorySettings", () => ({ DirectorySettings: ({ onBrowseDirectory, onResetDirectory, onDirectoryChange, onBrowseAppConfig, onResetAppConfig, onAppConfigChange, }: any) => (
), })); vi.mock("@/components/settings/AboutSection", () => ({ AboutSection: ({ isPortable }: any) =>
about:{String(isPortable)}
, })); let settingsApi: any; describe("SettingsDialog Component", () => { beforeEach(async () => { tMock.mockImplementation((key: string) => key); settingsMock = createSettingsMock(); importExportMock = createImportExportMock(); useImportExportSpy.mockReset(); useImportExportSpy.mockImplementation((options?: Record) => { lastUseImportExportOptions = options; return importExportMock; }); lastUseImportExportOptions = undefined; toastSuccessMock.mockReset(); toastErrorMock.mockReset(); settingsApi = (await import("@/lib/api")).settingsApi; settingsApi.restart.mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("should not render form content when loading", () => { settingsMock = createSettingsMock({ settings: null, isLoading: true }); render(); expect(screen.queryByText("language:zh")).not.toBeInTheDocument(); expect(screen.getByText("settings.title")).toBeInTheDocument(); }); it("should reset import/export status when dialog transitions to open", () => { const { rerender } = render( , ); importExportMock.resetStatus.mockClear(); rerender(); expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1); }); it("should render general and advanced tabs and trigger child callbacks", () => { const onOpenChange = vi.fn(); importExportMock = createImportExportMock({ selectedFile: "/tmp/config.json" }); render(); expect(screen.getByText("language:zh")).toBeInTheDocument(); expect(screen.getByText("theme-settings")).toBeInTheDocument(); fireEvent.click(screen.getByText("change-language")); expect(settingsMock.updateSettings).toHaveBeenCalledWith({ language: "en" }); fireEvent.click(screen.getByText("window-settings")); expect(settingsMock.updateSettings).toHaveBeenCalledWith({ minimizeToTrayOnClose: false }); fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByRole("button", { name: "settings.selectConfigFile" })); expect(importExportMock.selectImportFile).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "settings.exportConfig" })); expect(importExportMock.exportConfig).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "settings.import" })); expect(importExportMock.importConfig).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "common.clear" })); expect(importExportMock.clearSelection).toHaveBeenCalled(); }); it("should pass onImportSuccess callback to useImportExport hook", async () => { const onImportSuccess = vi.fn(); render( , ); expect(useImportExportSpy).toHaveBeenCalledWith( expect.objectContaining({ onImportSuccess }), ); expect(lastUseImportExportOptions?.onImportSuccess).toBe(onImportSuccess); if (typeof lastUseImportExportOptions?.onImportSuccess === "function") { await lastUseImportExportOptions.onImportSuccess(); } expect(onImportSuccess).toHaveBeenCalledTimes(1); }); it("should call saveSettings and close dialog when clicking save", async () => { const onOpenChange = vi.fn(); importExportMock = createImportExportMock(); render(); fireEvent.click(screen.getByText("common.save")); await waitFor(() => { expect(settingsMock.saveSettings).toHaveBeenCalledTimes(1); expect(importExportMock.clearSelection).toHaveBeenCalledTimes(1); expect(importExportMock.resetStatus).toHaveBeenCalledTimes(2); expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); it("should reset settings and close dialog when clicking cancel", () => { const onOpenChange = vi.fn(); render(); fireEvent.click(screen.getByText("common.cancel")); expect(settingsMock.resetSettings).toHaveBeenCalledTimes(1); expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1); expect(importExportMock.clearSelection).toHaveBeenCalledTimes(1); expect(importExportMock.resetStatus).toHaveBeenCalledTimes(2); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("should show restart prompt and allow immediate restart after save", async () => { settingsMock = createSettingsMock({ requiresRestart: true, saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }), }); render(); expect(await screen.findByText("settings.restartRequired")).toBeInTheDocument(); fireEvent.click(screen.getByText("settings.restartNow")); await waitFor(() => { expect(toastSuccessMock).toHaveBeenCalledWith("settings.devModeRestartHint"); }); }); it("should allow postponing restart and close dialog without restarting", async () => { const onOpenChange = vi.fn(); settingsMock = createSettingsMock({ requiresRestart: true }); render(); expect(await screen.findByText("settings.restartRequired")).toBeInTheDocument(); fireEvent.click(screen.getByText("settings.restartLater")); await waitFor(() => { expect(onOpenChange).toHaveBeenCalledWith(false); expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1); }); expect(settingsApi.restart).not.toHaveBeenCalled(); expect(toastSuccessMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled(); }); it("should trigger directory management callbacks inside advanced tab", () => { render(); fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("browse-directory")); expect(settingsMock.browseDirectory).toHaveBeenCalledWith("claude"); fireEvent.click(screen.getByText("reset-directory")); expect(settingsMock.resetDirectory).toHaveBeenCalledWith("claude"); fireEvent.click(screen.getByText("change-directory")); expect(settingsMock.updateDirectory).toHaveBeenCalledWith("codex", "/new/path"); fireEvent.click(screen.getByText("browse-app-config")); expect(settingsMock.browseAppConfigDir).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByText("reset-app-config")); expect(settingsMock.resetAppConfigDir).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByText("change-app-config")); expect(settingsMock.updateAppConfigDir).toHaveBeenCalledWith("/app/new"); }); });