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