test: shared React hook harness for the jsdom-less tests (#409) #434

Merged
maximus merged 3 commits from issue-409-hook-harness into main 2026-10-14 00:28:24 +00:00
6 changed files with 272 additions and 325 deletions
Showing only changes of commit 1ba3b8bae9 - Show all commits

View file

@ -1,8 +1,7 @@
// BudgetTable — how the grid tells the page its target (#400).
// BudgetTable — how the grid tells the page its target (#400, #409).
//
// renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's `useState`, `useRef` and `useEffect` are stubbed (technique of
// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// grid runs as a plain function, its state and refs survive from one call to
// the next as between two renders of a mounted component, and `commit()` runs
// the effects as React does, an effect only when one of its dependencies
@ -12,42 +11,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
effects: [] as Effect[],
/** Dependencies of each effect at its last run. */
ran: new Map<number, readonly unknown[] | undefined>(),
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
const slot = (init: () => unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) harness.slots[index] = init();
return index;
};
return {
...actual,
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
useRef: (init: unknown) => harness.slots[slot(() => ({ current: init }))],
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
@ -55,7 +23,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false);
return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed,
@ -64,7 +32,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
collapseAll: () => undefined,
allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
return keys.length > 0 && keys.every((key) => ctx.expanded.has(key));
},
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
};
@ -72,6 +40,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
};
});
import { hookHarness } from "../../test/hookHarness";
import BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types";
@ -89,43 +58,31 @@ const leaf = (category_id: number, parent_id: number | null = null): BudgetYearR
});
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
const harness = hookHarness();
/** One render of the mounted grid: its hooks are called in the same order each time. */
function render(
rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean,
) {
harness.cursor = 0;
harness.effects = [];
harness.render(() =>
BudgetTable({
rows,
onUpdatePlanned: () => undefined,
onSplitEvenly: () => undefined,
onboardingTarget,
onOnboardingTargetChange: onChange,
});
}),
);
}
/** After a render: run each effect whose dependencies changed since its last run, as React does. */
function commit() {
harness.effects.forEach((effect, index) => {
const previous = harness.ran.get(index);
const changed =
!harness.ran.has(index) ||
effect.deps === undefined ||
previous === undefined ||
effect.deps.some((dep, k) => !Object.is(dep, previous[k]));
harness.ran.set(index, effect.deps);
if (changed) effect.fn();
});
}
const commit = () => harness.commit();
beforeEach(() => {
harness.slots = [];
harness.setters = [];
harness.effects = [];
harness.ran = new Map();
harness.expanded = new Set();
harness.clear();
ctx.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
});
@ -146,7 +103,7 @@ describe("BudgetTable — the target told to the page (#400)", () => {
expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows.
harness.expanded = new Set(["p:10"]);
ctx.expanded = new Set(["p:10"]);
render(GROUPED, onChange, true);
commit();
render(GROUPED, onChange, true);

View file

@ -1,53 +1,37 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
//
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
// technique as useOnboardingChecklist.wiring.test.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.
// 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";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
cursor: 0,
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", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding,
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<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -79,21 +63,20 @@ function onboardingValue(): OnboardingValue {
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
harness.cursor = 0;
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.slots = [];
harness.pathname = "/reports";
harness.onboarding = onboardingValue();
harness.clear();
ctx.pathname = "/reports";
ctx.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = harness.onboarding as OnboardingValue;
const value = ctx.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
@ -105,7 +88,7 @@ describe("OnboardingBannerSlot (#397)", () => {
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
@ -121,19 +104,19 @@ describe("OnboardingBannerSlot (#397)", () => {
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import";
const shell = AppShell() as ReactNode;
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");
harness.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
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 = AppShell() as ReactNode;
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");

View file

@ -1,46 +1,28 @@
// OnboardingStepCard — the connected card's wiring (#398).
// OnboardingStepCard — the connected card's wiring (#398, #409).
//
// renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
// React's `useRef` and `useEffect` are stubbed (technique of
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): 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 its effects are
// captured and run by hand. The router and the path are faked.
// 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";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
refs: [] as Array<{ current: unknown }>,
cursor: 0,
effects: [] as Effect[],
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", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useRef: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.refs)) harness.refs[index] = { current: init };
return harness.refs[index];
},
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => harness.location,
useNavigate: () => harness.navigate,
useLocation: () => ctx.location,
useNavigate: () => ctx.navigate,
}));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
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";
@ -48,6 +30,8 @@ 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,
@ -86,28 +70,27 @@ 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 {
harness.cursor = 0;
harness.effects = [];
return OnboardingStepCard({ model }) as 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() };
harness.refs[0].current = title;
titleRef().current = title;
return title;
}
const runEffects = () => {
for (const effect of harness.effects) effect.fn();
};
/** The commit of the last render: its effect runs if one of its dependencies changed. */
const commit = () => harness.commit();
beforeEach(() => {
harness.refs = [];
harness.effects = [];
harness.location = { pathname: "/import", state: null, key: "arrival-1" };
harness.navigate.mockReset();
harness.onboarding = path(FOLDER_SET);
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)", () => {
@ -119,18 +102,18 @@ describe("OnboardingStepCard — when it shows (#398)", () => {
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) {
harness.onboarding = path(FOLDER_SET, { mode });
ctx.onboarding = path(FOLDER_SET, { mode });
expect(render(), mode).toBeNull();
}
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
expect(render()).toBeNull();
harness.onboarding = path(FOLDER_SET);
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", () => {
harness.onboarding = path(IMPORTED);
const value = harness.onboarding as OnboardingValue;
ctx.onboarding = path(IMPORTED);
const value = ctx.onboarding as OnboardingValue;
expect(value.nextStep?.to).toBe("/transactions");
const card = render(DONE);
expect(card).not.toBeNull();
@ -145,27 +128,27 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(props.position).toEqual({ n: 3, total: 6 });
expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false);
expect(props.titleRef).toBe(harness.refs[0]);
expect(props.titleRef).toBe(titleRef());
});
it("the next step once done, and a wait while the next step is still on this page", () => {
harness.onboarding = path(IMPORTED);
ctx.onboarding = path(IMPORTED);
expect(render(DONE)!.props.next).toEqual({
kind: "step",
step: (harness.onboarding as OnboardingValue).nextStep,
step: (ctx.onboarding as OnboardingValue).nextStep,
});
// Before the re-read, the next step is still the source step, on /import.
harness.onboarding = path(FOLDER_SET);
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", () => {
harness.onboarding = path(FOLDER_SET, { budgetReady: false });
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 = harness.onboarding as OnboardingValue;
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
props.onSkip();
expect(value.skip).toHaveBeenCalledWith("source");
@ -179,58 +162,60 @@ 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", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
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", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
render();
const title = attachTitle();
runEffects();
runEffects();
commit();
harness.replayEffects();
render();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1);
expect(ctx.navigate).toHaveBeenCalledTimes(1);
// A new arrival on the same mounted page asks again.
harness.location = { ...harness.location, key: "arrival-2" };
ctx.location = { ...ctx.location, key: "arrival-2" };
render();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(2);
expect(harness.navigate).toHaveBeenCalledTimes(2);
expect(ctx.navigate).toHaveBeenCalledTimes(2);
});
it("waits for the card to show before focusing and consuming", () => {
harness.location = { ...harness.location, state: ARRIVAL };
ctx.location = { ...ctx.location, state: ARRIVAL };
expect(render(null)).toBeNull();
runEffects();
expect(harness.navigate).not.toHaveBeenCalled();
commit();
expect(ctx.navigate).not.toHaveBeenCalled();
render();
const title = attachTitle();
runEffects();
commit();
expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).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" } }]) {
harness.location = { ...harness.location, state };
// A fresh mount per visit: its effect runs at its first commit.
harness.clear();
ctx.location = { ...ctx.location, state };
render();
const title = attachTitle();
expect(() => runEffects()).not.toThrow();
expect(() => commit()).not.toThrow();
expect(title.focus).not.toHaveBeenCalled();
}
expect(harness.navigate).not.toHaveBeenCalled();
expect(ctx.navigate).not.toHaveBeenCalled();
});
});

View file

@ -1,37 +1,18 @@
// useOnboardingChecklist — the hook's own wiring (#396).
// useOnboardingChecklist — the hook's own wiring (#396, #409).
//
// The reducer, the loader, the follower and the derivation are tested in
// useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what
// the hook does with React would stay unguarded. Here React's hooks are
// stubbed (same technique as useImportWizard.executeImport.test.ts): the hook
// runs as a plain function on a prepared state, its effects are captured and
// run by hand, and the onboarding service is mocked.
// the hook does with React would stay unguarded. Here React's hooks come from
// the shared harness (src/test/hookHarness.ts): the hook runs as a plain
// function, its state, memos and callbacks survive from one render to the
// next as in a mounted provider, its reducer is applied, and `commit()` runs
// its effects as React does. The onboarding service is mocked.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
const harness = vi.hoisted(() => ({
state: null as unknown,
dispatch: vi.fn(),
effects: [] as Effect[],
pathname: "/",
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useReducer: () => [harness.state, harness.dispatch],
useState: (init: unknown) => [typeof init === "function" ? (init as () => unknown)() : init, () => undefined],
useCallback: (fn: unknown) => fn,
useMemo: (fn: () => unknown) => fn(),
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
harness.effects.push({ fn, deps });
},
};
});
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: harness.pathname }) }));
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ pathname: "/" }));
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../services/onboardingService", () => ({
@ -43,6 +24,7 @@ vi.mock("../services/onboardingService", () => ({
resumeStep: vi.fn(),
}));
import { hookHarness } from "../test/hookHarness";
import {
useOnboardingChecklist,
initialOnboardingChecklistState,
@ -61,6 +43,8 @@ import {
type OnboardingSnapshot,
} from "../services/onboardingService";
const harness = hookHarness();
const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true,
@ -73,19 +57,25 @@ const ready: OnboardingChecklistState = [
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** One render of the mounted provider. */
const render = () => harness.render(useOnboardingChecklist);
/** The state the provider mounts with: its `useReducer` is its first React hook. */
const mountWith = (state: OnboardingChecklistState) => {
harness.slots[0] = state;
};
/** Lets the loader's promises settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const actionsOfType = (type: string) =>
harness.dispatch.mock.calls.map((c) => c[0]).filter((a) => a.type === type);
harness.dispatch.mock.calls.map((c) => c[0] as { type: string }).filter((a) => a.type === type);
beforeEach(() => {
harness.state = initialOnboardingChecklistState;
harness.pathname = "/";
harness.effects = [];
harness.dispatch.mockReset();
harness.clear();
ctx.pathname = "/";
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
@ -95,18 +85,15 @@ beforeEach(() => {
});
afterEach(() => {
harness.unmount();
vi.useRealTimers();
});
describe("useOnboardingChecklist — wiring (#396)", () => {
it("reads at mount, through rule D9, and again whenever the route changes", async () => {
harness.pathname = "/import";
useOnboardingChecklist();
const [reread] = harness.effects;
// The route is a dependency: React runs the read again on every new pathname.
expect(reread.deps).toContain("/import");
reread.fn();
ctx.pathname = "/import";
render();
harness.commit();
await flush();
expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
@ -114,15 +101,37 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_DONE", seq: 1, snapshot },
]);
// The loaded state renders: same route, no new read.
const loaded = render();
harness.commit();
await flush();
expect(loaded.status).toBe("ready");
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
// A new route: React runs the read again.
ctx.pathname = "/transactions";
render();
harness.commit();
await flush();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
});
it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers();
useOnboardingChecklist();
const follow = harness.effects[1];
expect(follow.deps).not.toContain(harness.pathname);
harness.dispatch.mockReset();
// A route change re-runs the read, not the follower: one subscription only.
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
harness.dispatch.mockReset();
const cleanup = follow.fn() as () => void;
notifyDataChanged();
notifyDataChanged();
vi.advanceTimersByTime(149);
@ -131,28 +140,28 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(actionsOfType("LOAD_START")).toHaveLength(1);
await flush();
cleanup();
harness.unmount();
notifyDataChanged();
vi.advanceTimersByTime(300);
expect(actionsOfType("LOAD_START")).toHaveLength(1);
});
it("stores a card latch that changed during render, and only then", () => {
harness.state = { ...ready, cardPage: "/import" };
harness.pathname = "/reports";
useOnboardingChecklist();
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_CARD_PAGE", cardPage: null });
mountWith({ ...ready, cardPage: "/import" });
ctx.pathname = "/reports";
render();
expect(harness.dispatch.mock.calls).toEqual([[{ type: "SET_CARD_PAGE", cardPage: null }]]);
// The reducer applied it: the next render finds the latch stored.
harness.dispatch.mockReset();
harness.state = ready;
useOnboardingChecklist();
expect(render().cardPage).toBeNull();
expect(harness.dispatch).not.toHaveBeenCalled();
});
it("shares the derived next step and mode with the state", () => {
harness.state = ready;
harness.pathname = "/reports";
const value = useOnboardingChecklist();
mountWith(ready);
ctx.pathname = "/reports";
const value = render();
expect(value.status).toBe("ready");
expect(value.nextStep?.key).toBe("manualCategory");
expect(value.mode).toBe("banner");
@ -161,8 +170,8 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
});
it("each action writes its preference first, then updates the state", async () => {
harness.state = ready;
const value = useOnboardingChecklist();
mountWith(ready);
const value = render();
await value.hide();
await value.show();
await value.skip("budget");
@ -183,20 +192,68 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
});
it("an action whose write fails leaves the state alone and logs", async () => {
harness.state = ready;
const value = useOnboardingChecklist();
mountWith(ready);
const value = render();
vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked"));
vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked"));
await value.hide();
await value.skip("source");
expect(harness.dispatch).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledTimes(2);
expect(render().hidden).toBe(false);
});
it("refresh is the loader itself", async () => {
const value = useOnboardingChecklist();
const value = render();
await value.refresh();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
expect(actionsOfType("LOAD_DONE")).toHaveLength(1);
});
});
describe("useOnboardingChecklist — what survives a render (#409)", () => {
it("keeps one loader for the provider's life: refresh, the value and the reads stay put", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const first = render();
harness.commit();
await flush();
const reads = vi.mocked(getOnboardingSnapshot).mock.calls.length;
const second = render();
harness.commit();
await flush();
expect(second.refresh).toBe(first.refresh);
// Nothing changed: the shared value keeps its identity, so no consumer re-renders.
expect(second).toBe(first);
// A new loader would re-run the read effect at every render: a loop of reads.
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(reads);
});
it("'Skip' moves the next step on the next render, without a re-read", async () => {
mountWith(ready);
ctx.pathname = "/reports";
const before = render();
expect(before.nextStep?.key).toBe("manualCategory");
await before.skip("manualCategory");
const after = render();
expect(getOnboardingSnapshot).not.toHaveBeenCalled();
expect(after.checklist).not.toBe(before.checklist);
expect(after.nextStep?.key).toBe("budget");
});
it("a change of mode alone reaches the consumers", () => {
// An empty profile: the dashboard shows its own empty state, other pages the banner.
mountWith({ ...ready, counters: { ...snapshot.counters, transactions: 0 } });
ctx.pathname = "/";
const dashboard = render();
ctx.pathname = "/reports";
const reports = render();
expect(reports.checklist).toBe(dashboard.checklist);
expect(reports.nextStep).toBe(dashboard.nextStep);
expect(reports.cardPage).toBe(dashboard.cardPage);
expect(reports.mode).not.toBe(dashboard.mode);
expect(reports.mode).toBe("banner");
});
});

View file

@ -1,7 +1,7 @@
// BudgetPage — what the page does with the grid's target (#400).
// BudgetPage — what the page does with the grid's target (#400, #409).
//
// A static render runs no effect, so the grid never reports there. React's
// `useState` is stubbed (technique of AppShell.slot.test.tsx): the page runs
// hooks come from the shared harness (src/test/hookHarness.ts): the page runs
// as a plain function, its state survives from one call to the next as
// between two renders of a mounted component, and its setters keep their
// identity as React's do. The test plays the grid by calling the callback the
@ -9,50 +9,31 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
pathname: "/budget",
onboarding: null as unknown,
budget: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
harness.setters[index] ??= (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], harness.setters[index]];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
// The page's modules pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
vi.mock("../hooks/useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
}));
vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => harness.budget,
useBudget: () => ctx.budget,
}));
import { hookHarness } from "../test/hookHarness";
import BudgetPage from "./BudgetPage";
import BudgetTable from "../components/budget/BudgetTable";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
@ -62,6 +43,8 @@ import { computeChecklist } from "../utils/onboardingChecklist";
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -103,8 +86,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
/** One render of the mounted page: its hooks are read in the same order each time. */
function render() {
harness.cursor = 0;
const page = BudgetPage() as AnyElement;
const page = harness.render(() => BudgetPage() as AnyElement);
const [card] = find(page, (el) => el.type === OnboardingStepCard);
const [grid] = find(page, (el) => el.type === BudgetTable);
return {
@ -116,12 +98,11 @@ function render() {
}
beforeEach(() => {
harness.slots = [];
harness.setters = [];
harness.pathname = "/budget";
harness.onboarding = path(BUDGET_LEFT);
harness.clear();
ctx.pathname = "/budget";
ctx.onboarding = path(BUDGET_LEFT);
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
harness.budget = new Proxy({ state } as Record<string, unknown>, {
ctx.budget = new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
});
});
@ -136,7 +117,7 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
});
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
const checklist = (harness.onboarding as OnboardingValue).checklist;
const checklist = (ctx.onboarding as OnboardingValue).checklist;
let view = render();
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
expect(view.highlight).toBe(false);
@ -164,16 +145,15 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
{ mode: "complete" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.slots = [];
harness.setters = [];
harness.onboarding = path(BUDGET_LEFT, overrides);
harness.clear();
ctx.onboarding = path(BUDGET_LEFT, overrides);
render().report("cell");
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
}
});
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
harness.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
ctx.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
render().report("cell");
const view = render();
expect(view.model).toEqual({ text: null, done: true, target: null });
@ -195,15 +175,14 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
budgetAllowed: true,
skipped: ["source", "import", "manualCategory"],
});
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = harness.budget as { state: BudgetState };
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = ctx.budget as { state: BudgetState };
budget.state = { ...budget.state, totalInDb: null };
render().report("expandAll");
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
budget.state = { ...budget.state, totalInDb: 0 };
harness.cursor = 0;
const page = BudgetPage() as AnyElement;
const page = harness.render(() => BudgetPage() as AnyElement);
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
const [card] = find(page, (el) => el.type === OnboardingStepCard);
expect(card.props.model).toEqual({ text: null, done: false, target: null });

View file

@ -1,41 +1,21 @@
// UsersSettingsPage — the re-show button reads the shared path (#397).
// UsersSettingsPage — the re-show button reads the shared path (#397, #409).
//
// React's `useState` is stubbed (same technique as
// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function
// and its state survives from one call to the next, as between two renders of
// the mounted page. So "the path got hidden by the banner of this very page"
// is a new `useOnboarding()` value on the next render, without remounting.
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// page runs as a plain function, its state survives from one call to the
// next, as between two renders of the mounted page, and `commit()` runs its
// effects as React would. So "the path got hidden by the banner of this very
// page" is a new `useOnboarding()` value on the next render, without
// remounting, and an effect of the page that read the preference would run.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
cursor: 0,
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ onboarding: null as unknown }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
useOnboarding: () => harness.onboarding,
useOnboarding: () => ctx.onboarding,
}));
// The page's cards are not under test, and they reach tauri.
vi.mock("../../components/settings/AccountCard", () => ({ default: () => null }));
@ -46,10 +26,13 @@ vi.mock("../../components/settings/DocsContent", () => ({ default: () => null })
vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() }));
vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() }));
import { hookHarness } from "../../test/hookHarness";
import UsersSettingsPage from "./UsersSettingsPage";
import { getPreference } from "../../services/userPreferenceService";
import { showChecklist } from "../../services/onboardingService";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -71,10 +54,11 @@ function path(hidden: boolean) {
return { hidden, show: vi.fn(async () => undefined) };
}
/** One render of the mounted page. */
/** One render of the mounted page, then its commit. */
function renderPage(): ReactNode {
harness.cursor = 0;
return UsersSettingsPage() as ReactNode;
const page = harness.render(() => UsersSettingsPage() as ReactNode);
harness.commit();
return page;
}
const showButton = (page: ReactNode) =>
@ -83,8 +67,8 @@ const confirmation = (page: ReactNode) =>
find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)"));
beforeEach(() => {
harness.slots = [];
harness.onboarding = path(false);
harness.clear();
ctx.onboarding = path(false);
vi.mocked(getPreference).mockReset();
vi.mocked(showChecklist).mockReset();
});
@ -99,7 +83,7 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => {
expect(showButton(renderPage())).toHaveLength(0);
harness.onboarding = path(true);
ctx.onboarding = path(true);
const page = renderPage();
expect(texts(page)).toContain("t(settings.users.onboarding.label)");
expect(showButton(page)).toHaveLength(1);
@ -108,14 +92,14 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("the button shows the path through the provider, then confirms in place", async () => {
const hiddenPath = path(true);
harness.onboarding = hiddenPath;
ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)();
expect(hiddenPath.show).toHaveBeenCalledTimes(1);
expect(showChecklist).not.toHaveBeenCalled();
// The provider applied SHOW: the next render reads the path shown.
harness.onboarding = path(false);
ctx.onboarding = path(false);
const page = renderPage();
expect(showButton(page)).toHaveLength(0);
expect(confirmation(page)).toHaveLength(1);
@ -123,13 +107,13 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
});
it("hidden again after a restore, it offers the button again", async () => {
harness.onboarding = path(true);
ctx.onboarding = path(true);
const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)();
harness.onboarding = path(false);
ctx.onboarding = path(false);
expect(confirmation(renderPage())).toHaveLength(1);
harness.onboarding = path(true);
ctx.onboarding = path(true);
const page = renderPage();
expect(showButton(page)).toHaveLength(1);
expect(confirmation(page)).toHaveLength(0);
@ -137,7 +121,7 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
it("keeps the button when showing failed: the provider left the path hidden", async () => {
const hiddenPath = path(true);
harness.onboarding = hiddenPath;
ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)();
const page = renderPage();
@ -145,8 +129,10 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
expect(confirmation(page)).toHaveLength(0);
});
it("never reads the preference itself", () => {
harness.onboarding = path(true);
it("never reads the preference itself, neither while rendering nor in an effect", () => {
ctx.onboarding = path(true);
renderPage();
ctx.onboarding = path(false);
renderPage();
expect(getPreference).not.toHaveBeenCalled();
});