test(web): add 57 UI component and hook tests with jsdom cleanup
Fix jsdom environment: add esbuild automatic JSX transform and afterEach cleanup to prevent DOM leakage between tests. Components: Badge (8), Button (13), FilterBar (5), EmptyState (8), ConfirmDialog (8), useSelection hook (15). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import { Badge } from "./Badge.js";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders children text", () => {
|
||||
render(<Badge>Active</Badge>);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies default variant classes when no variant is specified", () => {
|
||||
render(<Badge>Default</Badge>);
|
||||
const badge = screen.getByText("Default");
|
||||
expect(badge.className).toContain("bg-gray-100");
|
||||
expect(badge.className).toContain("text-gray-700");
|
||||
});
|
||||
|
||||
it("applies success variant classes", () => {
|
||||
render(<Badge variant="success">OK</Badge>);
|
||||
const badge = screen.getByText("OK");
|
||||
expect(badge.className).toContain("bg-green-100");
|
||||
expect(badge.className).toContain("text-green-700");
|
||||
});
|
||||
|
||||
it("applies danger variant classes", () => {
|
||||
render(<Badge variant="danger">Error</Badge>);
|
||||
expect(screen.getByText("Error").className).toContain("bg-red-100");
|
||||
});
|
||||
|
||||
it("applies warning variant classes", () => {
|
||||
render(<Badge variant="warning">Warn</Badge>);
|
||||
expect(screen.getByText("Warn").className).toContain("bg-yellow-100");
|
||||
});
|
||||
|
||||
it("applies info variant classes", () => {
|
||||
render(<Badge variant="info">Info</Badge>);
|
||||
expect(screen.getByText("Info").className).toContain("bg-blue-100");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Badge className="custom-class">Test</Badge>);
|
||||
expect(screen.getByText("Test").className).toContain("custom-class");
|
||||
});
|
||||
|
||||
it("renders as a span element", () => {
|
||||
render(<Badge>Span</Badge>);
|
||||
expect(screen.getByText("Span").tagName).toBe("SPAN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Button } from "./Button.js";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders children text", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies primary variant by default", () => {
|
||||
render(<Button>Primary</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("bg-brand-600");
|
||||
});
|
||||
|
||||
it("applies secondary variant", () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("border-gray-300");
|
||||
});
|
||||
|
||||
it("applies ghost variant", () => {
|
||||
render(<Button variant="ghost">Ghost</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("hover:bg-gray-100");
|
||||
});
|
||||
|
||||
it("applies danger variant", () => {
|
||||
render(<Button variant="danger">Delete</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("bg-red-600");
|
||||
});
|
||||
|
||||
it("applies small size", () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("px-3");
|
||||
expect(screen.getByRole("button").className).toContain("text-xs");
|
||||
});
|
||||
|
||||
it("applies medium size by default", () => {
|
||||
render(<Button>Medium</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("px-4");
|
||||
expect(screen.getByRole("button").className).toContain("text-sm");
|
||||
});
|
||||
|
||||
it("applies large size", () => {
|
||||
render(<Button size="lg">Large</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("px-6");
|
||||
expect(screen.getByRole("button").className).toContain("text-base");
|
||||
});
|
||||
|
||||
it("handles click events", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables the button when disabled prop is true", () => {
|
||||
render(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not fire click when disabled", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Disabled
|
||||
</Button>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Button className="extra">Styled</Button>);
|
||||
expect(screen.getByRole("button").className).toContain("extra");
|
||||
});
|
||||
|
||||
it("passes through additional HTML attributes", () => {
|
||||
render(
|
||||
<Button type="submit" data-testid="submit-btn">
|
||||
Submit
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByTestId("submit-btn")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
const defaultProps = {
|
||||
title: "Delete item?",
|
||||
message: "This action cannot be undone.",
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
|
||||
it("renders title and message", () => {
|
||||
render(<ConfirmDialog {...defaultProps} />);
|
||||
expect(screen.getByText("Delete item?")).toBeInTheDocument();
|
||||
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses default button labels", () => {
|
||||
render(<ConfirmDialog {...defaultProps} />);
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses custom button labels", () => {
|
||||
render(<ConfirmDialog {...defaultProps} confirmLabel="Yes, delete" cancelLabel="No, keep" />);
|
||||
expect(screen.getByRole("button", { name: "Yes, delete" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "No, keep" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onConfirm when confirm button is clicked", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCancel when cancel button is clicked", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies danger styling to confirm button when variant is danger", () => {
|
||||
render(<ConfirmDialog {...defaultProps} variant="danger" />);
|
||||
const confirmBtn = screen.getByRole("button", { name: "Confirm" });
|
||||
expect(confirmBtn.className).toContain("bg-red-600");
|
||||
});
|
||||
|
||||
it("applies default styling to confirm button when variant is default", () => {
|
||||
render(<ConfirmDialog {...defaultProps} variant="default" />);
|
||||
const confirmBtn = screen.getByRole("button", { name: "Confirm" });
|
||||
expect(confirmBtn.className).toContain("bg-brand-600");
|
||||
});
|
||||
|
||||
it("calls onCancel when clicking the backdrop", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const { container } = render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
|
||||
const backdrop = container.firstElementChild!;
|
||||
await userEvent.click(backdrop);
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EmptyState } from "./EmptyState.js";
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders the title", () => {
|
||||
render(<EmptyState title="No results" />);
|
||||
expect(screen.getByText("No results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders detail text when provided", () => {
|
||||
render(<EmptyState title="Empty" detail="Try adjusting your filters" />);
|
||||
expect(screen.getByText("Try adjusting your filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render detail when not provided", () => {
|
||||
const { container } = render(<EmptyState title="Empty" />);
|
||||
const paragraphs = container.querySelectorAll("p");
|
||||
expect(paragraphs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders an action button when action is provided", () => {
|
||||
const action = { label: "Create new", onClick: vi.fn() };
|
||||
render(<EmptyState title="Empty" action={action} />);
|
||||
expect(screen.getByRole("button", { name: "Create new" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls action onClick when button is clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<EmptyState title="Empty" action={{ label: "Add", onClick }} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not render action button when not provided", () => {
|
||||
render(<EmptyState title="Empty" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders icon when provided", () => {
|
||||
render(<EmptyState title="Empty" icon={<span data-testid="icon">Icon</span>} />);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies testId to the container", () => {
|
||||
render(<EmptyState title="Empty" testId="empty-state" />);
|
||||
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilterBar } from "./FilterBar.js";
|
||||
|
||||
describe("FilterBar", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<FilterBar>
|
||||
<span>Filter content</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByText("Filter content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show clear button when no active filters", () => {
|
||||
render(
|
||||
<FilterBar hasActiveFilters={false} onClearFilters={vi.fn()}>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show clear button when onClearFilters is not provided", () => {
|
||||
render(
|
||||
<FilterBar hasActiveFilters>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows clear button when hasActiveFilters and onClearFilters are both provided", () => {
|
||||
render(
|
||||
<FilterBar hasActiveFilters onClearFilters={vi.fn()}>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByText("Clear filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClearFilters when clear button is clicked", async () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<FilterBar hasActiveFilters onClearFilters={onClear}>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
await userEvent.click(screen.getByText("Clear filters"));
|
||||
expect(onClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSelection } from "./useSelection.js";
|
||||
|
||||
describe("useSelection", () => {
|
||||
it("starts with empty selection", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
expect(result.current.count).toBe(0);
|
||||
expect(result.current.selectedArray).toEqual([]);
|
||||
});
|
||||
|
||||
it("toggles an item on", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.selectedIds.has("a")).toBe(true);
|
||||
expect(result.current.count).toBe(1);
|
||||
});
|
||||
|
||||
it("toggles an item off", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.selectedIds.has("a")).toBe(false);
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleAll selects all items when none are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggleAll(["a", "b", "c"]));
|
||||
expect(result.current.count).toBe(3);
|
||||
expect(result.current.selectedIds.has("a")).toBe(true);
|
||||
expect(result.current.selectedIds.has("b")).toBe(true);
|
||||
expect(result.current.selectedIds.has("c")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleAll deselects all items when all are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggleAll(["a", "b"]));
|
||||
act(() => result.current.toggleAll(["a", "b"]));
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleAll selects remaining items when some are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => result.current.toggleAll(["a", "b", "c"]));
|
||||
expect(result.current.count).toBe(3);
|
||||
});
|
||||
|
||||
it("clear removes all selections", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => {
|
||||
result.current.toggle("a");
|
||||
result.current.toggle("b");
|
||||
});
|
||||
act(() => result.current.clear());
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
|
||||
it("isAllSelected returns true when all given ids are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggleAll(["a", "b"]));
|
||||
expect(result.current.isAllSelected(["a", "b"])).toBe(true);
|
||||
});
|
||||
|
||||
it("isAllSelected returns false when some ids are not selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.isAllSelected(["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("isAllSelected returns false for empty ids array", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
expect(result.current.isAllSelected([])).toBe(false);
|
||||
});
|
||||
|
||||
it("isIndeterminate returns true when some but not all are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.isIndeterminate(["a", "b"])).toBe(true);
|
||||
});
|
||||
|
||||
it("isIndeterminate returns false when all are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => result.current.toggleAll(["a", "b"]));
|
||||
expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("isIndeterminate returns false when none are selected", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("selectedArray reflects the current selection", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => {
|
||||
result.current.toggle("x");
|
||||
result.current.toggle("y");
|
||||
});
|
||||
expect(result.current.selectedArray.sort()).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
it("preserves other selections when toggling individual items", () => {
|
||||
const { result } = renderHook(() => useSelection());
|
||||
act(() => {
|
||||
result.current.toggle("a");
|
||||
result.current.toggle("b");
|
||||
});
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.selectedIds.has("b")).toBe(true);
|
||||
expect(result.current.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
|
||||
Reference in New Issue
Block a user