test: add comprehensive tests for hooks and components
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)
This commit is contained in:
245
tests/components/ProviderList.test.tsx
Normal file
245
tests/components/ProviderList.test.tsx
Normal file
@@ -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 (
|
||||||
|
<div data-testid={`provider-card-${provider.id}`}>
|
||||||
|
<button
|
||||||
|
data-testid={`switch-${provider.id}`}
|
||||||
|
onClick={() => onSwitch(provider)}
|
||||||
|
>
|
||||||
|
switch
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`edit-${provider.id}`}
|
||||||
|
onClick={() => onEdit(provider)}
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`duplicate-${provider.id}`}
|
||||||
|
onClick={() => onDuplicate(provider)}
|
||||||
|
>
|
||||||
|
duplicate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`usage-${provider.id}`}
|
||||||
|
onClick={() => onConfigureUsage(provider)}
|
||||||
|
>
|
||||||
|
usage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`delete-${provider.id}`}
|
||||||
|
onClick={() => onDelete(provider)}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
<span data-testid={`is-current-${provider.id}`}>
|
||||||
|
{props.isCurrent ? "current" : "inactive"}
|
||||||
|
</span>
|
||||||
|
<span data-testid={`edit-mode-${provider.id}`}>
|
||||||
|
{props.isEditMode ? "edit-mode" : "view-mode"}
|
||||||
|
</span>
|
||||||
|
<span data-testid={`drag-attr-${provider.id}`}>
|
||||||
|
{props.dragHandleProps?.attributes?.["data-dnd-id"] ?? "none"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/UsageFooter", () => ({
|
||||||
|
default: () => <div data-testid="usage-footer" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dnd-kit/sortable", async () => {
|
||||||
|
const actual = await vi.importActual<any>("@dnd-kit/sortable");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useSortable: (...args: unknown[]) => useSortableMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createProvider(overrides: Partial<Provider> = {}): 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(
|
||||||
|
<ProviderList
|
||||||
|
providers={{}}
|
||||||
|
currentProviderId=""
|
||||||
|
appType="claude"
|
||||||
|
onSwitch={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onDuplicate={vi.fn()}
|
||||||
|
onOpenWebsite={vi.fn()}
|
||||||
|
isLoading
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProviderList
|
||||||
|
providers={{}}
|
||||||
|
currentProviderId=""
|
||||||
|
appType="claude"
|
||||||
|
onSwitch={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onDuplicate={vi.fn()}
|
||||||
|
onOpenWebsite={vi.fn()}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProviderList
|
||||||
|
providers={{ a: providerA, b: providerB }}
|
||||||
|
currentProviderId="b"
|
||||||
|
appType="claude"
|
||||||
|
isEditMode
|
||||||
|
onSwitch={handleSwitch}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onConfigureUsage={handleUsage}
|
||||||
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
249
tests/hooks/useImportExport.test.tsx
Normal file
249
tests/hooks/useImportExport.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
170
tests/hooks/useSettingsForm.test.tsx
Normal file
170
tests/hooks/useSettingsForm.test.tsx
Normal file
@@ -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<typeof vi.spyOn<any, any>>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user