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,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user