test: add a shared React hook harness with its own proofs
src/test/hookHarness.ts gives the tests that call hooks and components as plain functions one fake React with React's semantics: state initialized once with stable setters, the reducer applied with a dispatch spy, refs kept, memos and callbacks recomputed only on a changed dependency, effects run by commit() only when a dependency changed (cleanups first), and every hook it does not cover throws instead of reaching the real React. Each semantic is proved by hookHarness.test.ts, which also checks that the module imports React only as a type. Refs #409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b161d54e3a
commit
86fd496e77
2 changed files with 551 additions and 0 deletions
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