/**
* @file Tests for NodeEditor context - validates node and connection CRUD operations
*/
import { render, screen, act } from "@testing-library/react";
import {
useEffect,
useRef,
useState,
memo,
type Dispatch,
type FC,
type MutableRefObject,
type SetStateAction,
} from "react";
import { NodeEditorProvider } from "./provider";
import { useNodeEditor } from "./context";
import { nodeEditorActions } from "./actions";
import { nodeEditorReducer } from "./reducer";
import type { NodeEditorData } from "../../../types/core";
import { NodeDefinitionProvider } from "../../node-definitions/provider";
import { asNodeDefinition, type NodeDefinition } from "../../../types/NodeDefinition";
import { StandardNodeDefinition } from "../../../node-definitions/standard";
const makeBasicData = (): NodeEditorData => ({
nodes: {
n1: {
id: "n1",
type: "standard",
position: { x: 0, y: 0 },
data: { title: "Node 1", content: "c1" },
},
},
connections: {},
});
const testNodeDefinitions: NodeDefinition[] = [asNodeDefinition(StandardNodeDefinition)];
const withNodeDefinitions = (children: React.ReactNode): React.ReactElement => (
{children}
);
const UncontrolledHarness: FC = () => {
const { state, actions } = useNodeEditor();
const hasRunRef = useRef(false);
useEffect(() => {
if (!hasRunRef.current) {
hasRunRef.current = true;
actions.updateNode("n1", { position: { x: 10, y: 20 } });
}
}, [actions]);
return (
{state.nodes.n1?.position.x}
{state.nodes.n1?.position.y}
);
};
const ControlledHarness: FC = () => {
const { actions, state } = useNodeEditor();
const hasRunRef = useRef(false);
useEffect(() => {
if (!hasRunRef.current) {
hasRunRef.current = true;
actions.updateNode("n1", { position: { x: 99, y: 77 } });
}
}, [actions]);
return (
{state.nodes.n1?.position.x}
{state.nodes.n1?.position.y}
);
};
type ControlledStabilityHarnessProps = {
onReady: (controls: ControlledStabilityControls) => void;
};
type ControlledStabilityControls = {
setData: Dispatch>;
getRenderCount: () => number;
getStateRef: () => NodeEditorData | null;
};
const ControlledStabilityConsumer: FC<{
renderCountRef: MutableRefObject;
stateRef: MutableRefObject;
}> = memo(({ renderCountRef, stateRef }) => {
const { state } = useNodeEditor();
renderCountRef.current += 1;
stateRef.current = state;
return null;
});
const ControlledStabilityHarness: FC = ({ onReady }) => {
const [data, setData] = useState(makeBasicData());
const renderCountRef = useRef(0);
const stateRef = useRef(null);
useEffect(() => {
onReady({
setData,
getRenderCount: () => renderCountRef.current,
getStateRef: () => stateRef.current,
});
}, [onReady, setData]);
return withNodeDefinitions(
{}}>
,
);
};
const ControlledSequentialUpdateHarness: FC = () => {
const [data, setData] = useState(makeBasicData());
const [callCount, setCallCount] = useState(0);
return withNodeDefinitions(
{
setCallCount((count) => count + 1);
setData(next);
}}
>
{String(callCount)}
{String(Boolean(data.nodes.temp))}
{data.nodes.temp ? `${data.nodes.temp.position.x},${data.nodes.temp.position.y}` : "none"}
,
);
};
const ControlledSequentialUpdater: FC = () => {
const { actions } = useNodeEditor();
const hasRunRef = useRef(false);
useEffect(() => {
if (hasRunRef.current) {
return;
}
hasRunRef.current = true;
actions.addNodeWithId({
id: "temp",
type: "standard",
position: { x: 0, y: 0 },
data: { title: "Temp" },
});
actions.moveNode("temp", { x: 5, y: 7 });
}, [actions]);
return null;
};
const ControlledPreferenceProbe: FC<{ label: string }> = ({ label }) => {
const { state } = useNodeEditor();
return (
{Object.values(state.nodes)
.map((node) => node.data.title || node.id)
.join(",")}
);
};
const ControlledNoParentUpdateHarness: FC = () => {
const { actions, state } = useNodeEditor();
const hasRunRef = useRef(false);
useEffect(() => {
if (hasRunRef.current) {
return;
}
hasRunRef.current = true;
actions.moveNode("n1", { x: 10, y: 20 });
actions.updateNode("n1", { size: { width: 300, height: 120 } });
}, [actions]);
const node = state.nodes.n1;
return (
{node ? `${node.position.x},${node.position.y}` : "none"}
{node?.size?.width ?? "none"}
);
};
describe("NodeEditorContext reducer - updates", () => {
it("UPDATE_NODE merges shallowly and preserves other fields", () => {
const initial = makeBasicData();
const next = nodeEditorReducer(initial, nodeEditorActions.updateNode("n1", { position: { x: 5, y: 6 } }));
expect(next.nodes.n1.position).toEqual({ x: 5, y: 6 });
expect(next.nodes.n1.data).toEqual(initial.nodes.n1.data);
const next2 = nodeEditorReducer(next, nodeEditorActions.updateNode("n1", { data: { title: "Changed" } }));
expect(next2.nodes.n1.data).toEqual({ title: "Changed" });
});
it("MOVE_NODES updates multiple positions", () => {
const initial: NodeEditorData = {
nodes: {
a: { id: "a", type: "standard", position: { x: 0, y: 0 }, data: {} },
b: { id: "b", type: "standard", position: { x: 1, y: 2 }, data: {} },
},
connections: {},
};
const updated = nodeEditorReducer(
initial,
nodeEditorActions.moveNodes({ a: { x: 10, y: 11 }, b: { x: 20, y: 21 } }),
);
expect(updated.nodes.a.position).toEqual({ x: 10, y: 11 });
expect(updated.nodes.b.position).toEqual({ x: 20, y: 21 });
});
it("DELETE_NODE removes related connections", () => {
const initial: NodeEditorData = {
nodes: {
a: { id: "a", type: "standard", position: { x: 0, y: 0 }, data: {} },
b: { id: "b", type: "standard", position: { x: 0, y: 0 }, data: {} },
},
connections: {
c1: { id: "c1", fromNodeId: "a", fromPortId: "o", toNodeId: "b", toPortId: "i" },
c2: { id: "c2", fromNodeId: "b", fromPortId: "o", toNodeId: "a", toPortId: "i" },
},
};
const updated = nodeEditorReducer(initial, nodeEditorActions.deleteNode("a"));
expect(updated.nodes.a).toBeUndefined();
expect(updated.connections.c1).toBeUndefined();
expect(updated.connections.c2).toBeUndefined();
});
it("ADD_CONNECTION does not enforce connection limits (validation is upstream)", () => {
const initial: NodeEditorData = {
nodes: {
a: { id: "a", type: "t", position: { x: 0, y: 0 }, data: {} },
b: { id: "b", type: "t", position: { x: 0, y: 0 }, data: {} },
c: { id: "c", type: "t", position: { x: 0, y: 0 }, data: {} },
},
connections: {},
};
const s1 = nodeEditorReducer(
initial,
nodeEditorActions.addConnection({ fromNodeId: "a", fromPortId: "out", toNodeId: "c", toPortId: "in" }),
);
const s2 = nodeEditorReducer(
s1,
nodeEditorActions.addConnection({ fromNodeId: "b", fromPortId: "out", toNodeId: "c", toPortId: "in" }),
);
const conns = Object.values(s2.connections);
expect(conns.length).toBe(2);
});
it("DUPLICATE_NODES creates offset copy with 'Copy' title and tracks lastDuplicatedNodeIds", () => {
const initial: NodeEditorData = {
nodes: { n1: { id: "n1", type: "t", position: { x: 10, y: 20 }, data: { title: "Original" } } },
connections: {},
};
const next = nodeEditorReducer(initial, nodeEditorActions.duplicateNodes(["n1"]));
const added = Object.values(next.nodes).filter((n) => n.id !== "n1");
expect(added.length).toBe(1);
const dup = added[0];
expect(dup.position).toEqual({ x: 60, y: 70 });
expect(String(dup.data.title)).toMatch(/Copy$/);
expect(next.lastDuplicatedNodeIds?.[0]).toBe(dup.id);
});
it("GROUP_NODES adds a group node bounding children and listing them", () => {
const initial: NodeEditorData = {
nodes: {
a: { id: "a", type: "t", position: { x: 100, y: 100 }, size: { width: 100, height: 50 }, data: {} },
b: { id: "b", type: "t", position: { x: 250, y: 180 }, size: { width: 120, height: 60 }, data: {} },
},
connections: {},
};
const next = nodeEditorReducer(initial, nodeEditorActions.groupNodes(["a", "b"]));
const groups = Object.values(next.nodes).filter((n) => n.type === "group");
expect(groups.length).toBe(1);
const g = groups[0];
expect(g.children).toEqual(["a", "b"]);
expect(g.size?.width).toBeGreaterThan(0);
expect(g.size?.height).toBeGreaterThan(0);
});
});
describe("NodeEditorProvider - uncontrolled vs controlled updates", () => {
it("uncontrolled: dispatch mutates internal state", async () => {
await act(async () => {
render(
withNodeDefinitions(
,
),
);
});
expect(screen.getByTestId("pos-x").textContent).toBe("10");
expect(screen.getByTestId("pos-y").textContent).toBe("20");
});
it("controlled: dispatch calls onDataChange with new state and does not mutate context state directly", async () => {
const onChangeCalls: NodeEditorData[] = [];
const onChange = (value: NodeEditorData): void => {
onChangeCalls.push(value);
};
const data = makeBasicData();
await act(async () => {
render(
withNodeDefinitions(
,
),
);
});
expect(screen.getByTestId("pos-x").textContent).toBe("99");
expect(screen.getByTestId("pos-y").textContent).toBe("77");
expect(onChangeCalls.length).toBeGreaterThan(0);
const lastCallArg = onChangeCalls.at(-1);
if (!lastCallArg) {
throw new Error("Expected onDataChange to be called at least once");
}
expect(lastCallArg.nodes.n1.position).toEqual({ x: 99, y: 77 });
});
it("treats presence of controlledData like React value prop even when initialState is provided", async () => {
const controlled = makeBasicData();
const initial: NodeEditorData = {
nodes: {
n1: { id: "n1", type: "standard", position: { x: 50, y: 50 }, data: { title: "Initial Should Ignore" } },
},
connections: {},
};
const { rerender } = render(
withNodeDefinitions(
,
),
);
expect(screen.getByTestId("controlled-probe-first").textContent).toContain("Node 1");
expect(screen.getByTestId("controlled-probe-first").textContent).not.toContain("Initial Should Ignore");
const updatedInitial: NodeEditorData = {
nodes: {
n1: { id: "n1", type: "standard", position: { x: 75, y: 75 }, data: { title: "Updated Initial" } },
},
connections: {},
};
rerender(
withNodeDefinitions(
,
),
);
expect(screen.getByTestId("controlled-probe-second").textContent).toContain("Node 1");
expect(screen.getByTestId("controlled-probe-second").textContent).not.toContain("Updated Initial");
});
});
describe("controlled data stability", () => {
it("reuses previous state reference when controlled data is structurally unchanged", async () => {
const controlsRef = { current: null as ControlledStabilityControls | null };
await act(async () => {
render( (controlsRef.current = controls)} />);
});
const controls = controlsRef.current;
if (!controls) {
throw new Error("Expected stability harness to provide controls");
}
const initialState = controls.getStateRef();
if (!initialState) {
throw new Error("Expected initial state to be available");
}
expect(controls.getRenderCount()).toBe(1);
await act(async () => {
controls.setData((current) => ({
nodes: { ...current.nodes },
connections: { ...current.connections },
}));
});
expect(controls.getRenderCount()).toBe(1);
expect(controls.getStateRef()).toBe(initialState);
await act(async () => {
controls.setData({
nodes: {
...initialState.nodes,
n1: { ...initialState.nodes.n1, position: { x: 42, y: 24 } },
},
connections: initialState.connections,
});
});
expect(controls.getRenderCount()).toBe(2);
const updatedState = controls.getStateRef();
if (!updatedState) {
throw new Error("Expected updated state to be available");
}
expect(updatedState.nodes.n1.position).toEqual({ x: 42, y: 24 });
});
});
describe("controlled rapid updates", () => {
it("does not roll back when multiple controlled actions occur before parent re-renders", async () => {
await act(async () => {
render();
});
expect(screen.getByTestId("call-count").textContent).toBe("2");
expect(screen.getByTestId("temp-exists").textContent).toBe("true");
expect(screen.getByTestId("temp-pos").textContent).toBe("5,7");
});
it("keeps latest state even when parent does not immediately reapply controlled data", async () => {
await act(async () => {
render(
withNodeDefinitions(
{}}>
,
),
);
});
expect(screen.getByTestId("no-parent-pos").textContent).toBe("10,20");
expect(screen.getByTestId("no-parent-size").textContent).toBe("300");
});
});
describe("onDataChange loop prevention", () => {
it("uncontrolled: parent setState in onDataChange does not cause infinite loops", async () => {
const initial = makeBasicData();
const Parent: FC = () => {
const [mirror, setMirror] = useState(null);
const callsRef = useRef(0);
return withNodeDefinitions(
{
callsRef.current += 1;
setMirror(d);
}}
>
{String(callsRef.current)}
{String(mirror === null)}
,
);
};
await act(async () => {
render();
});
const calls = Number(screen.getByTestId("calls").textContent);
expect(calls).toBeGreaterThanOrEqual(1);
expect(calls).toBeLessThan(5);
expect(screen.getByTestId("mirror-null").textContent).toBe("false");
});
it("controlled: onDataChange updates parent and does not re-trigger without new dispatch", async () => {
const initial = makeBasicData();
const Parent: FC = () => {
const [data, setData] = useState(initial);
const callsRef = useRef(0);
return withNodeDefinitions(
{
callsRef.current += 1;
setData(d);
}}
>
{String(callsRef.current)}
,
);
};
await act(async () => {
render();
});
const calls = Number(screen.getByTestId("calls").textContent);
expect(calls).toBe(1);
});
});