feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import {
computeEdgeInsertLayout,
computeEdgeInsertReflowPlan,
getSingleCharacterHotkey,
withResolvedCompareData,
} from "../canvas-helpers";
import {
@@ -315,6 +316,24 @@ describe("canvas preview graph helpers", () => {
});
});
describe("getSingleCharacterHotkey", () => {
it("returns a lowercase printable hotkey for single-character keys", () => {
expect(getSingleCharacterHotkey({ key: "K", type: "keydown" })).toBe("k");
expect(getSingleCharacterHotkey({ key: "v", type: "keydown" })).toBe("v");
expect(getSingleCharacterHotkey({ key: "Escape", type: "keydown" })).toBe("");
});
it("returns an empty string and logs when the event has no string key", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
expect(getSingleCharacterHotkey({ type: "keydown" } as { key?: string; type: string })).toBe("");
expect(warnSpy).toHaveBeenCalledWith("[Canvas] keyboard event missing string key", {
eventType: "keydown",
key: undefined,
});
});
});
describe("computeEdgeInsertLayout", () => {
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
const source = createNode({

View File

@@ -166,4 +166,99 @@ describe("CompareNode render preview inputs", () => {
preferPreview: true,
});
});
it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => {
storeState.nodes = [
{
id: "base-image",
type: "image",
data: { url: "https://cdn.example.com/base.png" },
},
{
id: "overlay-image",
type: "asset",
data: { url: "https://cdn.example.com/overlay.png" },
},
{
id: "mixer-1",
type: "mixer",
data: {
blendMode: "multiply",
opacity: 62,
offsetX: 12,
offsetY: -4,
},
},
{
id: "right-image",
type: "image",
data: { url: "https://cdn.example.com/right.png" },
},
];
storeState.edges = [
{
id: "edge-base-mixer",
source: "base-image",
target: "mixer-1",
targetHandle: "base",
},
{
id: "edge-overlay-mixer",
source: "overlay-image",
target: "mixer-1",
targetHandle: "overlay",
},
{
id: "edge-mixer-compare",
source: "mixer-1",
target: "compare-1",
targetHandle: "left",
},
{
id: "edge-image-compare",
source: "right-image",
target: "compare-1",
targetHandle: "right",
},
];
renderCompareNode({
id: "compare-1",
data: {
leftUrl: "https://cdn.example.com/base.png",
rightUrl: "https://cdn.example.com/right.png",
},
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(compareSurfaceSpy).toHaveBeenCalledTimes(2);
const mixerCall = compareSurfaceSpy.mock.calls.find(
([props]) =>
Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState),
);
expect(mixerCall?.[0]).toMatchObject({
finalUrl: undefined,
mixerPreviewState: {
status: "ready",
baseUrl: "https://cdn.example.com/base.png",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "multiply",
opacity: 62,
offsetX: 12,
offsetY: -4,
},
});
});
});

View File

@@ -0,0 +1,229 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
const mocks = vi.hoisted(() => ({
queueNodeDataUpdate: vi.fn(async () => undefined),
}));
vi.mock("@xyflow/react", () => ({
Handle: ({ id, type }: { id?: string; type: string }) => (
<div data-testid={`handle-${id ?? "default"}`} data-handle-id={id} data-handle-type={type} />
),
Position: { Left: "left", Right: "right" },
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
queueNodeResize: vi.fn(async () => undefined),
status: { pendingCount: 0, isSyncing: false, isOffline: false },
}),
}));
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import MixerNode from "@/components/canvas/nodes/mixer-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type TestNode = {
id: string;
type: string;
data?: unknown;
};
type TestEdge = {
id: string;
source: string;
target: string;
targetHandle?: string;
};
function buildMixerNodeProps(overrides?: Partial<React.ComponentProps<typeof MixerNode>>) {
return {
id: "mixer-1",
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "mixer",
xPos: 0,
yPos: 0,
width: 360,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
...overrides,
} as React.ComponentProps<typeof MixerNode>;
}
describe("MixerNode", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.queueNodeDataUpdate.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
async function renderNode(args?: {
nodes?: TestNode[];
edges?: TestEdge[];
props?: Partial<React.ComponentProps<typeof MixerNode>>;
}) {
const nodes = args?.nodes ?? [{ id: "mixer-1", type: "mixer", data: {} }];
const edges = args?.edges ?? [];
await act(async () => {
root?.render(
<CanvasGraphProvider nodes={nodes} edges={edges}>
<MixerNode {...buildMixerNodeProps(args?.props)} />
</CanvasGraphProvider>,
);
});
}
it("renders empty state copy when no inputs are connected", async () => {
await renderNode();
expect(container?.textContent).toContain("Connect base and overlay images");
});
it("renders partial state copy when only one input is connected", async () => {
await renderNode({
nodes: [
{ id: "image-1", type: "image", data: { url: "https://cdn.example.com/base.png" } },
{ id: "mixer-1", type: "mixer", data: {} },
],
edges: [{ id: "edge-base", source: "image-1", target: "mixer-1", targetHandle: "base" }],
});
expect(container?.textContent).toContain("Waiting for second input");
});
it("renders ready state with stacked base and overlay previews", async () => {
await renderNode({
nodes: [
{ id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } },
{ id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay.png" } },
{
id: "mixer-1",
type: "mixer",
data: { blendMode: "multiply", opacity: 60, offsetX: 14, offsetY: -8 },
},
],
edges: [
{ id: "edge-base", source: "image-base", target: "mixer-1", targetHandle: "base" },
{
id: "edge-overlay",
source: "image-overlay",
target: "mixer-1",
targetHandle: "overlay",
},
],
});
const baseImage = container?.querySelector('img[alt="Mixer base"]');
const overlayImage = container?.querySelector('img[alt="Mixer overlay"]');
expect(baseImage).toBeTruthy();
expect(overlayImage).toBeTruthy();
});
it("queues node data updates for blend mode, opacity, and overlay offsets", async () => {
await renderNode();
const blendMode = container?.querySelector('select[name="blendMode"]');
const opacity = container?.querySelector('input[name="opacity"]');
const offsetX = container?.querySelector('input[name="offsetX"]');
const offsetY = container?.querySelector('input[name="offsetY"]');
if (!(blendMode instanceof HTMLSelectElement)) {
throw new Error("blendMode select not found");
}
if (!(opacity instanceof HTMLInputElement)) {
throw new Error("opacity input not found");
}
if (!(offsetX instanceof HTMLInputElement)) {
throw new Error("offsetX input not found");
}
if (!(offsetY instanceof HTMLInputElement)) {
throw new Error("offsetY input not found");
}
await act(async () => {
blendMode.value = "screen";
blendMode.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
nodeId: "mixer-1",
data: expect.objectContaining({ blendMode: "screen" }),
});
await act(async () => {
opacity.value = "45";
opacity.dispatchEvent(new Event("input", { bubbles: true }));
opacity.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
nodeId: "mixer-1",
data: expect.objectContaining({ opacity: 45 }),
});
await act(async () => {
offsetX.value = "12";
offsetX.dispatchEvent(new Event("input", { bubbles: true }));
offsetX.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
nodeId: "mixer-1",
data: expect.objectContaining({ offsetX: 12 }),
});
await act(async () => {
offsetY.value = "-6";
offsetY.dispatchEvent(new Event("input", { bubbles: true }));
offsetY.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
nodeId: "mixer-1",
data: expect.objectContaining({ offsetY: -6 }),
});
});
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();
});
});

View File

@@ -22,16 +22,12 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
};
});
vi.mock("@/components/canvas/canvas-reconnect", () => ({
useCanvasReconnectHandlers: () => ({
onReconnectStart: vi.fn(),
onReconnect: vi.fn(),
onReconnectEnd: vi.fn(),
}),
}));
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
import { nodeTypes } from "@/components/canvas/node-types";
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
@@ -45,7 +41,10 @@ type HookHarnessProps = {
helperResult: DroppedConnectionTarget | null;
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
runRemoveEdgeMutation?: ReturnType<typeof vi.fn>;
runSwapMixerInputsMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
setEdgesMock?: ReturnType<typeof vi.fn>;
nodes?: RFNode[];
edges?: RFEdge[];
};
@@ -54,7 +53,10 @@ function HookHarness({
helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
runRemoveEdgeMutation = vi.fn(async () => undefined),
runSwapMixerInputsMutation = vi.fn(async () => undefined),
showConnectionRejectedToast = vi.fn(),
setEdgesMock,
nodes: providedNodes,
edges: providedEdges,
}: HookHarnessProps) {
@@ -71,7 +73,7 @@ function HookHarness({
const isReconnectDragActiveRef = useRef(false);
const pendingConnectionCreatesRef = useRef(new Set<string>());
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
const setEdges = vi.fn();
const setEdges = setEdgesMock ?? vi.fn();
const setEdgeSyncNonce = vi.fn();
useEffect(() => {
@@ -102,7 +104,8 @@ function HookHarness({
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation: vi.fn(async () => undefined),
runRemoveEdgeMutation,
runSwapMixerInputsMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
showConnectionRejectedToast,
@@ -132,6 +135,47 @@ describe("useCanvasConnections", () => {
container = null;
});
it("exposes mixer metadata required for placement and connection defaults", () => {
const mixerCatalogEntry = NODE_CATALOG.find((entry) => entry.type === "mixer");
const mixerTemplate = CANVAS_NODE_TEMPLATES.find(
(template) => (template.type as string) === "mixer",
);
expect(nodeTypes).toHaveProperty("mixer");
expect(mixerCatalogEntry).toEqual(
expect.objectContaining({
type: "mixer",
category: "control",
implemented: true,
}),
);
expect(mixerTemplate).toEqual(
expect.objectContaining({
type: "mixer",
defaultData: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
}),
);
expect(NODE_HANDLE_MAP.mixer).toEqual({
source: "mixer-out",
target: "base",
});
expect(NODE_DEFAULTS.mixer).toEqual(
expect.objectContaining({
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
}),
);
});
it("creates an edge when a body drop lands on another node", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
@@ -490,6 +534,320 @@ describe("useCanvasConnections", () => {
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
});
it("allows image-like sources to connect to mixer", 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={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
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: {} },
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(showConnectionRejectedToast).not.toHaveBeenCalled();
});
it("rejects disallowed source types to mixer", 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={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "video", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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: "video" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 400, y: 260 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-source-invalid");
});
it("rejects a second connection to the same mixer handle", 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={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onConnect({
source: "node-source",
target: "node-target",
sourceHandle: null,
targetHandle: "base",
});
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-handle-incoming-limit");
});
it("allows one incoming edge per mixer handle", 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={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "overlay",
}}
nodes={[
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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: "asset" },
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: "overlay",
});
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
});
it("rejects a third incoming edge to mixer", 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={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
}}
nodes={[
{ id: "node-source", type: "render", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
{ id: "node-asset", type: "asset", position: { x: -180, y: 180 }, data: {} },
]}
edges={[
{
id: "edge-existing-base",
source: "node-image",
target: "node-target",
targetHandle: "base",
},
{
id: "edge-existing-overlay",
source: "node-asset",
target: "node-target",
targetHandle: "overlay",
},
]}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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: "render" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 400, y: 260 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-incoming-limit");
});
it("ignores onConnectEnd when no connect drag is active", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
@@ -535,4 +893,364 @@ describe("useCanvasConnections", () => {
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
});
it("passes edgeIdToIgnore during reconnect replacement without client-side old-edge delete", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const runRemoveEdgeMutation = 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}
runRemoveEdgeMutation={runRemoveEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
/>,
);
});
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).toHaveBeenCalledWith({
canvasId: "canvas-1",
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "overlay",
edgeIdToIgnore: "edge-1",
});
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
});
it("does not remove old edge when reconnect create fails", async () => {
const runCreateEdgeMutation = vi.fn(async () => {
throw new Error("incoming limit reached");
});
const runRemoveEdgeMutation = 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}
runRemoveEdgeMutation={runRemoveEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
/>,
);
});
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();
await Promise.resolve();
});
expect(runCreateEdgeMutation).toHaveBeenCalledTimes(1);
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
});
it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (base->overlay)", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const runRemoveEdgeMutation = vi.fn(async () => undefined);
const runSwapMixerInputsMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
const setEdgesMock = vi.fn();
const initialEdges: RFEdge[] = [
{
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
},
{
id: "edge-overlay",
source: "node-source-overlay",
target: "node-mixer",
targetHandle: "overlay",
},
];
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
runRemoveEdgeMutation={runRemoveEdgeMutation}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
setEdgesMock={setEdgesMock}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={initialEdges}
/>,
);
});
const oldEdge = initialEdges[0] as RFEdge;
await act(async () => {
latestHandlersRef.current?.onReconnectStart();
latestHandlersRef.current?.onReconnect(oldEdge, {
source: "node-source-base",
target: "node-mixer",
sourceHandle: null,
targetHandle: "overlay",
});
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
await Promise.resolve();
});
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
edgeId: "edge-base",
otherEdgeId: "edge-overlay",
});
expect(setEdgesMock).toHaveBeenCalledTimes(1);
const applyEdges = setEdgesMock.mock.calls[0]?.[0] as ((edges: RFEdge[]) => RFEdge[]);
const swappedEdges = applyEdges(initialEdges);
const baseEdge = swappedEdges.find((edge) => edge.id === "edge-base");
const overlayEdge = swappedEdges.find((edge) => edge.id === "edge-overlay");
expect(baseEdge?.targetHandle).toBe("overlay");
expect(overlayEdge?.targetHandle).toBe("base");
});
it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (overlay->base)", async () => {
const runSwapMixerInputsMutation = 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}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
},
{
id: "edge-overlay",
source: "node-source-overlay",
target: "node-mixer",
targetHandle: "overlay",
},
]}
/>,
);
});
const oldEdge = {
id: "edge-overlay",
source: "node-source-overlay",
target: "node-mixer",
targetHandle: "overlay",
} as RFEdge;
await act(async () => {
latestHandlersRef.current?.onReconnectStart();
latestHandlersRef.current?.onReconnect(oldEdge, {
source: "node-source-overlay",
target: "node-mixer",
sourceHandle: null,
targetHandle: "base",
});
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
await Promise.resolve();
});
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
edgeId: "edge-overlay",
otherEdgeId: "edge-base",
});
});
it("does not swap mixer reconnect when target mixer is not fully populated", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const runSwapMixerInputsMutation = 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}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
},
]}
/>,
);
});
const oldEdge = {
id: "edge-base",
source: "node-source-base",
target: "node-mixer",
targetHandle: "base",
} as RFEdge;
await act(async () => {
latestHandlersRef.current?.onReconnectStart();
latestHandlersRef.current?.onReconnect(oldEdge, {
source: "node-source-base",
target: "node-mixer",
sourceHandle: null,
targetHandle: "overlay",
});
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
await Promise.resolve();
});
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
sourceNodeId: "node-source-base",
targetNodeId: "node-mixer",
sourceHandle: undefined,
targetHandle: "overlay",
edgeIdToIgnore: "edge-base",
});
});
it("does not perform mixer swap for non-mixer reconnect validation failures", async () => {
const runSwapMixerInputsMutation = 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}
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-render", type: "render", position: { x: 300, y: 0 }, data: {} },
]}
edges={[
{
id: "edge-1",
source: "node-image",
target: "node-render",
},
]}
/>,
);
});
const oldEdge = {
id: "edge-1",
source: "node-image",
target: "node-render",
} as RFEdge;
await act(async () => {
latestHandlersRef.current?.onReconnectStart();
latestHandlersRef.current?.onReconnect(oldEdge, {
source: "node-image",
target: "node-image",
sourceHandle: null,
targetHandle: null,
});
});
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
});
});