test: shared React hook harness for the jsdom-less tests (#409) #434
9 changed files with 853 additions and 325 deletions
|
|
@ -587,6 +587,36 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
|
||||||
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
|
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
|
||||||
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
|
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
|
||||||
|
|
||||||
|
## Tests (vitest sans jsdom)
|
||||||
|
|
||||||
|
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
|
||||||
|
|
||||||
|
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
|
||||||
|
|
||||||
|
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
import { hookHarness } from "../test/hookHarness";
|
||||||
|
const harness = hookHarness();
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
|
||||||
|
|
||||||
|
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
|
||||||
|
|
||||||
|
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
|
||||||
|
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
|
||||||
|
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
|
||||||
|
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
|
||||||
|
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
|
||||||
|
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
|
||||||
|
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
|
||||||
|
|
||||||
|
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit).
|
||||||
|
|
||||||
|
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`. Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
|
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
286
src/test/hookHarness.test.ts
Normal file
286
src/test/hookHarness.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
// hookHarness — each simulated semantic proved by its own test (#409).
|
||||||
|
//
|
||||||
|
// The tests that rely on the harness can only be as true as it is. Here it is
|
||||||
|
// driven directly: a harness of its own, a fake React built over the real one,
|
||||||
|
// and small components written for the test.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import * as actualReact from "react";
|
||||||
|
import { createHookHarness, fakeReact, hookHarness, type HookHarness } from "./hookHarness";
|
||||||
|
|
||||||
|
let harness: HookHarness;
|
||||||
|
let R: typeof actualReact;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness = createHookHarness();
|
||||||
|
R = fakeReact(actualReact, harness);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — the module itself", () => {
|
||||||
|
it("imports React only as a type: a value import would load the mock it builds", () => {
|
||||||
|
// The code only: the header comment shows the pattern of use.
|
||||||
|
const source = readFileSync(new URL("./hookHarness.ts", import.meta.url), "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.trimStart().startsWith("//"))
|
||||||
|
.join("\n");
|
||||||
|
const reactImports = source.split("\n").filter((line) => /from\s+["']react["']/.test(line));
|
||||||
|
expect(reactImports.length).toBeGreaterThan(0);
|
||||||
|
for (const line of reactImports) expect(line).toMatch(/^import type /);
|
||||||
|
expect(source).not.toMatch(/require\(["']react["']\)/);
|
||||||
|
expect(source).not.toMatch(/import\(["']react["']\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one harness through its accessor", () => {
|
||||||
|
expect(hookHarness()).toBe(hookHarness());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the rest of React real", () => {
|
||||||
|
expect(R.isValidElement).toBe(actualReact.isValidElement);
|
||||||
|
expect(R.Fragment).toBe(actualReact.Fragment);
|
||||||
|
expect(R.createElement).toBe(actualReact.createElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — useState", () => {
|
||||||
|
it("calls the initializer once, at the first render", () => {
|
||||||
|
const init = vi.fn(() => 1);
|
||||||
|
const Counter = () => R.useState(init)[0];
|
||||||
|
expect(harness.render(Counter)).toBe(1);
|
||||||
|
expect(harness.render(Counter)).toBe(1);
|
||||||
|
expect(init).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a stable setter, applies functional updates, and does not re-render", () => {
|
||||||
|
let renders = 0;
|
||||||
|
const Counter = () => {
|
||||||
|
renders++;
|
||||||
|
return R.useState(0);
|
||||||
|
};
|
||||||
|
const [, set] = harness.render(Counter);
|
||||||
|
set(5);
|
||||||
|
set((n: number) => n + 1);
|
||||||
|
expect(renders).toBe(1);
|
||||||
|
const [value, again] = harness.render(Counter);
|
||||||
|
expect(value).toBe(6);
|
||||||
|
expect(again).toBe(set);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each slot apart, by call order", () => {
|
||||||
|
const Pair = () => [R.useState("a")[0], R.useState("b")[0]];
|
||||||
|
expect(harness.render(Pair)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a slot the test preset before the first render", () => {
|
||||||
|
harness.slots[0] = "preset";
|
||||||
|
expect(harness.render(() => R.useState("init")[0])).toBe("preset");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — useReducer", () => {
|
||||||
|
const reducer = (state: number, action: { by: number }) => state + action.by;
|
||||||
|
|
||||||
|
it("applies the reducer, keeps a stable dispatch, and records every action in the spy", () => {
|
||||||
|
const Sum = () => R.useReducer(reducer, 10);
|
||||||
|
const [state, dispatch] = harness.render(Sum);
|
||||||
|
expect(state).toBe(10);
|
||||||
|
dispatch({ by: 2 });
|
||||||
|
dispatch({ by: 3 });
|
||||||
|
const [next, again] = harness.render(Sum);
|
||||||
|
expect(next).toBe(15);
|
||||||
|
expect(again).toBe(dispatch);
|
||||||
|
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([{ by: 2 }, { by: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the init function once", () => {
|
||||||
|
const init = vi.fn((n: number) => n * 2);
|
||||||
|
const Sum = () => R.useReducer(reducer, 4, init)[0];
|
||||||
|
expect(harness.render(Sum)).toBe(8);
|
||||||
|
expect(harness.render(Sum)).toBe(8);
|
||||||
|
expect(init).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — useRef", () => {
|
||||||
|
it("keeps one object from render to render", () => {
|
||||||
|
const Ref = () => R.useRef(0);
|
||||||
|
const first = harness.render(Ref);
|
||||||
|
first.current = 7;
|
||||||
|
const second = harness.render(Ref);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(second.current).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — useMemo and useCallback", () => {
|
||||||
|
it("useMemo keeps its value while the dependencies are equal, recomputes when one changes", () => {
|
||||||
|
const compute = vi.fn((n: number) => ({ n }));
|
||||||
|
let dep = 1;
|
||||||
|
const Memo = () => R.useMemo(() => compute(dep), [dep]);
|
||||||
|
const first = harness.render(Memo);
|
||||||
|
expect(harness.render(Memo)).toBe(first);
|
||||||
|
expect(compute).toHaveBeenCalledTimes(1);
|
||||||
|
dep = 2;
|
||||||
|
const second = harness.render(Memo);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(second).toEqual({ n: 2 });
|
||||||
|
expect(compute).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares the dependencies with Object.is", () => {
|
||||||
|
let dep: unknown = NaN;
|
||||||
|
const compute = vi.fn(() => ({}));
|
||||||
|
const Memo = () => R.useMemo(compute, [dep]);
|
||||||
|
harness.render(Memo);
|
||||||
|
harness.render(Memo);
|
||||||
|
expect(compute).toHaveBeenCalledTimes(1);
|
||||||
|
dep = { same: true };
|
||||||
|
harness.render(Memo);
|
||||||
|
dep = { same: true };
|
||||||
|
harness.render(Memo);
|
||||||
|
expect(compute).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useMemo without dependencies recomputes at every render", () => {
|
||||||
|
const compute = vi.fn(() => ({}));
|
||||||
|
const Memo = () => R.useMemo(compute, undefined as unknown as []);
|
||||||
|
harness.render(Memo);
|
||||||
|
harness.render(Memo);
|
||||||
|
expect(compute).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useCallback keeps the first function while the dependencies are equal", () => {
|
||||||
|
let dep = "a";
|
||||||
|
const Callback = () => R.useCallback(() => dep, [dep]);
|
||||||
|
const first = harness.render(Callback);
|
||||||
|
expect(harness.render(Callback)).toBe(first);
|
||||||
|
dep = "b";
|
||||||
|
const second = harness.render(Callback);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(second()).toBe("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — useEffect", () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
let dep = 1;
|
||||||
|
const Effects = () => {
|
||||||
|
const d = dep;
|
||||||
|
R.useEffect(() => {
|
||||||
|
log.push(`run ${d}`);
|
||||||
|
return () => {
|
||||||
|
log.push(`cleanup ${d}`);
|
||||||
|
};
|
||||||
|
}, [d]);
|
||||||
|
R.useEffect(() => {
|
||||||
|
log.push("once");
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
log.length = 0;
|
||||||
|
dep = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the effects during the render, without running them", () => {
|
||||||
|
harness.render(Effects);
|
||||||
|
expect(log).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit runs them all at the first commit, then only those whose dependencies changed", () => {
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
expect(log).toEqual(["run 1", "once"]);
|
||||||
|
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
expect(log).toEqual(["run 1", "once"]);
|
||||||
|
|
||||||
|
dep = 2;
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
// The cleanup of the previous run, with its own closure, before the new run.
|
||||||
|
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a render once", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const Always = () => R.useEffect(fn);
|
||||||
|
harness.render(Always);
|
||||||
|
harness.commit();
|
||||||
|
harness.commit();
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
harness.render(Always);
|
||||||
|
harness.commit();
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs every cleanup before any effect of the same commit", () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const Two = () => {
|
||||||
|
const k = n;
|
||||||
|
R.useEffect(() => {
|
||||||
|
order.push(`a${k}`);
|
||||||
|
return () => {
|
||||||
|
order.push(`~a${k}`);
|
||||||
|
};
|
||||||
|
}, [k]);
|
||||||
|
R.useEffect(() => {
|
||||||
|
order.push(`b${k}`);
|
||||||
|
return () => {
|
||||||
|
order.push(`~b${k}`);
|
||||||
|
};
|
||||||
|
}, [k]);
|
||||||
|
};
|
||||||
|
harness.render(Two);
|
||||||
|
harness.commit();
|
||||||
|
n = 1;
|
||||||
|
harness.render(Two);
|
||||||
|
harness.commit();
|
||||||
|
expect(order).toEqual(["a0", "b0", "~a0", "~b0", "a1", "b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replayEffects plays StrictMode's double run: every cleanup, then every effect again", () => {
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
harness.replayEffects();
|
||||||
|
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 1", "once"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmount runs every cleanup, once", () => {
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
harness.unmount();
|
||||||
|
harness.unmount();
|
||||||
|
expect(log).toEqual(["run 1", "once", "cleanup 1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear starts a fresh mount", () => {
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
harness.dispatch("x");
|
||||||
|
harness.clear();
|
||||||
|
expect(harness.slots).toEqual([]);
|
||||||
|
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||||
|
harness.render(Effects);
|
||||||
|
harness.commit();
|
||||||
|
expect(log).toEqual(["run 1", "once", "run 1", "once"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hookHarness — hooks it does not simulate", () => {
|
||||||
|
it.each(["useContext", "useLayoutEffect", "useSyncExternalStore", "useId", "useTransition", "use"])(
|
||||||
|
"%s throws instead of reaching the real React",
|
||||||
|
(name) => {
|
||||||
|
const hook = (R as unknown as Record<string, () => unknown>)[name];
|
||||||
|
expect(hook).toBeTypeOf("function");
|
||||||
|
expect(() => hook()).toThrow(`hookHarness: ${name} is not simulated`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("on the default export too", () => {
|
||||||
|
const fake = R as unknown as { default: Record<string, (...args: unknown[]) => unknown> };
|
||||||
|
expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated");
|
||||||
|
expect(fake.default.useState).toBe((R as unknown as Record<string, unknown>).useState);
|
||||||
|
});
|
||||||
|
});
|
||||||
265
src/test/hookHarness.ts
Normal file
265
src/test/hookHarness.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
// hookHarness — React's hooks with their semantics, for tests without jsdom (#409).
|
||||||
|
//
|
||||||
|
// The repository has no jsdom: tests call hooks and components as plain
|
||||||
|
// functions, under `vi.mock("react")`. A fake hook that drops React's
|
||||||
|
// semantics (an initializer called at every render, a memo recomputed every
|
||||||
|
// time) leaves one-line mutations green. This module is the one harness those
|
||||||
|
// tests share, so the semantics are written, and proved, once
|
||||||
|
// (hookHarness.test.ts).
|
||||||
|
//
|
||||||
|
// USE — one pattern, at the head of the test module, before the imports of
|
||||||
|
// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic
|
||||||
|
// import inside it):
|
||||||
|
//
|
||||||
|
// vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
// import { hookHarness } from "../test/hookHarness";
|
||||||
|
// const harness = hookHarness();
|
||||||
|
//
|
||||||
|
// then, per test: `harness.clear()` in `beforeEach` (a fresh mount),
|
||||||
|
// `harness.render(() => Component(props))` for one render of the mounted
|
||||||
|
// component, `harness.commit()` to run its effects. Every test module gets its
|
||||||
|
// own copy of this module, so the singleton is shared by the mock and the test
|
||||||
|
// of one file, never across files. Values the test changes between renders
|
||||||
|
// (a pathname, a context value) live in a `vi.hoisted` object of the test.
|
||||||
|
//
|
||||||
|
// SEMANTICS
|
||||||
|
// - Slots by call order, as React: `render()` resets the cursor, so a
|
||||||
|
// component must call its hooks in the same order at every render. A test
|
||||||
|
// may preset the slot of a `useState` or `useReducer` (its value) before the
|
||||||
|
// first render.
|
||||||
|
// - `useState`: initializer called once (at the first render), stable setter,
|
||||||
|
// functional updates applied to the latest value. A setter does NOT
|
||||||
|
// re-render: the test renders again when it wants to read the new state.
|
||||||
|
// - `useReducer`: the reducer IS applied (the slot holds the new state for the
|
||||||
|
// next render), its `dispatch` is stable, and every action is also recorded
|
||||||
|
// by `harness.dispatch`, a `vi.fn()` spy shared by all the reducers.
|
||||||
|
// - `useRef`: one object per slot, kept from render to render.
|
||||||
|
// - `useMemo` / `useCallback`: recomputed only when a dependency changed
|
||||||
|
// (`Object.is`, as React); without a dependency array, at every render.
|
||||||
|
// - `useEffect`: captured during the render, never run by it. `commit()` runs
|
||||||
|
// the effects of the last render whose dependencies changed since their last
|
||||||
|
// run (all of them at the first commit, every time without an array): first
|
||||||
|
// the cleanups of those effects, then the effects, in call order, as React.
|
||||||
|
// A render is committed once. `replayEffects()` plays StrictMode's double
|
||||||
|
// run at mount (every cleanup, then every effect again); `unmount()` runs
|
||||||
|
// every cleanup.
|
||||||
|
// - Any other hook of React (`useContext`, `useLayoutEffect`,
|
||||||
|
// `useSyncExternalStore`, `useId`, `use`…) THROWS: it would otherwise reach
|
||||||
|
// the real React outside a render ("Invalid hook call"), and a test would go
|
||||||
|
// red by accident rather than on its assertion. Mock the module that calls
|
||||||
|
// it, or cover the hook here with its own test.
|
||||||
|
// - Everything else (`isValidElement`, `Fragment`, `createElement`…) is the
|
||||||
|
// real React, on the named exports and on the default export.
|
||||||
|
//
|
||||||
|
// NOT SIMULATED: re-rendering (on a setter, a dispatch, a context change),
|
||||||
|
// children (a component in the returned tree is an element, never called),
|
||||||
|
// the DOM, concurrent features, the bail-out of a setter called with the same
|
||||||
|
// value, the render-phase update loop React runs for a dispatch during render
|
||||||
|
// (the next `render()` reads it).
|
||||||
|
//
|
||||||
|
// React is imported here only as a type: a value import, from inside the
|
||||||
|
// factory that builds the mock of "react", would load that very mock.
|
||||||
|
import type * as React from "react";
|
||||||
|
import { vi, type Mock } from "vitest";
|
||||||
|
|
||||||
|
type Deps = readonly unknown[] | undefined;
|
||||||
|
type EffectFn = () => void | (() => void);
|
||||||
|
|
||||||
|
const EFFECT = Symbol("hookHarness.effect");
|
||||||
|
|
||||||
|
interface EffectSlot {
|
||||||
|
[EFFECT]: true;
|
||||||
|
/** Dependencies at the last run, `null` before the first one. */
|
||||||
|
ran: Deps | null;
|
||||||
|
cleanup: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEffect {
|
||||||
|
index: number;
|
||||||
|
fn: EffectFn;
|
||||||
|
deps: Deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookHarness {
|
||||||
|
/** The slots of the mounted component, by call order; a test may preset a state slot before the first render. */
|
||||||
|
slots: unknown[];
|
||||||
|
/** Every action dispatched to any `useReducer` of the component, in order. */
|
||||||
|
dispatch: Mock<(action: unknown) => void>;
|
||||||
|
/** One render of the mounted component: resets the cursor and the captured effects, then calls `fn`. */
|
||||||
|
render<T>(fn: () => T): T;
|
||||||
|
/** Starts a render by hand (cursor at 0, no captured effect), for a test that calls the component itself. */
|
||||||
|
reset(): void;
|
||||||
|
/** Runs the effects of the last render whose dependencies changed, cleanups first; once per render. */
|
||||||
|
commit(): void;
|
||||||
|
/** StrictMode at mount: every cleanup of the last render's effects, then every effect again. */
|
||||||
|
replayEffects(): void;
|
||||||
|
/** Runs every cleanup, as React does when the component goes away. */
|
||||||
|
unmount(): void;
|
||||||
|
/** A fresh mount: no slot, no effect, no recorded action. */
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Internal = HookHarness & {
|
||||||
|
writers: Array<((arg: unknown) => void) | undefined>;
|
||||||
|
next(): number;
|
||||||
|
capture(effect: PendingEffect): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function depsChanged(previous: Deps | null, next: Deps): boolean {
|
||||||
|
if (previous === null || previous === undefined || next === undefined) return true;
|
||||||
|
if (previous.length !== next.length) return true;
|
||||||
|
return next.some((dep, k) => !Object.is(dep, previous[k]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEffectSlot = (slot: unknown): slot is EffectSlot =>
|
||||||
|
typeof slot === "object" && slot !== null && EFFECT in slot;
|
||||||
|
|
||||||
|
/** A harness of its own, for the harness's tests; the mock uses the shared one (`hookHarness()`). */
|
||||||
|
export function createHookHarness(): HookHarness {
|
||||||
|
let cursor = 0;
|
||||||
|
let pending: PendingEffect[] = [];
|
||||||
|
/** A render is committed once, as React does. */
|
||||||
|
let committed = false;
|
||||||
|
|
||||||
|
const run = (effects: PendingEffect[]) => {
|
||||||
|
for (const { index } of effects) {
|
||||||
|
const slot = harness.slots[index] as EffectSlot;
|
||||||
|
const cleanup = slot.cleanup;
|
||||||
|
slot.cleanup = undefined;
|
||||||
|
cleanup?.();
|
||||||
|
}
|
||||||
|
for (const { index, fn, deps } of effects) {
|
||||||
|
const slot = harness.slots[index] as EffectSlot;
|
||||||
|
slot.ran = deps;
|
||||||
|
const cleanup = fn();
|
||||||
|
slot.cleanup = typeof cleanup === "function" ? cleanup : undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const harness: Internal = {
|
||||||
|
slots: [],
|
||||||
|
writers: [],
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
render(fn) {
|
||||||
|
harness.reset();
|
||||||
|
return fn();
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
cursor = 0;
|
||||||
|
pending = [];
|
||||||
|
committed = false;
|
||||||
|
},
|
||||||
|
commit() {
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
run(pending.filter(({ index, deps }) => depsChanged((harness.slots[index] as EffectSlot).ran, deps)));
|
||||||
|
},
|
||||||
|
replayEffects() {
|
||||||
|
run(pending);
|
||||||
|
},
|
||||||
|
unmount() {
|
||||||
|
for (const slot of harness.slots) {
|
||||||
|
if (!isEffectSlot(slot)) continue;
|
||||||
|
const cleanup = slot.cleanup;
|
||||||
|
slot.cleanup = undefined;
|
||||||
|
cleanup?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
harness.slots = [];
|
||||||
|
harness.writers = [];
|
||||||
|
harness.dispatch.mockReset();
|
||||||
|
harness.reset();
|
||||||
|
},
|
||||||
|
next: () => cursor++,
|
||||||
|
capture: (effect) => {
|
||||||
|
pending.push(effect);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = createHookHarness();
|
||||||
|
|
||||||
|
/** The harness the mock of "react" writes to, shared with the test of the same module. */
|
||||||
|
export function hookHarness(): HookHarness {
|
||||||
|
return shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COVERED = new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module that replaces "react": the real one, with the covered hooks
|
||||||
|
* backed by `harness` (the shared one by default) and every other hook
|
||||||
|
* throwing.
|
||||||
|
*/
|
||||||
|
export function fakeReact(actual: typeof React, harness: HookHarness = shared): typeof React {
|
||||||
|
const h = harness as Internal;
|
||||||
|
|
||||||
|
/** Index of this hook's slot, filled by `init` at the first render only. */
|
||||||
|
const slot = (init: () => unknown): number => {
|
||||||
|
const index = h.next();
|
||||||
|
if (!(index in h.slots)) h.slots[index] = init();
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const memo = (compute: () => unknown, deps: Deps): unknown => {
|
||||||
|
const index = slot(() => ({ deps: null as Deps | null, value: undefined as unknown }));
|
||||||
|
const entry = h.slots[index] as { deps: Deps | null; value: unknown };
|
||||||
|
if (depsChanged(entry.deps, deps)) {
|
||||||
|
entry.value = compute();
|
||||||
|
entry.deps = deps;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stable setter or dispatch of a state slot, created at its first use. */
|
||||||
|
const writer = (index: number, make: () => (arg: unknown) => void) => (h.writers[index] ??= make());
|
||||||
|
|
||||||
|
const covered = {
|
||||||
|
useState: (init: unknown) => {
|
||||||
|
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
|
||||||
|
const set = writer(index, () => (next: unknown) => {
|
||||||
|
h.slots[index] =
|
||||||
|
typeof next === "function" ? (next as (previous: unknown) => unknown)(h.slots[index]) : next;
|
||||||
|
});
|
||||||
|
return [h.slots[index], set];
|
||||||
|
},
|
||||||
|
useReducer: (
|
||||||
|
reducer: (state: unknown, action: unknown) => unknown,
|
||||||
|
initialArg: unknown,
|
||||||
|
init?: (arg: unknown) => unknown,
|
||||||
|
) => {
|
||||||
|
const index = slot(() => (init ? init(initialArg) : initialArg));
|
||||||
|
const dispatch = writer(index, () => (action: unknown) => {
|
||||||
|
h.dispatch(action);
|
||||||
|
h.slots[index] = reducer(h.slots[index], action);
|
||||||
|
});
|
||||||
|
return [h.slots[index], dispatch];
|
||||||
|
},
|
||||||
|
useRef: (init: unknown) => h.slots[slot(() => ({ current: init }))],
|
||||||
|
useMemo: (compute: () => unknown, deps: Deps) => memo(compute, deps),
|
||||||
|
useCallback: (fn: unknown, deps: Deps) => memo(() => fn, deps),
|
||||||
|
useEffect: (fn: EffectFn, deps?: Deps) => {
|
||||||
|
const index = slot((): EffectSlot => ({ [EFFECT]: true, ran: null, cleanup: undefined }));
|
||||||
|
h.capture({ index, fn, deps });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const uncovered: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(actual)) {
|
||||||
|
if ((key === "use" || /^use[A-Z]/.test(key)) && !COVERED.has(key)) {
|
||||||
|
uncovered[key] = () => {
|
||||||
|
throw new Error(
|
||||||
|
`hookHarness: ${key} is not simulated; mock the module that calls it, or cover it in src/test/hookHarness.ts`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fake: Record<string, unknown> = { ...actual, ...uncovered, ...covered };
|
||||||
|
// `import React from "react"` then `React.useState`: the default export too.
|
||||||
|
const original = (actual as { default?: unknown }).default;
|
||||||
|
if (original && typeof original === "object") fake.default = { ...original, ...uncovered, ...covered };
|
||||||
|
return fake as unknown as typeof React;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue