// @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 & { className?: string; style?: React.CSSProperties; }) =>
, 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>; }) { connectionStateRef.current = args?.connectionState ?? { inProgress: false }; await act(async () => { root?.render( ["position"]} id="image-in" {...args?.props} /> , ); }); } 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["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"); }); });