feat(canvas): implement dropped connection resolution and enhance connection handling

This commit is contained in:
2026-04-04 09:56:01 +02:00
parent 12202ad337
commit 90d6fe55b1
18 changed files with 1288 additions and 165 deletions

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
getCanvasConnectionValidationMessage,
validateCanvasConnectionPolicy,
} from "@/lib/canvas-connection-policy";
describe("canvas connection policy", () => {
it("limits compare nodes to two incoming connections", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "image",
targetType: "compare",
targetIncomingCount: 2,
}),
).toBe("compare-incoming-limit");
});
it("describes the compare incoming limit", () => {
expect(
getCanvasConnectionValidationMessage("compare-incoming-limit"),
).toBe("Compare-Nodes erlauben genau zwei eingehende Verbindungen.");
});
});

View File

@@ -0,0 +1,186 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types";
type ParameterSliderProps = {
values: Array<{ id: string; value: number }>;
onChange: (values: Array<{ id: string; value: number }>) => void;
};
const parameterSliderState = vi.hoisted(() => ({
latestProps: null as ParameterSliderProps | null,
}));
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Left: "left", Right: "right" },
}));
vi.mock("convex/react", () => ({
useMutation: () => vi.fn(async () => undefined),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("lucide-react", () => ({
Sun: () => null,
}));
vi.mock("@/components/canvas/canvas-presets-context", () => ({
useCanvasAdjustmentPresets: () => [],
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: vi.fn(async () => undefined),
}),
}));
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
}));
vi.mock("@/components/canvas/nodes/adjustment-preview", () => ({
default: () => null,
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectValue: () => null,
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: vi.fn(),
},
}));
vi.mock("@/src/components/tool-ui/parameter-slider", () => ({
ParameterSlider: (props: ParameterSliderProps) => {
parameterSliderState.latestProps = props;
return null;
},
}));
import LightAdjustNode from "@/components/canvas/nodes/light-adjust-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("LightAdjustNode", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.useFakeTimers();
parameterSliderState.latestProps = null;
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;
vi.clearAllMocks();
vi.useRealTimers();
});
it("keeps the locally dragged slider value when stale node data rerenders", async () => {
const staleData: LightAdjustData = {
...DEFAULT_LIGHT_ADJUST_DATA,
vignette: {
...DEFAULT_LIGHT_ADJUST_DATA.vignette,
},
};
const renderNode = (data: LightAdjustData) =>
root?.render(
createElement(LightAdjustNode, {
id: "light-1",
data,
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "light-adjust",
xPos: 0,
yPos: 0,
width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
);
await act(async () => {
renderNode({ ...staleData, vignette: { ...staleData.vignette } });
vi.runOnlyPendingTimers();
});
const sliderPropsBeforeDrag = parameterSliderState.latestProps;
expect(sliderPropsBeforeDrag).not.toBeNull();
await act(async () => {
sliderPropsBeforeDrag?.onChange(
sliderPropsBeforeDrag.values.map((entry) =>
entry.id === "brightness" ? { ...entry, value: 35 } : entry,
),
);
});
expect(
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
).toBe(35);
await act(async () => {
renderNode({ ...staleData, vignette: { ...staleData.vignette } });
vi.runOnlyPendingTimers();
});
expect(
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
).toBe(35);
await act(async () => {
renderNode({
...staleData,
brightness: 35,
vignette: { ...staleData.vignette },
});
vi.runOnlyPendingTimers();
});
await act(async () => {
renderNode({
...staleData,
brightness: 60,
vignette: { ...staleData.vignette },
});
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
).toBe(60);
});
});

View File

@@ -0,0 +1,122 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emptyHistogram } from "@/lib/image-pipeline/histogram";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
const workerClientMocks = vi.hoisted(() => ({
renderPreviewWithWorkerFallback: vi.fn(),
}));
vi.mock("@/lib/image-pipeline/worker-client", () => ({
isPipelineAbortError: () => false,
renderPreviewWithWorkerFallback: workerClientMocks.renderPreviewWithWorkerFallback,
}));
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
function PreviewHarness({
sourceUrl,
steps,
}: {
sourceUrl: string | null;
steps: PipelineStep[];
}) {
const { canvasRef } = usePipelinePreview({
sourceUrl,
steps,
nodeWidth: 320,
});
return createElement("canvas", { ref: canvasRef });
}
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("usePipelinePreview", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.useFakeTimers();
workerClientMocks.renderPreviewWithWorkerFallback.mockReset();
workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({
width: 120,
height: 80,
imageData: { data: new Uint8ClampedArray(120 * 80 * 4) },
histogram: emptyHistogram(),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
putImageData: vi.fn(),
} as unknown as CanvasRenderingContext2D);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
vi.restoreAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
vi.useRealTimers();
});
it("does not restart preview rendering when only step references change", async () => {
const stepsA: PipelineStep[] = [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 10 },
},
];
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: stepsA,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(16);
await Promise.resolve();
});
const stepsB: PipelineStep[] = [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 10 },
},
];
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: stepsB,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(16);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
});
});