Simpl-Resultat/src/test/hookHarness.test.ts
le king fu 86fd496e77 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>
2026-10-12 19:44:36 -04:00

286 lines
9.2 KiB
TypeScript

// 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);
});
});