test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold: one loader for the provider's life (refresh and the shared value keep their identity, no re-read), 'Skip' moving the next step on the next render, and a change of mode alone reaching the consumers. UsersSettingsPage now commits its effects, so an effect that read the preference goes red on the assertion rather than on 'Invalid hook call'. BudgetTable.wiring, BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local slot copies; OnboardingStepCard plays StrictMode through replayEffects and runs its effect only on a changed dependency, which now pins location.key. Refs #409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
86fd496e77
commit
1ba3b8bae9
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 = [];
|
||||
BudgetTable({
|
||||
rows,
|
||||
onUpdatePlanned: () => undefined,
|
||||
onSplitEvenly: () => undefined,
|
||||
onboardingTarget,
|
||||
onOnboardingTargetChange: onChange,
|
||||
});
|
||||
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