test(web): add 210 tests for lib utils, hooks, and UI components
Lib utilities: format (38), sanitize (12), project-colors (18), csv-export (14). Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18), useColumnConfig (19). Components: BatchActionBar (17), SortableColumnHeader (14), FilterChips (14), ErrorBoundary (16). Web test suite: 63 → 75 files, 343 → 553 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BatchActionBar } from "./BatchActionBar.js";
|
||||
|
||||
describe("BatchActionBar", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders nothing when count is 0", () => {
|
||||
const { container } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the bar when count is greater than 0", () => {
|
||||
render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the correct count", () => {
|
||||
render(<BatchActionBar count={42} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("42 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
const actions = [
|
||||
{ label: "Edit", onClick: vi.fn() },
|
||||
{ label: "Archive", onClick: vi.fn() },
|
||||
];
|
||||
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Clear button", () => {
|
||||
render(<BatchActionBar count={1} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action variants", () => {
|
||||
it("applies danger styling to actions with variant='danger'", () => {
|
||||
const actions = [{ label: "Delete", onClick: vi.fn(), variant: "danger" as const }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.className).toContain("bg-red-600");
|
||||
});
|
||||
|
||||
it("applies default styling to actions without a variant", () => {
|
||||
const actions = [{ label: "Edit", onClick: vi.fn() }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Edit" });
|
||||
expect(btn.className).toContain("bg-white/10");
|
||||
});
|
||||
|
||||
it("applies default styling to actions with variant='default'", () => {
|
||||
const actions = [{ label: "Edit", onClick: vi.fn(), variant: "default" as const }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Edit" });
|
||||
expect(btn.className).toContain("bg-white/10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disabled state", () => {
|
||||
it("disables an action button when disabled prop is true", () => {
|
||||
const actions = [{ label: "Export", onClick: vi.fn(), disabled: true }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables an action button when disabled prop is false", () => {
|
||||
const actions = [{ label: "Export", onClick: vi.fn(), disabled: false }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not fire onClick when action button is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
const actions = [{ label: "Export", onClick: handleClick, disabled: true }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Export" }));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls action onClick when an action button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
const actions = [{ label: "Edit", onClick: handleClick }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onClear when the Clear button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClear = vi.fn();
|
||||
render(<BatchActionBar count={1} actions={[]} onClear={handleClear} />);
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls each action's onClick independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEdit = vi.fn();
|
||||
const handleDelete = vi.fn();
|
||||
const actions = [
|
||||
{ label: "Edit", onClick: handleEdit },
|
||||
{ label: "Delete", onClick: handleDelete, variant: "danger" as const },
|
||||
];
|
||||
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(handleEdit).toHaveBeenCalledOnce();
|
||||
expect(handleDelete).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(handleDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional rendering on count change", () => {
|
||||
it("renders when count transitions from 0 to positive", () => {
|
||||
const { rerender } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<BatchActionBar count={5} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("5 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the bar when count goes back to 0", () => {
|
||||
const { rerender } = render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
|
||||
rerender(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ErrorInfo } from "react";
|
||||
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
|
||||
|
||||
// Suppress React's console.error output during error boundary tests
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// A component that unconditionally throws
|
||||
function ThrowingChild({ message = "Test error" }: { message?: string }) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// A component that throws only when `shouldThrow` is true
|
||||
function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) throw new Error("Conditional error");
|
||||
return <div>OK</div>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
describe("normal rendering", () => {
|
||||
it("renders children when no error is thrown", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Hello World</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple children normally", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error catching", () => {
|
||||
it("catches a thrown error and renders the default fallback UI", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the error message in the default fallback", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild message="Custom error message" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Custom error message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a custom fallback node instead of the default when provided", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Custom fallback")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError callback when an error is caught", () => {
|
||||
const handleError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={handleError}>
|
||||
<ThrowingChild message="Tracked error" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(handleError).toHaveBeenCalledOnce();
|
||||
const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
|
||||
expect(error.message).toBe("Tracked error");
|
||||
expect(info).toHaveProperty("componentStack");
|
||||
});
|
||||
|
||||
it("does not call onError when no error is thrown", () => {
|
||||
const handleError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={handleError}>
|
||||
<div>No error here</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(handleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("default fallback UI", () => {
|
||||
it("renders the 'Something went wrong' heading", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'Try again' button", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'Go to dashboard' button", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a generic message when error.message is empty", () => {
|
||||
function ThrowEmptyMessage() {
|
||||
const e = new Error("");
|
||||
throw e;
|
||||
}
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowEmptyMessage />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred. The team has been notified."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset / Try again", () => {
|
||||
it("clears the error state and re-renders children when Try again is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Use a mutable ref-like object to control throwing from outside JSX
|
||||
let shouldThrow = true;
|
||||
|
||||
function ControlledThrower() {
|
||||
if (shouldThrow) throw new Error("Controlled error");
|
||||
return <div>OK</div>;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ControlledThrower />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
|
||||
// Stop throwing, then click "Try again" so ErrorBoundary resets its state
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
// Trigger a re-render so ControlledThrower runs with shouldThrow=false
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ControlledThrower />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("OK")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultErrorFallback", () => {
|
||||
it("renders the heading", () => {
|
||||
render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error message", () => {
|
||||
render(<DefaultErrorFallback error={new Error("Specific error")} reset={vi.fn()} />);
|
||||
expect(screen.getByText("Specific error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to generic text when error message is empty", () => {
|
||||
render(<DefaultErrorFallback error={new Error("")} reset={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred. The team has been notified."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when Try again is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleReset = vi.fn();
|
||||
render(<DefaultErrorFallback error={new Error("Err")} reset={handleReset} />);
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(handleReset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("navigates to /dashboard when Go to dashboard is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// jsdom does not navigate; spy on the assignment
|
||||
const originalHref = window.location.href;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: originalHref },
|
||||
writable: true,
|
||||
});
|
||||
render(<DefaultErrorFallback error={new Error("Err")} reset={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
|
||||
expect(window.location.href).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilterChips, type Chip } from "./FilterChips.js";
|
||||
|
||||
describe("FilterChips", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders nothing when chips array is empty", () => {
|
||||
const { container } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders chip labels when chips are provided", () => {
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: vi.fn() },
|
||||
{ label: "Design", onRemove: vi.fn() },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a remove button for each chip", () => {
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: vi.fn() },
|
||||
{ label: "Design", onRemove: vi.fn() },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Active" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Design" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the 'Clear all' button when chips are present", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render 'Clear all' when chips array is empty", () => {
|
||||
render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a single chip correctly", () => {
|
||||
const chips: Chip[] = [{ label: "Pending", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Pending" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls onRemove of the correct chip when its remove button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemoveActive = vi.fn();
|
||||
const handleRemoveDesign = vi.fn();
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: handleRemoveActive },
|
||||
{ label: "Design", onRemove: handleRemoveDesign },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove filter: Active" }));
|
||||
expect(handleRemoveActive).toHaveBeenCalledOnce();
|
||||
expect(handleRemoveDesign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onRemove for the second chip independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemoveActive = vi.fn();
|
||||
const handleRemoveDesign = vi.fn();
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: handleRemoveActive },
|
||||
{ label: "Design", onRemove: handleRemoveDesign },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove filter: Design" }));
|
||||
expect(handleRemoveDesign).toHaveBeenCalledOnce();
|
||||
expect(handleRemoveActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClearAll when the Clear all button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClearAll = vi.fn();
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(handleClearAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not call other handlers when Clear all is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
const handleClearAll = vi.fn();
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: handleRemove }];
|
||||
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(handleRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional rendering on chip changes", () => {
|
||||
it("shows chips after transitioning from empty to non-empty", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
const { rerender } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByText("Active")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides when all chips are removed", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
const { rerender } = render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
|
||||
rerender(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByText("Active")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chip remove button aria-label", () => {
|
||||
it("uses the chip label in the aria-label", () => {
|
||||
const chips: Chip[] = [{ label: "In Progress", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove filter: In Progress" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SortableColumnHeader } from "./SortableColumnHeader.js";
|
||||
|
||||
// Helper: render inside a table/thead/tr to satisfy HTML semantics
|
||||
function renderInTable(ui: React.ReactElement) {
|
||||
return render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{ui}</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SortableColumnHeader", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders the label text", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a button for the sortable label", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Budget"
|
||||
field="budget"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /budget/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inside a <th> element", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Status"
|
||||
field="status"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("th")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sort direction icons", () => {
|
||||
it("shows no active direction when sortField does not match field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="budget"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Neither chevron should be brand-colored
|
||||
const paths = container.querySelectorAll("path");
|
||||
paths.forEach((path) => {
|
||||
expect(path.className.baseVal).not.toContain("stroke-brand-600");
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the up chevron when sorted asc on this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
// First path = up chevron (asc)
|
||||
expect(paths[0]?.className.baseVal).toContain("stroke-brand-600");
|
||||
// Second path = down chevron should be gray
|
||||
expect(paths[1]?.className.baseVal).toContain("stroke-gray-300");
|
||||
});
|
||||
|
||||
it("highlights the down chevron when sorted desc on this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="desc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
// First path = up chevron should be gray
|
||||
expect(paths[0]?.className.baseVal).toContain("stroke-gray-300");
|
||||
// Second path = down chevron (desc)
|
||||
expect(paths[1]?.className.baseVal).toContain("stroke-brand-600");
|
||||
});
|
||||
|
||||
it("shows no active direction when sortDir is null for this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
paths.forEach((path) => {
|
||||
expect(path.className.baseVal).not.toContain("stroke-brand-600");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls onSort with the field when the button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSort = vi.fn();
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /name/i }));
|
||||
expect(handleSort).toHaveBeenCalledOnce();
|
||||
expect(handleSort).toHaveBeenCalledWith("name");
|
||||
});
|
||||
|
||||
it("passes the correct field string when multiple columns exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSort = vi.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableColumnHeader
|
||||
label="Budget"
|
||||
field="budget"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /budget/i }));
|
||||
expect(handleSort).toHaveBeenCalledWith("budget");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alignment", () => {
|
||||
it("applies justify-start for left alignment (default)", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-start");
|
||||
});
|
||||
|
||||
it("applies justify-end for right alignment", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Amount"
|
||||
field="amount"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
align="right"
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-end");
|
||||
});
|
||||
|
||||
it("applies justify-center for center alignment", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Status"
|
||||
field="status"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
align="center"
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-center");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooltip", () => {
|
||||
it("does not render an info tooltip when tooltip prop is absent", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "More information" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an info tooltip button when tooltip prop is provided", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
tooltip="Helpful hint"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "More information" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom className", () => {
|
||||
it("forwards className to the <th> element", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
className="w-48"
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("th")?.className).toContain("w-48");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ColumnDef, ColumnPreferences } from "@capakraken/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the tRPC client so the hook can be tested without a real server
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockGetColumnPreferences = vi.fn();
|
||||
const mockSetColumnPreferences = vi.fn();
|
||||
|
||||
vi.mock("~/lib/trpc/client.js", () => ({
|
||||
trpc: {
|
||||
user: {
|
||||
getColumnPreferences: {
|
||||
useQuery: () => ({
|
||||
data: mockGetColumnPreferences(),
|
||||
}),
|
||||
},
|
||||
setColumnPreferences: {
|
||||
useMutation: () => ({
|
||||
mutate: mockSetColumnPreferences,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after the mock is registered
|
||||
const { useColumnConfig } = await import("./useColumnConfig.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub
|
||||
// ---------------------------------------------------------------------------
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
_store: () => store,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
const builtinColumns: ColumnDef[] = [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "age", label: "Age", defaultVisible: true, hideable: true },
|
||||
{ key: "status", label: "Status", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
const customColumns: ColumnDef[] = [
|
||||
{ key: "custom1", label: "Custom 1", defaultVisible: true, hideable: true, isCustom: true },
|
||||
{ key: "custom2", label: "Custom 2", defaultVisible: false, hideable: true, isCustom: true },
|
||||
];
|
||||
|
||||
describe("useColumnConfig", () => {
|
||||
let localStorageStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
|
||||
// Default: no server preferences
|
||||
mockGetColumnPreferences.mockReturnValue(undefined);
|
||||
mockSetColumnPreferences.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// allColumns
|
||||
// -------------------------------------------------------------------------
|
||||
describe("allColumns", () => {
|
||||
it("combines builtinColumns and customColumns", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnConfig("resources", builtinColumns, customColumns),
|
||||
);
|
||||
expect(result.current.allColumns).toHaveLength(builtinColumns.length + customColumns.length);
|
||||
});
|
||||
|
||||
it("returns only builtinColumns when customColumns is omitted", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.allColumns).toEqual(builtinColumns);
|
||||
});
|
||||
|
||||
it("puts builtinColumns before customColumns", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useColumnConfig("resources", builtinColumns, customColumns),
|
||||
);
|
||||
const keys = result.current.allColumns.map((c) => c.key);
|
||||
expect(keys).toEqual(["name", "age", "status", "custom1", "custom2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — default fallback (no localStorage, no server prefs)
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — defaults", () => {
|
||||
it("includes columns with defaultVisible=true", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
});
|
||||
|
||||
it("excludes columns with defaultVisible=false", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
expect(result.current.visibleKeys).not.toContain("status");
|
||||
});
|
||||
|
||||
it("always includes non-hideable columns when a saved list is present", () => {
|
||||
// alwaysVisible is only force-injected when merging with a saved key list.
|
||||
// When falling back to defaults, only columns with defaultVisible=true are shown.
|
||||
const cols: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", defaultVisible: true, hideable: false },
|
||||
{ key: "opt", label: "Opt", defaultVisible: false, hideable: true },
|
||||
{ key: "extra", label: "Extra", defaultVisible: false, hideable: true },
|
||||
];
|
||||
// Provide a saved list that omits "id" — the hook must still inject it
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["opt"]));
|
||||
const { result } = renderHook(() => useColumnConfig("resources", cols));
|
||||
expect(result.current.visibleKeys).toContain("id");
|
||||
expect(result.current.visibleKeys).toContain("opt");
|
||||
expect(result.current.visibleKeys).not.toContain("extra");
|
||||
});
|
||||
|
||||
it("shows only defaultVisible=true columns when no saved list exists", () => {
|
||||
const cols: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", defaultVisible: false, hideable: false },
|
||||
{ key: "opt", label: "Opt", defaultVisible: false, hideable: true },
|
||||
];
|
||||
const { result } = renderHook(() => useColumnConfig("resources", cols));
|
||||
// No saved list → falls back to defaultVisible; neither column qualifies
|
||||
expect(result.current.visibleKeys).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — localStorage takes priority
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — localStorage persistence", () => {
|
||||
it("uses the saved keys from localStorage on mount", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["status"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
// "name" is non-hideable so it must always appear; "status" is from localStorage
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
});
|
||||
|
||||
it("filters out stale keys that no longer exist in allColumns", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "obsoleteKey"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
expect(result.current.visibleKeys).not.toContain("obsoleteKey");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleKeys — server prefs fallback
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleKeys — server preferences fallback", () => {
|
||||
it("uses server preferences when localStorage is empty", () => {
|
||||
const serverPrefs: ColumnPreferences = {
|
||||
resources: { visible: ["status", "age"] },
|
||||
};
|
||||
mockGetColumnPreferences.mockReturnValue(serverPrefs);
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
expect(result.current.visibleKeys).toContain("age");
|
||||
// non-hideable always present
|
||||
expect(result.current.visibleKeys).toContain("name");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visibleColumns
|
||||
// -------------------------------------------------------------------------
|
||||
describe("visibleColumns", () => {
|
||||
it("returns only columns whose keys are in visibleKeys", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
const visibleSet = new Set(result.current.visibleKeys);
|
||||
for (const col of result.current.visibleColumns) {
|
||||
expect(visibleSet.has(col.key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders visibleColumns to match the order of visibleKeys", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "name"]));
|
||||
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
const colKeys = result.current.visibleColumns.map((c) => c.key);
|
||||
// "name" is non-hideable and always inserted first in visibleKeys logic,
|
||||
// then "age" from localStorage; ordering must follow visibleKeys
|
||||
expect(colKeys.indexOf("name")).toBeLessThan(colKeys.indexOf("age"));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setVisible
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setVisible", () => {
|
||||
it("updates visibleKeys immediately (optimistic)", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "status"]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleKeys).toContain("status");
|
||||
});
|
||||
|
||||
it("persists the new visible keys to localStorage", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "age"]);
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith(
|
||||
"colvis_resources",
|
||||
JSON.stringify(["name", "age"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls the server mutation with the correct payload", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "age"]);
|
||||
});
|
||||
|
||||
expect(mockSetColumnPreferences).toHaveBeenCalledWith({
|
||||
view: "resources",
|
||||
visible: ["name", "age"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reflects the new keys in visibleColumns after setVisible", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name", "status"]);
|
||||
});
|
||||
|
||||
const keys = result.current.visibleColumns.map((c) => c.key);
|
||||
expect(keys).toContain("status");
|
||||
expect(keys).not.toContain("age");
|
||||
});
|
||||
|
||||
it("can hide all hideable columns", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
// Only "name" (non-hideable) in the list
|
||||
result.current.setVisible(["name"]);
|
||||
});
|
||||
|
||||
expect(result.current.visibleKeys).toEqual(["name"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ViewKey variation
|
||||
// -------------------------------------------------------------------------
|
||||
describe("view isolation", () => {
|
||||
it("reads from the correct localStorage key for the given view", () => {
|
||||
renderHook(() => useColumnConfig("projects", builtinColumns));
|
||||
expect(localStorageStub.getItem).toHaveBeenCalledWith("colvis_projects");
|
||||
});
|
||||
|
||||
it("writes to the correct localStorage key for the given view", () => {
|
||||
const { result } = renderHook(() => useColumnConfig("projects", builtinColumns));
|
||||
|
||||
act(() => {
|
||||
result.current.setVisible(["name"]);
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("colvis_projects", expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useLocalStorage } from "./useLocalStorage.js";
|
||||
|
||||
// Helper to create a fresh in-memory localStorage stub
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
let localStorageStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial value", () => {
|
||||
it("returns the defaultValue when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"));
|
||||
expect(result.current[0]).toBe("default");
|
||||
});
|
||||
|
||||
it("returns the stored value when localStorage already has data", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify("stored"));
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"));
|
||||
expect(result.current[0]).toBe("stored");
|
||||
});
|
||||
|
||||
it("merges stored object with defaultValue for schema evolution", () => {
|
||||
const stored = { existing: "value" };
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify(stored));
|
||||
const defaultValue = { existing: "old", newField: "fallback" };
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("obj-key", defaultValue));
|
||||
|
||||
expect(result.current[0]).toEqual({ existing: "value", newField: "fallback" });
|
||||
});
|
||||
|
||||
it("does not merge when the stored value is not a plain object", () => {
|
||||
localStorageStub.getItem.mockReturnValue(JSON.stringify([1, 2, 3]));
|
||||
const { result } = renderHook(() => useLocalStorage("arr-key", [0]));
|
||||
expect(result.current[0]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns defaultValue when stored JSON is malformed", () => {
|
||||
localStorageStub.getItem.mockReturnValue("not-valid-json{{{");
|
||||
const { result } = renderHook(() => useLocalStorage("bad-key", 99));
|
||||
expect(result.current[0]).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set function — direct value", () => {
|
||||
it("updates the in-memory state", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("updated");
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe("updated");
|
||||
});
|
||||
|
||||
it("persists the new value to localStorage", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("saved");
|
||||
});
|
||||
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("key", JSON.stringify("saved"));
|
||||
});
|
||||
|
||||
it("handles numeric values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("num", 0));
|
||||
|
||||
act(() => {
|
||||
result.current[1](42);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(42);
|
||||
expect(localStorageStub.setItem).toHaveBeenCalledWith("num", "42");
|
||||
});
|
||||
|
||||
it("handles boolean values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("bool", false));
|
||||
|
||||
act(() => {
|
||||
result.current[1](true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("handles object values", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("obj", { a: 1 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ a: 2, b: 3 });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ a: 2, b: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("set function — updater callback", () => {
|
||||
it("passes the current value to the updater", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("count", 5));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(6);
|
||||
});
|
||||
|
||||
it("supports multiple sequential updater calls", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("count", 0));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(2);
|
||||
});
|
||||
|
||||
it("can spread objects in updater", () => {
|
||||
const { result } = renderHook(() => useLocalStorage<Record<string, number>>("map", {}));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => ({ ...prev, x: 10 }));
|
||||
});
|
||||
act(() => {
|
||||
result.current[1]((prev) => ({ ...prev, y: 20 }));
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEventName cross-instance sync", () => {
|
||||
it("dispatches a CustomEvent on write when changeEventName is provided", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useLocalStorage("key", "a", "myEvent"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
const dispatched = dispatchSpy.mock.calls.find(
|
||||
([e]) => e instanceof CustomEvent && e.type === "myEvent",
|
||||
);
|
||||
expect(dispatched).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not dispatch a CustomEvent when changeEventName is omitted", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
const { result } = renderHook(() => useLocalStorage("key", "a"));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
const customEvents = dispatchSpy.mock.calls.filter(([e]) => e instanceof CustomEvent);
|
||||
expect(customEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates state when the matching CustomEvent fires on window", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent("syncEvent", { detail: "from-other-instance" }));
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe("from-other-instance");
|
||||
});
|
||||
|
||||
it("does not respond to events after unmount", () => {
|
||||
const { result, unmount } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
|
||||
|
||||
unmount();
|
||||
|
||||
// Should not throw or update after unmount
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent("syncEvent", { detail: "post-unmount" }));
|
||||
});
|
||||
|
||||
// Value should remain whatever it was at unmount time
|
||||
expect(result.current[0]).toBe("initial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("key change", () => {
|
||||
it("reads from the new key when the key prop changes", () => {
|
||||
localStorageStub.getItem.mockImplementation((k: string) => {
|
||||
if (k === "key-a") return JSON.stringify("value-a");
|
||||
if (k === "key-b") return JSON.stringify("value-b");
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ key }: { key: string }) => useLocalStorage(key, "default"),
|
||||
{ initialProps: { key: "key-a" } },
|
||||
);
|
||||
expect(result.current[0]).toBe("value-a");
|
||||
|
||||
rerender({ key: "key-b" });
|
||||
expect(result.current[0]).toBe("value-b");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTableSort } from "./useTableSort.js";
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
score?: number | null;
|
||||
}
|
||||
|
||||
const people: Person[] = [
|
||||
{ name: "Charlie", age: 30 },
|
||||
{ name: "Alice", age: 25 },
|
||||
{ name: "Bob", age: 35 },
|
||||
];
|
||||
|
||||
describe("useTableSort", () => {
|
||||
describe("initial state", () => {
|
||||
it("returns rows unsorted when no initial options are provided", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
expect(result.current.sortField).toBeNull();
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("applies initialField and initialDir when provided", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSort(people, { initialField: "name", initialDir: "asc" }),
|
||||
);
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
|
||||
it("sorts descending when initialDir is desc", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSort(people, { initialField: "name", initialDir: "desc" }),
|
||||
);
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("sets a new sort field with asc direction on first toggle", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
});
|
||||
|
||||
it("cycles asc → desc on the same field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sortDir).toBe("desc");
|
||||
});
|
||||
|
||||
it("cycles desc → null on the same field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // null
|
||||
|
||||
expect(result.current.sortField).toBe("name");
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("resets to asc when switching to a different field", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // name asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // name desc
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // age asc (new field)
|
||||
|
||||
expect(result.current.sortField).toBe("age");
|
||||
expect(result.current.sortDir).toBe("asc");
|
||||
});
|
||||
|
||||
it("accepts a custom getValue extractor", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name", (row) => row.name.toLowerCase());
|
||||
});
|
||||
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears sortField and sortDir", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.sortField).toBeNull();
|
||||
expect(result.current.sortDir).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the original row order after reset", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorted output", () => {
|
||||
it("sorts strings alphabetically ascending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
|
||||
});
|
||||
|
||||
it("sorts strings alphabetically descending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
|
||||
});
|
||||
|
||||
it("sorts numbers ascending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
});
|
||||
expect(result.current.sorted.map((p) => p.age)).toEqual([25, 30, 35]);
|
||||
});
|
||||
|
||||
it("sorts numbers descending", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((p) => p.age)).toEqual([35, 30, 25]);
|
||||
});
|
||||
|
||||
it("places nulls last regardless of direction", () => {
|
||||
const rows: Person[] = [
|
||||
{ name: "A", age: 1, score: null },
|
||||
{ name: "B", age: 2, score: 10 },
|
||||
{ name: "C", age: 3, score: null },
|
||||
{ name: "D", age: 4, score: 5 },
|
||||
];
|
||||
const { result } = renderHook(() => useTableSort(rows));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("score");
|
||||
}); // asc
|
||||
expect(result.current.sorted.map((r) => r.score)).toEqual([5, 10, null, null]);
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("score");
|
||||
}); // desc
|
||||
expect(result.current.sorted.map((r) => r.score)).toEqual([10, 5, null, null]);
|
||||
});
|
||||
|
||||
it("does not mutate the original rows array", () => {
|
||||
const originalOrder = people.map((p) => p.name);
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(people.map((p) => p.name)).toEqual(originalOrder);
|
||||
});
|
||||
|
||||
it("returns original rows when sortDir is null even if sortField is set", () => {
|
||||
const { result } = renderHook(() => useTableSort(people));
|
||||
// Cycle through to null dir
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // asc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // desc
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
}); // null
|
||||
expect(result.current.sorted).toEqual(people);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSortChange callback", () => {
|
||||
it("does not call onSortChange on the initial render", () => {
|
||||
const onSortChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useTableSort(people, {
|
||||
initialField: "name",
|
||||
initialDir: "asc",
|
||||
onSortChange,
|
||||
}),
|
||||
);
|
||||
expect(onSortChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSortChange when the sort field changes", () => {
|
||||
const onSortChange = vi.fn();
|
||||
const { result } = renderHook(() => useTableSort(people, { onSortChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledWith("name", "asc");
|
||||
});
|
||||
|
||||
it("calls onSortChange with null when reset", () => {
|
||||
const onSortChange = vi.fn();
|
||||
const { result } = renderHook(() => useTableSort(people, { onSortChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggle("age");
|
||||
});
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(onSortChange).toHaveBeenLastCalledWith(null, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles an empty rows array", () => {
|
||||
const { result } = renderHook(() => useTableSort<Person>([]));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles a single-element array", () => {
|
||||
const { result } = renderHook(() => useTableSort([people[0]!]));
|
||||
act(() => {
|
||||
result.current.toggle("name");
|
||||
});
|
||||
expect(result.current.sorted).toEqual([people[0]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildProjectColorMap, getProjectColor, getProjectHex } from "./project-colors.js";
|
||||
|
||||
// The palette has exactly 16 entries — exported indirectly through the type.
|
||||
const PALETTE_SIZE = 16;
|
||||
|
||||
describe("getProjectColor", () => {
|
||||
it("returns an object with bg, dark, border, and hex fields", () => {
|
||||
const color = getProjectColor("proj-1");
|
||||
expect(color).toHaveProperty("bg");
|
||||
expect(color).toHaveProperty("dark");
|
||||
expect(color).toHaveProperty("border");
|
||||
expect(color).toHaveProperty("hex");
|
||||
});
|
||||
|
||||
it("is deterministic — same ID always yields the same color", () => {
|
||||
const id = "stable-id-abc";
|
||||
const a = getProjectColor(id);
|
||||
const b = getProjectColor(id);
|
||||
expect(a).toBe(b); // same object reference from the const palette
|
||||
});
|
||||
|
||||
it("returns different colors for different IDs (at least across a sample)", () => {
|
||||
const colors = new Set(
|
||||
Array.from({ length: 20 }, (_, i) => getProjectColor(`project-${i}`).hex),
|
||||
);
|
||||
// With 20 IDs spread across 16 slots we expect several distinct colors
|
||||
expect(colors.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("handles an empty string ID without throwing", () => {
|
||||
expect(() => getProjectColor("")).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles a very long ID without throwing", () => {
|
||||
const longId = "x".repeat(1000);
|
||||
expect(() => getProjectColor(longId)).not.toThrow();
|
||||
});
|
||||
|
||||
it("returned hex value starts with '#'", () => {
|
||||
const { hex } = getProjectColor("test-id");
|
||||
expect(hex).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
});
|
||||
|
||||
it("cycles through all 16 palette slots given enough IDs", () => {
|
||||
// Generate enough IDs to guarantee every palette slot is hit
|
||||
const hexSet = new Set<string>();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
hexSet.add(getProjectColor(`id-collision-test-${i}`).hex);
|
||||
}
|
||||
expect(hexSet.size).toBe(PALETTE_SIZE);
|
||||
});
|
||||
|
||||
it("bg class contains a valid Tailwind color segment", () => {
|
||||
const { bg } = getProjectColor("tailwind-check");
|
||||
expect(bg).toMatch(/^bg-[a-z]+-500\/70$/);
|
||||
});
|
||||
|
||||
it("border class follows expected pattern", () => {
|
||||
const { border } = getProjectColor("border-check");
|
||||
expect(border).toMatch(/^border-[a-z]+-600$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectHex", () => {
|
||||
it("appends the default alpha suffix 'B3'", () => {
|
||||
const result = getProjectHex("proj-alpha");
|
||||
expect(result).toMatch(/^#[0-9a-fA-F]{6}B3$/);
|
||||
});
|
||||
|
||||
it("appends a custom alpha suffix", () => {
|
||||
const result = getProjectHex("proj-alpha", "FF");
|
||||
expect(result).toMatch(/^#[0-9a-fA-F]{6}FF$/);
|
||||
});
|
||||
|
||||
it("is deterministic for the same ID", () => {
|
||||
const a = getProjectHex("same-id");
|
||||
const b = getProjectHex("same-id");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns a different hex when a different alpha is supplied", () => {
|
||||
const withDefault = getProjectHex("id-x");
|
||||
const withCustom = getProjectHex("id-x", "00");
|
||||
expect(withDefault).not.toBe(withCustom);
|
||||
// The base hex part should be the same
|
||||
expect(withDefault.slice(0, 7)).toBe(withCustom.slice(0, 7));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProjectColorMap", () => {
|
||||
it("returns a Map with an entry for each ID", () => {
|
||||
const ids = ["proj-a", "proj-b", "proj-c"];
|
||||
const map = buildProjectColorMap(ids);
|
||||
expect(map.size).toBe(3);
|
||||
for (const id of ids) {
|
||||
expect(map.has(id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty Map for an empty array", () => {
|
||||
const map = buildProjectColorMap([]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it("each entry equals the direct getProjectColor result", () => {
|
||||
const ids = ["x", "y", "z"];
|
||||
const map = buildProjectColorMap(ids);
|
||||
for (const id of ids) {
|
||||
expect(map.get(id)).toBe(getProjectColor(id));
|
||||
}
|
||||
});
|
||||
|
||||
it("handles duplicate IDs — last write wins (same color anyway)", () => {
|
||||
const map = buildProjectColorMap(["dup", "dup", "dup"]);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get("dup")).toBe(getProjectColor("dup"));
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const ids = ["a", "b"];
|
||||
const copy = [...ids];
|
||||
buildProjectColorMap(ids);
|
||||
expect(ids).toEqual(copy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeHtml } from "./sanitize.js";
|
||||
|
||||
// The vitest environment is jsdom, so `window` is defined.
|
||||
// DOMPurify will operate in client mode and strip tags.
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("returns plain text unchanged", () => {
|
||||
expect(sanitizeHtml("Hello, World!")).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
it("strips a single HTML tag", () => {
|
||||
expect(sanitizeHtml("<b>bold</b>")).toBe("bold");
|
||||
});
|
||||
|
||||
it("strips nested HTML tags", () => {
|
||||
expect(sanitizeHtml("<div><p>text</p></div>")).toBe("text");
|
||||
});
|
||||
|
||||
it("strips attributes", () => {
|
||||
expect(sanitizeHtml('<a href="https://example.com">link</a>')).toBe("link");
|
||||
});
|
||||
|
||||
it("strips script tags and their content", () => {
|
||||
// DOMPurify removes script tags entirely (content too)
|
||||
const result = sanitizeHtml('<script>alert("xss")</script>safe');
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("alert");
|
||||
expect(result).toContain("safe");
|
||||
});
|
||||
|
||||
it("strips event handler attributes", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
|
||||
expect(result).not.toContain("onerror");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("passes through text that contains HTML entities", () => {
|
||||
// DOMPurify in jsdom returns the serialised form, keeping '&' as-is
|
||||
// (it is not the job of sanitiseHtml to decode entities)
|
||||
const result = sanitizeHtml("&");
|
||||
expect(result).toBeTruthy();
|
||||
// Either decoded ('&') or kept encoded ('&') is acceptable; the
|
||||
// important thing is that no HTML tags survive.
|
||||
expect(result).not.toContain("<");
|
||||
expect(result).not.toContain(">");
|
||||
});
|
||||
|
||||
it("strips style tags", () => {
|
||||
const result = sanitizeHtml("<style>body{color:red}</style>text");
|
||||
expect(result).not.toContain("<style>");
|
||||
expect(result).toContain("text");
|
||||
});
|
||||
|
||||
it("handles input with only whitespace", () => {
|
||||
expect(sanitizeHtml(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("handles deeply nested tags leaving only text", () => {
|
||||
expect(sanitizeHtml("<ul><li><span>item</span></li></ul>")).toBe("item");
|
||||
});
|
||||
|
||||
it("strips iframe tags", () => {
|
||||
const result = sanitizeHtml('<iframe src="evil.com"></iframe>safe');
|
||||
expect(result).not.toContain("iframe");
|
||||
expect(result).toContain("safe");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user