Merge branch 'feat/canvas-magnetism-20260411-082412'
This commit is contained in:
@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
|
||||
|
||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
...overrides,
|
||||
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
||||
return element;
|
||||
}
|
||||
|
||||
function makeHandleElement(args: {
|
||||
nodeId: string;
|
||||
handleType: "source" | "target";
|
||||
handleId?: string;
|
||||
rect: Partial<DOMRect>;
|
||||
}): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.className = "react-flow__handle";
|
||||
element.dataset.nodeId = args.nodeId;
|
||||
element.dataset.handleType = args.handleType;
|
||||
if (args.handleId !== undefined) {
|
||||
element.dataset.handleId = args.handleId;
|
||||
}
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
x: args.rect.left ?? 0,
|
||||
y: args.rect.top ?? 0,
|
||||
top: args.rect.top ?? 0,
|
||||
left: args.rect.left ?? 0,
|
||||
right: args.rect.right ?? 10,
|
||||
bottom: args.rect.bottom ?? 10,
|
||||
width: args.rect.width ?? 10,
|
||||
height: args.rect.height ?? 10,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
document.body.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("resolveDroppedConnectionTarget", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -144,6 +171,169 @@ describe("resolveDroppedConnectionTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves nearest valid target handle even without a node body hit", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const compareNode = createNode({
|
||||
id: "node-compare",
|
||||
type: "compare",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
makeHandleElement({
|
||||
nodeId: "node-compare",
|
||||
handleType: "target",
|
||||
handleId: "left",
|
||||
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||
});
|
||||
makeHandleElement({
|
||||
nodeId: "node-compare",
|
||||
handleType: "target",
|
||||
handleId: "right",
|
||||
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 364, y: 258 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, compareNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-compare",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "left",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a closer invalid handle and picks the nearest valid handle", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const mixerNode = createNode({
|
||||
id: "node-mixer",
|
||||
type: "mixer",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
makeHandleElement({
|
||||
nodeId: "node-mixer",
|
||||
handleType: "target",
|
||||
handleId: "base",
|
||||
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||
});
|
||||
makeHandleElement({
|
||||
nodeId: "node-mixer",
|
||||
handleType: "target",
|
||||
handleId: "overlay",
|
||||
rect: { left: 386, top: 278, width: 12, height: 12, right: 398, bottom: 290 },
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 364, y: 258 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, mixerNode],
|
||||
edges: [
|
||||
createEdge({
|
||||
id: "edge-base-taken",
|
||||
source: "node-source",
|
||||
target: "node-mixer",
|
||||
targetHandle: "base",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-mixer",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "overlay",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the actually nearest handle for compare and mixer targets", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const compareNode = createNode({
|
||||
id: "node-compare",
|
||||
type: "compare",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const mixerNode = createNode({
|
||||
id: "node-mixer",
|
||||
type: "mixer",
|
||||
position: { x: 640, y: 200 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
makeHandleElement({
|
||||
nodeId: "node-compare",
|
||||
handleType: "target",
|
||||
handleId: "left",
|
||||
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||
});
|
||||
makeHandleElement({
|
||||
nodeId: "node-compare",
|
||||
handleType: "target",
|
||||
handleId: "right",
|
||||
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||
});
|
||||
makeHandleElement({
|
||||
nodeId: "node-mixer",
|
||||
handleType: "target",
|
||||
handleId: "base",
|
||||
rect: { left: 678, top: 252, width: 12, height: 12, right: 690, bottom: 264 },
|
||||
});
|
||||
makeHandleElement({
|
||||
nodeId: "node-mixer",
|
||||
handleType: "target",
|
||||
handleId: "overlay",
|
||||
rect: { left: 678, top: 292, width: 12, height: 12, right: 690, bottom: 304 },
|
||||
});
|
||||
|
||||
const compareResult = resolveDroppedConnectionTarget({
|
||||
point: { x: 364, y: 258 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, compareNode, mixerNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const mixerResult = resolveDroppedConnectionTarget({
|
||||
point: { x: 684, y: 299 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, compareNode, mixerNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(compareResult?.targetHandle).toBe("left");
|
||||
expect(mixerResult?.targetHandle).toBe("overlay");
|
||||
});
|
||||
|
||||
it("reverses the connection when the drag starts from a target handle", () => {
|
||||
const droppedNode = createNode({
|
||||
id: "node-dropped",
|
||||
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves nearest source handle when drag starts from target handle", () => {
|
||||
const fromNode = createNode({
|
||||
id: "node-compare-target",
|
||||
type: "compare",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const compareNode = createNode({
|
||||
id: "node-compare-source",
|
||||
type: "compare",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
makeHandleElement({
|
||||
nodeId: "node-compare-source",
|
||||
handleType: "source",
|
||||
handleId: "compare-out",
|
||||
rect: { left: 478, top: 288, width: 12, height: 12, right: 490, bottom: 300 },
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 484, y: 294 },
|
||||
fromNodeId: "node-compare-target",
|
||||
fromHandleId: "left",
|
||||
fromHandleType: "target",
|
||||
nodes: [fromNode, compareNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-compare-source",
|
||||
targetNodeId: "node-compare-target",
|
||||
sourceHandle: "compare-out",
|
||||
targetHandle: "left",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
346
components/canvas/__tests__/canvas-handle.test.tsx
Normal file
346
components/canvas/__tests__/canvas-handle.test.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
HANDLE_GLOW_RADIUS_PX,
|
||||
HANDLE_SNAP_RADIUS_PX,
|
||||
} from "@/components/canvas/canvas-connection-magnetism";
|
||||
import {
|
||||
CanvasConnectionMagnetismProvider,
|
||||
useCanvasConnectionMagnetism,
|
||||
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||
|
||||
const connectionStateRef: {
|
||||
current: {
|
||||
inProgress?: boolean;
|
||||
fromNode?: { id: string };
|
||||
fromHandle?: { id?: string; type?: "source" | "target" };
|
||||
toNode?: { id: string } | null;
|
||||
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
|
||||
isValid?: boolean | null;
|
||||
};
|
||||
} = {
|
||||
current: { inProgress: false },
|
||||
};
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: ({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => <div className={className} style={style} {...props} />,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => connectionStateRef.current,
|
||||
}));
|
||||
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function MagnetTargetSetter({
|
||||
target,
|
||||
}: {
|
||||
target:
|
||||
| {
|
||||
nodeId: string;
|
||||
handleId?: string;
|
||||
handleType: "source" | "target";
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
distancePx: number;
|
||||
}
|
||||
| null;
|
||||
}) {
|
||||
const { setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTarget(target);
|
||||
}, [setActiveTarget, target]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("CanvasHandle", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
connectionStateRef.current = { inProgress: false };
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
document.documentElement.classList.remove("dark");
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
async function renderHandle(args?: {
|
||||
connectionState?: {
|
||||
inProgress?: boolean;
|
||||
fromNode?: { id: string };
|
||||
fromHandle?: { id?: string; type?: "source" | "target" };
|
||||
toNode?: { id: string } | null;
|
||||
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
|
||||
isValid?: boolean | null;
|
||||
};
|
||||
activeTarget?: {
|
||||
nodeId: string;
|
||||
handleId?: string;
|
||||
handleType: "source" | "target";
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
distancePx: number;
|
||||
} | null;
|
||||
props?: Partial<React.ComponentProps<typeof CanvasHandle>>;
|
||||
}) {
|
||||
connectionStateRef.current = args?.connectionState ?? { inProgress: false };
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<MagnetTargetSetter target={args?.activeTarget ?? null} />
|
||||
<CanvasHandle
|
||||
nodeId="node-1"
|
||||
nodeType="image"
|
||||
type="target"
|
||||
position={"left" as React.ComponentProps<typeof CanvasHandle>["position"]}
|
||||
id="image-in"
|
||||
{...args?.props}
|
||||
/>
|
||||
</CanvasConnectionMagnetismProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getHandleElement() {
|
||||
const handle = container?.querySelector("[data-node-id='node-1'][data-handle-type]");
|
||||
if (!(handle instanceof HTMLElement)) {
|
||||
throw new Error("CanvasHandle element not found");
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders default handle chrome with expected size and border", async () => {
|
||||
await renderHandle();
|
||||
|
||||
const handle = getHandleElement();
|
||||
expect(handle.className).toContain("!h-3");
|
||||
expect(handle.className).toContain("!w-3");
|
||||
expect(handle.className).toContain("!border-2");
|
||||
expect(handle.className).toContain("!border-background");
|
||||
expect(handle.getAttribute("data-glow-state")).toBe("idle");
|
||||
});
|
||||
|
||||
it("turns on near-target glow when this handle is active target", async () => {
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 2,
|
||||
},
|
||||
});
|
||||
|
||||
const handle = getHandleElement();
|
||||
expect(handle.getAttribute("data-glow-state")).toBe("near");
|
||||
});
|
||||
|
||||
it("renders a stronger glow in snapped state than near state", async () => {
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 6,
|
||||
},
|
||||
});
|
||||
|
||||
const nearHandle = getHandleElement();
|
||||
const nearGlow = nearHandle.style.boxShadow;
|
||||
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
|
||||
},
|
||||
});
|
||||
|
||||
const snappedHandle = getHandleElement();
|
||||
expect(snappedHandle.getAttribute("data-glow-state")).toBe("snapped");
|
||||
expect(snappedHandle.style.boxShadow).not.toBe(nearGlow);
|
||||
});
|
||||
|
||||
it("ramps up glow intensity as pointer gets closer within glow radius", async () => {
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_GLOW_RADIUS_PX - 1,
|
||||
},
|
||||
});
|
||||
|
||||
const farHandle = getHandleElement();
|
||||
const farStrength = Number(farHandle.getAttribute("data-glow-strength") ?? "0");
|
||||
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const nearHandle = getHandleElement();
|
||||
const nearStrength = Number(nearHandle.getAttribute("data-glow-strength") ?? "0");
|
||||
|
||||
expect(farHandle.getAttribute("data-glow-state")).toBe("near");
|
||||
expect(nearHandle.getAttribute("data-glow-state")).toBe("near");
|
||||
expect(nearStrength).toBeGreaterThan(farStrength);
|
||||
});
|
||||
|
||||
it("does not glow for non-target handles during the same drag", async () => {
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "other-node",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
|
||||
},
|
||||
});
|
||||
|
||||
const handle = getHandleElement();
|
||||
expect(handle.getAttribute("data-glow-state")).toBe("idle");
|
||||
});
|
||||
|
||||
it("shows glow while dragging when connection payload exists without inProgress", async () => {
|
||||
await renderHandle({
|
||||
connectionState: {
|
||||
fromNode: { id: "source-node" },
|
||||
fromHandle: { id: "image-out", type: "source" },
|
||||
},
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 2,
|
||||
},
|
||||
});
|
||||
|
||||
const handle = getHandleElement();
|
||||
expect(handle.getAttribute("data-glow-state")).toBe("near");
|
||||
});
|
||||
|
||||
it("shows glow from native connection hover target even without custom magnet target", async () => {
|
||||
await renderHandle({
|
||||
connectionState: {
|
||||
inProgress: true,
|
||||
isValid: true,
|
||||
toNode: { id: "node-1" },
|
||||
toHandle: { id: "image-in", type: "target" },
|
||||
},
|
||||
activeTarget: null,
|
||||
});
|
||||
|
||||
const handle = getHandleElement();
|
||||
expect(handle.getAttribute("data-glow-state")).toBe("snapped");
|
||||
});
|
||||
|
||||
it("adapts glow rendering between light and dark modes", async () => {
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const lightHandle = getHandleElement();
|
||||
const lightShadow = lightHandle.style.boxShadow;
|
||||
const lightMode = lightHandle.getAttribute("data-glow-mode");
|
||||
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
await renderHandle({
|
||||
connectionState: { inProgress: true },
|
||||
activeTarget: {
|
||||
nodeId: "node-1",
|
||||
handleId: "image-in",
|
||||
handleType: "target",
|
||||
centerX: 120,
|
||||
centerY: 80,
|
||||
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const darkHandle = getHandleElement();
|
||||
const darkShadow = darkHandle.style.boxShadow;
|
||||
const darkMode = darkHandle.getAttribute("data-glow-mode");
|
||||
|
||||
expect(lightMode).toBe("light");
|
||||
expect(darkMode).toBe("dark");
|
||||
expect(darkShadow).not.toBe(lightShadow);
|
||||
});
|
||||
|
||||
it("emits stable handle geometry data attributes", async () => {
|
||||
await renderHandle({
|
||||
props: {
|
||||
nodeId: "node-2",
|
||||
id: undefined,
|
||||
type: "source",
|
||||
position: "right" as React.ComponentProps<typeof CanvasHandle>["position"],
|
||||
},
|
||||
});
|
||||
|
||||
const handle = container?.querySelector("[data-node-id='node-2'][data-handle-type='source']");
|
||||
if (!(handle instanceof HTMLElement)) {
|
||||
throw new Error("CanvasHandle source element not found");
|
||||
}
|
||||
|
||||
expect(handle.getAttribute("data-node-id")).toBe("node-2");
|
||||
expect(handle.getAttribute("data-handle-id")).toBe("");
|
||||
expect(handle.getAttribute("data-handle-type")).toBe("source");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,31 @@ vi.mock("@xyflow/react", () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||
default: ({
|
||||
id,
|
||||
type,
|
||||
nodeId,
|
||||
nodeType,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
type: "source" | "target";
|
||||
nodeId: string;
|
||||
nodeType?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => (
|
||||
<div
|
||||
data-canvas-handle="true"
|
||||
data-handle-id={id ?? ""}
|
||||
data-handle-type={type}
|
||||
data-node-id={nodeId}
|
||||
data-node-type={nodeType ?? ""}
|
||||
data-top={typeof style?.top === "string" ? style.top : ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
@@ -261,4 +286,35 @@ describe("CompareNode render preview inputs", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders compare handles through CanvasHandle with preserved ids and positions", () => {
|
||||
const markup = renderCompareNode({
|
||||
id: "compare-1",
|
||||
data: {},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "compare",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 500,
|
||||
height: 380,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
});
|
||||
|
||||
expect(markup).toContain('data-canvas-handle="true"');
|
||||
expect(markup).toContain('data-node-id="compare-1"');
|
||||
expect(markup).toContain('data-node-type="compare"');
|
||||
expect(markup).toContain('data-handle-id="left"');
|
||||
expect(markup).toContain('data-handle-id="right"');
|
||||
expect(markup).toContain('data-handle-id="compare-out"');
|
||||
expect(markup).toContain('data-handle-type="target"');
|
||||
expect(markup).toContain('data-handle-type="source"');
|
||||
expect(markup).toContain('data-top="35%"');
|
||||
expect(markup).toContain('data-top="55%"');
|
||||
});
|
||||
});
|
||||
|
||||
310
components/canvas/__tests__/custom-connection-line.test.tsx
Normal file
310
components/canvas/__tests__/custom-connection-line.test.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import {
|
||||
ConnectionLineType,
|
||||
Position,
|
||||
type ConnectionLineComponentProps,
|
||||
} from "@xyflow/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
CanvasConnectionMagnetismProvider,
|
||||
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||
import { connectionLineAccentRgb } from "@/lib/canvas-utils";
|
||||
|
||||
const reactFlowStateRef: {
|
||||
current: {
|
||||
nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>;
|
||||
edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>;
|
||||
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => { x: number; y: number };
|
||||
};
|
||||
} = {
|
||||
current: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
||||
},
|
||||
};
|
||||
|
||||
const connectionStateRef: {
|
||||
current: {
|
||||
fromHandle?: { type?: "source" | "target" };
|
||||
};
|
||||
} = {
|
||||
current: {
|
||||
fromHandle: { type: "source" },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: () => ({
|
||||
getNodes: () => reactFlowStateRef.current.nodes,
|
||||
getEdges: () => reactFlowStateRef.current.edges,
|
||||
screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition,
|
||||
}),
|
||||
useConnection: () => connectionStateRef.current,
|
||||
};
|
||||
});
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const baseProps = {
|
||||
connectionLineType: ConnectionLineType.Straight,
|
||||
fromNode: {
|
||||
id: "source-node",
|
||||
type: "image",
|
||||
},
|
||||
fromHandle: {
|
||||
id: "image-out",
|
||||
type: "source",
|
||||
nodeId: "source-node",
|
||||
position: Position.Right,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
},
|
||||
fromX: 20,
|
||||
fromY: 40,
|
||||
toX: 290,
|
||||
toY: 210,
|
||||
fromPosition: Position.Right,
|
||||
toPosition: Position.Left,
|
||||
connectionStatus: "valid",
|
||||
} as unknown as ConnectionLineComponentProps;
|
||||
|
||||
describe("CustomConnectionLine", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
document
|
||||
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
|
||||
.forEach((element) => element.remove());
|
||||
document.documentElement.classList.remove("dark");
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
function renderLine(args?: {
|
||||
withMagnetHandle?: boolean;
|
||||
connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
|
||||
omitFromHandleType?: boolean;
|
||||
toX?: number;
|
||||
toY?: number;
|
||||
pointer?: { x: number; y: number };
|
||||
}) {
|
||||
document
|
||||
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
|
||||
.forEach((element) => element.remove());
|
||||
|
||||
reactFlowStateRef.current = {
|
||||
nodes: [
|
||||
{ id: "source-node", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [],
|
||||
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
||||
};
|
||||
|
||||
connectionStateRef.current = {
|
||||
fromHandle: { type: "source" },
|
||||
};
|
||||
|
||||
if (args?.withMagnetHandle && container) {
|
||||
const handleEl = document.createElement("div");
|
||||
handleEl.setAttribute("data-testid", "custom-line-magnet-handle");
|
||||
handleEl.setAttribute("data-node-id", "target-node");
|
||||
handleEl.setAttribute("data-handle-id", "");
|
||||
handleEl.setAttribute("data-handle-type", "target");
|
||||
handleEl.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 294,
|
||||
y: 214,
|
||||
top: 214,
|
||||
left: 294,
|
||||
right: 306,
|
||||
bottom: 226,
|
||||
width: 12,
|
||||
height: 12,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
document.body.appendChild(handleEl);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
const lineProps = {
|
||||
...baseProps,
|
||||
...(args?.toX !== undefined ? { toX: args.toX } : null),
|
||||
...(args?.toY !== undefined ? { toY: args.toY } : null),
|
||||
...(args?.pointer ? { pointer: args.pointer } : null),
|
||||
fromHandle: {
|
||||
...baseProps.fromHandle,
|
||||
...(args?.omitFromHandleType ? { type: undefined } : null),
|
||||
},
|
||||
} as ConnectionLineComponentProps;
|
||||
|
||||
root?.render(
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<svg>
|
||||
<CustomConnectionLine
|
||||
{...lineProps}
|
||||
connectionStatus={args?.connectionStatus ?? "valid"}
|
||||
/>
|
||||
</svg>
|
||||
</CanvasConnectionMagnetismProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getPath() {
|
||||
const path = container?.querySelector("path");
|
||||
if (!(path instanceof Element) || path.tagName.toLowerCase() !== "path") {
|
||||
throw new Error("Connection line path not rendered");
|
||||
}
|
||||
return path as SVGElement;
|
||||
}
|
||||
|
||||
it("renders with the existing accent color when no magnet target is active", () => {
|
||||
renderLine();
|
||||
|
||||
const [r, g, b] = connectionLineAccentRgb("image", "image-out");
|
||||
const path = getPath();
|
||||
|
||||
expect(path.style.stroke).toBe(`rgb(${r}, ${g}, ${b})`);
|
||||
expect(path.getAttribute("d")).toContain("290");
|
||||
expect(path.getAttribute("d")).toContain("210");
|
||||
});
|
||||
|
||||
it("snaps endpoint to active magnet target center", () => {
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
});
|
||||
|
||||
const path = getPath();
|
||||
expect(path.getAttribute("d")).toContain("300");
|
||||
expect(path.getAttribute("d")).toContain("220");
|
||||
});
|
||||
|
||||
it("still resolves magnet target when fromHandle.type is missing", () => {
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
omitFromHandleType: true,
|
||||
});
|
||||
|
||||
const path = getPath();
|
||||
expect(path.getAttribute("d")).toContain("300");
|
||||
expect(path.getAttribute("d")).toContain("220");
|
||||
});
|
||||
|
||||
it("strengthens stroke visual feedback while snapped", () => {
|
||||
renderLine();
|
||||
const idlePath = getPath();
|
||||
const idleStrokeWidth = idlePath.style.strokeWidth;
|
||||
const idleFilter = idlePath.style.filter;
|
||||
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
});
|
||||
const snappedPath = getPath();
|
||||
|
||||
expect(snappedPath.style.strokeWidth).not.toBe(idleStrokeWidth);
|
||||
expect(snappedPath.style.filter).not.toBe(idleFilter);
|
||||
});
|
||||
|
||||
it("ramps stroke feedback up as pointer gets closer before snap", () => {
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
toX: 252,
|
||||
toY: 220,
|
||||
pointer: { x: 252, y: 220 },
|
||||
});
|
||||
const farNearPath = getPath();
|
||||
const farNearWidth = Number(farNearPath.style.strokeWidth || "0");
|
||||
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
toX: 266,
|
||||
toY: 220,
|
||||
pointer: { x: 266, y: 220 },
|
||||
});
|
||||
const closeNearPath = getPath();
|
||||
const closeNearWidth = Number(closeNearPath.style.strokeWidth || "0");
|
||||
|
||||
expect(farNearWidth).toBeGreaterThan(2.5);
|
||||
expect(closeNearWidth).toBeGreaterThan(farNearWidth);
|
||||
});
|
||||
|
||||
it("keeps invalid connection opacity behavior while snapped", () => {
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
connectionStatus: "invalid",
|
||||
});
|
||||
|
||||
const path = getPath();
|
||||
expect(path.style.opacity).toBe("0.45");
|
||||
});
|
||||
|
||||
it("uses client pointer coordinates for magnet lookup and converts snapped endpoint back to flow space", () => {
|
||||
reactFlowStateRef.current.screenToFlowPosition = ({ x, y }) => ({
|
||||
x: Math.round(x / 10),
|
||||
y: Math.round(y / 10),
|
||||
});
|
||||
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
toX: 29,
|
||||
toY: 21,
|
||||
pointer: { x: 300, y: 220 },
|
||||
});
|
||||
|
||||
const path = getPath();
|
||||
expect(path.getAttribute("d")).toContain("30");
|
||||
expect(path.getAttribute("d")).toContain("22");
|
||||
});
|
||||
|
||||
it("adjusts glow filter between light and dark mode", () => {
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
toX: 266,
|
||||
toY: 220,
|
||||
pointer: { x: 266, y: 220 },
|
||||
});
|
||||
const lightPath = getPath();
|
||||
const lightFilter = lightPath.style.filter;
|
||||
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
renderLine({
|
||||
withMagnetHandle: true,
|
||||
toX: 266,
|
||||
toY: 220,
|
||||
pointer: { x: 266, y: 220 },
|
||||
});
|
||||
const darkPath = getPath();
|
||||
const darkFilter = darkPath.style.filter;
|
||||
|
||||
expect(lightFilter).not.toBe("");
|
||||
expect(darkFilter).not.toBe("");
|
||||
expect(darkFilter).not.toBe(lightFilter);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,31 @@ vi.mock("@xyflow/react", () => ({
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||
default: ({
|
||||
id,
|
||||
type,
|
||||
nodeId,
|
||||
nodeType,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
type: "source" | "target";
|
||||
nodeId: string;
|
||||
nodeType?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => (
|
||||
<div
|
||||
data-canvas-handle="true"
|
||||
data-handle-id={id ?? ""}
|
||||
data-handle-type={type}
|
||||
data-node-id={nodeId}
|
||||
data-node-type={nodeType ?? ""}
|
||||
data-top={typeof style?.top === "string" ? style.top : ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||
@@ -222,8 +247,20 @@ describe("MixerNode", () => {
|
||||
it("renders expected mixer handles", async () => {
|
||||
await renderNode();
|
||||
|
||||
expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="base"][data-handle-type="target"][data-top="35%"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="overlay"][data-handle-type="target"][data-top="58%"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="mixer-out"][data-handle-type="source"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,11 @@ 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 type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDroppedConnectionTarget: vi.fn(),
|
||||
resolveCanvasMagnetTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
@@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-connection-magnetism", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/components/canvas/canvas-connection-magnetism")
|
||||
>("@/components/canvas/canvas-connection-magnetism");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget,
|
||||
};
|
||||
});
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
import {
|
||||
CanvasConnectionMagnetismProvider,
|
||||
useCanvasConnectionMagnetism,
|
||||
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
@@ -35,6 +52,14 @@ const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasConnections> | null;
|
||||
} = { current: null };
|
||||
|
||||
const latestMagnetTargetRef: {
|
||||
current: CanvasMagnetTarget | null;
|
||||
} = { current: null };
|
||||
|
||||
const latestSetActiveTargetRef: {
|
||||
current: ((target: CanvasMagnetTarget | null) => void) | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
@@ -47,9 +72,12 @@ type HookHarnessProps = {
|
||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
initialMagnetTarget?: CanvasMagnetTarget | null;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
type HookHarnessInnerProps = HookHarnessProps;
|
||||
|
||||
function HookHarnessInner({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
@@ -59,7 +87,10 @@ function HookHarness({
|
||||
setEdgesMock,
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
initialMagnetTarget,
|
||||
}: HookHarnessInnerProps) {
|
||||
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
const didInitializeMagnetTargetRef = useRef(false);
|
||||
const [nodes] = useState<RFNode[]>(
|
||||
providedNodes ?? [
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
@@ -88,6 +119,17 @@ function HookHarness({
|
||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||
}, [helperResult]);
|
||||
|
||||
useEffect(() => {
|
||||
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
|
||||
didInitializeMagnetTargetRef.current = true;
|
||||
setActiveTarget(initialMagnetTarget);
|
||||
}
|
||||
}, [initialMagnetTarget, setActiveTarget]);
|
||||
|
||||
const handlers = useCanvasConnections({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
@@ -115,15 +157,36 @@ function HookHarness({
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
latestMagnetTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSetActiveTargetRef.current = setActiveTarget;
|
||||
return () => {
|
||||
latestSetActiveTargetRef.current = null;
|
||||
};
|
||||
}, [setActiveTarget]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function HookHarness(props: HookHarnessProps) {
|
||||
return (
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<HookHarnessInner {...props} />
|
||||
</CanvasConnectionMagnetismProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCanvasConnections", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
latestMagnetTargetRef.current = null;
|
||||
latestSetActiveTargetRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
|
||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
});
|
||||
|
||||
it("falls back to active magnet target when direct drop resolution misses", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleId: "base",
|
||||
handleType: "target",
|
||||
centerX: 320,
|
||||
centerY: 180,
|
||||
distancePx: 12,
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "base",
|
||||
});
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid active magnet target and clears transient state", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-source",
|
||||
handleType: "target",
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
distancePx: 10,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 120, clientY: 120 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 120, y: 120 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("clears transient magnet state when dropping on background opens menu", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleType: "target",
|
||||
centerX: 200,
|
||||
centerY: 220,
|
||||
distancePx: 14,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 500, clientY: 460 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 500, y: 460 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
|
||||
expect.objectContaining({
|
||||
screenX: 500,
|
||||
screenY: 460,
|
||||
}),
|
||||
);
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("clears transient magnet state when reconnect drag ends", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
]}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleType: "target",
|
||||
handleId: "overlay",
|
||||
centerX: 300,
|
||||
centerY: 180,
|
||||
distancePx: 11,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = {
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
} as RFEdge;
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
sourceHandle: null,
|
||||
targetHandle: "overlay",
|
||||
});
|
||||
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalled();
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,6 +699,7 @@ describe("favorite retention in strict local node flows", () => {
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
const importedModule = (await import(modulePath)) as {
|
||||
|
||||
Reference in New Issue
Block a user