diff --git a/tests/components/ImportExportSection.test.tsx b/tests/components/ImportExportSection.test.tsx new file mode 100644 index 0000000..af7b8a1 --- /dev/null +++ b/tests/components/ImportExportSection.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ImportExportSection } from "@/components/settings/ImportExportSection"; + +const tMock = vi.fn((key: string) => key); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: tMock }), +})); + +describe("ImportExportSection Component", () => { + const baseProps = { + status: "idle" as const, + selectedFile: "", + errorMessage: null, + backupId: null, + isImporting: false, + onSelectFile: vi.fn(), + onImport: vi.fn(), + onExport: vi.fn(), + onClear: vi.fn(), + }; + + beforeEach(() => { + tMock.mockImplementation((key: string) => key); + baseProps.onSelectFile.mockReset(); + baseProps.onImport.mockReset(); + baseProps.onExport.mockReset(); + baseProps.onClear.mockReset(); + }); + + it("should disable import button and show placeholder when no file selected", () => { + render(); + + expect(screen.getByText("settings.noFileSelected")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "settings.import" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "settings.exportConfig" })); + expect(baseProps.onExport).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "settings.selectConfigFile" })); + expect(baseProps.onSelectFile).toHaveBeenCalledTimes(1); + }); + + it("should show filename and enable import/clear when file is selected", () => { + render( + , + ); + + expect(screen.getByText("config.json")).toBeInTheDocument(); + const importButton = screen.getByRole("button", { name: "settings.import" }); + expect(importButton).toBeEnabled(); + fireEvent.click(importButton); + expect(baseProps.onImport).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "common.clear" })); + expect(baseProps.onClear).toHaveBeenCalledTimes(1); + }); + + it("should show loading text and disable import button during import", () => { + render( + , + ); + + const importingLabels = screen.getAllByText("settings.importing"); + expect(importingLabels.length).toBeGreaterThanOrEqual(2); + expect( + screen.getByRole("button", { name: "settings.importing" }), + ).toBeDisabled(); + expect(screen.getByText("common.loading")).toBeInTheDocument(); + }); + + it("should display backup information on successful import", () => { + render( + , + ); + + expect(screen.getByText("settings.importSuccess")).toBeInTheDocument(); + expect(screen.getByText(/backup-001/)).toBeInTheDocument(); + expect(screen.getByText("settings.autoReload")).toBeInTheDocument(); + }); + + it("should display error message when import fails", () => { + render( + , + ); + + expect(screen.getByText("settings.importFailed")).toBeInTheDocument(); + expect(screen.getByText("Parse failed")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/SettingsDialog.test.tsx b/tests/components/SettingsDialog.test.tsx new file mode 100644 index 0000000..844309f --- /dev/null +++ b/tests/components/SettingsDialog.test.tsx @@ -0,0 +1,279 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } 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(); + +vi.mock("@/hooks/useSettings", () => ({ + useSettings: () => settingsMock, +})); + +vi.mock("@/hooks/useImportExport", () => ({ + useImportExport: () => importExportMock, +})); + +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 }: 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(); + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); + settingsApi = (await import("@/lib/api")).settingsApi; + settingsApi.restart.mockClear(); + }); + + 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 render general and advanced tabs and trigger child callbacks", () => { + const onOpenChange = vi.fn(); + importExportMock = createImportExportMock({ selectedFile: "" }); + + 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(); + }); + + 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"); + }); + }); +});