From c2031c9b5c5a5ed6205ca61d2470f5ecdc7cd126 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 25 Oct 2025 11:16:38 +0800 Subject: [PATCH] test: add comprehensive tests for hooks and components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extensive unit and component tests covering import/export, settings, and provider list functionality, advancing to Sprint 2 of test development. Hook Tests: - useImportExport (11 tests): * File selection success/failure flows * Import process with success/failure/exception paths * Export functionality with error handling * User cancellation scenarios * State management (clear selection, reset status) * Fake timers for async callback testing - useSettingsForm (5 tests): * Settings normalization on initialization * Language persistence from localStorage * Field updates with language sync * Reset functionality with initial language restoration * Optimization to avoid redundant language changes Component Tests: - ProviderList (3 tests): * Loading state with skeleton placeholders * Empty state with create callback * Render order from useDragSort with action callbacks * Props pass-through (isCurrent, isEditMode, dragHandleProps) * Mock ProviderCard to isolate component under test Technical Highlights: - Fake timers (vi.useFakeTimers) for async control - i18n mock with changeLanguage spy - Partial mock of @dnd-kit/sortable using vi.importActual - ProviderCard render spy for props verification - Comprehensive error handling coverage Test Coverage: ✓ 19 new test cases (11 + 5 + 3) ✓ Total: 35 tests passing ✓ Execution time: 865ms ✓ TypeScript: 0 errors Related: Import/export, settings management, provider list rendering Sprint Progress: Sprint 1 complete, Sprint 2 in progress (component tests) --- tests/components/ProviderList.test.tsx | 245 ++++++++++++++++++++++++ tests/hooks/useImportExport.test.tsx | 249 +++++++++++++++++++++++++ tests/hooks/useSettingsForm.test.tsx | 170 +++++++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 tests/components/ProviderList.test.tsx create mode 100644 tests/hooks/useImportExport.test.tsx create mode 100644 tests/hooks/useSettingsForm.test.tsx 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(); + }); +});