// OnboardingStepCard — the connected card's wiring (#398, #409). // // renderToStaticMarkup runs no effect, so the focus move would stay unguarded. // React's hooks come from the shared harness (src/test/hookHarness.ts): the // card runs as a plain function, its refs survive from one call to the next as // they would between two renders of a mounted component, and `commit()` runs // its effect when one of its dependencies changed, as React does. The router // and the path are faked. import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement } from "react"; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig())); const ctx = vi.hoisted(() => ({ location: { pathname: "/import", state: null as unknown, key: "arrival-1" }, navigate: vi.fn(), onboarding: null as unknown, })); vi.mock("react-router-dom", async (importOriginal) => ({ ...(await importOriginal()), useLocation: () => ctx.location, useNavigate: () => ctx.navigate, })); vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding })); import { hookHarness } from "../../test/hookHarness"; import OnboardingStepCard from "./OnboardingStepCard"; import StepCard, { type StepCardProps } from "./StepCard"; import { computeChecklist } from "../../utils/onboardingChecklist"; import { nextStep, type StepCardModel } from "../../utils/onboardingPath"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingCounters } from "../../shared/types"; const harness = hookHarness(); const ZERO: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; /** Folder chosen, no source yet: the next step is the source step, on /import. */ const FOLDER_SET = ZERO; /** Source and import done: the next step is the manual category, on /transactions. */ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 }; /** The path latched on /import, as the provider shares it. */ function path(counters: OnboardingCounters, overrides: Partial = {}): OnboardingValue { const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }); return { status: "ready", checklist, counters, hidden: false, budgetReady: true, nextStep: nextStep(checklist), mode: "card", cardPage: "/import", 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), ...overrides, }; } const SOURCE_LIST: StepCardModel = { text: { key: "onboarding.card.import.sourceList" }, done: false }; const DONE: StepCardModel = { text: null, done: true }; /** One render of the mounted card: its hooks are called in the same order each time. */ function render(model: StepCardModel | null = SOURCE_LIST): ReactElement | null { return harness.render(() => OnboardingStepCard({ model }) as ReactElement | null); } /** The card's title ref: its first hook. */ const titleRef = () => harness.slots[0] as { current: unknown }; /** The title the card's ref points at, once React would have attached it. */ function attachTitle(): { focus: ReturnType } { const title = { focus: vi.fn() }; titleRef().current = title; return title; } /** The commit of the last render: its effect runs if one of its dependencies changed. */ const commit = () => harness.commit(); beforeEach(() => { harness.clear(); ctx.location = { pathname: "/import", state: null, key: "arrival-1" }; ctx.navigate.mockReset(); ctx.onboarding = path(FOLDER_SET); }); describe("OnboardingStepCard — when it shows (#398)", () => { it("renders the card in card mode, latched on the current route", () => { const card = render(); expect(card).not.toBeNull(); expect(card!.type).toBe(StepCard); }); it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => { for (const mode of ["none", "banner", "complete"] as const) { ctx.onboarding = path(FOLDER_SET, { mode }); expect(render(), mode).toBeNull(); } ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" }); expect(render()).toBeNull(); ctx.onboarding = path(FOLDER_SET); expect(render(null)).toBeNull(); }); it("stays once the page's steps are done: the latch keeps it, not the next step", () => { ctx.onboarding = path(IMPORTED); const value = ctx.onboarding as OnboardingValue; expect(value.nextStep?.to).toBe("/transactions"); const card = render(DONE); expect(card).not.toBeNull(); expect(card!.props.done).toBe(true); }); }); describe("OnboardingStepCard — what it hands the card (#398)", () => { it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => { const props = render()!.props; expect(props.step.key).toBe("source"); expect(props.position).toEqual({ n: 3, total: 6 }); expect(props.text).toBe(SOURCE_LIST.text); expect(props.done).toBe(false); expect(props.titleRef).toBe(titleRef()); }); it("the next step once done, and a wait while the next step is still on this page", () => { ctx.onboarding = path(IMPORTED); expect(render(DONE)!.props.next).toEqual({ kind: "step", step: (ctx.onboarding as OnboardingValue).nextStep, }); // Before the re-read, the next step is still the source step, on /import. ctx.onboarding = path(FOLDER_SET); expect(render(DONE)!.props.next).toEqual({ kind: "wait" }); }); it("no 'Step n of N' until the Budget entitlement is known", () => { ctx.onboarding = path(FOLDER_SET, { budgetReady: false }); expect(render()!.props.position).toBeNull(); }); it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => { const value = ctx.onboarding as OnboardingValue; const props = render()!.props; props.onSkip(); expect(value.skip).toHaveBeenCalledWith("source"); expect(value.hide).not.toHaveBeenCalled(); props.onHide(); expect(value.hide).toHaveBeenCalledTimes(1); }); }); describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => { const ARRIVAL = { onboarding: { focus: true } }; it("moves the focus to the title, then consumes the navigation state", () => { ctx.location = { ...ctx.location, state: ARRIVAL }; render(); const title = attachTitle(); commit(); expect(title.focus).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null }); expect(vi.mocked(ctx.navigate).mock.invocationCallOrder[0]).toBeGreaterThan( title.focus.mock.invocationCallOrder[0], ); }); it("does it once per arrival: StrictMode's second run and later renders do nothing", () => { ctx.location = { ...ctx.location, state: ARRIVAL }; render(); const title = attachTitle(); commit(); harness.replayEffects(); render(); commit(); expect(title.focus).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1); // A new arrival on the same mounted page asks again. ctx.location = { ...ctx.location, key: "arrival-2" }; render(); commit(); expect(title.focus).toHaveBeenCalledTimes(2); expect(ctx.navigate).toHaveBeenCalledTimes(2); }); it("waits for the card to show before focusing and consuming", () => { ctx.location = { ...ctx.location, state: ARRIVAL }; expect(render(null)).toBeNull(); commit(); expect(ctx.navigate).not.toHaveBeenCalled(); render(); const title = attachTitle(); commit(); expect(title.focus).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1); }); it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => { for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) { // A fresh mount per visit: its effect runs at its first commit. harness.clear(); ctx.location = { ...ctx.location, state }; render(); const title = attachTitle(); expect(() => commit()).not.toThrow(); expect(title.focus).not.toHaveBeenCalled(); } expect(ctx.navigate).not.toHaveBeenCalled(); }); });