fix(canvas): reject invalid edge splits before mutation

This commit is contained in:
2026-04-03 22:38:20 +02:00
parent 99cd1b5233
commit 81edfa6da7
5 changed files with 271 additions and 42 deletions

View File

@@ -0,0 +1,162 @@
// @vitest-environment jsdom
import React, { act, useEffect, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasNodeInteractions } from "@/components/canvas/use-canvas-node-interactions";
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
vi.mock("@/components/canvas/canvas-helpers", async () => {
const actual = await vi.importActual<
typeof import("@/components/canvas/canvas-helpers")
>("@/components/canvas/canvas-helpers");
return {
...actual,
getNodeCenterClientPosition: vi.fn(() => ({ x: 240, y: 140 })),
getIntersectedEdgeId: vi.fn(() => "edge-image-curves"),
};
});
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
type HarnessProps = {
nodes: RFNode[];
edges: RFEdge[];
runMoveNodeMutation: ReturnType<typeof vi.fn>;
runBatchMoveNodesMutation: ReturnType<typeof vi.fn>;
runResizeNodeMutation: ReturnType<typeof vi.fn>;
runSplitEdgeAtExistingNodeMutation: ReturnType<typeof vi.fn>;
onInvalidConnection: ReturnType<typeof vi.fn<(reason: CanvasConnectionValidationReason) => void>>;
syncPendingMoveForClientRequest: ReturnType<typeof vi.fn>;
};
const latestHandlersRef: {
current: ReturnType<typeof useCanvasNodeInteractions> | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HookHarness(props: HarnessProps) {
const [, setNodes] = useState<RFNode[]>(props.nodes);
const [edges, setEdges] = useState<RFEdge[]>(props.edges);
const isDragging = useRef(false);
const isResizing = useRef(false);
const pendingLocalPositionUntilConvexMatchesRef = useRef(new Map());
const preferLocalPositionNodeIdsRef = useRef(new Set<string>());
const pendingMoveAfterCreateRef = useRef(new Map());
const resolvedRealIdByClientRequestRef = useRef(new Map());
const pendingEdgeSplitByClientRequestRef = useRef(new Map());
const handlers = useCanvasNodeInteractions({
canvasId: asCanvasId("canvas-1"),
nodes: props.nodes,
edges,
setNodes,
setEdges,
refs: {
isDragging,
isResizing,
pendingLocalPositionUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef,
pendingMoveAfterCreateRef,
resolvedRealIdByClientRequestRef,
pendingEdgeSplitByClientRequestRef,
},
runResizeNodeMutation: props.runResizeNodeMutation,
runMoveNodeMutation: props.runMoveNodeMutation,
runBatchMoveNodesMutation: props.runBatchMoveNodesMutation,
runSplitEdgeAtExistingNodeMutation: props.runSplitEdgeAtExistingNodeMutation,
onInvalidConnection: props.onInvalidConnection,
syncPendingMoveForClientRequest: props.syncPendingMoveForClientRequest,
});
useEffect(() => {
latestHandlersRef.current = handlers;
}, [handlers]);
return null;
}
describe("useCanvasNodeInteractions", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
latestHandlersRef.current = null;
vi.clearAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("does not call splitEdgeAtExistingNode for an invalid drag-split", async () => {
const runMoveNodeMutation = vi.fn(async () => undefined);
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const runResizeNodeMutation = vi.fn(async () => undefined);
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
const onInvalidConnection = vi.fn<(reason: CanvasConnectionValidationReason) => void>();
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const draggedNode: RFNode = {
id: "node-video",
type: "video",
position: { x: 320, y: 180 },
data: {},
};
await act(async () => {
root?.render(
<HookHarness
nodes={[
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-curves", type: "curves", position: { x: 400, y: 120 }, data: {} },
draggedNode,
]}
edges={[
{
id: "edge-image-curves",
source: "node-image",
target: "node-curves",
},
]}
runMoveNodeMutation={runMoveNodeMutation}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
runResizeNodeMutation={runResizeNodeMutation}
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
onInvalidConnection={onInvalidConnection}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onNodeDrag({} as React.MouseEvent, draggedNode);
latestHandlersRef.current?.onNodeDragStop(
{} as React.MouseEvent,
draggedNode,
[draggedNode],
);
});
expect(runSplitEdgeAtExistingNodeMutation).not.toHaveBeenCalled();
expect(onInvalidConnection).toHaveBeenCalledWith("adjustment-source-invalid");
expect(runMoveNodeMutation).toHaveBeenCalledWith({
nodeId: "node-video",
positionX: 320,
positionY: 180,
});
});
});