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. // renderToStaticMarkup runs no effect, so the report would stay unguarded.
// React's `useState`, `useRef` and `useEffect` are stubbed (technique of // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the
// grid runs as a plain function, its state and refs survive from one call to // 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 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 // 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 { BudgetYearRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
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>(),
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */ /** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
expanded: new Set<string>(), 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", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), 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"); const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
return { return {
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => { 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 { return {
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed), visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
isCollapsed, isCollapsed,
@ -64,7 +32,7 @@ vi.mock("../../hooks/useCollapsibleGroups", async () => {
collapseAll: () => undefined, collapseAll: () => undefined,
allExpanded: (rows: T[]) => { allExpanded: (rows: T[]) => {
const keys = collapsibleKeys(rows, acc); 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, 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 BudgetTable from "./BudgetTable";
import type { BudgetOnboardingTargetKind } from "../../shared/types"; 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 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. */ /** One render of the mounted grid: its hooks are called in the same order each time. */
function render( function render(
rows: BudgetYearRow[], rows: BudgetYearRow[],
onChange: (kind: BudgetOnboardingTargetKind | null) => void, onChange: (kind: BudgetOnboardingTargetKind | null) => void,
onboardingTarget?: boolean, onboardingTarget?: boolean,
) { ) {
harness.cursor = 0; harness.render(() =>
harness.effects = []; BudgetTable({
BudgetTable({ rows,
rows, onUpdatePlanned: () => undefined,
onUpdatePlanned: () => undefined, onSplitEvenly: () => undefined,
onSplitEvenly: () => undefined, onboardingTarget,
onboardingTarget, onOnboardingTargetChange: onChange,
onOnboardingTargetChange: onChange, }),
}); );
} }
/** After a render: run each effect whose dependencies changed since its last run, as React does. */ /** After a render: run each effect whose dependencies changed since its last run, as React does. */
function commit() { const commit = () => harness.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();
});
}
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.setters = []; ctx.expanded = new Set();
harness.effects = [];
harness.ran = new Map();
harness.expanded = new Set();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
}); });
@ -146,7 +103,7 @@ describe("BudgetTable — the target told to the page (#400)", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
// The user opens the group: an editable row shows. // 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); render(GROUPED, onChange, true);
commit(); commit();
render(GROUPED, onChange, true); 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 // renderToStaticMarkup runs no click, so React's hooks come from the shared
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a // harness (src/test/hookHarness.ts): the slot runs as a plain function, and
// plain function, and its state survives from one call to the next as it // its state survives from one call to the next as it would between two
// would between two renders of a mounted component. // renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({
cursor: 0,
pathname: "/reports", pathname: "/reports",
onboarding: null as unknown, 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) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }), useLocation: () => ({ pathname: ctx.pathname }),
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children, OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding, useOnboarding: () => ctx.onboarding,
})); }));
vi.mock("./Sidebar", () => ({ default: () => null })); vi.mock("./Sidebar", () => ({ default: () => null }));
import { hookHarness } from "../../test/hookHarness";
import AppShell, { OnboardingBannerSlot } from "./AppShell"; import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner"; import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath"; import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { 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. */ /** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps { function renderSlot(): OnboardingBannerProps {
harness.cursor = 0; const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
expect(element.type).toBe(OnboardingBanner); expect(element.type).toBe(OnboardingBanner);
return element.props; return element.props;
} }
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.pathname = "/reports"; ctx.pathname = "/reports";
harness.onboarding = onboardingValue(); ctx.onboarding = onboardingValue();
}); });
describe("OnboardingBannerSlot (#397)", () => { describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => { 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(); const props = renderSlot();
expect(props.mode).toBe("banner"); expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist); 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", () => { 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(); const props = renderSlot();
expect(props.mode).toBe("none"); expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false); 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", () => { it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import"; ctx.pathname = "/import";
const shell = AppShell() as ReactNode; const shell = harness.render(() => AppShell() as ReactNode);
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot); const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined(); expect(slot).toBeDefined();
expect(slot.key).toBe("/import"); expect(slot.key).toBe("/import");
harness.pathname = "/transactions"; ctx.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot); const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions"); expect(next.key).toBe("/transactions");
}); });
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => { 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"); const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined(); expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function"); 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. // renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
// React's `useRef` and `useEffect` are stubbed (technique of // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card // card runs as a plain function, its refs survive from one call to the next as
// runs as a plain function, its refs survive from one call to the next as they // they would between two renders of a mounted component, and `commit()` runs
// would between two renders of a mounted component, and its effects are // its effect when one of its dependencies changed, as React does. The router
// captured and run by hand. The router and the path are faked. // and the path are faked.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
const harness = vi.hoisted(() => ({
refs: [] as Array<{ current: unknown }>,
cursor: 0,
effects: [] as Effect[],
location: { pathname: "/import", state: null as unknown, key: "arrival-1" }, location: { pathname: "/import", state: null as unknown, key: "arrival-1" },
navigate: vi.fn(), navigate: vi.fn(),
onboarding: null as unknown, 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) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => harness.location, useLocation: () => ctx.location,
useNavigate: () => harness.navigate, 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 OnboardingStepCard from "./OnboardingStepCard";
import StepCard, { type StepCardProps } from "./StepCard"; import StepCard, { type StepCardProps } from "./StepCard";
import { computeChecklist } from "../../utils/onboardingChecklist"; import { computeChecklist } from "../../utils/onboardingChecklist";
@ -48,6 +30,8 @@ import { nextStep, type StepCardModel } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist"; import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
import type { OnboardingCounters } from "../../shared/types"; import type { OnboardingCounters } from "../../shared/types";
const harness = hookHarness();
const ZERO: OnboardingCounters = { const ZERO: OnboardingCounters = {
sources: 0, sources: 0,
imports: 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. */ /** 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 { function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null {
harness.cursor = 0; return harness.render(() => OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null);
harness.effects = [];
return 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. */ /** The title the card's ref points at, once React would have attached it. */
function attachTitle(): { focus: ReturnType<typeof vi.fn> } { function attachTitle(): { focus: ReturnType<typeof vi.fn> } {
const title = { focus: vi.fn() }; const title = { focus: vi.fn() };
harness.refs[0].current = title; titleRef().current = title;
return title; return title;
} }
const runEffects = () => { /** The commit of the last render: its effect runs if one of its dependencies changed. */
for (const effect of harness.effects) effect.fn(); const commit = () => harness.commit();
};
beforeEach(() => { beforeEach(() => {
harness.refs = []; harness.clear();
harness.effects = []; ctx.location = { pathname: "/import", state: null, key: "arrival-1" };
harness.location = { pathname: "/import", state: null, key: "arrival-1" }; ctx.navigate.mockReset();
harness.navigate.mockReset(); ctx.onboarding = path(FOLDER_SET);
harness.onboarding = path(FOLDER_SET);
}); });
describe("OnboardingStepCard — when it shows (#398)", () => { 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", () => { 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) { 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(); expect(render(), mode).toBeNull();
} }
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" }); ctx.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
expect(render()).toBeNull(); expect(render()).toBeNull();
harness.onboarding = path(FOLDER_SET); ctx.onboarding = path(FOLDER_SET);
expect(render(null)).toBeNull(); expect(render(null)).toBeNull();
}); });
it("stays once the page's steps are done: the latch keeps it, not the next step", () => { it("stays once the page's steps are done: the latch keeps it, not the next step", () => {
harness.onboarding = path(IMPORTED); ctx.onboarding = path(IMPORTED);
const value = harness.onboarding as OnboardingValue; const value = ctx.onboarding as OnboardingValue;
expect(value.nextStep?.to).toBe("/transactions"); expect(value.nextStep?.to).toBe("/transactions");
const card = render(DONE); const card = render(DONE);
expect(card).not.toBeNull(); 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.position).toEqual({ n: 3, total: 6 });
expect(props.text).toBe(SOURCE_LIST.text); expect(props.text).toBe(SOURCE_LIST.text);
expect(props.done).toBe(false); 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", () => { 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({ expect(render(DONE)!.props.next).toEqual({
kind: "step", 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. // 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" }); expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
}); });
it("no 'Step n of N' until the Budget entitlement is known", () => { 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(); expect(render()!.props.position).toBeNull();
}); });
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => { 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; const props = render()!.props;
props.onSkip(); props.onSkip();
expect(value.skip).toHaveBeenCalledWith("source"); expect(value.skip).toHaveBeenCalledWith("source");
@ -179,58 +162,60 @@ describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => {
const ARRIVAL = { onboarding: { focus: true } }; const ARRIVAL = { onboarding: { focus: true } };
it("moves the focus to the title, then consumes the navigation state", () => { 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(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); expect(title.focus).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledTimes(1); expect(ctx.navigate).toHaveBeenCalledTimes(1);
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null }); expect(ctx.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan( expect(vi.mocked(ctx.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
title.focus.mock.invocationCallOrder[0], title.focus.mock.invocationCallOrder[0],
); );
}); });
it("does it once per arrival: StrictMode's second run and later renders do nothing", () => { 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(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
runEffects(); harness.replayEffects();
render(); render();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); 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. // 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(); render();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(2); 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", () => { 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(); expect(render(null)).toBeNull();
runEffects(); commit();
expect(harness.navigate).not.toHaveBeenCalled(); expect(ctx.navigate).not.toHaveBeenCalled();
render(); render();
const title = attachTitle(); const title = attachTitle();
runEffects(); commit();
expect(title.focus).toHaveBeenCalledTimes(1); 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", () => { 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" } }]) { 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(); render();
const title = attachTitle(); const title = attachTitle();
expect(() => runEffects()).not.toThrow(); expect(() => commit()).not.toThrow();
expect(title.focus).not.toHaveBeenCalled(); 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 // The reducer, the loader, the follower and the derivation are tested in
// useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what // useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what
// the hook does with React would stay unguarded. Here React's hooks are // the hook does with React would stay unguarded. Here React's hooks come from
// stubbed (same technique as useImportWizard.executeImport.test.ts): the hook // the shared harness (src/test/hookHarness.ts): the hook runs as a plain
// runs as a plain function on a prepared state, its effects are captured and // function, its state, memos and callbacks survive from one render to the
// run by hand, and the onboarding service is mocked. // 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"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ pathname: "/" }));
const harness = vi.hoisted(() => ({ vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
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("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) })); vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() })); vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../services/onboardingService", () => ({ vi.mock("../services/onboardingService", () => ({
@ -43,6 +24,7 @@ vi.mock("../services/onboardingService", () => ({
resumeStep: vi.fn(), resumeStep: vi.fn(),
})); }));
import { hookHarness } from "../test/hookHarness";
import { import {
useOnboardingChecklist, useOnboardingChecklist,
initialOnboardingChecklistState, initialOnboardingChecklistState,
@ -61,6 +43,8 @@ import {
type OnboardingSnapshot, type OnboardingSnapshot,
} from "../services/onboardingService"; } from "../services/onboardingService";
const harness = hookHarness();
const snapshot: OnboardingSnapshot = { const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true, importFolderSet: true,
@ -73,19 +57,25 @@ const ready: OnboardingChecklistState = [
{ type: "LOAD_DONE", seq: 1, snapshot } as const, { type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState); ].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. */ /** Lets the loader's promises settle. */
const flush = async () => { const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve(); for (let i = 0; i < 10; i++) await Promise.resolve();
}; };
const actionsOfType = (type: string) => 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(() => { beforeEach(() => {
harness.state = initialOnboardingChecklistState; harness.clear();
harness.pathname = "/"; ctx.pathname = "/";
harness.effects = [];
harness.dispatch.mockReset();
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot); vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false }); vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) { for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
@ -95,18 +85,15 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
harness.unmount();
vi.useRealTimers(); vi.useRealTimers();
}); });
describe("useOnboardingChecklist — wiring (#396)", () => { describe("useOnboardingChecklist — wiring (#396)", () => {
it("reads at mount, through rule D9, and again whenever the route changes", async () => { it("reads at mount, through rule D9, and again whenever the route changes", async () => {
harness.pathname = "/import"; ctx.pathname = "/import";
useOnboardingChecklist(); render();
const [reread] = harness.effects; harness.commit();
// The route is a dependency: React runs the read again on every new pathname.
expect(reread.deps).toContain("/import");
reread.fn();
await flush(); await flush();
expect(initializeGuidedPath).toHaveBeenCalledTimes(1); expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
@ -114,15 +101,37 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
{ type: "LOAD_START", seq: 1 }, { type: "LOAD_START", seq: 1 },
{ type: "LOAD_DONE", seq: 1, snapshot }, { 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 () => { it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers(); vi.useFakeTimers();
useOnboardingChecklist(); harness.dispatch.mockReset();
const follow = harness.effects[1];
expect(follow.deps).not.toContain(harness.pathname); // 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();
notifyDataChanged(); notifyDataChanged();
vi.advanceTimersByTime(149); vi.advanceTimersByTime(149);
@ -131,28 +140,28 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(actionsOfType("LOAD_START")).toHaveLength(1); expect(actionsOfType("LOAD_START")).toHaveLength(1);
await flush(); await flush();
cleanup(); harness.unmount();
notifyDataChanged(); notifyDataChanged();
vi.advanceTimersByTime(300); vi.advanceTimersByTime(300);
expect(actionsOfType("LOAD_START")).toHaveLength(1); expect(actionsOfType("LOAD_START")).toHaveLength(1);
}); });
it("stores a card latch that changed during render, and only then", () => { it("stores a card latch that changed during render, and only then", () => {
harness.state = { ...ready, cardPage: "/import" }; mountWith({ ...ready, cardPage: "/import" });
harness.pathname = "/reports"; ctx.pathname = "/reports";
useOnboardingChecklist(); render();
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_CARD_PAGE", cardPage: null }); 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.dispatch.mockReset();
harness.state = ready; expect(render().cardPage).toBeNull();
useOnboardingChecklist();
expect(harness.dispatch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled();
}); });
it("shares the derived next step and mode with the state", () => { it("shares the derived next step and mode with the state", () => {
harness.state = ready; mountWith(ready);
harness.pathname = "/reports"; ctx.pathname = "/reports";
const value = useOnboardingChecklist(); const value = render();
expect(value.status).toBe("ready"); expect(value.status).toBe("ready");
expect(value.nextStep?.key).toBe("manualCategory"); expect(value.nextStep?.key).toBe("manualCategory");
expect(value.mode).toBe("banner"); 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 () => { it("each action writes its preference first, then updates the state", async () => {
harness.state = ready; mountWith(ready);
const value = useOnboardingChecklist(); const value = render();
await value.hide(); await value.hide();
await value.show(); await value.show();
await value.skip("budget"); 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 () => { it("an action whose write fails leaves the state alone and logs", async () => {
harness.state = ready; mountWith(ready);
const value = useOnboardingChecklist(); const value = render();
vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked")); vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked"));
vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked")); vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked"));
await value.hide(); await value.hide();
await value.skip("source"); await value.skip("source");
expect(harness.dispatch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledTimes(2); expect(logError).toHaveBeenCalledTimes(2);
expect(render().hidden).toBe(false);
}); });
it("refresh is the loader itself", async () => { it("refresh is the loader itself", async () => {
const value = useOnboardingChecklist(); const value = render();
await value.refresh(); await value.refresh();
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1); expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
expect(actionsOfType("LOAD_DONE")).toHaveLength(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 // 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 // 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 // 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 // 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({
setters: [] as Array<(next: unknown) => void>,
cursor: 0,
pathname: "/budget", pathname: "/budget",
onboarding: null as unknown, onboarding: null as unknown,
budget: 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) => ({ vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()), ...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }), useLocation: () => ({ pathname: ctx.pathname }),
})); }));
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
})); }));
// The page's modules pull the database: stub the connection. // The page's modules pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); 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", () => ({ vi.mock("../hooks/useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }), useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
})); }));
vi.mock("../hooks/useBudget", async (importOriginal) => ({ vi.mock("../hooks/useBudget", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useBudget")>()), ...(await importOriginal<typeof import("../hooks/useBudget")>()),
useBudget: () => harness.budget, useBudget: () => ctx.budget,
})); }));
import { hookHarness } from "../test/hookHarness";
import BudgetPage from "./BudgetPage"; import BudgetPage from "./BudgetPage";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
@ -62,6 +43,8 @@ import { computeChecklist } from "../utils/onboardingChecklist";
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath"; import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types"; import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { 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. */ /** One render of the mounted page: its hooks are read in the same order each time. */
function render() { function render() {
harness.cursor = 0; const page = harness.render(() => BudgetPage() as AnyElement);
const page = BudgetPage() as AnyElement;
const [card] = find(page, (el) => el.type === OnboardingStepCard); const [card] = find(page, (el) => el.type === OnboardingStepCard);
const [grid] = find(page, (el) => el.type === BudgetTable); const [grid] = find(page, (el) => el.type === BudgetTable);
return { return {
@ -116,12 +98,11 @@ function render() {
} }
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.setters = []; ctx.pathname = "/budget";
harness.pathname = "/budget"; ctx.onboarding = path(BUDGET_LEFT);
harness.onboarding = path(BUDGET_LEFT);
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 }; 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()), 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", () => { 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(); let view = render();
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist)); expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
expect(view.highlight).toBe(false); 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: "complete" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" }, { mode: "card" as const, cardPage: "/transactions" },
]) { ]) {
harness.slots = []; harness.clear();
harness.setters = []; ctx.onboarding = path(BUDGET_LEFT, overrides);
harness.onboarding = path(BUDGET_LEFT, overrides);
render().report("cell"); render().report("cell");
expect(render().highlight, JSON.stringify(overrides)).toBe(false); expect(render().highlight, JSON.stringify(overrides)).toBe(false);
} }
}); });
it("asks for no ring once the step is checked: the card says 'Done.'", () => { 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"); render().report("cell");
const view = render(); const view = render();
expect(view.model).toEqual({ text: null, done: true, target: null }); 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, budgetAllowed: true,
skipped: ["source", "import", "manualCategory"], skipped: ["source", "import", "manualCategory"],
}); });
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) }); ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
const budget = harness.budget as { state: BudgetState }; const budget = ctx.budget as { state: BudgetState };
budget.state = { ...budget.state, totalInDb: null }; budget.state = { ...budget.state, totalInDb: null };
render().report("expandAll"); render().report("expandAll");
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed"); expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
budget.state = { ...budget.state, totalInDb: 0 }; budget.state = { ...budget.state, totalInDb: 0 };
harness.cursor = 0; const page = harness.render(() => BudgetPage() as AnyElement);
const page = BudgetPage() as AnyElement;
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0); expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
const [card] = find(page, (el) => el.type === OnboardingStepCard); const [card] = find(page, (el) => el.type === OnboardingStepCard);
expect(card.props.model).toEqual({ text: null, done: false, target: null }); 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 // React's hooks come from the shared harness (src/test/hookHarness.ts): the
// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function // page runs as a plain function, its state survives from one call to the
// and its state survives from one call to the next, as between two renders of // next, as between two renders of the mounted page, and `commit()` runs its
// the mounted page. So "the path got hidden by the banner of this very page" // effects as React would. So "the path got hidden by the banner of this very
// is a new `useOnboarding()` value on the next render, without remounting. // 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
const harness = vi.hoisted(() => ({ vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
slots: [] as unknown[], const ctx = vi.hoisted(() => ({ onboarding: null 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-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
})); }));
vi.mock("../../contexts/OnboardingContext", () => ({ vi.mock("../../contexts/OnboardingContext", () => ({
useOnboarding: () => harness.onboarding, useOnboarding: () => ctx.onboarding,
})); }));
// The page's cards are not under test, and they reach tauri. // The page's cards are not under test, and they reach tauri.
vi.mock("../../components/settings/AccountCard", () => ({ default: () => null })); 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/userPreferenceService", () => ({ getPreference: vi.fn() }));
vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() })); vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() }));
import { hookHarness } from "../../test/hookHarness";
import UsersSettingsPage from "./UsersSettingsPage"; import UsersSettingsPage from "./UsersSettingsPage";
import { getPreference } from "../../services/userPreferenceService"; import { getPreference } from "../../services/userPreferenceService";
import { showChecklist } from "../../services/onboardingService"; import { showChecklist } from "../../services/onboardingService";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>; type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
@ -71,10 +54,11 @@ function path(hidden: boolean) {
return { hidden, show: vi.fn(async () => undefined) }; 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 { function renderPage(): ReactNode {
harness.cursor = 0; const page = harness.render(() => UsersSettingsPage() as ReactNode);
return UsersSettingsPage() as ReactNode; harness.commit();
return page;
} }
const showButton = (page: ReactNode) => 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)")); find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)"));
beforeEach(() => { beforeEach(() => {
harness.slots = []; harness.clear();
harness.onboarding = path(false); ctx.onboarding = path(false);
vi.mocked(getPreference).mockReset(); vi.mocked(getPreference).mockReset();
vi.mocked(showChecklist).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", () => { it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => {
expect(showButton(renderPage())).toHaveLength(0); expect(showButton(renderPage())).toHaveLength(0);
harness.onboarding = path(true); ctx.onboarding = path(true);
const page = renderPage(); const page = renderPage();
expect(texts(page)).toContain("t(settings.users.onboarding.label)"); expect(texts(page)).toContain("t(settings.users.onboarding.label)");
expect(showButton(page)).toHaveLength(1); 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 () => { it("the button shows the path through the provider, then confirms in place", async () => {
const hiddenPath = path(true); const hiddenPath = path(true);
harness.onboarding = hiddenPath; ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
expect(hiddenPath.show).toHaveBeenCalledTimes(1); expect(hiddenPath.show).toHaveBeenCalledTimes(1);
expect(showChecklist).not.toHaveBeenCalled(); expect(showChecklist).not.toHaveBeenCalled();
// The provider applied SHOW: the next render reads the path shown. // The provider applied SHOW: the next render reads the path shown.
harness.onboarding = path(false); ctx.onboarding = path(false);
const page = renderPage(); const page = renderPage();
expect(showButton(page)).toHaveLength(0); expect(showButton(page)).toHaveLength(0);
expect(confirmation(page)).toHaveLength(1); 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 () => { it("hidden again after a restore, it offers the button again", async () => {
harness.onboarding = path(true); ctx.onboarding = path(true);
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
harness.onboarding = path(false); ctx.onboarding = path(false);
expect(confirmation(renderPage())).toHaveLength(1); expect(confirmation(renderPage())).toHaveLength(1);
harness.onboarding = path(true); ctx.onboarding = path(true);
const page = renderPage(); const page = renderPage();
expect(showButton(page)).toHaveLength(1); expect(showButton(page)).toHaveLength(1);
expect(confirmation(page)).toHaveLength(0); 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 () => { it("keeps the button when showing failed: the provider left the path hidden", async () => {
const hiddenPath = path(true); const hiddenPath = path(true);
harness.onboarding = hiddenPath; ctx.onboarding = hiddenPath;
const [button] = showButton(renderPage()); const [button] = showButton(renderPage());
await (button.props.onClick as () => Promise<void>)(); await (button.props.onClick as () => Promise<void>)();
const page = renderPage(); const page = renderPage();
@ -145,8 +129,10 @@ describe("UsersSettingsPage — showing the path again (#397)", () => {
expect(confirmation(page)).toHaveLength(0); expect(confirmation(page)).toHaveLength(0);
}); });
it("never reads the preference itself", () => { it("never reads the preference itself, neither while rendering nor in an effect", () => {
harness.onboarding = path(true); ctx.onboarding = path(true);
renderPage();
ctx.onboarding = path(false);
renderPage(); renderPage();
expect(getPreference).not.toHaveBeenCalled(); expect(getPreference).not.toHaveBeenCalled();
}); });