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:
2026-04-10 17:11:00 +02:00
parent c0ba062460
commit 98dca6126f
12 changed files with 2280 additions and 0 deletions
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { BatchActionBar } from "./BatchActionBar.js";
describe("BatchActionBar", () => {
describe("rendering", () => {
it("renders nothing when count is 0", () => {
const { container } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("renders the bar when count is greater than 0", () => {
render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
expect(screen.getByText("3 selected")).toBeInTheDocument();
});
it("displays the correct count", () => {
render(<BatchActionBar count={42} actions={[]} onClear={vi.fn()} />);
expect(screen.getByText("42 selected")).toBeInTheDocument();
});
it("renders action buttons", () => {
const actions = [
{ label: "Edit", onClick: vi.fn() },
{ label: "Archive", onClick: vi.fn() },
];
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
});
it("renders the Clear button", () => {
render(<BatchActionBar count={1} actions={[]} onClear={vi.fn()} />);
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});
describe("action variants", () => {
it("applies danger styling to actions with variant='danger'", () => {
const actions = [{ label: "Delete", onClick: vi.fn(), variant: "danger" as const }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
const btn = screen.getByRole("button", { name: "Delete" });
expect(btn.className).toContain("bg-red-600");
});
it("applies default styling to actions without a variant", () => {
const actions = [{ label: "Edit", onClick: vi.fn() }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
const btn = screen.getByRole("button", { name: "Edit" });
expect(btn.className).toContain("bg-white/10");
});
it("applies default styling to actions with variant='default'", () => {
const actions = [{ label: "Edit", onClick: vi.fn(), variant: "default" as const }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
const btn = screen.getByRole("button", { name: "Edit" });
expect(btn.className).toContain("bg-white/10");
});
});
describe("disabled state", () => {
it("disables an action button when disabled prop is true", () => {
const actions = [{ label: "Export", onClick: vi.fn(), disabled: true }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
});
it("enables an action button when disabled prop is false", () => {
const actions = [{ label: "Export", onClick: vi.fn(), disabled: false }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
});
it("does not fire onClick when action button is disabled", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
const actions = [{ label: "Export", onClick: handleClick, disabled: true }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Export" }));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe("interactions", () => {
it("calls action onClick when an action button is clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
const actions = [{ label: "Edit", onClick: handleClick }];
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(handleClick).toHaveBeenCalledOnce();
});
it("calls onClear when the Clear button is clicked", async () => {
const user = userEvent.setup();
const handleClear = vi.fn();
render(<BatchActionBar count={1} actions={[]} onClear={handleClear} />);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(handleClear).toHaveBeenCalledOnce();
});
it("calls each action's onClick independently", async () => {
const user = userEvent.setup();
const handleEdit = vi.fn();
const handleDelete = vi.fn();
const actions = [
{ label: "Edit", onClick: handleEdit },
{ label: "Delete", onClick: handleDelete, variant: "danger" as const },
];
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(handleEdit).toHaveBeenCalledOnce();
expect(handleDelete).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(handleDelete).toHaveBeenCalledOnce();
});
});
describe("conditional rendering on count change", () => {
it("renders when count transitions from 0 to positive", () => {
const { rerender } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
rerender(<BatchActionBar count={5} actions={[]} onClear={vi.fn()} />);
expect(screen.getByText("5 selected")).toBeInTheDocument();
});
it("hides the bar when count goes back to 0", () => {
const { rerender } = render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
expect(screen.getByText("3 selected")).toBeInTheDocument();
rerender(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,224 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import type { ErrorInfo } from "react";
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
// Suppress React's console.error output during error boundary tests
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
// A component that unconditionally throws
function ThrowingChild({ message = "Test error" }: { message?: string }) {
throw new Error(message);
}
// A component that throws only when `shouldThrow` is true
function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error("Conditional error");
return <div>OK</div>;
}
describe("ErrorBoundary", () => {
describe("normal rendering", () => {
it("renders children when no error is thrown", () => {
render(
<ErrorBoundary>
<div>Hello World</div>
</ErrorBoundary>,
);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
it("renders multiple children normally", () => {
render(
<ErrorBoundary>
<span>First</span>
<span>Second</span>
</ErrorBoundary>,
);
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
});
});
describe("error catching", () => {
it("catches a thrown error and renders the default fallback UI", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("displays the error message in the default fallback", () => {
render(
<ErrorBoundary>
<ThrowingChild message="Custom error message" />
</ErrorBoundary>,
);
expect(screen.getByText("Custom error message")).toBeInTheDocument();
});
it("renders a custom fallback node instead of the default when provided", () => {
render(
<ErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByText("Custom fallback")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
it("calls onError callback when an error is caught", () => {
const handleError = vi.fn();
render(
<ErrorBoundary onError={handleError}>
<ThrowingChild message="Tracked error" />
</ErrorBoundary>,
);
expect(handleError).toHaveBeenCalledOnce();
const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
expect(error.message).toBe("Tracked error");
expect(info).toHaveProperty("componentStack");
});
it("does not call onError when no error is thrown", () => {
const handleError = vi.fn();
render(
<ErrorBoundary onError={handleError}>
<div>No error here</div>
</ErrorBoundary>,
);
expect(handleError).not.toHaveBeenCalled();
});
});
describe("default fallback UI", () => {
it("renders the 'Something went wrong' heading", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
});
it("renders a 'Try again' button", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
});
it("renders a 'Go to dashboard' button", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
});
it("shows a generic message when error.message is empty", () => {
function ThrowEmptyMessage() {
const e = new Error("");
throw e;
}
render(
<ErrorBoundary>
<ThrowEmptyMessage />
</ErrorBoundary>,
);
expect(
screen.getByText("An unexpected error occurred. The team has been notified."),
).toBeInTheDocument();
});
});
describe("reset / Try again", () => {
it("clears the error state and re-renders children when Try again is clicked", async () => {
const user = userEvent.setup();
// Use a mutable ref-like object to control throwing from outside JSX
let shouldThrow = true;
function ControlledThrower() {
if (shouldThrow) throw new Error("Controlled error");
return <div>OK</div>;
}
const { rerender } = render(
<ErrorBoundary>
<ControlledThrower />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
// Stop throwing, then click "Try again" so ErrorBoundary resets its state
shouldThrow = false;
await user.click(screen.getByRole("button", { name: "Try again" }));
// Trigger a re-render so ControlledThrower runs with shouldThrow=false
rerender(
<ErrorBoundary>
<ControlledThrower />
</ErrorBoundary>,
);
expect(screen.getByText("OK")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
});
});
describe("DefaultErrorFallback", () => {
it("renders the heading", () => {
render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
});
it("renders the error message", () => {
render(<DefaultErrorFallback error={new Error("Specific error")} reset={vi.fn()} />);
expect(screen.getByText("Specific error")).toBeInTheDocument();
});
it("falls back to generic text when error message is empty", () => {
render(<DefaultErrorFallback error={new Error("")} reset={vi.fn()} />);
expect(
screen.getByText("An unexpected error occurred. The team has been notified."),
).toBeInTheDocument();
});
it("calls reset when Try again is clicked", async () => {
const user = userEvent.setup();
const handleReset = vi.fn();
render(<DefaultErrorFallback error={new Error("Err")} reset={handleReset} />);
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(handleReset).toHaveBeenCalledOnce();
});
it("navigates to /dashboard when Go to dashboard is clicked", async () => {
const user = userEvent.setup();
// jsdom does not navigate; spy on the assignment
const originalHref = window.location.href;
Object.defineProperty(window, "location", {
value: { href: originalHref },
writable: true,
});
render(<DefaultErrorFallback error={new Error("Err")} reset={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
expect(window.location.href).toBe("/dashboard");
});
});
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { FilterChips, type Chip } from "./FilterChips.js";
describe("FilterChips", () => {
describe("rendering", () => {
it("renders nothing when chips array is empty", () => {
const { container } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("renders chip labels when chips are provided", () => {
const chips: Chip[] = [
{ label: "Active", onRemove: vi.fn() },
{ label: "Design", onRemove: vi.fn() },
];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Design")).toBeInTheDocument();
});
it("renders a remove button for each chip", () => {
const chips: Chip[] = [
{ label: "Active", onRemove: vi.fn() },
{ label: "Design", onRemove: vi.fn() },
];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByRole("button", { name: "Remove filter: Active" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove filter: Design" })).toBeInTheDocument();
});
it("renders the 'Clear all' button when chips are present", () => {
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
});
it("does not render 'Clear all' when chips array is empty", () => {
render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
});
it("renders a single chip correctly", () => {
const chips: Chip[] = [{ label: "Pending", onRemove: vi.fn() }];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove filter: Pending" })).toBeInTheDocument();
});
});
describe("interactions", () => {
it("calls onRemove of the correct chip when its remove button is clicked", async () => {
const user = userEvent.setup();
const handleRemoveActive = vi.fn();
const handleRemoveDesign = vi.fn();
const chips: Chip[] = [
{ label: "Active", onRemove: handleRemoveActive },
{ label: "Design", onRemove: handleRemoveDesign },
];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Remove filter: Active" }));
expect(handleRemoveActive).toHaveBeenCalledOnce();
expect(handleRemoveDesign).not.toHaveBeenCalled();
});
it("calls onRemove for the second chip independently", async () => {
const user = userEvent.setup();
const handleRemoveActive = vi.fn();
const handleRemoveDesign = vi.fn();
const chips: Chip[] = [
{ label: "Active", onRemove: handleRemoveActive },
{ label: "Design", onRemove: handleRemoveDesign },
];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Remove filter: Design" }));
expect(handleRemoveDesign).toHaveBeenCalledOnce();
expect(handleRemoveActive).not.toHaveBeenCalled();
});
it("calls onClearAll when the Clear all button is clicked", async () => {
const user = userEvent.setup();
const handleClearAll = vi.fn();
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(handleClearAll).toHaveBeenCalledOnce();
});
it("does not call other handlers when Clear all is clicked", async () => {
const user = userEvent.setup();
const handleRemove = vi.fn();
const handleClearAll = vi.fn();
const chips: Chip[] = [{ label: "Active", onRemove: handleRemove }];
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(handleRemove).not.toHaveBeenCalled();
});
});
describe("conditional rendering on chip changes", () => {
it("shows chips after transitioning from empty to non-empty", () => {
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
const { rerender } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
expect(screen.queryByText("Active")).not.toBeInTheDocument();
rerender(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("hides when all chips are removed", () => {
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
const { rerender } = render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(screen.getByText("Active")).toBeInTheDocument();
rerender(<FilterChips chips={[]} onClearAll={vi.fn()} />);
expect(screen.queryByText("Active")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
});
});
describe("chip remove button aria-label", () => {
it("uses the chip label in the aria-label", () => {
const chips: Chip[] = [{ label: "In Progress", onRemove: vi.fn() }];
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Remove filter: In Progress" }),
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { SortableColumnHeader } from "./SortableColumnHeader.js";
// Helper: render inside a table/thead/tr to satisfy HTML semantics
function renderInTable(ui: React.ReactElement) {
return render(
<table>
<thead>
<tr>{ui}</tr>
</thead>
</table>,
);
}
describe("SortableColumnHeader", () => {
describe("rendering", () => {
it("renders the label text", () => {
renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={vi.fn()}
/>,
);
expect(screen.getByText("Name")).toBeInTheDocument();
});
it("renders a button for the sortable label", () => {
renderInTable(
<SortableColumnHeader
label="Budget"
field="budget"
sortField={null}
sortDir={null}
onSort={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /budget/i })).toBeInTheDocument();
});
it("renders inside a <th> element", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Status"
field="status"
sortField={null}
sortDir={null}
onSort={vi.fn()}
/>,
);
expect(container.querySelector("th")).toBeInTheDocument();
});
});
describe("sort direction icons", () => {
it("shows no active direction when sortField does not match field", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField="budget"
sortDir="asc"
onSort={vi.fn()}
/>,
);
// Neither chevron should be brand-colored
const paths = container.querySelectorAll("path");
paths.forEach((path) => {
expect(path.className.baseVal).not.toContain("stroke-brand-600");
});
});
it("highlights the up chevron when sorted asc on this field", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField="name"
sortDir="asc"
onSort={vi.fn()}
/>,
);
const paths = container.querySelectorAll("path");
// First path = up chevron (asc)
expect(paths[0]?.className.baseVal).toContain("stroke-brand-600");
// Second path = down chevron should be gray
expect(paths[1]?.className.baseVal).toContain("stroke-gray-300");
});
it("highlights the down chevron when sorted desc on this field", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField="name"
sortDir="desc"
onSort={vi.fn()}
/>,
);
const paths = container.querySelectorAll("path");
// First path = up chevron should be gray
expect(paths[0]?.className.baseVal).toContain("stroke-gray-300");
// Second path = down chevron (desc)
expect(paths[1]?.className.baseVal).toContain("stroke-brand-600");
});
it("shows no active direction when sortDir is null for this field", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField="name"
sortDir={null}
onSort={vi.fn()}
/>,
);
const paths = container.querySelectorAll("path");
paths.forEach((path) => {
expect(path.className.baseVal).not.toContain("stroke-brand-600");
});
});
});
describe("interactions", () => {
it("calls onSort with the field when the button is clicked", async () => {
const user = userEvent.setup();
const handleSort = vi.fn();
renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={handleSort}
/>,
);
await user.click(screen.getByRole("button", { name: /name/i }));
expect(handleSort).toHaveBeenCalledOnce();
expect(handleSort).toHaveBeenCalledWith("name");
});
it("passes the correct field string when multiple columns exist", async () => {
const user = userEvent.setup();
const handleSort = vi.fn();
render(
<table>
<thead>
<tr>
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={handleSort}
/>
<SortableColumnHeader
label="Budget"
field="budget"
sortField={null}
sortDir={null}
onSort={handleSort}
/>
</tr>
</thead>
</table>,
);
await user.click(screen.getByRole("button", { name: /budget/i }));
expect(handleSort).toHaveBeenCalledWith("budget");
});
});
describe("alignment", () => {
it("applies justify-start for left alignment (default)", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={vi.fn()}
/>,
);
const div = container.querySelector("th > div");
expect(div?.className).toContain("justify-start");
});
it("applies justify-end for right alignment", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Amount"
field="amount"
sortField={null}
sortDir={null}
onSort={vi.fn()}
align="right"
/>,
);
const div = container.querySelector("th > div");
expect(div?.className).toContain("justify-end");
});
it("applies justify-center for center alignment", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Status"
field="status"
sortField={null}
sortDir={null}
onSort={vi.fn()}
align="center"
/>,
);
const div = container.querySelector("th > div");
expect(div?.className).toContain("justify-center");
});
});
describe("tooltip", () => {
it("does not render an info tooltip when tooltip prop is absent", () => {
renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "More information" })).not.toBeInTheDocument();
});
it("renders an info tooltip button when tooltip prop is provided", () => {
renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={vi.fn()}
tooltip="Helpful hint"
/>,
);
expect(screen.getByRole("button", { name: "More information" })).toBeInTheDocument();
});
});
describe("custom className", () => {
it("forwards className to the <th> element", () => {
const { container } = renderInTable(
<SortableColumnHeader
label="Name"
field="name"
sortField={null}
sortDir={null}
onSort={vi.fn()}
className="w-48"
/>,
);
expect(container.querySelector("th")?.className).toContain("w-48");
});
});
});
+297
View File
@@ -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));
});
});
});
+139
View File
@@ -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");
});
});
+228
View File
@@ -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");
});
});
});
+285
View File
@@ -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]]);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { generateCsv } from "./csv-export.js";
// downloadCsv is a browser-only side-effect function (creates DOM elements and
// triggers a download). It is intentionally excluded here — testing it would
// require mocking the entire DOM download flow and adds no value for a pure
// utility test suite.
type Row = { name: string; value: number | null; note: string };
const columns = [
{ header: "Name", accessor: (r: Row) => r.name },
{ header: "Value", accessor: (r: Row) => r.value },
{ header: "Note", accessor: (r: Row) => r.note },
];
describe("generateCsv", () => {
it("produces a header row followed by a data row", () => {
const rows: Row[] = [{ name: "Alice", value: 42, note: "ok" }];
const csv = generateCsv(rows, columns);
const lines = csv.split("\n");
expect(lines[0]).toBe("Name,Value,Note");
expect(lines[1]).toBe("Alice,42,ok");
});
it("returns only the header row when there are no data rows", () => {
const csv = generateCsv([], columns);
// generateCsv always appends '\n' between header and body.
// With an empty body the result is "header\n"
expect(csv).toBe("Name,Value,Note\n");
});
it("handles multiple rows", () => {
const rows: Row[] = [
{ name: "Alice", value: 1, note: "a" },
{ name: "Bob", value: 2, note: "b" },
{ name: "Carol", value: 3, note: "c" },
];
const csv = generateCsv(rows, columns);
const lines = csv.split("\n");
expect(lines).toHaveLength(4); // header + 3 data rows
expect(lines[1]).toBe("Alice,1,a");
expect(lines[2]).toBe("Bob,2,b");
expect(lines[3]).toBe("Carol,3,c");
});
it("wraps cell values that contain a comma in double quotes", () => {
const rows: Row[] = [{ name: "Smith, John", value: 10, note: "ok" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"Smith, John",10,ok');
});
it("escapes double quotes inside cell values by doubling them", () => {
const rows: Row[] = [{ name: 'Say "hello"', value: 0, note: "x" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"Say ""hello""",0,x');
});
it("wraps cells that contain a newline in double quotes", () => {
const rows: Row[] = [{ name: "line1\nline2", value: 0, note: "x" }];
const csv = generateCsv(rows, columns);
// The header is the first line; the wrapped cell starts on line 2
const raw = csv.split("\n");
// Header + quoted cell (which contains an embedded newline) + remainder
expect(raw[0]).toBe("Name,Value,Note");
expect(raw[1]).toBe('"line1');
expect(raw[2]).toContain("line2");
});
it("converts null accessor results to empty strings", () => {
const rows: Row[] = [{ name: "Test", value: null, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe("Test,,");
});
it("converts undefined accessor results to empty strings", () => {
type Partial = { name?: string };
const partialCols = [{ header: "Name", accessor: (r: Partial) => r.name }];
const csv = generateCsv([{}] as Partial[], partialCols);
expect(csv.split("\n")[1]).toBe("");
});
it("wraps header cells that contain a comma", () => {
const colsWithComma = [{ header: "Last, First", accessor: (r: Row) => r.name }];
const csv = generateCsv([], colsWithComma);
expect(csv.startsWith('"Last, First"')).toBe(true);
});
it("handles numeric zero values without treating them as empty", () => {
const rows: Row[] = [{ name: "Zero", value: 0, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe("Zero,0,");
});
it("handles boolean accessor return values", () => {
const boolCols = [
{ header: "Active", accessor: (_r: unknown) => true },
{ header: "Deleted", accessor: (_r: unknown) => false },
];
const csv = generateCsv([{}], boolCols);
expect(csv.split("\n")[1]).toBe("true,false");
});
it("handles a value that contains both a comma and a double quote", () => {
const rows: Row[] = [{ name: 'a, "b"', value: 1, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"a, ""b""",1,');
});
it("does not wrap plain numeric strings", () => {
const numCols = [{ header: "Num", accessor: (_r: unknown) => "12345" }];
const csv = generateCsv([{}], numCols);
expect(csv.split("\n")[1]).toBe("12345");
});
it("generates correct CSV for a single-column dataset", () => {
const singleCol = [{ header: "ID", accessor: (_r: unknown) => "abc" }];
const csv = generateCsv([{}, {}], singleCol);
const lines = csv.split("\n");
expect(lines[0]).toBe("ID");
expect(lines[1]).toBe("abc");
expect(lines[2]).toBe("abc");
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import {
formatCents,
formatDate,
formatDateLong,
formatDateMedium,
formatDateShort,
formatMonthYear,
formatMoney,
toDateInputValue,
} from "./format.js";
// ---------------------------------------------------------------------------
// toDateInputValue
// ---------------------------------------------------------------------------
describe("toDateInputValue", () => {
it("returns empty string for null", () => {
expect(toDateInputValue(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(toDateInputValue(undefined)).toBe("");
});
it("formats a Date object to yyyy-mm-dd", () => {
const d = new Date(2026, 2, 4); // local: 4 March 2026
expect(toDateInputValue(d)).toBe("2026-03-04");
});
it("pads single-digit month and day with leading zeros", () => {
const d = new Date(2026, 0, 9); // local: 9 January 2026
expect(toDateInputValue(d)).toBe("2026-01-09");
});
it("accepts an ISO date string", () => {
// Use a date string that is unambiguous when parsed by Date constructor
const result = toDateInputValue("2026-11-30T00:00:00");
expect(result).toBe("2026-11-30");
});
it("handles end-of-year boundary", () => {
const d = new Date(2025, 11, 31); // 31 December 2025
expect(toDateInputValue(d)).toBe("2025-12-31");
});
});
// ---------------------------------------------------------------------------
// formatDate
// ---------------------------------------------------------------------------
describe("formatDate", () => {
it("returns empty string for null", () => {
expect(formatDate(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatDate(undefined)).toBe("");
});
it("formats a Date using en-GB locale (dd/mm/yyyy)", () => {
// Use a date created from a local timestamp so locale formatting is stable
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDate(d)).toBe("04/03/2026");
});
it("accepts a string input", () => {
const result = formatDate("2026-07-15T00:00:00");
expect(result).toBe("15/07/2026");
});
});
// ---------------------------------------------------------------------------
// formatDateShort
// ---------------------------------------------------------------------------
describe("formatDateShort", () => {
it("returns DD MMM format", () => {
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDateShort(d)).toBe("04 Mar");
});
it("pads day with leading zero", () => {
const d = new Date(2026, 0, 5); // 5 January 2026
expect(formatDateShort(d)).toBe("05 Jan");
});
it("accepts a date string", () => {
const result = formatDateShort("2026-12-01T00:00:00");
expect(result).toBe("01 Dec");
});
});
// ---------------------------------------------------------------------------
// formatMonthYear
// ---------------------------------------------------------------------------
describe("formatMonthYear", () => {
it("returns MMM YY format", () => {
const d = new Date(2026, 2, 1); // March 2026
expect(formatMonthYear(d)).toBe("Mar 26");
});
it("handles year boundary (December 2025)", () => {
const d = new Date(2025, 11, 1);
expect(formatMonthYear(d)).toBe("Dec 25");
});
it("accepts a date string", () => {
const result = formatMonthYear("2026-07-01T00:00:00");
expect(result).toBe("Jul 26");
});
});
// ---------------------------------------------------------------------------
// formatDateLong
// ---------------------------------------------------------------------------
describe("formatDateLong", () => {
it("returns long form including full month name and year", () => {
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDateLong(d)).toBe("4 March 2026");
});
it("accepts a date string", () => {
const result = formatDateLong("2026-01-01T00:00:00");
expect(result).toBe("1 January 2026");
});
it("handles single-digit day without zero-padding", () => {
const d = new Date(2026, 5, 7); // 7 June 2026
expect(formatDateLong(d)).toBe("7 June 2026");
});
});
// ---------------------------------------------------------------------------
// formatDateMedium
// ---------------------------------------------------------------------------
describe("formatDateMedium", () => {
it("returns empty string for null", () => {
expect(formatDateMedium(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatDateMedium(undefined)).toBe("");
});
it("returns compact date with short month and year", () => {
const d = new Date(2026, 2, 16); // 16 March 2026
expect(formatDateMedium(d)).toBe("16 Mar 2026");
});
it("accepts a date string", () => {
const result = formatDateMedium("2026-08-05T00:00:00");
expect(result).toBe("5 Aug 2026");
});
});
// ---------------------------------------------------------------------------
// formatMoney
// ---------------------------------------------------------------------------
describe("formatMoney", () => {
it("formats zero cents as 0 EUR", () => {
// The de-DE locale uses non-breaking space before the currency symbol
const result = formatMoney(0);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("formats null as 0 EUR", () => {
const result = formatMoney(null);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("formats undefined as 0 EUR", () => {
const result = formatMoney(undefined);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("converts cents to euros (divides by 100)", () => {
// 123400 cents = 1234 EUR, de-DE: "1.234 €"
const result = formatMoney(123400);
expect(result).toContain("1.234");
expect(result).toContain("€");
});
it("rounds to whole euros by default (fractionDigits = 0)", () => {
const result = formatMoney(9950); // 99.50 EUR → rounds to 100
expect(result).toContain("100");
});
it("respects fractionDigits = 2 for precise display", () => {
const result = formatMoney(9950, "EUR", 2); // 99.50 EUR
expect(result).toContain("99,50");
});
it("supports other currencies", () => {
const result = formatMoney(10000, "USD", 0); // 100 USD
expect(result).toContain("100");
expect(result).toContain("$");
});
it("formats negative values correctly", () => {
const result = formatMoney(-50000); // -500 EUR
expect(result).toContain("500");
// de-DE locale may use a regular hyphen-minus or a minus sign
const hasNegativeSign = result.includes("") || result.includes("-");
expect(hasNegativeSign).toBe(true);
});
});
// ---------------------------------------------------------------------------
// formatCents
// ---------------------------------------------------------------------------
describe("formatCents", () => {
it("returns '-' for null", () => {
expect(formatCents(null)).toBe("-");
});
it("returns '-' for undefined", () => {
expect(formatCents(undefined)).toBe("-");
});
it("formats zero as '0,00'", () => {
expect(formatCents(0)).toBe("0,00");
});
it("formats 100 cents as '1,00'", () => {
expect(formatCents(100)).toBe("1,00");
});
it("formats 123456 cents as '1.234,56' (de-DE locale)", () => {
expect(formatCents(123456)).toBe("1.234,56");
});
it("formats negative cents correctly", () => {
expect(formatCents(-100)).toBe("-1,00");
});
it("always shows exactly 2 decimal places", () => {
expect(formatCents(50)).toBe("0,50");
expect(formatCents(5)).toBe("0,05");
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { buildProjectColorMap, getProjectColor, getProjectHex } from "./project-colors.js";
// The palette has exactly 16 entries — exported indirectly through the type.
const PALETTE_SIZE = 16;
describe("getProjectColor", () => {
it("returns an object with bg, dark, border, and hex fields", () => {
const color = getProjectColor("proj-1");
expect(color).toHaveProperty("bg");
expect(color).toHaveProperty("dark");
expect(color).toHaveProperty("border");
expect(color).toHaveProperty("hex");
});
it("is deterministic — same ID always yields the same color", () => {
const id = "stable-id-abc";
const a = getProjectColor(id);
const b = getProjectColor(id);
expect(a).toBe(b); // same object reference from the const palette
});
it("returns different colors for different IDs (at least across a sample)", () => {
const colors = new Set(
Array.from({ length: 20 }, (_, i) => getProjectColor(`project-${i}`).hex),
);
// With 20 IDs spread across 16 slots we expect several distinct colors
expect(colors.size).toBeGreaterThan(1);
});
it("handles an empty string ID without throwing", () => {
expect(() => getProjectColor("")).not.toThrow();
});
it("handles a very long ID without throwing", () => {
const longId = "x".repeat(1000);
expect(() => getProjectColor(longId)).not.toThrow();
});
it("returned hex value starts with '#'", () => {
const { hex } = getProjectColor("test-id");
expect(hex).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it("cycles through all 16 palette slots given enough IDs", () => {
// Generate enough IDs to guarantee every palette slot is hit
const hexSet = new Set<string>();
for (let i = 0; i < 200; i++) {
hexSet.add(getProjectColor(`id-collision-test-${i}`).hex);
}
expect(hexSet.size).toBe(PALETTE_SIZE);
});
it("bg class contains a valid Tailwind color segment", () => {
const { bg } = getProjectColor("tailwind-check");
expect(bg).toMatch(/^bg-[a-z]+-500\/70$/);
});
it("border class follows expected pattern", () => {
const { border } = getProjectColor("border-check");
expect(border).toMatch(/^border-[a-z]+-600$/);
});
});
describe("getProjectHex", () => {
it("appends the default alpha suffix 'B3'", () => {
const result = getProjectHex("proj-alpha");
expect(result).toMatch(/^#[0-9a-fA-F]{6}B3$/);
});
it("appends a custom alpha suffix", () => {
const result = getProjectHex("proj-alpha", "FF");
expect(result).toMatch(/^#[0-9a-fA-F]{6}FF$/);
});
it("is deterministic for the same ID", () => {
const a = getProjectHex("same-id");
const b = getProjectHex("same-id");
expect(a).toBe(b);
});
it("returns a different hex when a different alpha is supplied", () => {
const withDefault = getProjectHex("id-x");
const withCustom = getProjectHex("id-x", "00");
expect(withDefault).not.toBe(withCustom);
// The base hex part should be the same
expect(withDefault.slice(0, 7)).toBe(withCustom.slice(0, 7));
});
});
describe("buildProjectColorMap", () => {
it("returns a Map with an entry for each ID", () => {
const ids = ["proj-a", "proj-b", "proj-c"];
const map = buildProjectColorMap(ids);
expect(map.size).toBe(3);
for (const id of ids) {
expect(map.has(id)).toBe(true);
}
});
it("returns an empty Map for an empty array", () => {
const map = buildProjectColorMap([]);
expect(map.size).toBe(0);
});
it("each entry equals the direct getProjectColor result", () => {
const ids = ["x", "y", "z"];
const map = buildProjectColorMap(ids);
for (const id of ids) {
expect(map.get(id)).toBe(getProjectColor(id));
}
});
it("handles duplicate IDs — last write wins (same color anyway)", () => {
const map = buildProjectColorMap(["dup", "dup", "dup"]);
expect(map.size).toBe(1);
expect(map.get("dup")).toBe(getProjectColor("dup"));
});
it("does not mutate the input array", () => {
const ids = ["a", "b"];
const copy = [...ids];
buildProjectColorMap(ids);
expect(ids).toEqual(copy);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { sanitizeHtml } from "./sanitize.js";
// The vitest environment is jsdom, so `window` is defined.
// DOMPurify will operate in client mode and strip tags.
describe("sanitizeHtml", () => {
it("returns plain text unchanged", () => {
expect(sanitizeHtml("Hello, World!")).toBe("Hello, World!");
});
it("strips a single HTML tag", () => {
expect(sanitizeHtml("<b>bold</b>")).toBe("bold");
});
it("strips nested HTML tags", () => {
expect(sanitizeHtml("<div><p>text</p></div>")).toBe("text");
});
it("strips attributes", () => {
expect(sanitizeHtml('<a href="https://example.com">link</a>')).toBe("link");
});
it("strips script tags and their content", () => {
// DOMPurify removes script tags entirely (content too)
const result = sanitizeHtml('<script>alert("xss")</script>safe');
expect(result).not.toContain("<script>");
expect(result).not.toContain("alert");
expect(result).toContain("safe");
});
it("strips event handler attributes", () => {
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("returns empty string for empty input", () => {
expect(sanitizeHtml("")).toBe("");
});
it("passes through text that contains HTML entities", () => {
// DOMPurify in jsdom returns the serialised form, keeping '&amp;' as-is
// (it is not the job of sanitiseHtml to decode entities)
const result = sanitizeHtml("&amp;");
expect(result).toBeTruthy();
// Either decoded ('&') or kept encoded ('&amp;') is acceptable; the
// important thing is that no HTML tags survive.
expect(result).not.toContain("<");
expect(result).not.toContain(">");
});
it("strips style tags", () => {
const result = sanitizeHtml("<style>body{color:red}</style>text");
expect(result).not.toContain("<style>");
expect(result).toContain("text");
});
it("handles input with only whitespace", () => {
expect(sanitizeHtml(" ")).toBe(" ");
});
it("handles deeply nested tags leaving only text", () => {
expect(sanitizeHtml("<ul><li><span>item</span></li></ul>")).toBe("item");
});
it("strips iframe tags", () => {
const result = sanitizeHtml('<iframe src="evil.com"></iframe>safe');
expect(result).not.toContain("iframe");
expect(result).toContain("safe");
});
});