// ImportEmptyState — unit tests (#356). // // FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react` // mocked, the component called as a function and its element tree walked. // `t` returns the key, with the interpolation values appended, so the asserts // read the i18n keys and never a hard-coded string. The inner `EmptyState` is // expanded (called as a function too) so its buttons and links are visible. import { describe, it, expect, vi } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { Link } from "react-router-dom"; vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); vi.mock("lucide-react", () => ({ FolderOpen: () => null, Inbox: () => null, })); import { FolderOpen, Inbox } from "lucide-react"; import ImportEmptyState, { CSV_EXPORT_GUIDE_PATH, folderNameFromPath, type ImportEmptyStateProps, } from "./ImportEmptyState"; type AnyElement = ReactElement & { children?: ReactNode }>; function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); if (!isValidElement(node)) return []; const el = node as AnyElement; 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 AnyElement).props.children); } // Leaves of the walk: react-router's Link and the mocked icons keep their type. const LEAVES = new Set([Link, FolderOpen, Inbox]); /** Resolves the inner `EmptyState` component so its buttons and links are visible. */ function expand(node: ReactNode): ReactNode { if (Array.isArray(node)) return node.map(expand); if (!isValidElement(node)) return node; const el = node as AnyElement; if (typeof el.type === "function" && !LEAVES.has(el.type)) { const render = el.type as (props: Record) => ReactNode; return expand(render(el.props)); } return { ...el, props: { ...el.props, children: expand(el.props.children) } } as ReactNode; } type ButtonEl = ReactElement<{ type: string; disabled: boolean; onClick: () => void; children?: ReactNode }>; type LinkEl = ReactElement<{ to: string; children?: ReactNode }>; function render(overrides: Partial = {}) { const props: ImportEmptyStateProps = { hasFolder: false, folderName: null, onBrowse: vi.fn(), onCreateSample: vi.fn(), onRefresh: vi.fn(), isCreatingSample: false, ...overrides, }; const tree = expand(ImportEmptyState(props)); const buttons = find(tree, (e) => e.type === "button") as ButtonEl[]; const links = find(tree, (e) => e.type === Link) as LinkEl[]; return { props, tree, buttons, links }; } describe("ImportEmptyState — no folder (#356)", () => { it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => { const { props, tree, buttons, links } = render({ hasFolder: false }); expect(texts(tree)).toContain("t(import.empty.noFolder.title)"); expect(texts(tree)).toContain("t(import.empty.noFolder.description)"); expect(buttons).toHaveLength(1); expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)"); expect(buttons[0].props.disabled).toBe(false); buttons[0].props.onClick(); expect(props.onBrowse).toHaveBeenCalledTimes(1); expect(props.onCreateSample).not.toHaveBeenCalled(); expect(links).toHaveLength(1); expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH); expect(links[0].props.to).toBe("/settings/users#import-export"); expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)"); }); it("never shows the sample or refresh actions without a folder", () => { const { tree } = render({ hasFolder: false }); expect(texts(tree)).not.toContain("t(import.empty.createSample)"); expect(texts(tree)).not.toContain("t(import.empty.refresh)"); }); }); describe("ImportEmptyState — folder without sources (#356)", () => { it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => { const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" }); expect(texts(tree)).toContain("t(import.empty.noSources.title)"); expect(buttons).toHaveLength(2); expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)"); expect(buttons[0].props.disabled).toBe(false); buttons[0].props.onClick(); expect(props.onCreateSample).toHaveBeenCalledTimes(1); expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)"); buttons[1].props.onClick(); expect(props.onRefresh).toHaveBeenCalledTimes(1); expect(props.onBrowse).not.toHaveBeenCalled(); expect(links).toHaveLength(1); expect(links[0].props.to).toBe("/settings/users#import-export"); expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)"); }); it("disables the create button while the sample folders are being written", () => { const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true }); expect(buttons[0].props.disabled).toBe(true); // Refresh stays available: it is not the action in flight. expect(buttons[1].props.disabled).toBe(false); }); it("names the folder in the description, never the full path", () => { const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés"); const { tree } = render({ hasFolder: true, folderName }); const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description")); expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])"); expect(description).not.toContain("Users"); }); }); describe("ImportEmptyState — getting-started target (#398)", () => { const highlighted = (tree: ReactNode) => find(tree, (e) => e.props["data-onboarding-highlight"] === "true"); it("highlights 'Choose a folder' without a folder, and only it", () => { const { tree } = render({ hasFolder: false, onboardingTarget: true }); const targets = highlighted(tree); expect(targets).toHaveLength(1); expect(targets[0].type).toBe("button"); expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)"); }); it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => { const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true }); const targets = highlighted(tree); expect(targets).toHaveLength(1); expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)"); }); it("highlights nothing without the property", () => { expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0); expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0); expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0); }); }); describe("folderNameFromPath (#356)", () => { it("returns the last segment for POSIX and Windows paths", () => { expect(folderNameFromPath("/home/max/releves")).toBe("releves"); expect(folderNameFromPath("C:\\Users\\max\\Relevés")).toBe("Relevés"); }); it("ignores trailing separators", () => { expect(folderNameFromPath("/home/max/releves/")).toBe("releves"); expect(folderNameFromPath("C:\\Users\\max\\Relevés\\")).toBe("Relevés"); }); it("falls back to the path itself when it has no segment", () => { expect(folderNameFromPath("/")).toBe("/"); expect(folderNameFromPath("")).toBe(""); }); });