diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx
new file mode 100644
index 0000000..79184e4
--- /dev/null
+++ b/tests/components/ProviderList.test.tsx
@@ -0,0 +1,245 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { Provider } from "@/types";
+import { ProviderList } from "@/components/providers/ProviderList";
+
+const useDragSortMock = vi.fn();
+const useSortableMock = vi.fn();
+const providerCardRenderSpy = vi.fn();
+
+vi.mock("@/hooks/useDragSort", () => ({
+ useDragSort: (...args: unknown[]) => useDragSortMock(...args),
+}));
+
+vi.mock("@/components/providers/ProviderCard", () => ({
+ ProviderCard: (props: any) => {
+ providerCardRenderSpy(props);
+ const {
+ provider,
+ onSwitch,
+ onEdit,
+ onDelete,
+ onDuplicate,
+ onConfigureUsage,
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+ {props.isCurrent ? "current" : "inactive"}
+
+
+ {props.isEditMode ? "edit-mode" : "view-mode"}
+
+
+ {props.dragHandleProps?.attributes?.["data-dnd-id"] ?? "none"}
+
+
+ );
+ },
+}));
+
+vi.mock("@/components/UsageFooter", () => ({
+ default: () => ,
+}));
+
+vi.mock("@dnd-kit/sortable", async () => {
+ const actual = await vi.importActual("@dnd-kit/sortable");
+
+ return {
+ ...actual,
+ useSortable: (...args: unknown[]) => useSortableMock(...args),
+ };
+});
+
+function createProvider(overrides: Partial = {}): Provider {
+ return {
+ id: overrides.id ?? "provider-1",
+ name: overrides.name ?? "Test Provider",
+ settingsConfig: overrides.settingsConfig ?? {},
+ category: overrides.category,
+ createdAt: overrides.createdAt,
+ sortIndex: overrides.sortIndex,
+ meta: overrides.meta,
+ websiteUrl: overrides.websiteUrl,
+ };
+}
+
+beforeEach(() => {
+ useDragSortMock.mockReset();
+ useSortableMock.mockReset();
+ providerCardRenderSpy.mockClear();
+
+ useSortableMock.mockImplementation(({ id }: { id: string }) => ({
+ setNodeRef: vi.fn(),
+ attributes: { "data-dnd-id": id },
+ listeners: { onPointerDown: vi.fn() },
+ transform: null,
+ transition: null,
+ isDragging: false,
+ }));
+
+ useDragSortMock.mockReturnValue({
+ sortedProviders: [],
+ sensors: [],
+ handleDragEnd: vi.fn(),
+ });
+});
+
+describe("ProviderList Component", () => {
+ it("should render skeleton placeholders when loading", () => {
+ const { container } = render(
+ ,
+ );
+
+ const placeholders = container.querySelectorAll(
+ ".border-dashed.border-muted-foreground\\/40",
+ );
+ expect(placeholders).toHaveLength(3);
+ });
+
+ it("should show empty state and trigger create callback when no providers exist", () => {
+ const handleCreate = vi.fn();
+ useDragSortMock.mockReturnValueOnce({
+ sortedProviders: [],
+ sensors: [],
+ handleDragEnd: vi.fn(),
+ });
+
+ render(
+ ,
+ );
+
+ const addButton = screen.getByRole("button", {
+ name: "provider.addProvider",
+ });
+ fireEvent.click(addButton);
+
+ expect(handleCreate).toHaveBeenCalledTimes(1);
+ });
+
+ it("should render in order returned by useDragSort and pass through action callbacks", () => {
+ const providerA = createProvider({ id: "a", name: "A" });
+ const providerB = createProvider({ id: "b", name: "B" });
+
+ const handleSwitch = vi.fn();
+ const handleEdit = vi.fn();
+ const handleDelete = vi.fn();
+ const handleDuplicate = vi.fn();
+ const handleUsage = vi.fn();
+ const handleOpenWebsite = vi.fn();
+
+ useDragSortMock.mockReturnValue({
+ sortedProviders: [providerB, providerA],
+ sensors: [],
+ handleDragEnd: vi.fn(),
+ });
+
+ render(
+ ,
+ );
+
+ // Verify sort order
+ expect(providerCardRenderSpy).toHaveBeenCalledTimes(2);
+ expect(providerCardRenderSpy.mock.calls[0][0].provider.id).toBe("b");
+ expect(providerCardRenderSpy.mock.calls[1][0].provider.id).toBe("a");
+
+ // Verify current provider marker and edit mode pass-through
+ expect(
+ providerCardRenderSpy.mock.calls[0][0].isCurrent,
+ ).toBe(true);
+ expect(providerCardRenderSpy.mock.calls[0][0].isEditMode).toBe(true);
+
+ // Drag attributes from useSortable
+ expect(
+ providerCardRenderSpy.mock.calls[0][0].dragHandleProps?.attributes[
+ "data-dnd-id"
+ ],
+ ).toBe("b");
+ expect(
+ providerCardRenderSpy.mock.calls[1][0].dragHandleProps?.attributes[
+ "data-dnd-id"
+ ],
+ ).toBe("a");
+
+ // Trigger action buttons
+ fireEvent.click(screen.getByTestId("switch-b"));
+ fireEvent.click(screen.getByTestId("edit-b"));
+ fireEvent.click(screen.getByTestId("duplicate-b"));
+ fireEvent.click(screen.getByTestId("usage-b"));
+ fireEvent.click(screen.getByTestId("delete-a"));
+
+ expect(handleSwitch).toHaveBeenCalledWith(providerB);
+ expect(handleEdit).toHaveBeenCalledWith(providerB);
+ expect(handleDuplicate).toHaveBeenCalledWith(providerB);
+ expect(handleUsage).toHaveBeenCalledWith(providerB);
+ expect(handleDelete).toHaveBeenCalledWith(providerA);
+
+ // Verify useDragSort call parameters
+ expect(useDragSortMock).toHaveBeenCalledWith(
+ { a: providerA, b: providerB },
+ "claude",
+ );
+ });
+});
diff --git a/tests/hooks/useImportExport.test.tsx b/tests/hooks/useImportExport.test.tsx
new file mode 100644
index 0000000..08aa30c
--- /dev/null
+++ b/tests/hooks/useImportExport.test.tsx
@@ -0,0 +1,249 @@
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { useImportExport } from "@/hooks/useImportExport";
+
+const toastSuccessMock = vi.fn();
+const toastErrorMock = vi.fn();
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccessMock(...args),
+ error: (...args: unknown[]) => toastErrorMock(...args),
+ },
+}));
+
+const openFileDialogMock = vi.fn();
+const importConfigMock = vi.fn();
+const saveFileDialogMock = vi.fn();
+const exportConfigMock = vi.fn();
+
+vi.mock("@/lib/api", () => ({
+ settingsApi: {
+ openFileDialog: (...args: unknown[]) => openFileDialogMock(...args),
+ importConfigFromFile: (...args: unknown[]) => importConfigMock(...args),
+ saveFileDialog: (...args: unknown[]) => saveFileDialogMock(...args),
+ exportConfigToFile: (...args: unknown[]) => exportConfigMock(...args),
+ },
+}));
+
+beforeEach(() => {
+ openFileDialogMock.mockReset();
+ importConfigMock.mockReset();
+ saveFileDialogMock.mockReset();
+ exportConfigMock.mockReset();
+ toastSuccessMock.mockReset();
+ toastErrorMock.mockReset();
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("useImportExport Hook", () => {
+ it("should update state after successfully selecting file", async () => {
+ openFileDialogMock.mockResolvedValue("/path/config.json");
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ expect(result.current.selectedFile).toBe("/path/config.json");
+ expect(result.current.status).toBe("idle");
+ expect(result.current.errorMessage).toBeNull();
+ });
+
+ it("should show error toast and keep initial state when file dialog fails", async () => {
+ openFileDialogMock.mockRejectedValue(new Error("file dialog error"));
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ expect(toastErrorMock).toHaveBeenCalledTimes(1);
+ expect(result.current.selectedFile).toBe("");
+ expect(result.current.status).toBe("idle");
+ });
+
+ it("should show error and return early when no file is selected for import", async () => {
+ const { result } = renderHook(() =>
+ useImportExport({ onImportSuccess: vi.fn() }),
+ );
+
+ await act(async () => {
+ await result.current.importConfig();
+ });
+
+ expect(toastErrorMock).toHaveBeenCalledTimes(1);
+ expect(importConfigMock).not.toHaveBeenCalled();
+ expect(result.current.status).toBe("idle");
+ });
+
+ it("should set success status, record backup ID, and call callback on successful import", async () => {
+ openFileDialogMock.mockResolvedValue("/config.json");
+ importConfigMock.mockResolvedValue({
+ success: true,
+ backupId: "backup-123",
+ });
+ const onImportSuccess = vi.fn();
+
+ const { result } = renderHook(() =>
+ useImportExport({ onImportSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ await act(async () => {
+ await result.current.importConfig();
+ });
+
+ expect(importConfigMock).toHaveBeenCalledWith("/config.json");
+ expect(result.current.status).toBe("success");
+ expect(result.current.backupId).toBe("backup-123");
+ expect(toastSuccessMock).toHaveBeenCalledTimes(1);
+
+ // Skip delay to execute callback
+ await act(async () => {
+ vi.runOnlyPendingTimers();
+ });
+
+ expect(onImportSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it("should show error message and keep selected file when import result fails", async () => {
+ openFileDialogMock.mockResolvedValue("/config.json");
+ importConfigMock.mockResolvedValue({
+ success: false,
+ message: "Config corrupted",
+ });
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ await act(async () => {
+ await result.current.importConfig();
+ });
+
+ expect(result.current.status).toBe("error");
+ expect(result.current.errorMessage).toBe("Config corrupted");
+ expect(result.current.selectedFile).toBe("/config.json");
+ expect(toastErrorMock).toHaveBeenCalledWith("Config corrupted");
+ });
+
+ it("should catch and display error when import process throws exception", async () => {
+ openFileDialogMock.mockResolvedValue("/config.json");
+ importConfigMock.mockRejectedValue(new Error("Import failed"));
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ await act(async () => {
+ await result.current.importConfig();
+ });
+
+ expect(result.current.status).toBe("error");
+ expect(result.current.errorMessage).toBe("Import failed");
+ expect(toastErrorMock).toHaveBeenCalledWith(
+ expect.stringContaining("导入配置失败:"),
+ );
+ });
+
+ it("should export successfully with default filename and show path in toast", async () => {
+ saveFileDialogMock.mockResolvedValue("/export.json");
+ exportConfigMock.mockResolvedValue({
+ success: true,
+ filePath: "/backup/export.json",
+ });
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.exportConfig();
+ });
+
+ expect(saveFileDialogMock).toHaveBeenCalledTimes(1);
+ expect(exportConfigMock).toHaveBeenCalledWith("/export.json");
+ expect(toastSuccessMock).toHaveBeenCalledWith(
+ expect.stringContaining("/backup/export.json"),
+ );
+ });
+
+ it("should show error message when export fails", async () => {
+ saveFileDialogMock.mockResolvedValue("/export.json");
+ exportConfigMock.mockResolvedValue({
+ success: false,
+ message: "Write failed",
+ });
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.exportConfig();
+ });
+
+ expect(toastErrorMock).toHaveBeenCalledWith(
+ expect.stringContaining("Write failed"),
+ );
+ });
+
+ it("should catch and show error when export throws exception", async () => {
+ saveFileDialogMock.mockResolvedValue("/export.json");
+ exportConfigMock.mockRejectedValue(new Error("Disk read-only"));
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.exportConfig();
+ });
+
+ expect(toastErrorMock).toHaveBeenCalledWith(
+ expect.stringContaining("Disk read-only"),
+ );
+ });
+
+ it("should show error and return when user cancels save dialog during export", async () => {
+ saveFileDialogMock.mockResolvedValue(null);
+
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.exportConfig();
+ });
+
+ expect(exportConfigMock).not.toHaveBeenCalled();
+ expect(toastErrorMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should restore initial values when clearing selection and resetting status", async () => {
+ openFileDialogMock.mockResolvedValue("/config.json");
+ const { result } = renderHook(() => useImportExport());
+
+ await act(async () => {
+ await result.current.selectImportFile();
+ });
+
+ act(() => {
+ result.current.clearSelection();
+ });
+
+ expect(result.current.selectedFile).toBe("");
+ expect(result.current.status).toBe("idle");
+
+ act(() => {
+ result.current.resetStatus();
+ });
+
+ expect(result.current.errorMessage).toBeNull();
+ expect(result.current.backupId).toBeNull();
+ });
+});
diff --git a/tests/hooks/useSettingsForm.test.tsx b/tests/hooks/useSettingsForm.test.tsx
new file mode 100644
index 0000000..4a84c9e
--- /dev/null
+++ b/tests/hooks/useSettingsForm.test.tsx
@@ -0,0 +1,170 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import i18n from "i18next";
+import { useSettingsForm } from "@/hooks/useSettingsForm";
+
+const useSettingsQueryMock = vi.fn();
+
+vi.mock("@/lib/query", () => ({
+ useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args),
+}));
+
+let changeLanguageSpy: ReturnType>;
+
+beforeEach(() => {
+ useSettingsQueryMock.mockReset();
+ window.localStorage.clear();
+ (i18n as any).language = "zh";
+ changeLanguageSpy = vi
+ .spyOn(i18n, "changeLanguage")
+ .mockImplementation(async (lang?: string) => {
+ (i18n as any).language = lang;
+ return i18n.t;
+ });
+});
+
+afterEach(() => {
+ changeLanguageSpy.mockRestore();
+});
+
+describe("useSettingsForm Hook", () => {
+ it("should normalize settings and sync language on initialization", async () => {
+ useSettingsQueryMock.mockReturnValue({
+ data: {
+ showInTray: undefined,
+ minimizeToTrayOnClose: undefined,
+ enableClaudePluginIntegration: undefined,
+ claudeConfigDir: " /Users/demo ",
+ codexConfigDir: " ",
+ language: "en",
+ },
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSettingsForm());
+
+ await waitFor(() => {
+ expect(result.current.settings).not.toBeNull();
+ });
+
+ const settings = result.current.settings!;
+ expect(settings.showInTray).toBe(true);
+ expect(settings.minimizeToTrayOnClose).toBe(true);
+ expect(settings.enableClaudePluginIntegration).toBe(false);
+ expect(settings.claudeConfigDir).toBe("/Users/demo");
+ expect(settings.codexConfigDir).toBeUndefined();
+ expect(settings.language).toBe("en");
+ expect(result.current.initialLanguage).toBe("en");
+ expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+ });
+
+ it("should prioritize reading language from local storage in readPersistedLanguage", () => {
+ useSettingsQueryMock.mockReturnValue({
+ data: null,
+ isLoading: false,
+ });
+ window.localStorage.setItem("language", "en");
+
+ const { result } = renderHook(() => useSettingsForm());
+
+ const lang = result.current.readPersistedLanguage();
+ expect(lang).toBe("en");
+ expect(changeLanguageSpy).not.toHaveBeenCalled();
+ });
+
+ it("should update fields and sync language when language changes in updateSettings", () => {
+ useSettingsQueryMock.mockReturnValue({
+ data: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSettingsForm());
+
+ act(() => {
+ result.current.updateSettings({ showInTray: false });
+ });
+
+ expect(result.current.settings?.showInTray).toBe(false);
+
+ changeLanguageSpy.mockClear();
+ act(() => {
+ result.current.updateSettings({ language: "en" });
+ });
+
+ expect(result.current.settings?.language).toBe("en");
+ expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+ });
+
+ it("should reset with server data and restore initial language in resetSettings", async () => {
+ useSettingsQueryMock.mockReturnValue({
+ data: {
+ showInTray: true,
+ minimizeToTrayOnClose: true,
+ enableClaudePluginIntegration: false,
+ claudeConfigDir: "/origin",
+ codexConfigDir: null,
+ language: "en",
+ },
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSettingsForm());
+
+ await waitFor(() => {
+ expect(result.current.settings).not.toBeNull();
+ });
+
+ changeLanguageSpy.mockClear();
+ (i18n as any).language = "zh";
+
+ act(() => {
+ result.current.resetSettings({
+ showInTray: false,
+ minimizeToTrayOnClose: false,
+ enableClaudePluginIntegration: true,
+ claudeConfigDir: " /reset ",
+ codexConfigDir: " ",
+ language: "zh",
+ });
+ });
+
+ const settings = result.current.settings!;
+ expect(settings.showInTray).toBe(false);
+ expect(settings.minimizeToTrayOnClose).toBe(false);
+ expect(settings.enableClaudePluginIntegration).toBe(true);
+ expect(settings.claudeConfigDir).toBe("/reset");
+ expect(settings.codexConfigDir).toBeUndefined();
+ expect(settings.language).toBe("zh");
+ expect(result.current.initialLanguage).toBe("en");
+ expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+ });
+
+ it("should not call changeLanguage repeatedly when language is consistent in syncLanguage", async () => {
+ useSettingsQueryMock.mockReturnValue({
+ data: {
+ showInTray: true,
+ minimizeToTrayOnClose: true,
+ enableClaudePluginIntegration: false,
+ claudeConfigDir: null,
+ codexConfigDir: null,
+ language: "zh",
+ },
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSettingsForm());
+
+ await waitFor(() => {
+ expect(result.current.settings).not.toBeNull();
+ });
+
+ changeLanguageSpy.mockClear();
+ (i18n as any).language = "zh";
+
+ act(() => {
+ result.current.syncLanguage("zh");
+ });
+
+ expect(changeLanguageSpy).not.toHaveBeenCalled();
+ });
+});