useOnboardingChecklist.wiring gains three pins the old stubs could not hold: one loader for the provider's life (refresh and the shared value keep their identity, no re-read), 'Skip' moving the next step on the next render, and a change of mode alone reaching the consumers. UsersSettingsPage now commits its effects, so an effect that read the preference goes red on the assertion rather than on 'Invalid hook call'. BudgetTable.wiring, BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local slot copies; OnboardingStepCard plays StrictMode through replayEffects and runs its effect only on a changed dependency, which now pins location.key. Refs #409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
8.4 KiB
TypeScript
221 lines
8.4 KiB
TypeScript
// 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<typeof import("react-router-dom")>()),
|
|
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> = {}): 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<StepCardProps> | null {
|
|
return harness.render(() => OnboardingStepCard({ model }) as ReactElement<StepCardProps> | 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<typeof vi.fn> } {
|
|
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();
|
|
});
|
|
});
|