test(web): add 210 tests for lib utils, hooks, and UI components
Lib utilities: format (38), sanitize (12), project-colors (18), csv-export (14). Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18), useColumnConfig (19). Components: BatchActionBar (17), SortableColumnHeader (14), FilterChips (14), ErrorBoundary (16). Web test suite: 63 → 75 files, 343 → 553 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ColumnDef, ColumnPreferences } from "@capakraken/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the tRPC client so the hook can be tested without a real server
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockGetColumnPreferences = vi.fn();
|
||||
const mockSetColumnPreferences = vi.fn();
|
||||
|
||||
vi.mock("~/lib/trpc/client.js", () => ({
|
||||
trpc: {
|
||||
user: {
|
||||
getColumnPreferences: {
|
||||
useQuery: () => ({
|
||||
data: mockGetColumnPreferences(),
|
||||
}),
|
||||
},
|
||||
setColumnPreferences: {
|
||||
useMutation: () => ({
|
||||
mutate: mockSetColumnPreferences,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after the mock is registered
|
||||
const { useColumnConfig } = await import("./useColumnConfig.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub
|
||||
// ---------------------------------------------------------------------------
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
_store: () => store,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
const builtinColumns: ColumnDef[] = [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "age", label: "Age", defaultVisible: true, hideable: true },
|
||||
{ key: "status", label: "Status", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
const customColumns: ColumnDef[] = [
|
||||
{ key: "custom1", label: "Custom 1", defaultVisible: true, hideable: true, isCustom: true },
|
||||
{ key: "custom2", label: "Custom 2", defaultVisible: false, hideable: true, isCustom: true },
|
||||
];
|
||||
|
||||
describe("useColumnConfig", () => {
|
||||
let localStorageStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
|
||||
// Default: no server preferences
|
||||
mockGetColumnPreferences.mockReturnValue(undefined);
|
||||
mockSetColumnPreferences.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// allColumns
|
||||
// -------------------------------------------------------------------------
|
||||
describe("allColumns", () => {
|
||||
it("combines builtinColumns and customColumns", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnConfig("resources", builtinColumns, customColumns),
|
||||
);
|
||||
expect(result.current.allColumns).toHaveLength(builtinColumns.length + customColumns.length);
|
||||
});
|
||||
|
||||
it("returns only builtinColumns when customColumns is omitted", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.allColumns).toEqual(builtinColumns);
|
||||
});
|
||||
|
||||
it("puts builtinColumns before customColumns", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnConfig("resources", builtinColumns, customColumns),
|
||||
);
|
||||
const keys = result.current.allColumns.map((c) => c.key);
|
||||
expect(keys).toEqual(["name", "age", "status", "custom1", "custom2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — default fallback (no localStorage, no server prefs)
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — defaults", () => {
|
||||
it("includes columns with defaultVisible=true", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
});
|
||||
|
||||
it("excludes columns with defaultVisible=false", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.visibleKeys).not.toContain("status");
|
||||
});
|
||||
|
||||
it("always includes non-hideable columns when a saved list is present", () => {
|
||||
// alwaysVisible is only force-injected when merging with a saved key list.
|
||||
// When falling back to defaults, only columns with defaultVisible=true are shown.
|
||||
const cols: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", defaultVisible: true, hideable: false },
|
||||
{ key: "opt", label: "Opt", defaultVisible: false, hideable: true },
|
||||
{ key: "extra", label: "Extra", defaultVisible: false, hideable: true },
|
||||
];
|
||||
// Provide a saved list that omits "id" — the hook must still inject it
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["opt"]));
|
||||
const { result } = renderHook(() => useColumnConfig("resources", cols));
|
||||
expect(result.current.visibleKeys).toContain("id");
|
||||
expect(result.current.visibleKeys).toContain("opt");
|
||||
expect(result.current.visibleKeys).not.toContain("extra");
|
||||
});
|
||||
|
||||
it("shows only defaultVisible=true columns when no saved list exists", () => {
|
||||
const cols: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", defaultVisible: false, hideable: false },
|
||||
{ key: "opt", label: "Opt", defaultVisible: false, hideable: true },
|
||||
];
|
||||
const { result } = renderHook(() => useColumnConfig("resources", cols));
|
||||
// No saved list → falls back to defaultVisible; neither column qualifies
|
||||
expect(result.current.visibleKeys).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — localStorage takes priority
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — localStorage persistence", () => {
|
||||
it("uses the saved keys from localStorage on mount", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["status"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
// "name" is non-hideable so it must always appear; "status" is from localStorage
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
});
|
||||
|
||||
it("filters out stale keys that no longer exist in allColumns", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "obsoleteKey"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
expect(result.current.visibleKeys).not.toContain("obsoleteKey");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — server prefs fallback
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — server preferences fallback", () => {
|
||||
it("uses server preferences when localStorage is empty", () => {
|
||||
const serverPrefs: ColumnPreferences = {
|
||||
resources: { visible: ["status", "age"] },
|
||||
};
|
||||
mockGetColumnPreferences.mockReturnValue(serverPrefs);
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
// non-hideable always present
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleColumns
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleColumns", () => {
|
||||
it("returns only columns whose keys are in visibleKeys", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
const visibleSet = new Set(result.current.visibleKeys);
|
||||
for (const col of result.current.visibleColumns) {
|
||||
expect(visibleSet.has(col.key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders visibleColumns to match the order of visibleKeys", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "name"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
const colKeys = result.current.visibleColumns.map((c) => c.key);
|
||||
// "name" is non-hideable and always inserted first in visibleKeys logic,
|
||||
// then "age" from localStorage; ordering must follow visibleKeys
|
||||
expect(colKeys.indexOf("name")).toBeLessThan(colKeys.indexOf("age"));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setVisible
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setVisible", () => {
|
||||
it("updates visibleKeys immediately (optimistic)", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "status"]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
});
|
||||
|
||||
it("persists the new visible keys to localStorage", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "age"]);
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith(
|
||||
"colvis_resources",
|
||||
JSON.stringify(["name", "age"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls the server mutation with the correct payload", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "age"]);
|
||||
});
|
||||
|
||||
expect(mockSetColumnPreferences).toHaveBeenCalledWith({
|
||||
view: "resources",
|
||||
visible: ["name", "age"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reflects the new keys in visibleColumns after setVisible", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "status"]);
|
||||
});
|
||||
|
||||
const keys = result.current.visibleColumns.map((c) => c.key);
|
||||
expect(keys).toContain("status");
|
||||
expect(keys).not.toContain("age");
|
||||
});
|
||||
|
||||
it("can hide all hideable columns", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
// Only "name" (non-hideable) in the list
|
||||
result.current.setVisible(["name"]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleKeys).toEqual(["name"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ViewKey variation
|
||||
// -------------------------------------------------------------------------
|
||||
describe("view isolation", () => {
|
||||
it("reads from the correct localStorage key for the given view", () => {
|
||||
renderHook(() => useColumnConfig("projects", builtinColumns));
|
||||
expect(localStorageStub.getItem).toHaveBeenCalledWith("colvis_projects");
|
||||
});
|
||||
|
||||
it("writes to the correct localStorage key for the given view", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("projects", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name"]);
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("colvis_projects", expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useDebounce } from "./useDebounce.js";
|
||||
|
||||
describe("useDebounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the initial value immediately", () => {
|
||||
const { result } = renderHook(() => useDebounce("hello", 300));
|
||||
expect(result.current).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not update the value before the delay expires", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "initial", delay: 300 } },
|
||||
);
|
||||
|
||||
rerender({ value: "updated", delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe("initial");
|
||||
});
|
||||
|
||||
it("updates the value after the delay expires", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "initial", delay: 300 } },
|
||||
);
|
||||
|
||||
rerender({ value: "updated", delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(result.current).toBe("updated");
|
||||
});
|
||||
|
||||
it("resets the timer when the value changes before the delay expires", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "initial", delay: 300 } },
|
||||
);
|
||||
|
||||
rerender({ value: "first", delay: 300 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
// Timer not expired yet — value should still be "initial"
|
||||
expect(result.current).toBe("initial");
|
||||
|
||||
rerender({ value: "second", delay: 300 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
// Only 200ms since the second change — still debouncing
|
||||
expect(result.current).toBe("initial");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
// Now 300ms since "second" — should have resolved
|
||||
expect(result.current).toBe("second");
|
||||
});
|
||||
|
||||
it("works with number values", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: number; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: 0, delay: 500 } },
|
||||
);
|
||||
|
||||
rerender({ value: 42, delay: 500 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(42);
|
||||
});
|
||||
|
||||
it("works with object values", () => {
|
||||
const initial = { name: "Alice" };
|
||||
const updated = { name: "Bob" };
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: { name: string }; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: initial, delay: 200 } },
|
||||
);
|
||||
|
||||
rerender({ value: updated, delay: 200 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ name: "Bob" });
|
||||
});
|
||||
|
||||
it("handles a delay of 0", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }: { value: string }) => useDebounce(value, 0),
|
||||
{ initialProps: { value: "initial" } },
|
||||
);
|
||||
|
||||
rerender({ value: "instant" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
expect(result.current).toBe("instant");
|
||||
});
|
||||
|
||||
it("updates the debounced value when delay changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "hello", delay: 1000 } },
|
||||
);
|
||||
|
||||
// Reduce delay — old timer is cleared, new shorter timer starts
|
||||
rerender({ value: "hello", delay: 100 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useLocalStorage } from "./useLocalStorage.js";
|
||||
|
||||
// Helper to create a fresh in-memory localStorage stub
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
let localStorageStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial value", () => {
|
||||
it("returns the defaultValue when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"));
|
||||
expect(result.current[0]).toBe("default");
|
||||
});
|
||||
|
||||
it("returns the stored value when localStorage already has data", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify("stored"));
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"));
|
||||
expect(result.current[0]).toBe("stored");
|
||||
});
|
||||
|
||||
it("merges stored object with defaultValue for schema evolution", () => {
|
||||
const stored = { existing: "value" };
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(stored));
|
||||
const defaultValue = { existing: "old", newField: "fallback" };
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("obj-key", defaultValue));
|
||||
|
||||
expect(result.current[0]).toEqual({ existing: "value", newField: "fallback" });
|
||||
});
|
||||
|
||||
it("does not merge when the stored value is not a plain object", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify([1, 2, 3]));
|
||||
const { result } = renderHook(() => useLocalStorage("arr-key", [0]));
|
||||
expect(result.current[0]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns defaultValue when stored JSON is malformed", () => {
|
||||
localStorageStub.getItem.mockReturnValue("not-valid-json{{{");
|
||||
const { result } = renderHook(() => useLocalStorage("bad-key", 99));
|
||||
expect(result.current[0]).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set function — direct value", () => {
|
||||
it("updates the in-memory state", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("updated");
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe("updated");
|
||||
});
|
||||
|
||||
it("persists the new value to localStorage", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("saved");
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("key", JSON.stringify("saved"));
|
||||
});
|
||||
|
||||
it("handles numeric values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("num", 0));
|
||||
|
||||
act(() => {
|
||||
result.current[1](42);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(42);
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("num", "42");
|
||||
});
|
||||
|
||||
it("handles boolean values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("bool", false));
|
||||
|
||||
act(() => {
|
||||
result.current[1](true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("handles object values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("obj", { a: 1 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ a: 2, b: 3 });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ a: 2, b: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("set function — updater callback", () => {
|
||||
it("passes the current value to the updater", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("count", 5));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(6);
|
||||
});
|
||||
|
||||
it("supports multiple sequential updater calls", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("count", 0));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(2);
|
||||
});
|
||||
|
||||
it("can spread objects in updater", () => {
|
||||
const { result } = renderHook(() => useLocalStorage<Record<string, number>>("map", {}));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => ({ ...prev, x: 10 }));
|
||||
});
|
||||
act(() => {
|
||||
result.current[1]((prev) => ({ ...prev, y: 20 }));
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEventName cross-instance sync", () => {
|
||||
it("dispatches a CustomEvent on write when changeEventName is provided", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useLocalStorage("key", "a", "myEvent"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
const dispatched = dispatchSpy.mock.calls.find(
|
||||
([e]) => e instanceof CustomEvent && e.type === "myEvent",
|
||||
);
|
||||
expect(dispatched).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not dispatch a CustomEvent when changeEventName is omitted", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useLocalStorage("key", "a"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
const customEvents = dispatchSpy.mock.calls.filter(([e]) => e instanceof CustomEvent);
|
||||
expect(customEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates state when the matching CustomEvent fires on window", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent("syncEvent", { detail: "from-other-instance" }));
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe("from-other-instance");
|
||||
});
|
||||
|
||||
it("does not respond to events after unmount", () => {
|
||||
const { result, unmount } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
|
||||
|
||||
unmount();
|
||||
|
||||
// Should not throw or update after unmount
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent("syncEvent", { detail: "post-unmount" }));
|
||||
});
|
||||
|
||||
// Value should remain whatever it was at unmount time
|
||||
expect(result.current[0]).toBe("initial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("key change", () => {
|
||||
it("reads from the new key when the key prop changes", () => {
|
||||
localStorageStub.getItem.mockImplementation((k: string) => {
|
||||
if (k === "key-a") return JSON.stringify("value-a");
|
||||
if (k === "key-b") return JSON.stringify("value-b");
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ key }: { key: string }) => useLocalStorage(key, "default"),
|
||||
{ initialProps: { key: "key-a" } },
|
||||
);
|
||||
expect(result.current[0]).toBe("value-a");
|
||||
|
||||
rerender({ key: "key-b" });
|
||||
expect(result.current[0]).toBe("value-b");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTableSort } from "./useTableSort.js";
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
score?: number | null;
|
||||
}
|
||||
|
||||
const people: Person[] = [
|
||||
{ name: "Charlie", age: 30 },
|
||||
{ name: "Alice", age: 25 },
|
||||
{ name: "Bob", age: 35 },
|
||||
];
|
||||
|
||||
describe("useTableSort", () => {
|
||||
describe("initial state", () => {
|
||||
it("returns rows unsorted when no initial options are provided", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
expect(result.current.sortField).toBeNull();
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("applies initialField and initialDir when provided", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSort(people, { initialField: "name", initialDir: "asc" }),
|
||||
);
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
|
||||
it("sorts descending when initialDir is desc", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSort(people, { initialField: "name", initialDir: "desc" }),
|
||||
);
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("sets a new sort field with asc direction on first toggle", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
});
|
||||
|
||||
it("cycles asc → desc on the same field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sortDir).toBe("desc");
|
||||
});
|
||||
|
||||
it("cycles desc → null on the same field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // null
|
||||
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("resets to asc when switching to a different field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // name asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // name desc
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // age asc (new field)
|
||||
|
||||
expect(result.current.sortField).toBe("age");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
});
|
||||
|
||||
it("accepts a custom getValue extractor", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name", (row) => row.name.toLowerCase());
|
||||
});
|
||||
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears sortField and sortDir", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.sortField).toBeNull();
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the original row order after reset", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorted output", () => {
|
||||
it("sorts strings alphabetically ascending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
|
||||
it("sorts strings alphabetically descending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
|
||||
});
|
||||
|
||||
it("sorts numbers ascending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
});
|
||||
expect(result.current.sorted.map((p) => p.age)).toEqual([25, 30, 35]);
|
||||
});
|
||||
|
||||
it("sorts numbers descending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((p) => p.age)).toEqual([35, 30, 25]);
|
||||
});
|
||||
|
||||
it("places nulls last regardless of direction", () => {
|
||||
const rows: Person[] = [
|
||||
{ name: "A", age: 1, score: null },
|
||||
{ name: "B", age: 2, score: 10 },
|
||||
{ name: "C", age: 3, score: null },
|
||||
{ name: "D", age: 4, score: 5 },
|
||||
];
|
||||
const { result } = renderHook(() => useTableSort(rows));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("score");
|
||||
}); // asc
|
||||
expect(result.current.sorted.map((r) => r.score)).toEqual([5, 10, null, null]);
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("score");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((r) => r.score)).toEqual([10, 5, null, null]);
|
||||
});
|
||||
|
||||
it("does not mutate the original rows array", () => {
|
||||
const originalOrder = people.map((p) => p.name);
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(people.map((p) => p.name)).toEqual(originalOrder);
|
||||
});
|
||||
|
||||
it("returns original rows when sortDir is null even if sortField is set", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
// Cycle through to null dir
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // null
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSortChange callback", () => {
|
||||
it("does not call onSortChange on the initial render", () => {
|
||||
const onSortChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useTableSort(people, {
|
||||
initialField: "name",
|
||||
initialDir: "asc",
|
||||
onSortChange,
|
||||
}),
|
||||
);
|
||||
expect(onSortChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSortChange when the sort field changes", () => {
|
||||
const onSortChange = vi.fn();
|
||||
const { result } = renderHook(() => useTableSort(people, { onSortChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledWith("name", "asc");
|
||||
});
|
||||
|
||||
it("calls onSortChange with null when reset", () => {
|
||||
const onSortChange = vi.fn();
|
||||
const { result } = renderHook(() => useTableSort(people, { onSortChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(onSortChange).toHaveBeenLastCalledWith(null, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles an empty rows array", () => {
|
||||
const { result } = renderHook(() => useTableSort<Person>([]));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles a single-element array", () => {
|
||||
const { result } = renderHook(() => useTableSort([people[0]!]));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted).toEqual([people[0]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user