test: shared React hook harness for the jsdom-less tests (#409) #434
6 changed files with 272 additions and 325 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue