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");
});
});
});