// EmptyState — unit tests (#352). // // No jsdom / testing-library in this project (see FilterPanel.test.tsx): the // component is pure (no hook), so it is called as a plain function and its // React-element tree is walked directly. `Link` is react-router's real // component: creating the element never needs a router, only rendering would. import { describe, it, expect, vi } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { Link } from "react-router-dom"; import EmptyState from "./EmptyState"; function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] { if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); if (!isValidElement(node)) return []; const el = node as ReactElement<{ children?: ReactNode }>; return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)]; } function texts(node: ReactNode): string[] { if (Array.isArray(node)) return node.flatMap(texts); if (typeof node === "string") return [node]; if (!isValidElement(node)) return []; return texts((node as ReactElement<{ children?: ReactNode }>).props.children); } const ICON = "ICON_MARKER"; describe("EmptyState", () => { it("renders the icon, the title and the description", () => { const el = EmptyState({ icon: ICON, title: "Title", description: "Desc" }); const text = texts(el); expect(text).toContain(ICON); expect(text).toContain("Title"); expect(text).toContain("Desc"); }); it("renders no action container when no action is given", () => { const el = EmptyState({ icon: ICON, title: "Title" }); expect(find(el, (e) => e.type === Link)).toHaveLength(0); expect(find(el, (e) => e.type === "button")).toHaveLength(0); }); it("renders a `to` action as a react-router Link, never a button", () => { const el = EmptyState({ icon: ICON, title: "Title", primaryAction: { label: "Go", to: "/import" }, }); const links = find(el, (e) => e.type === Link) as ReactElement<{ to: string; children: ReactNode }>[]; expect(links).toHaveLength(1); expect(links[0].props.to).toBe("/import"); expect(texts(links[0].props.children)).toContain("Go"); expect(find(el, (e) => e.type === "button")).toHaveLength(0); }); it("renders an `onClick` action as a type=button that calls the handler", () => { const onClick = vi.fn(); const el = EmptyState({ icon: ICON, title: "Title", primaryAction: { label: "Do", onClick }, }); const buttons = find(el, (e) => e.type === "button") as ReactElement<{ type: string; disabled: boolean; onClick: () => void; }>[]; expect(buttons).toHaveLength(1); expect(buttons[0].props.type).toBe("button"); expect(buttons[0].props.disabled).toBe(false); buttons[0].props.onClick(); expect(onClick).toHaveBeenCalledTimes(1); }); it("propagates `disabled` on the button variant", () => { const el = EmptyState({ icon: ICON, title: "Title", primaryAction: { label: "Do", onClick: vi.fn(), disabled: true }, }); const [button] = find(el, (e) => e.type === "button") as ReactElement<{ disabled: boolean }>[]; expect(button.props.disabled).toBe(true); }); it("renders both actions, primary first", () => { const el = EmptyState({ icon: ICON, title: "Title", primaryAction: { label: "Primary", to: "/a" }, secondaryAction: { label: "Secondary", onClick: vi.fn() }, }); const actions = find(el, (e) => e.type === Link || e.type === "button"); expect(actions).toHaveLength(2); expect(actions[0].type).toBe(Link); expect(actions[1].type).toBe("button"); }); it("renders children between the description and the actions", () => { const el = EmptyState({ icon: ICON, title: "Title", description: "Desc", primaryAction: { label: "Go", to: "/a" }, children: "CHILD_MARKER", }); const text = texts(el); expect(text.indexOf("Desc")).toBeLessThan(text.indexOf("CHILD_MARKER")); expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go")); }); });