test(web): add 232 tests for catalog, presets, skeleton, hooks
Lib: blueprint-field-catalog (74). Hooks: useAppPreferences (25), useTheme (19), useMultiSelectIntersection (12), useTimelineKeyboard (21). Components: ColumnTogglePanel, DateRangePresets (17, timezone-safe), ShimmerSkeleton (29), SuccessToast. Fix ShimmerGroup tests to use plain divs (ShimmerSkeleton doesn't forward the style prop from cloneElement). Fix DateRangePresets tests to compute expected dates via toISOString matching the component's UTC conversion. Web test suite: 87 → 96 files, 844 → 1076 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ColumnTogglePanel } from "./ColumnTogglePanel.js";
|
||||
import type { ColumnDef } from "@capakraken/shared";
|
||||
|
||||
// Mock useAnchoredOverlay – in jsdom there is no real layout engine, so we
|
||||
// just return stable refs and a no-op handleOpenChange.
|
||||
vi.mock("~/hooks/useAnchoredOverlay.js", () => ({
|
||||
useAnchoredOverlay: ({ onClose }: { onClose: () => void }) => ({
|
||||
triggerRef: { current: null },
|
||||
panelRef: { current: null },
|
||||
position: { top: 0, left: 0 },
|
||||
handleOpenChange: (open: boolean) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const builtinColumns: ColumnDef[] = [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "role", label: "Role", defaultVisible: true, hideable: true },
|
||||
{ key: "dept", label: "Dept", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
const customColumns: ColumnDef[] = [
|
||||
{ key: "cf_level", label: "Level", defaultVisible: true, hideable: true, isCustom: true },
|
||||
];
|
||||
|
||||
const allColumns: ColumnDef[] = [...builtinColumns, ...customColumns];
|
||||
|
||||
const defaultKeys = ["name", "role"];
|
||||
const visibleKeys = ["name", "role", "cf_level"];
|
||||
|
||||
function renderPanel(overrides: Partial<React.ComponentProps<typeof ColumnTogglePanel>> = {}) {
|
||||
const onSetVisible = vi.fn();
|
||||
const utils = render(
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={onSetVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onSetVisible };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("ColumnTogglePanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("trigger button", () => {
|
||||
it("renders the column-visibility toggle button", () => {
|
||||
renderPanel();
|
||||
expect(screen.getByRole("button", { name: /column visibility/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("panel is not visible before the button is clicked", () => {
|
||||
renderPanel();
|
||||
expect(screen.queryByText("Columns")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel when the trigger button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.getByText("Columns")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the panel when the trigger button is clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
const trigger = screen.getByRole("button", { name: /column visibility/i });
|
||||
await user.click(trigger);
|
||||
await user.click(trigger);
|
||||
expect(screen.queryByText("Columns")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel content – builtin columns", () => {
|
||||
async function openPanel() {
|
||||
const user = userEvent.setup();
|
||||
const utils = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
return { user, ...utils };
|
||||
}
|
||||
|
||||
it("renders all builtin column labels inside the panel", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dept")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows visible columns as checked", async () => {
|
||||
await openPanel();
|
||||
// "name" and "role" are in visibleKeys
|
||||
const roleCheckbox = screen.getByRole("checkbox", { name: /role/i });
|
||||
expect(roleCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows non-visible hideable columns as unchecked", async () => {
|
||||
await openPanel();
|
||||
const deptCheckbox = screen.getByRole("checkbox", { name: /dept/i });
|
||||
expect(deptCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("disables the checkbox for non-hideable columns", async () => {
|
||||
await openPanel();
|
||||
const nameCheckbox = screen.getByRole("checkbox", { name: /name/i });
|
||||
expect(nameCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables the checkbox for hideable columns", async () => {
|
||||
await openPanel();
|
||||
const roleCheckbox = screen.getByRole("checkbox", { name: /role/i });
|
||||
expect(roleCheckbox).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel content – custom columns", () => {
|
||||
async function openPanel() {
|
||||
const user = userEvent.setup();
|
||||
const utils = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
return { user, ...utils };
|
||||
}
|
||||
|
||||
it("renders the 'Custom Fields' section header", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Custom Fields")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom column labels", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Level")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render 'Custom Fields' header when there are no custom columns", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({ allColumns: builtinColumns });
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.queryByText("Custom Fields")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggling visibility", () => {
|
||||
it("calls onSetVisible without a key when a visible hideable column is unchecked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /role/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
expect(keys).not.toContain("role");
|
||||
});
|
||||
|
||||
it("calls onSetVisible with the key added when a hidden hideable column is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /dept/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
expect(keys).toContain("dept");
|
||||
});
|
||||
|
||||
it("does NOT call onSetVisible when a non-hideable column checkbox is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
// The non-hideable checkbox is disabled – userEvent won't fire onChange on disabled inputs.
|
||||
const nameCheckbox = screen.getByRole("checkbox", { name: /name/i });
|
||||
await user.click(nameCheckbox);
|
||||
expect(onSetVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset button", () => {
|
||||
it("renders the Reset button inside the panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSetVisible with defaultKeys when Reset is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("button", { name: /reset/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledWith(defaultKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drag-and-drop reordering", () => {
|
||||
it("calls onSetVisible with reordered keys after a drop onto a different row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel({
|
||||
visibleKeys: ["name", "role", "dept"],
|
||||
allColumns: [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "role", label: "Role", defaultVisible: true, hideable: true },
|
||||
{ key: "dept", label: "Dept", defaultVisible: true, hideable: true },
|
||||
],
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
|
||||
// Simulate dragStart on "role" row and drop onto "dept" row using fireEvent
|
||||
// (userEvent doesn't support drag-and-drop; we use direct fireEvent).
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const rows = screen.getAllByRole("checkbox");
|
||||
// role is index 1, dept is index 2
|
||||
const roleRow = rows[1]!.closest("div[draggable]") as HTMLElement;
|
||||
const deptRow = rows[2]!.closest("div[draggable]") as HTMLElement;
|
||||
expect(roleRow).not.toBeNull();
|
||||
expect(deptRow).not.toBeNull();
|
||||
|
||||
fireEvent.dragStart(roleRow);
|
||||
fireEvent.dragOver(deptRow);
|
||||
fireEvent.drop(deptRow);
|
||||
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
// After moving "role" onto "dept": ["name", "dept", "role"]
|
||||
expect(keys.indexOf("dept")).toBeLessThan(keys.indexOf("role"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DateRangePresets } from "./DateRangePresets.js";
|
||||
|
||||
describe("DateRangePresets", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders all four preset buttons", () => {
|
||||
render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "This month" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "This quarter" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next 3 months" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "This year" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("all buttons have type=button to avoid accidental form submission", () => {
|
||||
render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn).toHaveAttribute("type", "button");
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a custom className to the wrapper", () => {
|
||||
const { container } = render(
|
||||
<DateRangePresets onSelect={vi.fn()} className="my-custom-class" />,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("my-custom-class");
|
||||
});
|
||||
|
||||
it("renders without error when className is omitted", () => {
|
||||
const { container } = render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSelect callback", () => {
|
||||
it("calls onSelect with start and end date strings when 'This month' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'This quarter' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'Next 3 months' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "Next 3 months" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'This year' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect exactly once per click and not before any interaction", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onSelect independently for each separate button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
const firstCall = onSelect.mock.calls[0] as [string, string];
|
||||
const secondCall = onSelect.mock.calls[1] as [string, string];
|
||||
// "This month" start should differ from "This year" end
|
||||
expect(firstCall[1]).not.toBe(secondCall[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("date correctness", () => {
|
||||
// The component uses `new Date(y, m, d).toISOString().slice(0, 10)` which
|
||||
// converts local-midnight dates to UTC. We compute expected values the same
|
||||
// way so the tests are timezone-safe.
|
||||
const toIso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2024-03-15T12:00:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("'This month' returns the first and last day of the current month", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 2, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 3, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q1 bounds for a March date", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 0, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 3, 0)));
|
||||
});
|
||||
|
||||
it("'This year' returns Jan 1 to Dec 31 of the current year", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 0, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 11, 31)));
|
||||
});
|
||||
|
||||
it("'Next 3 months' start equals today", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "Next 3 months" }));
|
||||
const [start] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 2, 15)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q2 bounds for an April date", async () => {
|
||||
vi.setSystemTime(new Date("2024-04-10T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 3, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 6, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q3 bounds for a July date", async () => {
|
||||
vi.setSystemTime(new Date("2024-07-01T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 6, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 9, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q4 bounds for an October date", async () => {
|
||||
vi.setSystemTime(new Date("2024-10-20T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 9, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 12, 0)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import { ShimmerSkeleton, ShimmerGroup, ShimmerStyles } from "./ShimmerSkeleton.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the single shimmer div rendered by ShimmerSkeleton. */
|
||||
function getSkeleton(container: HTMLElement) {
|
||||
return container.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerSkeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ShimmerSkeleton", () => {
|
||||
describe("variant defaults", () => {
|
||||
it("applies the shimmer-skeleton base class", () => {
|
||||
const { container } = render(<ShimmerSkeleton />);
|
||||
expect(getSkeleton(container)).toHaveClass("shimmer-skeleton");
|
||||
});
|
||||
|
||||
it("defaults to the 'rect' variant: 100% wide, 40px tall, rounded-md", () => {
|
||||
const { container } = render(<ShimmerSkeleton />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("40px");
|
||||
expect(el).toHaveClass("rounded-md");
|
||||
});
|
||||
|
||||
it("'text' variant uses 100% width, 1em height, rounded", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="text" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("1em");
|
||||
expect(el).toHaveClass("rounded");
|
||||
});
|
||||
|
||||
it("'circle' variant uses 40px width, 40px height, rounded-full", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("40px");
|
||||
expect(el.style.height).toBe("40px");
|
||||
expect(el).toHaveClass("rounded-full");
|
||||
});
|
||||
|
||||
it("'card' variant uses 100% width, 120px height, rounded-xl", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="card" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("120px");
|
||||
expect(el).toHaveClass("rounded-xl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit width / height", () => {
|
||||
it("accepts a numeric width and converts it to px", () => {
|
||||
const { container } = render(<ShimmerSkeleton width={200} />);
|
||||
expect(getSkeleton(container).style.width).toBe("200px");
|
||||
});
|
||||
|
||||
it("accepts a string width directly", () => {
|
||||
const { container } = render(<ShimmerSkeleton width="50%" />);
|
||||
expect(getSkeleton(container).style.width).toBe("50%");
|
||||
});
|
||||
|
||||
it("accepts a numeric height and converts it to px", () => {
|
||||
const { container } = render(<ShimmerSkeleton height={80} />);
|
||||
expect(getSkeleton(container).style.height).toBe("80px");
|
||||
});
|
||||
|
||||
it("accepts a string height directly", () => {
|
||||
const { container } = render(<ShimmerSkeleton height="3rem" />);
|
||||
expect(getSkeleton(container).style.height).toBe("3rem");
|
||||
});
|
||||
|
||||
it("explicit width overrides the variant default", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" width={64} />);
|
||||
expect(getSkeleton(container).style.width).toBe("64px");
|
||||
});
|
||||
|
||||
it("explicit height overrides the variant default", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="card" height={200} />);
|
||||
expect(getSkeleton(container).style.height).toBe("200px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rounded prop", () => {
|
||||
const roundedCases: Array<[React.ComponentProps<typeof ShimmerSkeleton>["rounded"], string]> = [
|
||||
["sm", "rounded-sm"],
|
||||
["md", "rounded-md"],
|
||||
["lg", "rounded-lg"],
|
||||
["xl", "rounded-xl"],
|
||||
["2xl", "rounded-2xl"],
|
||||
["full", "rounded-full"],
|
||||
];
|
||||
|
||||
for (const [rounded, expectedClass] of roundedCases) {
|
||||
it(`rounded="${rounded}" applies class ${expectedClass}`, () => {
|
||||
const { container } = render(<ShimmerSkeleton rounded={rounded} />);
|
||||
expect(getSkeleton(container)).toHaveClass(expectedClass);
|
||||
});
|
||||
}
|
||||
|
||||
it("explicit rounded prop overrides the variant default", () => {
|
||||
// 'circle' default is rounded-full; override with 'md'
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" rounded="md" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el).toHaveClass("rounded-md");
|
||||
expect(el).not.toHaveClass("rounded-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("appends extra className to the element", () => {
|
||||
const { container } = render(<ShimmerSkeleton className="mt-4 w-1/2" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el).toHaveClass("mt-4");
|
||||
expect(el).toHaveClass("w-1/2");
|
||||
});
|
||||
|
||||
it("preserves the shimmer-skeleton base class when className is provided", () => {
|
||||
const { container } = render(<ShimmerSkeleton className="extra" />);
|
||||
expect(getSkeleton(container)).toHaveClass("shimmer-skeleton");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all shimmer divs that are direct children of the ShimmerGroup wrapper. */
|
||||
function getGroupChildren(container: HTMLElement): HTMLElement[] {
|
||||
// ShimmerGroup renders a single wrapper div; its children are the skeleton divs.
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
return Array.from(wrapper.children) as HTMLElement[];
|
||||
}
|
||||
|
||||
describe("ShimmerGroup", () => {
|
||||
it("renders all children", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
<ShimmerSkeleton />
|
||||
<ShimmerSkeleton />
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(getGroupChildren(container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("injects animationDelay on each valid React child", () => {
|
||||
// ShimmerGroup uses cloneElement to inject style.animationDelay.
|
||||
// Use plain divs so the style prop is forwarded to the DOM.
|
||||
const { container } = render(
|
||||
<ShimmerGroup staggerMs={100}>
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(["", "0ms"]).toContain(children[0]!.style.animationDelay);
|
||||
expect(children[1]!.style.animationDelay).toBe("100ms");
|
||||
expect(children[2]!.style.animationDelay).toBe("200ms");
|
||||
});
|
||||
|
||||
it("uses 50ms as the default stagger delay", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(["", "0ms"]).toContain(children[0]!.style.animationDelay);
|
||||
expect(children[1]!.style.animationDelay).toBe("50ms");
|
||||
});
|
||||
|
||||
it("applies a custom className to the wrapper div", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup className="grid grid-cols-3">
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("grid");
|
||||
expect(container.firstChild).toHaveClass("grid-cols-3");
|
||||
});
|
||||
|
||||
it("passes through non-element children without crashing", () => {
|
||||
// A text node is not a valid React element and should be returned as-is.
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
{"plain text"}
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(container).toHaveTextContent("plain text");
|
||||
// One skeleton element should still be present.
|
||||
expect(container.querySelectorAll(".shimmer-skeleton")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves existing style properties when merged with animationDelay", () => {
|
||||
// Use plain divs with explicit style so cloneElement merges correctly.
|
||||
const { container } = render(
|
||||
<ShimmerGroup staggerMs={75}>
|
||||
<div style={{ width: "120px", height: "30px" }} />
|
||||
<div style={{ width: "120px", height: "30px" }} />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(children[1]!.style.animationDelay).toBe("75ms");
|
||||
expect(children[0]!.style.width).toBe("120px");
|
||||
expect(children[0]!.style.height).toBe("30px");
|
||||
expect(children[1]!.style.width).toBe("120px");
|
||||
expect(children[1]!.style.height).toBe("30px");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerStyles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ShimmerStyles", () => {
|
||||
it("renders a <style> element", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl).not.toBeNull();
|
||||
});
|
||||
|
||||
it("style element contains the shimmer keyframe definition", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl?.innerHTML).toContain("shimmer");
|
||||
});
|
||||
|
||||
it("style element contains the shimmer-skeleton class definition", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl?.innerHTML).toContain(".shimmer-skeleton");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "~/test-utils.js";
|
||||
import { SuccessToast } from "./SuccessToast.js";
|
||||
|
||||
// Mock framer-motion so animations resolve synchronously in jsdom.
|
||||
vi.mock("framer-motion", () => ({
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
// strip animation props that are irrelevant in jsdom
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: React.ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: unknown;
|
||||
exit?: unknown;
|
||||
transition?: unknown;
|
||||
}) => <div {...props}>{children}</div>,
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUTO_DISMISS_MS = 2500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SuccessToast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visibility
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("visibility", () => {
|
||||
it("renders the message when show is true", () => {
|
||||
render(<SuccessToast show message="Saved!" />);
|
||||
expect(screen.getByText("Saved!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when show is false", () => {
|
||||
render(<SuccessToast show={false} message="Saved!" />);
|
||||
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disappears when show transitions from true to false", () => {
|
||||
const { rerender } = render(<SuccessToast show message="Saved!" />);
|
||||
expect(screen.getByText("Saved!")).toBeInTheDocument();
|
||||
rerender(<SuccessToast show={false} message="Saved!" />);
|
||||
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appears when show transitions from false to true", () => {
|
||||
const { rerender } = render(<SuccessToast show={false} message="Now visible" />);
|
||||
expect(screen.queryByText("Now visible")).not.toBeInTheDocument();
|
||||
rerender(<SuccessToast show message="Now visible" />);
|
||||
expect(screen.getByText("Now visible")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-dismiss timer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("auto-dismiss timer", () => {
|
||||
it("calls onDone after 2500 ms when show is true", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show message="Done" onDone={onDone} />);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT call onDone before 2500 ms have passed", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show message="Done" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS - 1);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onDone when show is false", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show={false} message="Done" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS * 2);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw when onDone is omitted", () => {
|
||||
expect(() => {
|
||||
render(<SuccessToast show message="No callback" />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("resets the timer when show flips from false to true", () => {
|
||||
const onDone = vi.fn();
|
||||
const { rerender } = render(<SuccessToast show={false} message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
rerender(<SuccessToast show message="Msg" onDone={onDone} />);
|
||||
// Timer starts fresh from the rerender — onDone should fire after another 2500 ms.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS - 1);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears the pending timer when show changes to false before 2500 ms", () => {
|
||||
const onDone = vi.fn();
|
||||
const { rerender } = render(<SuccessToast show message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
rerender(<SuccessToast show={false} message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
// onDone should NOT have been called because the timer was cleared.
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("variants", () => {
|
||||
it("defaults to the 'success' variant and renders the check-mark path", () => {
|
||||
const { container } = render(<SuccessToast show message="OK" />);
|
||||
// The success icon has the path "M5 13l4 4L19 7"
|
||||
const path = container.querySelector('path[d="M5 13l4 4L19 7"]');
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the 'info' variant icon (circle element)", () => {
|
||||
const { container } = render(<SuccessToast show message="Info" variant="info" />);
|
||||
// The info icon contains a <circle> element
|
||||
expect(container.querySelector("circle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the 'warning' variant icon (triangle path)", () => {
|
||||
const { container } = render(<SuccessToast show message="Warn" variant="warning" />);
|
||||
// The warning icon path contains the triangle shape with "ZM12 15.75"
|
||||
const path = container.querySelector('path[d*="ZM12 15.75"]');
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("applies success background class for the 'success' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="OK" variant="success" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("emerald");
|
||||
});
|
||||
|
||||
it("applies info background class for the 'info' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="Info" variant="info" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("blue");
|
||||
});
|
||||
|
||||
it("applies warning background class for the 'warning' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="Warn" variant="warning" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("amber");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Message rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("message", () => {
|
||||
it("renders the exact message string", () => {
|
||||
render(<SuccessToast show message="Profile updated successfully" />);
|
||||
expect(screen.getByText("Profile updated successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the message text when the prop changes", () => {
|
||||
const { rerender } = render(<SuccessToast show message="First" />);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
rerender(<SuccessToast show message="Second" />);
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
expect(screen.queryByText("First")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("message is wrapped in a <span> with font-medium class", () => {
|
||||
render(<SuccessToast show message="Check me" />);
|
||||
const span = screen.getByText("Check me");
|
||||
expect(span.tagName.toLowerCase()).toBe("span");
|
||||
expect(span.className).toContain("font-medium");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user