// DocsSubsections — unit tests (Issue #355). // // Same approach as FilterPanel.test.tsx: no jsdom in this project, and the // component's only hook is `useTranslation` (mocked by `i18nMock` over a // fixture), so it is invoked directly as a function and its React-element // tree is walked without a renderer. import { describe, it, expect, vi } from "vitest"; // Hoisted: the factory of `vi.mock` below reads it when the module loads. const FIXTURE = vi.hoisted((): Record => ({ "docs.import.subsections": [ { id: "import-export", title: "Export a CSV from your bank", steps: ["One subfolder per account", "Pick the widest range"], note: "Trailing note", }, { id: "import-bnc", title: "National Bank", status: "No public procedure found", steps: ["Expected header"], }, ], })); // The fixture stands for what the locale declares; every other key goes // through the marked `t` (#389), as i18next returns a string for a missing key. vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock({ fixture: FIXTURE }), ); import DocsSubsections, { readSubsections } from "./DocsSubsections"; // --------------------------------------------------------------------------- // Minimal React-element tree walker (no renderer/DOM involved) // --------------------------------------------------------------------------- interface ElementNode { type: unknown; props: Record & { children?: unknown }; } type FlatNode = ElementNode | string | number; function isElementNode(x: unknown): x is ElementNode { return typeof x === "object" && x !== null && "props" in x; } function flatten(node: unknown): FlatNode[] { if (Array.isArray(node)) return node.flatMap(flatten); if (node === null || node === undefined || typeof node === "boolean") return []; if (typeof node === "string" || typeof node === "number") return [node]; if (isElementNode(node)) return [node, ...flatten(node.props.children)]; return []; } function elementsOfType(root: unknown, type: string): ElementNode[] { return flatten(root).filter( (n): n is ElementNode => isElementNode(n) && n.type === type, ); } function textOf(node: ElementNode): string { return flatten(node.props.children) .filter((n): n is string => typeof n === "string") .join(""); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("DocsSubsections", () => { it("renders nothing for a section without subsections", () => { const tree = DocsSubsections({ sectionKey: "budget", registerRef: () => {}, }); expect(tree).toBeNull(); }); it("renders one block per entry with its id and registers each element", () => { const registered: string[] = []; const tree = DocsSubsections({ sectionKey: "import", registerRef: (id) => registered.push(id), }); const blocks = flatten(tree).filter( (n): n is ElementNode => isElementNode(n) && typeof n.props.id === "string", ); expect(blocks.map((b) => b.props.id)).toEqual([ "import-export", "import-bnc", ]); // The ref callbacks are what DocsContent uses for hash navigation. for (const block of blocks) { (block.props.ref as (el: HTMLElement | null) => void)(null); } expect(registered).toEqual(["import-export", "import-bnc"]); const titles = elementsOfType(tree, "h4").map(textOf); expect(titles).toEqual(["Export a CSV from your bank", "National Bank"]); const steps = elementsOfType(tree, "li").map(textOf); expect(steps).toEqual([ "One subfolder per account", "Pick the widest range", "Expected header", ]); }); it("renders the status line in italic only when present, and the note only when present", () => { const tree = DocsSubsections({ sectionKey: "import", registerRef: () => {}, }); const paragraphs = elementsOfType(tree, "p"); const italics = paragraphs.filter((p) => String(p.props.className).includes("italic"), ); expect(italics.map(textOf)).toEqual(["No public procedure found"]); const notes = paragraphs.filter( (p) => !String(p.props.className).includes("italic"), ); expect(notes.map(textOf)).toEqual(["Trailing note"]); }); }); describe("readSubsections", () => { it("returns an empty array for a missing key (i18next echoes the key)", () => { expect(readSubsections("docs.budget.subsections")).toEqual([]); }); it("drops malformed entries", () => { expect( readSubsections([ { id: "ok", title: "Ok", steps: [] }, { id: "no-steps", title: "Missing steps" }, "garbage", null, ]), ).toEqual([{ id: "ok", title: "Ok", steps: [] }]); }); });