diff --git a/apps/web/src/components/ui/Badge.test.tsx b/apps/web/src/components/ui/Badge.test.tsx new file mode 100644 index 0000000..743ffd5 --- /dev/null +++ b/apps/web/src/components/ui/Badge.test.tsx @@ -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(Active); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("applies default variant classes when no variant is specified", () => { + render(Default); + 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(OK); + 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(Error); + expect(screen.getByText("Error").className).toContain("bg-red-100"); + }); + + it("applies warning variant classes", () => { + render(Warn); + expect(screen.getByText("Warn").className).toContain("bg-yellow-100"); + }); + + it("applies info variant classes", () => { + render(Info); + expect(screen.getByText("Info").className).toContain("bg-blue-100"); + }); + + it("merges custom className", () => { + render(Test); + expect(screen.getByText("Test").className).toContain("custom-class"); + }); + + it("renders as a span element", () => { + render(Span); + expect(screen.getByText("Span").tagName).toBe("SPAN"); + }); +}); diff --git a/apps/web/src/components/ui/Button.test.tsx b/apps/web/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..acf0c3f --- /dev/null +++ b/apps/web/src/components/ui/Button.test.tsx @@ -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(); + expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); + }); + + it("applies primary variant by default", () => { + render(); + expect(screen.getByRole("button").className).toContain("bg-brand-600"); + }); + + it("applies secondary variant", () => { + render(); + expect(screen.getByRole("button").className).toContain("border-gray-300"); + }); + + it("applies ghost variant", () => { + render(); + expect(screen.getByRole("button").className).toContain("hover:bg-gray-100"); + }); + + it("applies danger variant", () => { + render(); + expect(screen.getByRole("button").className).toContain("bg-red-600"); + }); + + it("applies small size", () => { + render(); + expect(screen.getByRole("button").className).toContain("px-3"); + expect(screen.getByRole("button").className).toContain("text-xs"); + }); + + it("applies medium size by default", () => { + render(); + expect(screen.getByRole("button").className).toContain("px-4"); + expect(screen.getByRole("button").className).toContain("text-sm"); + }); + + it("applies large size", () => { + render(); + 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(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("disables the button when disabled prop is true", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("does not fire click when disabled", async () => { + const onClick = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("merges custom className", () => { + render(); + expect(screen.getByRole("button").className).toContain("extra"); + }); + + it("passes through additional HTML attributes", () => { + render( + , + ); + expect(screen.getByTestId("submit-btn")).toHaveAttribute("type", "submit"); + }); +}); diff --git a/apps/web/src/components/ui/ConfirmDialog.test.tsx b/apps/web/src/components/ui/ConfirmDialog.test.tsx new file mode 100644 index 0000000..66d03aa --- /dev/null +++ b/apps/web/src/components/ui/ConfirmDialog.test.tsx @@ -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(); + expect(screen.getByText("Delete item?")).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + }); + + it("uses default button labels", () => { + render(); + expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }); + + it("uses custom button labels", () => { + render(); + 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(); + 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(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("applies danger styling to confirm button when variant is danger", () => { + render(); + 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(); + 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(); + const backdrop = container.firstElementChild!; + await userEvent.click(backdrop); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/ui/EmptyState.test.tsx b/apps/web/src/components/ui/EmptyState.test.tsx new file mode 100644 index 0000000..ffe7775 --- /dev/null +++ b/apps/web/src/components/ui/EmptyState.test.tsx @@ -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(); + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("renders detail text when provided", () => { + render(); + expect(screen.getByText("Try adjusting your filters")).toBeInTheDocument(); + }); + + it("does not render detail when not provided", () => { + const { container } = render(); + 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(); + expect(screen.getByRole("button", { name: "Create new" })).toBeInTheDocument(); + }); + + it("calls action onClick when button is clicked", async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Add" })); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("does not render action button when not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders icon when provided", () => { + render(Icon} />); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + }); + + it("applies testId to the container", () => { + render(); + expect(screen.getByTestId("empty-state")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ui/FilterBar.test.tsx b/apps/web/src/components/ui/FilterBar.test.tsx new file mode 100644 index 0000000..688d03c --- /dev/null +++ b/apps/web/src/components/ui/FilterBar.test.tsx @@ -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( + + Filter content + , + ); + expect(screen.getByText("Filter content")).toBeInTheDocument(); + }); + + it("does not show clear button when no active filters", () => { + render( + + Filters + , + ); + expect(screen.queryByText("Clear filters")).not.toBeInTheDocument(); + }); + + it("does not show clear button when onClearFilters is not provided", () => { + render( + + Filters + , + ); + expect(screen.queryByText("Clear filters")).not.toBeInTheDocument(); + }); + + it("shows clear button when hasActiveFilters and onClearFilters are both provided", () => { + render( + + Filters + , + ); + expect(screen.getByText("Clear filters")).toBeInTheDocument(); + }); + + it("calls onClearFilters when clear button is clicked", async () => { + const onClear = vi.fn(); + render( + + Filters + , + ); + await userEvent.click(screen.getByText("Clear filters")); + expect(onClear).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/useSelection.test.ts b/apps/web/src/hooks/useSelection.test.ts new file mode 100644 index 0000000..6d5163a --- /dev/null +++ b/apps/web/src/hooks/useSelection.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/vitest-setup.ts b/apps/web/src/vitest-setup.ts index f149f27..e262193 100644 --- a/apps/web/src/vitest-setup.ts +++ b/apps/web/src/vitest-setup.ts @@ -1 +1,7 @@ import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts index 83f4f73..8189c6a 100644 --- a/apps/web/vitest.config.mts +++ b/apps/web/vitest.config.mts @@ -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"),