From dcac9952cae110a8fa9d3f213fb778e13f75d88c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 17:27:35 +0200 Subject: [PATCH] test(web): add 232 tests for catalog, presets, skeleton, hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/ui/ColumnTogglePanel.test.tsx | 239 ++++++ .../components/ui/DateRangePresets.test.tsx | 196 +++++ .../components/ui/ShimmerSkeleton.test.tsx | 244 ++++++ .../src/components/ui/SuccessToast.test.tsx | 221 ++++++ apps/web/src/hooks/useAppPreferences.test.ts | 291 ++++++++ .../hooks/useMultiSelectIntersection.test.ts | 694 ++++++++++++++++++ apps/web/src/hooks/useTheme.test.ts | 219 ++++++ .../web/src/hooks/useTimelineKeyboard.test.ts | 490 +++++++++++++ .../src/lib/blueprint-field-catalog.test.ts | 542 ++++++++++++++ 9 files changed, 3136 insertions(+) create mode 100644 apps/web/src/components/ui/ColumnTogglePanel.test.tsx create mode 100644 apps/web/src/components/ui/DateRangePresets.test.tsx create mode 100644 apps/web/src/components/ui/ShimmerSkeleton.test.tsx create mode 100644 apps/web/src/components/ui/SuccessToast.test.tsx create mode 100644 apps/web/src/hooks/useAppPreferences.test.ts create mode 100644 apps/web/src/hooks/useMultiSelectIntersection.test.ts create mode 100644 apps/web/src/hooks/useTheme.test.ts create mode 100644 apps/web/src/hooks/useTimelineKeyboard.test.ts create mode 100644 apps/web/src/lib/blueprint-field-catalog.test.ts diff --git a/apps/web/src/components/ui/ColumnTogglePanel.test.tsx b/apps/web/src/components/ui/ColumnTogglePanel.test.tsx new file mode 100644 index 0000000..5c24199 --- /dev/null +++ b/apps/web/src/components/ui/ColumnTogglePanel.test.tsx @@ -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> = {}) { + const onSetVisible = vi.fn(); + const utils = render( + , + ); + 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")); + }); + }); +}); diff --git a/apps/web/src/components/ui/DateRangePresets.test.tsx b/apps/web/src/components/ui/DateRangePresets.test.tsx new file mode 100644 index 0000000..d345e38 --- /dev/null +++ b/apps/web/src/components/ui/DateRangePresets.test.tsx @@ -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(); + 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(); + 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( + , + ); + expect(container.firstChild).toHaveClass("my-custom-class"); + }); + + it("renders without error when className is omitted", () => { + const { container } = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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))); + }); + }); +}); diff --git a/apps/web/src/components/ui/ShimmerSkeleton.test.tsx b/apps/web/src/components/ui/ShimmerSkeleton.test.tsx new file mode 100644 index 0000000..a3282b0 --- /dev/null +++ b/apps/web/src/components/ui/ShimmerSkeleton.test.tsx @@ -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(); + expect(getSkeleton(container)).toHaveClass("shimmer-skeleton"); + }); + + it("defaults to the 'rect' variant: 100% wide, 40px tall, rounded-md", () => { + const { container } = render(); + 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(); + 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(); + 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(); + 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(); + expect(getSkeleton(container).style.width).toBe("200px"); + }); + + it("accepts a string width directly", () => { + const { container } = render(); + expect(getSkeleton(container).style.width).toBe("50%"); + }); + + it("accepts a numeric height and converts it to px", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("80px"); + }); + + it("accepts a string height directly", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("3rem"); + }); + + it("explicit width overrides the variant default", () => { + const { container } = render(); + expect(getSkeleton(container).style.width).toBe("64px"); + }); + + it("explicit height overrides the variant default", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("200px"); + }); + }); + + describe("rounded prop", () => { + const roundedCases: Array<[React.ComponentProps["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(); + expect(getSkeleton(container)).toHaveClass(expectedClass); + }); + } + + it("explicit rounded prop overrides the variant default", () => { + // 'circle' default is rounded-full; override with 'md' + const { container } = render(); + 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(); + 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(); + 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( + + + + + , + ); + 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( + +
+
+
+ , + ); + 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( + +
+
+ , + ); + 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( + + + , + ); + 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( + + {"plain text"} + + , + ); + 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( + +
+
+ , + ); + 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