// OnboardingChecklist — unit tests (#352, #397). // // 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. 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", () => ({ CheckCircle2: () => null, Circle: () => null, Lock: () => null, SkipForward: () => null, })); import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react"; import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist"; import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types"; 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, CheckCircle2, Circle, Lock, SkipForward]); /** Resolves the inner `RowIcon` component so the icon elements 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)); } const children = el.props.children; return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode; } const ZERO: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; function render( overrides: Partial = {}, counters = ZERO, budgetAllowed = true, skipped: ChecklistItemKey[] = [], ) { const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped }); const onHide = vi.fn(); const onResume = vi.fn(); const props: OnboardingChecklistProps = { items: checklist.items, done: checklist.done, total: checklist.total, budgetReady: true, onHide, onResume, ...overrides, }; return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist }; } function rowOf(element: ReactNode, key: string): AnyElement { const row = find(element, (e) => e.type === "li" && e.key === key)[0]; expect(row, `row ${key}`).toBeDefined(); return row; } /** The visually hidden texts of a row: what assistive technologies read after the label. */ function stateTextsOf(row: AnyElement): string[] { return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) => texts(e.props.children).map((s) => s.trim()).filter(Boolean), ); } describe("OnboardingChecklist", () => { it("renders the title, the counter 1 / 6 and the hide button", () => { const { element, onHide } = render(); const text = texts(element); expect(text).toContain("t(onboarding.checklist.title)"); expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)"); expect(text).toContain("t(onboarding.checklist.counter[done=1,total=6])"); const [hide] = find(element, (e) => e.type === "button"); expect(hide.props.type).toBe("button"); expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)"); (hide.props.onClick as () => void)(); expect(onHide).toHaveBeenCalledTimes(1); }); it("renders six rows with their i18n labels", () => { const { element } = render(); expect(find(element, (e) => e.type === "li")).toHaveLength(6); const text = texts(element); for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) { expect(text).toContain(`t(onboarding.checklist.items.${key})`); } }); it("links every undone row to its `to`, and never a done row", () => { const { element, checklist } = render(); const links = find(element, (e) => e.type === Link); const undone = checklist.items.filter((i) => !i.done); expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to)); const profile = rowOf(element, "profile"); expect(find(profile, (e) => e.type === Link)).toHaveLength(0); expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1); }); it("gives the link of a row to do the step's navigation state, guided on the manual category", () => { const { element } = render(); const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link); expect(folder.props.state).toEqual({ onboarding: { focus: true } }); const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link); expect(manual.props.to).toBe("/transactions"); expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } }); }); it("marks a row done when its counter is reached", () => { const { element } = render({}, { ...ZERO, sources: 1 }); const source = rowOf(element, "source"); expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1); expect(find(source, (e) => e.type === Link)).toHaveLength(0); }); it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => { const { element } = render({}, ZERO, false); const budget = rowOf(element, "budget"); expect(find(budget, (e) => e.type === Lock)).toHaveLength(1); expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)"); const [link] = find(budget, (e) => e.type === Link); expect(link.props.to).toBe("/settings/users"); // The license card carries no step card: nothing to focus there. expect(link.props.state).toBeUndefined(); expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=5])"); }); it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => { const { element } = render({ budgetReady: false }, ZERO, false); const budget = rowOf(element, "budget"); expect(find(budget, (e) => e.type === Lock)).toHaveLength(0); expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0); expect(find(budget, (e) => e.type === Circle)).toHaveLength(1); expect(find(budget, (e) => e.type === Link)).toHaveLength(0); expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)"); expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false); }); }); describe("OnboardingChecklist — states read out (#384, #397)", () => { it("reads done, skipped and locked after the label, and nothing for a row still to do", () => { const { element } = render({}, ZERO, false, ["source"]); expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]); expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]); expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]); expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]); expect(stateTextsOf(rowOf(element, "import"))).toEqual([]); }); it("reads the budget row as pending while the entitlement is unknown", () => { const { element } = render({ budgetReady: false }, ZERO, false); expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]); }); it("puts the state text right after the label, inside the same element", () => { const { element } = render({}, { ...ZERO, sources: 1 }); const [labelled] = find( rowOf(element, "source"), (e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"), ); expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([ "t(onboarding.checklist.items.source)", "t(onboarding.state.done)", ]); }); }); describe("OnboardingChecklist — skipped rows (#397)", () => { it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => { const { element } = render({}, ZERO, true, ["source"]); const source = rowOf(element, "source"); expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1); expect(find(source, (e) => e.type === Circle)).toHaveLength(0); const [badge] = find( source, (e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"), ); // The hidden "(skipped)" text already says it: the badge is not read twice. expect(badge.props["aria-hidden"]).toBe("true"); expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=6])"); }); it("'Resume' takes the step back, then opens its page with the step's navigation state", () => { const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]); const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link); // The label is no link: only "Resume" leads to the page, after taking the step back. expect(sourceLinks).toHaveLength(1); const [resume] = sourceLinks; expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]); expect(resume.props.to).toBe("/import"); expect(resume.props.state).toEqual({ onboarding: { focus: true } }); (resume.props.onClick as () => void)(); expect(onResume).toHaveBeenCalledTimes(1); expect(onResume).toHaveBeenCalledWith("source"); const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link); expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } }); (manual.props.onClick as () => void)(); expect(onResume).toHaveBeenLastCalledWith("manualCategory"); }); it("a row done and skipped shows as done, without 'Resume'", () => { const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]); const source = rowOf(element, "source"); expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1); expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0); expect(find(source, (e) => e.type === Link)).toHaveLength(0); expect(texts(source)).not.toContain("t(onboarding.checklist.resume)"); }); it("a locked row stays locked even when skipped", () => { const { element } = render({}, ZERO, false, ["budget"]); const budget = rowOf(element, "budget"); expect(find(budget, (e) => e.type === Lock)).toHaveLength(1); expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0); expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)"); const [link] = find(budget, (e) => e.type === Link); expect(link.props.to).toBe("/settings/users"); }); });