feat(canvas): enhance edge insertion and local node data handling
- Added support for new edge insertion features, including default edge types and improved layout calculations. - Introduced local node data persistence during flow reconciliation to ensure data integrity. - Updated connection drop menu to handle edge insertions and node interactions more effectively. - Enhanced testing for edge insert layout and local node data management.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CanvasConnectionDropMenu } from "@/components/canvas/canvas-connection-drop-menu";
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
|
||||
vi.mock("@/components/ui/command", () => ({
|
||||
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandInput: () => <input aria-label="command-input" />,
|
||||
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-node-template-picker", () => ({
|
||||
CanvasNodeTemplatePicker: ({
|
||||
onPick,
|
||||
}: {
|
||||
onPick: (template: CanvasNodeTemplate) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-template"
|
||||
onClick={() => onPick({ type: "note", label: "Notiz" } as CanvasNodeTemplate)}
|
||||
>
|
||||
pick
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CanvasConnectionDropMenu", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders using a generic anchor and forwards template picks", () => {
|
||||
const onPick = vi.fn<(template: CanvasNodeTemplate) => void>();
|
||||
const onClose = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CanvasConnectionDropMenu
|
||||
anchor={{ screenX: 140, screenY: 200 }}
|
||||
onClose={onClose}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
|
||||
const pickButton = document.querySelector('[data-testid="pick-template"]');
|
||||
if (!(pickButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Template pick button was not rendered");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
pickButton.click();
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -256,6 +256,96 @@ describe("canvas flow reconciliation helpers", () => {
|
||||
expect(result.nextPendingLocalPositionPins.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps pinned local node data until convex catches up", () => {
|
||||
const pinnedData = { blackPoint: 209, whitePoint: 255 };
|
||||
const staleIncomingData = { blackPoint: 124, whitePoint: 255 };
|
||||
|
||||
const result = reconcileCanvasFlowNodes({
|
||||
previousNodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: pinnedData,
|
||||
},
|
||||
],
|
||||
incomingNodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: staleIncomingData,
|
||||
},
|
||||
],
|
||||
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
|
||||
deletingNodeIds: new Set(),
|
||||
resolvedRealIdByClientRequest: new Map(),
|
||||
pendingConnectionCreateIds: new Set(),
|
||||
preferLocalPositionNodeIds: new Set(),
|
||||
pendingLocalPositionPins: new Map(),
|
||||
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
|
||||
pendingMovePins: new Map(),
|
||||
});
|
||||
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: pinnedData,
|
||||
},
|
||||
]);
|
||||
expect(result.nextPendingLocalNodeDataPins).toEqual(
|
||||
new Map([["node-1", pinnedData]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears pinned local node data once incoming data includes the persisted values", () => {
|
||||
const pinnedData = { blackPoint: 209, whitePoint: 255 };
|
||||
const incomingData = {
|
||||
blackPoint: 209,
|
||||
whitePoint: 255,
|
||||
_status: "idle",
|
||||
};
|
||||
|
||||
const result = reconcileCanvasFlowNodes({
|
||||
previousNodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: pinnedData,
|
||||
},
|
||||
],
|
||||
incomingNodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: incomingData,
|
||||
},
|
||||
],
|
||||
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
|
||||
deletingNodeIds: new Set(),
|
||||
resolvedRealIdByClientRequest: new Map(),
|
||||
pendingConnectionCreateIds: new Set(),
|
||||
preferLocalPositionNodeIds: new Set(),
|
||||
pendingLocalPositionPins: new Map(),
|
||||
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
|
||||
pendingMovePins: new Map(),
|
||||
});
|
||||
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 80 },
|
||||
data: incomingData,
|
||||
},
|
||||
]);
|
||||
expect(result.nextPendingLocalNodeDataPins.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters deleting nodes from incoming reconciliation results", () => {
|
||||
const result = reconcileCanvasFlowNodes({
|
||||
previousNodes: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import { withResolvedCompareData } from "../canvas-helpers";
|
||||
import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
pruneCanvasGraphNodeDataOverrides,
|
||||
@@ -310,3 +310,107 @@ describe("canvas preview graph helpers", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEdgeInsertLayout", () => {
|
||||
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
position: { x: 0, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
position: { x: 120, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 80,
|
||||
newNodeHeight: 40,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(layout.insertPosition).toEqual({ x: 70, y: 10 });
|
||||
expect(layout.sourcePosition).toEqual({ x: -40, y: 0 });
|
||||
expect(layout.targetPosition).toEqual({ x: 160, y: 0 });
|
||||
});
|
||||
|
||||
it("keeps diagonal-axis spacing adjustments aligned to the edge direction", () => {
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
position: { x: 0, y: 0 },
|
||||
style: { width: 100, height: 100 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
position: { x: 100, y: 100 },
|
||||
style: { width: 100, height: 100 },
|
||||
});
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 80,
|
||||
newNodeHeight: 80,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(layout.insertPosition).toEqual({ x: 60, y: 60 });
|
||||
expect(layout.sourcePosition).toBeDefined();
|
||||
expect(layout.targetPosition).toBeDefined();
|
||||
expect(layout.sourcePosition?.x).toBeCloseTo(layout.sourcePosition?.y ?? 0, 6);
|
||||
expect(layout.targetPosition?.x).toBeCloseTo(layout.targetPosition?.y ?? 0, 6);
|
||||
expect(layout.sourcePosition?.x).toBeLessThan(source.position.x);
|
||||
expect(layout.targetPosition?.x).toBeGreaterThan(target.position.x);
|
||||
});
|
||||
|
||||
it("does not shift source or target when there is enough spacing", () => {
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
position: { x: 0, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
position: { x: 320, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 80,
|
||||
newNodeHeight: 40,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(layout.insertPosition).toEqual({ x: 170, y: 10 });
|
||||
expect(layout.sourcePosition).toBeUndefined();
|
||||
expect(layout.targetPosition).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to midpoint placement without aggressive shifts in degenerate cases", () => {
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
position: { x: 40, y: 80 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
position: { x: 40, y: 80 },
|
||||
});
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 30,
|
||||
newNodeHeight: 10,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(layout.insertPosition).toEqual({ x: 25, y: 75 });
|
||||
expect(layout.sourcePosition).toBeUndefined();
|
||||
expect(layout.targetPosition).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
188
components/canvas/__tests__/default-edge.test.tsx
Normal file
188
components/canvas/__tests__/default-edge.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Position } from "@xyflow/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import DefaultEdge from "@/components/canvas/edges/default-edge";
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual<typeof import("@xyflow/react")>(
|
||||
"@xyflow/react",
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => (
|
||||
<foreignObject>{children}</foreignObject>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type EdgeInsertAnchor = {
|
||||
edgeId: string;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
type DefaultEdgeRenderProps = {
|
||||
id: string;
|
||||
edgeId?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
sourcePosition: Position;
|
||||
targetPosition: Position;
|
||||
isMenuOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
onInsertClick?: (anchor: EdgeInsertAnchor) => void;
|
||||
};
|
||||
|
||||
const DefaultEdgeComponent = DefaultEdge as unknown as (
|
||||
props: DefaultEdgeRenderProps,
|
||||
) => React.JSX.Element;
|
||||
|
||||
const baseProps: DefaultEdgeRenderProps = {
|
||||
id: "edge-1",
|
||||
edgeId: "edge-1",
|
||||
source: "node-a",
|
||||
target: "node-b",
|
||||
sourceX: 40,
|
||||
sourceY: 80,
|
||||
targetX: 260,
|
||||
targetY: 80,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
|
||||
function renderEdge(overrides: Partial<DefaultEdgeRenderProps> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<svg>
|
||||
<DefaultEdgeComponent {...baseProps} {...overrides} />
|
||||
</svg>,
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function getInsertButton(container: HTMLDivElement): HTMLButtonElement {
|
||||
const button = container.querySelector(
|
||||
'[data-testid="default-edge-insert-button"]',
|
||||
);
|
||||
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error("Insert button was not rendered");
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("DefaultEdge", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps plus hidden initially and shows it on hover and when menu is open", () => {
|
||||
const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>();
|
||||
({ container, root } = renderEdge({ onInsertClick }));
|
||||
|
||||
const insertButton = getInsertButton(container);
|
||||
expect(insertButton.getAttribute("data-visible")).toBe("false");
|
||||
|
||||
const edgeContainer = container.querySelector('[data-testid="default-edge"]');
|
||||
if (!(edgeContainer instanceof Element)) {
|
||||
throw new Error("Edge container was not rendered");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
edgeContainer.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(insertButton.getAttribute("data-visible")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<svg>
|
||||
<DefaultEdgeComponent {...baseProps} onInsertClick={onInsertClick} isMenuOpen />
|
||||
</svg>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(insertButton.getAttribute("data-visible")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onInsertClick with edge id and anchor screen coordinates", () => {
|
||||
const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>();
|
||||
({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true }));
|
||||
|
||||
const insertButton = getInsertButton(container);
|
||||
vi.spyOn(insertButton, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 200,
|
||||
left: 100,
|
||||
right: 160,
|
||||
bottom: 260,
|
||||
width: 60,
|
||||
height: 60,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
act(() => {
|
||||
insertButton.click();
|
||||
});
|
||||
|
||||
expect(onInsertClick).toHaveBeenCalledWith({
|
||||
edgeId: "edge-1",
|
||||
screenX: 130,
|
||||
screenY: 230,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses insert interaction in disabled mode", () => {
|
||||
const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>();
|
||||
({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true, disabled: true }));
|
||||
|
||||
const insertButton = getInsertButton(container);
|
||||
expect(insertButton.disabled).toBe(true);
|
||||
expect(insertButton.getAttribute("data-visible")).toBe("false");
|
||||
|
||||
act(() => {
|
||||
insertButton.click();
|
||||
});
|
||||
|
||||
expect(onInsertClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the edge path", () => {
|
||||
({ container, root } = renderEdge());
|
||||
|
||||
const edgePath = container.querySelector("path.react-flow__edge-path");
|
||||
expect(edgePath).not.toBeNull();
|
||||
expect(edgePath?.getAttribute("d")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -44,19 +44,27 @@ const latestHandlersRef: {
|
||||
type HookHarnessProps = {
|
||||
helperResult: DroppedConnectionTarget | null;
|
||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
const [nodes] = useState<RFNode[]>([
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
|
||||
]);
|
||||
const [edges] = useState<RFEdge[]>([]);
|
||||
const [nodes] = useState<RFNode[]>(
|
||||
providedNodes ?? [
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
|
||||
],
|
||||
);
|
||||
const [edges] = useState<RFEdge[]>(providedEdges ?? []);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
const edgeReconnectSuccessful = useRef(true);
|
||||
@@ -90,9 +98,10 @@ function HookHarness({
|
||||
resolvedRealIdByClientRequestRef,
|
||||
setEdges,
|
||||
setEdgeSyncNonce,
|
||||
screenToFlowPosition: (position) => position,
|
||||
screenToFlowPosition: (position: { x: number; y: number }) => position,
|
||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||
@@ -346,6 +355,83 @@ describe("useCanvasConnections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("splits the existing incoming edge when dropping onto an already-connected adjustment node", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runSplitEdgeAtExistingNodeMutation = 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={{
|
||||
sourceNodeId: "node-curves",
|
||||
targetNodeId: "node-light",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
nodes={[
|
||||
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-curves", type: "curves", position: { x: 180, y: 120 }, data: {} },
|
||||
{ id: "node-light", type: "light-adjust", position: { x: 360, y: 120 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-image-light",
|
||||
source: "node-image",
|
||||
target: "node-light",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-curves",
|
||||
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-curves", type: "curves" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
splitEdgeId: "edge-image-light",
|
||||
middleNodeId: "node-curves",
|
||||
splitSourceHandle: undefined,
|
||||
splitTargetHandle: undefined,
|
||||
newNodeSourceHandle: undefined,
|
||||
newNodeTargetHandle: undefined,
|
||||
});
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores onConnectEnd when no connect drag is active", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Edge as RFEdge } from "@xyflow/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -33,26 +34,32 @@ type HookHarnessProps = {
|
||||
isSyncOnline?: boolean;
|
||||
generateUploadUrl?: ReturnType<typeof vi.fn>;
|
||||
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
|
||||
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
|
||||
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
|
||||
edges?: RFEdge[];
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
isSyncOnline = true,
|
||||
generateUploadUrl = vi.fn(async () => "https://upload.test"),
|
||||
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
|
||||
notifyOfflineUnsupported = vi.fn(),
|
||||
syncPendingMoveForClientRequest = vi.fn(async () => undefined),
|
||||
screenToFlowPosition = (position) => position,
|
||||
edges = [],
|
||||
}: HookHarnessProps) {
|
||||
const handlers = useCanvasDrop({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
isSyncOnline,
|
||||
t: ((key: string) => key) as (key: string) => string,
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
syncPendingMoveForClientRequest,
|
||||
});
|
||||
@@ -260,6 +267,72 @@ describe("useCanvasDrop", () => {
|
||||
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
|
||||
});
|
||||
|
||||
it("splits an intersected persisted edge for sidebar node drops", async () => {
|
||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-note");
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note");
|
||||
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
|
||||
const edgeContainer = document.createElement("g");
|
||||
edgeContainer.classList.add("react-flow__edge");
|
||||
edgeContainer.setAttribute("data-id", "edge-a");
|
||||
const interaction = document.createElement("path");
|
||||
interaction.classList.add("react-flow__edge-interaction");
|
||||
edgeContainer.appendChild(interaction);
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [interaction]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
|
||||
edges={[{ id: "edge-a", source: "node-1", target: "node-2" } as RFEdge]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.onDrop({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 120,
|
||||
clientY: 340,
|
||||
dataTransfer: {
|
||||
getData: vi.fn((type: string) =>
|
||||
type === CANVAS_NODE_DND_MIME ? "note" : "",
|
||||
),
|
||||
files: [],
|
||||
},
|
||||
} as unknown as React.DragEvent);
|
||||
});
|
||||
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
type: "note",
|
||||
positionX: 120,
|
||||
positionY: 340,
|
||||
width: NODE_DEFAULTS.note.width,
|
||||
height: NODE_DEFAULTS.note.height,
|
||||
data: {
|
||||
...NODE_DEFAULTS.note.data,
|
||||
canvasId: "canvas-1",
|
||||
},
|
||||
splitEdgeId: "edge-a",
|
||||
newNodeTargetHandle: undefined,
|
||||
newNodeSourceHandle: undefined,
|
||||
splitSourceHandle: undefined,
|
||||
splitTargetHandle: undefined,
|
||||
clientRequestId: "req-1",
|
||||
});
|
||||
expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled();
|
||||
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-note");
|
||||
});
|
||||
|
||||
it("shows an upload failure toast when the dropped file upload fails", async () => {
|
||||
const generateUploadUrl = vi.fn(async () => "https://upload.test");
|
||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
|
||||
|
||||
316
components/canvas/__tests__/use-canvas-edge-insertions.test.tsx
Normal file
316
components/canvas/__tests__/use-canvas-edge-insertions.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect } 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 type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import { useCanvasEdgeInsertions } from "@/components/canvas/use-canvas-edge-insertions";
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasEdgeInsertions> | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||
return {
|
||||
type: "note",
|
||||
position: { x: 0, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
data: {},
|
||||
...overrides,
|
||||
} as RFNode;
|
||||
}
|
||||
|
||||
function createEdge(
|
||||
overrides: Partial<RFEdge> & Pick<RFEdge, "id" | "source" | "target">,
|
||||
): RFEdge {
|
||||
return {
|
||||
...overrides,
|
||||
} as RFEdge;
|
||||
}
|
||||
|
||||
type HookHarnessProps = {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
nodes,
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"),
|
||||
runBatchMoveNodesMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
}: HookHarnessProps) {
|
||||
const handlers = useCanvasEdgeInsertions({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runBatchMoveNodesMutation,
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasEdgeInsertions", () => {
|
||||
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("opens edge insert menu for persisted edges", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image" }),
|
||||
createNode({ id: "target", type: "text" }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({
|
||||
edgeId: "edge-1",
|
||||
screenX: 120,
|
||||
screenY: 240,
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toEqual({
|
||||
edgeId: "edge-1",
|
||||
screenX: 120,
|
||||
screenY: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores temp, optimistic, and missing edges when opening menu", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image" }),
|
||||
createNode({ id: "target", type: "text" }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({ id: "edge-temp", source: "source", target: "target", className: "temp" }),
|
||||
createEdge({ id: "optimistic_edge_1", source: "source", target: "target" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({
|
||||
edgeId: "edge-temp",
|
||||
screenX: 1,
|
||||
screenY: 2,
|
||||
});
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({
|
||||
edgeId: "optimistic_edge_1",
|
||||
screenX: 3,
|
||||
screenY: 4,
|
||||
});
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({
|
||||
edgeId: "edge-missing",
|
||||
screenX: 5,
|
||||
screenY: 6,
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("shows toast and skips create when split validation fails", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = 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
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "curves", position: { x: 300, y: 0 } }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "prompt",
|
||||
label: "Prompt",
|
||||
width: 320,
|
||||
height: 220,
|
||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("adjustment-source-invalid");
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).not.toHaveBeenCalled();
|
||||
expect(runBatchMoveNodesMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates split node with computed payload when split is valid", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = 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
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({
|
||||
id: "edge-1",
|
||||
source: "source",
|
||||
target: "target",
|
||||
sourceHandle: "source-handle",
|
||||
targetHandle: "target-handle",
|
||||
}),
|
||||
]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "prompt",
|
||||
label: "Prompt",
|
||||
width: 320,
|
||||
height: 220,
|
||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
type: "prompt",
|
||||
positionX: 140,
|
||||
positionY: -80,
|
||||
width: 320,
|
||||
height: 220,
|
||||
data: {
|
||||
prompt: "",
|
||||
model: "",
|
||||
aspectRatio: "1:1",
|
||||
canvasId: "canvas-1",
|
||||
},
|
||||
splitEdgeId: "edge-1",
|
||||
newNodeTargetHandle: "image-in",
|
||||
newNodeSourceHandle: "prompt-out",
|
||||
splitSourceHandle: "source-handle",
|
||||
splitTargetHandle: "target-handle",
|
||||
});
|
||||
expect(runBatchMoveNodesMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("moves source and target nodes when spacing is too tight", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "note",
|
||||
label: "Notiz",
|
||||
width: 220,
|
||||
height: 120,
|
||||
defaultData: { content: "" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
||||
expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({
|
||||
moves: [
|
||||
{ nodeId: "source", positionX: -110, positionY: 0 },
|
||||
{ nodeId: "target", positionX: 230, positionY: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
122
components/canvas/__tests__/use-canvas-edge-types.test.tsx
Normal file
122
components/canvas/__tests__/use-canvas-edge-types.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useCanvasEdgeTypes } from "@/components/canvas/use-canvas-edge-types";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
edgeInsertMenuEdgeId: string | null;
|
||||
scissorsMode: boolean;
|
||||
onInsertClick: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const latestRef: {
|
||||
current: ReturnType<typeof useCanvasEdgeTypes> | null;
|
||||
} = { current: null };
|
||||
|
||||
function HookHarness({
|
||||
edgeInsertMenuEdgeId,
|
||||
scissorsMode,
|
||||
onInsertClick,
|
||||
}: HookHarnessProps) {
|
||||
const edgeTypes = useCanvasEdgeTypes({
|
||||
edgeInsertMenuEdgeId,
|
||||
scissorsMode,
|
||||
onInsertClick,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestRef.current = edgeTypes;
|
||||
}, [edgeTypes]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasEdgeTypes", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
latestRef.current = null;
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("keeps edgeTypes reference stable while using latest UI state", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const onInsertClickA = vi.fn();
|
||||
const onInsertClickB = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
edgeInsertMenuEdgeId={null}
|
||||
scissorsMode={false}
|
||||
onInsertClick={onInsertClickA}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const firstEdgeTypes = latestRef.current;
|
||||
if (!firstEdgeTypes) {
|
||||
throw new Error("edgeTypes not initialized");
|
||||
}
|
||||
|
||||
const renderer = firstEdgeTypes["canvas-default"] as
|
||||
| ((props: { id: string }) => React.JSX.Element)
|
||||
| undefined;
|
||||
if (!renderer) {
|
||||
throw new Error("canvas-default edge renderer missing");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
const renderedEdge = renderer({ id: "edge-1" });
|
||||
expect(renderedEdge.props).toEqual(
|
||||
expect.objectContaining({
|
||||
edgeId: "edge-1",
|
||||
isMenuOpen: false,
|
||||
disabled: false,
|
||||
onInsertClick: onInsertClickA,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
edgeInsertMenuEdgeId="edge-1"
|
||||
scissorsMode
|
||||
onInsertClick={onInsertClickB}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(latestRef.current).toBe(firstEdgeTypes);
|
||||
|
||||
act(() => {
|
||||
const renderedEdge = renderer({ id: "edge-1" });
|
||||
expect(renderedEdge.props).toEqual(
|
||||
expect.objectContaining({
|
||||
edgeId: "edge-1",
|
||||
isMenuOpen: true,
|
||||
disabled: true,
|
||||
onInsertClick: onInsertClickB,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ type HarnessProps = {
|
||||
pendingConnectionCreateIds: Set<string>;
|
||||
previousConvexNodeIdsSnapshot: Set<string>;
|
||||
pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
|
||||
pendingLocalNodeDataPins?: Map<string, unknown>;
|
||||
preferLocalPositionNodeIds?: Set<string>;
|
||||
isResizingRefOverride?: { current: boolean };
|
||||
};
|
||||
@@ -78,6 +79,9 @@ function HookHarness(props: HarnessProps) {
|
||||
const pendingLocalPositionUntilConvexMatchesRef = useRef(
|
||||
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
|
||||
);
|
||||
const pendingLocalNodeDataUntilConvexMatchesRef = useRef(
|
||||
props.pendingLocalNodeDataPins ?? new Map<string, unknown>(),
|
||||
);
|
||||
const preferLocalPositionNodeIdsRef = useRef(
|
||||
props.preferLocalPositionNodeIds ?? new Set<string>(),
|
||||
);
|
||||
@@ -115,6 +119,7 @@ function HookHarness(props: HarnessProps) {
|
||||
resolvedRealIdByClientRequestRef,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
isDragging: isDraggingRef,
|
||||
isResizing: isResizingRef,
|
||||
|
||||
@@ -74,4 +74,44 @@ describe("useCanvasSyncEngine", () => {
|
||||
expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false);
|
||||
expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
it("pins local node data immediately when queueing an update", async () => {
|
||||
const enqueueSyncMutation = vi.fn(async () => undefined);
|
||||
let nodes = [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "curves",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { blackPoint: 124 },
|
||||
},
|
||||
];
|
||||
const setNodes = (updater: (current: typeof nodes) => typeof nodes) => {
|
||||
nodes = updater(nodes);
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const controller = createCanvasSyncEngineController({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
isSyncOnline: true,
|
||||
getEnqueueSyncMutation: () => enqueueSyncMutation,
|
||||
getRunBatchRemoveNodes: () => vi.fn(async () => undefined),
|
||||
getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined),
|
||||
getSetNodes: () => setNodes,
|
||||
});
|
||||
|
||||
await controller.queueNodeDataUpdate({
|
||||
nodeId: asNodeId("node-1"),
|
||||
data: { blackPoint: 209 },
|
||||
});
|
||||
|
||||
expect(nodes[0]?.data).toEqual({ blackPoint: 209 });
|
||||
expect(controller.pendingLocalNodeDataUntilConvexMatchesRef.current).toEqual(
|
||||
new Map([["node-1", { blackPoint: 209 }]]),
|
||||
);
|
||||
expect(enqueueSyncMutation).toHaveBeenCalledWith("updateData", {
|
||||
nodeId: asNodeId("node-1"),
|
||||
data: { blackPoint: 209 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,4 +445,140 @@ describe("useNodeLocalData preview overrides", () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps local data when save resolves before Convex catches up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TestApp
|
||||
nodeId="node-1"
|
||||
data={{ exposure: 0.2, label: "persisted" }}
|
||||
mounted
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHookRef.current?.applyLocalData({
|
||||
exposure: 0.8,
|
||||
label: "local",
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestHookRef.current?.localData).toEqual({
|
||||
exposure: 0.8,
|
||||
label: "local",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TestApp
|
||||
nodeId="node-1"
|
||||
data={{ exposure: 0.2, label: "persisted" }}
|
||||
mounted
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(latestHookRef.current?.localData).toEqual({
|
||||
exposure: 0.8,
|
||||
label: "local",
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts a later normalized server value after blocking a stale rerender", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TestApp
|
||||
nodeId="node-1"
|
||||
data={{ exposure: 0.2, label: "persisted" }}
|
||||
mounted
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHookRef.current?.applyLocalData({
|
||||
exposure: 0.8,
|
||||
label: "local",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TestApp
|
||||
nodeId="node-1"
|
||||
data={{ exposure: 0.2, label: "persisted" }}
|
||||
mounted
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(latestHookRef.current?.localData).toEqual({
|
||||
exposure: 0.8,
|
||||
label: "local",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TestApp
|
||||
nodeId="node-1"
|
||||
data={{ exposure: 0.75, label: "server-normalized" }}
|
||||
mounted
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(latestHookRef.current?.localData).toEqual({
|
||||
exposure: 0.75,
|
||||
label: "server-normalized",
|
||||
});
|
||||
expect(latestOverridesRef.current).toEqual(new Map());
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user