// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409). // // renderToStaticMarkup runs no click, so React's hooks come from the shared // harness (src/test/hookHarness.ts): the slot runs as a plain function, and // its state survives from one call to the next as it would between two // renders of a mounted component. import { describe, it, expect, vi, beforeEach } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { Outlet } from "react-router-dom"; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); const ctx = vi.hoisted(() => ({ pathname: "/reports", onboarding: null as unknown, })); vi.mock("react-router-dom", async (importOriginal) => ({ ...(await importOriginal()), useLocation: () => ({ pathname: ctx.pathname }), })); vi.mock("../../contexts/OnboardingContext", () => ({ OnboardingProvider: ({ children }: { children: ReactNode }) => children, useOnboarding: () => ctx.onboarding, })); vi.mock("./Sidebar", () => ({ default: () => null })); import { hookHarness } from "../../test/hookHarness"; import AppShell, { OnboardingBannerSlot } from "./AppShell"; import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner"; import { computeChecklist } from "../../utils/onboardingChecklist"; import { nextStep } from "../../utils/onboardingPath"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; const harness = hookHarness(); 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 onboardingValue(): OnboardingValue { const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 }; const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }); return { status: "ready", checklist, counters, hidden: false, budgetReady: true, nextStep: nextStep(checklist), mode: "banner", cardPage: null, hide: vi.fn(async () => undefined), show: vi.fn(async () => undefined), skip: vi.fn(async () => undefined), resume: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), }; } /** One render of the mounted slot: its hooks are read in the same order each time. */ function renderSlot(): OnboardingBannerProps { const element = harness.render(() => OnboardingBannerSlot() as ReactElement); expect(element.type).toBe(OnboardingBanner); return element.props; } beforeEach(() => { harness.clear(); ctx.pathname = "/reports"; ctx.onboarding = onboardingValue(); }); describe("OnboardingBannerSlot (#397)", () => { it("hands the shared path to the banner, the list folded", () => { const value = ctx.onboarding as OnboardingValue; const props = renderSlot(); expect(props.mode).toBe("banner"); expect(props.checklist).toBe(value.checklist); expect(props.nextStep).toBe(value.nextStep); expect(props.budgetReady).toBe(true); expect(props.listOpen).toBe(false); expect(props.onHide).toBe(value.hide); expect(props.onResume).toBe(value.resume); }); it("passes the mode and the entitlement as they come: the banner decides what to show", () => { ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false }; const props = renderSlot(); expect(props.mode).toBe("none"); expect(props.budgetReady).toBe(false); }); it("'Show the list' unfolds it, 'Close the list' folds it back", () => { renderSlot().onToggleList(); expect(renderSlot().listOpen).toBe(true); // Another render without a click keeps it unfolded. expect(renderSlot().listOpen).toBe(true); renderSlot().onToggleList(); expect(renderSlot().listOpen).toBe(false); }); it("AppShell mounts one slot per route, so each page starts with the list folded", () => { ctx.pathname = "/import"; const shell = harness.render(() => AppShell() as ReactNode); const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot); expect(slot).toBeDefined(); expect(slot.key).toBe("/import"); ctx.pathname = "/transactions"; const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot); expect(next.key).toBe("/transactions"); }); it("AppShell puts the slot before the routed page, inside the provider, inside main", () => { const shell = harness.render(() => AppShell() as ReactNode); const [main] = find(shell, (e) => e.type === "main"); expect(main).toBeDefined(); const [provider] = find(main.props.children, (e) => typeof e.type === "function"); const children = provider.props.children as ReactNode[]; expect(Array.isArray(children)).toBe(true); const [first, second] = children as AnyElement[]; expect(first.type).toBe(OnboardingBannerSlot); expect(second.type).toBe(Outlet); }); });