// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders // in vitest's node environment for the markup (region, aria-expanded, hrefs), // and the component is called as a function for what the markup does not // carry (a link's navigation state, the handlers). import { describe, it, expect, vi } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Link, MemoryRouter } from "react-router-dom"; // The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that // skipped `t` would show unmarked. vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); import OnboardingBanner, { GETTING_STARTED_GUIDE, ONBOARDING_BANNER_LIST_ID, type OnboardingBannerProps, } from "./OnboardingBanner"; import OnboardingChecklist from "../dashboard/OnboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist"; import { nextStep } from "../../utils/onboardingPath"; import type { 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); } const ZERO: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; /** Profile, folder and source done: 3/6, the next step is "import" on /import. */ const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 }; /** Up to the import: the next step is "manualCategory" on /transactions. */ const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 }; const ALL_DONE: OnboardingCounters = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 40, }; function bannerProps( overrides: Partial = {}, counters: OnboardingCounters = THREE_DONE, ): OnboardingBannerProps { const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }); return { mode: checklist.complete ? "complete" : "banner", checklist, nextStep: nextStep(checklist), budgetReady: true, listOpen: false, onToggleList: vi.fn(), onHide: vi.fn(), onResume: vi.fn(), ...overrides, }; } const markup = (props: OnboardingBannerProps) => renderToStaticMarkup( , ); const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode; const buttonNamed = (node: ReactNode, key: string) => { const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`)); expect(button, `button ${key}`).toBeDefined(); return button; }; describe("OnboardingBanner — banner mode (#397)", () => { it("is a region named for the path, with the counter, the next step and its link", () => { const html = markup(bannerProps()); expect(html).toContain('role="region"'); expect(html).toContain('aria-label="t(onboarding.banner.label)"'); expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])"); expect(html).toContain("t(onboarding.banner.next)"); expect(html).toMatch(/]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/); expect(html).toContain("t(onboarding.checklist.hide)"); }); it("links the next step to its page with the step's navigation state", () => { const [toImport] = find(tree(bannerProps()), (e) => e.type === Link); expect(toImport.props.to).toBe("/import"); expect(toImport.props.state).toEqual({ onboarding: { focus: true } }); // The manual category opens Transactions in its guided entry too. const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link); expect(toTransactions.props.to).toBe("/transactions"); expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } }); }); it("the counter waits for the Budget entitlement, the next step does not", () => { const html = markup(bannerProps({ budgetReady: false })); expect(html).not.toContain("onboarding.banner.progress"); expect(html).toMatch(/href="\/import"/); }); it("folded: 'Show the list' with aria-expanded false, and no list", () => { const html = markup(bannerProps()); expect(html).toMatch( new RegExp(`