diff --git a/apps/web/src/components/ui/BatchActionBar.test.tsx b/apps/web/src/components/ui/BatchActionBar.test.tsx new file mode 100644 index 0000000..32ddaa1 --- /dev/null +++ b/apps/web/src/components/ui/BatchActionBar.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the bar when count is greater than 0", () => { + render(); + expect(screen.getByText("3 selected")).toBeInTheDocument(); + }); + + it("displays the correct count", () => { + render(); + expect(screen.getByText("42 selected")).toBeInTheDocument(); + }); + + it("renders action buttons", () => { + const actions = [ + { label: "Edit", onClick: vi.fn() }, + { label: "Archive", onClick: vi.fn() }, + ]; + render(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); + }); + + it("renders the Clear button", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("5 selected")).toBeInTheDocument(); + }); + + it("hides the bar when count goes back to 0", () => { + const { rerender } = render(); + expect(screen.getByText("3 selected")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText(/selected/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/ui/ErrorBoundary.test.tsx b/apps/web/src/components/ui/ErrorBoundary.test.tsx new file mode 100644 index 0000000..261ba8f --- /dev/null +++ b/apps/web/src/components/ui/ErrorBoundary.test.tsx @@ -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; + +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
OK
; +} + +describe("ErrorBoundary", () => { + describe("normal rendering", () => { + it("renders children when no error is thrown", () => { + render( + +
Hello World
+
, + ); + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + it("renders multiple children normally", () => { + render( + + First + Second + , + ); + 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( + + + , + ); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("displays the error message in the default fallback", () => { + render( + + + , + ); + expect(screen.getByText("Custom error message")).toBeInTheDocument(); + }); + + it("renders a custom fallback node instead of the default when provided", () => { + render( + Custom fallback}> + + , + ); + 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( + + + , + ); + 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( + +
No error here
+
, + ); + expect(handleError).not.toHaveBeenCalled(); + }); + }); + + describe("default fallback UI", () => { + it("renders the 'Something went wrong' heading", () => { + render( + + + , + ); + expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument(); + }); + + it("renders a 'Try again' button", () => { + render( + + + , + ); + expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + }); + + it("renders a 'Go to dashboard' button", () => { + render( + + + , + ); + 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( + + + , + ); + 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
OK
; + } + + const { rerender } = render( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByText("OK")).toBeInTheDocument(); + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); + }); + }); +}); + +describe("DefaultErrorFallback", () => { + it("renders the heading", () => { + render(); + expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument(); + }); + + it("renders the error message", () => { + render(); + expect(screen.getByText("Specific error")).toBeInTheDocument(); + }); + + it("falls back to generic text when error message is empty", () => { + render(); + 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(); + 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(); + await user.click(screen.getByRole("button", { name: "Go to dashboard" })); + expect(window.location.href).toBe("/dashboard"); + }); +}); diff --git a/apps/web/src/components/ui/FilterChips.test.tsx b/apps/web/src/components/ui/FilterChips.test.tsx new file mode 100644 index 0000000..8a66cdc --- /dev/null +++ b/apps/web/src/components/ui/FilterChips.test.tsx @@ -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(); + 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(); + 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(); + 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(); + expect(screen.getByRole("button", { name: "Clear all" })).toBeInTheDocument(); + }); + + it("does not render 'Clear all' when chips array is empty", () => { + render(); + expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument(); + }); + + it("renders a single chip correctly", () => { + const chips: Chip[] = [{ label: "Pending", onRemove: vi.fn() }]; + render(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + expect(screen.queryByText("Active")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("hides when all chips are removed", () => { + const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }]; + const { rerender } = render(); + expect(screen.getByText("Active")).toBeInTheDocument(); + + rerender(); + 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(); + expect( + screen.getByRole("button", { name: "Remove filter: In Progress" }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/ui/SortableColumnHeader.test.tsx b/apps/web/src/components/ui/SortableColumnHeader.test.tsx new file mode 100644 index 0000000..82e6f47 --- /dev/null +++ b/apps/web/src/components/ui/SortableColumnHeader.test.tsx @@ -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( + + + {ui} + +
, + ); +} + +describe("SortableColumnHeader", () => { + describe("rendering", () => { + it("renders the label text", () => { + renderInTable( + , + ); + expect(screen.getByText("Name")).toBeInTheDocument(); + }); + + it("renders a button for the sortable label", () => { + renderInTable( + , + ); + expect(screen.getByRole("button", { name: /budget/i })).toBeInTheDocument(); + }); + + it("renders inside a element", () => { + const { container } = renderInTable( + , + ); + expect(container.querySelector("th")).toBeInTheDocument(); + }); + }); + + describe("sort direction icons", () => { + it("shows no active direction when sortField does not match field", () => { + const { container } = renderInTable( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + + + + + + + +
, + ); + 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( + , + ); + const div = container.querySelector("th > div"); + expect(div?.className).toContain("justify-start"); + }); + + it("applies justify-end for right alignment", () => { + const { container } = renderInTable( + , + ); + const div = container.querySelector("th > div"); + expect(div?.className).toContain("justify-end"); + }); + + it("applies justify-center for center alignment", () => { + const { container } = renderInTable( + , + ); + 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( + , + ); + expect(screen.queryByRole("button", { name: "More information" })).not.toBeInTheDocument(); + }); + + it("renders an info tooltip button when tooltip prop is provided", () => { + renderInTable( + , + ); + expect(screen.getByRole("button", { name: "More information" })).toBeInTheDocument(); + }); + }); + + describe("custom className", () => { + it("forwards className to the element", () => { + const { container } = renderInTable( + , + ); + expect(container.querySelector("th")?.className).toContain("w-48"); + }); + }); +}); diff --git a/apps/web/src/hooks/useColumnConfig.test.ts b/apps/web/src/hooks/useColumnConfig.test.ts new file mode 100644 index 0000000..c94b34b --- /dev/null +++ b/apps/web/src/hooks/useColumnConfig.test.ts @@ -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 = {}; + 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; + + 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)); + }); + }); +}); diff --git a/apps/web/src/hooks/useDebounce.test.ts b/apps/web/src/hooks/useDebounce.test.ts new file mode 100644 index 0000000..3cec6e7 --- /dev/null +++ b/apps/web/src/hooks/useDebounce.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000..7c54de9 --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -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 = {}; + 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; + + 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>("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"); + }); + }); +}); diff --git a/apps/web/src/hooks/useTableSort.test.ts b/apps/web/src/hooks/useTableSort.test.ts new file mode 100644 index 0000000..791589f --- /dev/null +++ b/apps/web/src/hooks/useTableSort.test.ts @@ -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([])); + 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]]); + }); + }); +}); diff --git a/apps/web/src/lib/csv-export.test.ts b/apps/web/src/lib/csv-export.test.ts new file mode 100644 index 0000000..b2f26ee --- /dev/null +++ b/apps/web/src/lib/csv-export.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts new file mode 100644 index 0000000..c2b9c3c --- /dev/null +++ b/apps/web/src/lib/format.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/project-colors.test.ts b/apps/web/src/lib/project-colors.test.ts new file mode 100644 index 0000000..b62966f --- /dev/null +++ b/apps/web/src/lib/project-colors.test.ts @@ -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(); + 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); + }); +}); diff --git a/apps/web/src/lib/sanitize.test.ts b/apps/web/src/lib/sanitize.test.ts new file mode 100644 index 0000000..2e2d841 --- /dev/null +++ b/apps/web/src/lib/sanitize.test.ts @@ -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("bold")).toBe("bold"); + }); + + it("strips nested HTML tags", () => { + expect(sanitizeHtml("

text

")).toBe("text"); + }); + + it("strips attributes", () => { + expect(sanitizeHtml('link')).toBe("link"); + }); + + it("strips script tags and their content", () => { + // DOMPurify removes script tags entirely (content too) + const result = sanitizeHtml('safe'); + expect(result).not.toContain("