feat(canvas): accelerate local previews and harden edge flows
This commit is contained in:
135
tests/canvas-delete-handlers.test.ts
Normal file
135
tests/canvas-delete-handlers.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect, useRef, useState } 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 { useCanvasDeleteHandlers } from "@/components/canvas/canvas-delete-handlers";
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasDeleteHandlers> | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HarnessProps = {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
||||
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function HookHarness(props: HarnessProps) {
|
||||
const deletingNodeIds = useRef(new Set<string>());
|
||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
|
||||
|
||||
const handlers = useCanvasDeleteHandlers({
|
||||
t: ((key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key) as never,
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes: props.nodes,
|
||||
edges: props.edges,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation: props.runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasDeleteHandlers", () => {
|
||||
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("creates bridge edges only after batch node removal resolves", async () => {
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-color",
|
||||
type: "color-adjust",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const renderNode: RFNode = { id: "node-render", type: "render", position: { x: 400, y: 0 }, data: {} };
|
||||
|
||||
const edges: RFEdge[] = [
|
||||
{ id: "edge-in", source: "node-image", target: "node-color" },
|
||||
{ id: "edge-out", source: "node-color", target: "node-render" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [imageNode, deletedNode, renderNode],
|
||||
edges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
expect(runBatchRemoveNodesMutation).toHaveBeenCalledWith({
|
||||
nodeIds: ["node-color"],
|
||||
});
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-image",
|
||||
targetNodeId: "node-render",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,14 +85,14 @@ export const parityTolerances: Record<ParityPipelineKey, ParityTolerance> = {
|
||||
spatialRmse: 52.5,
|
||||
},
|
||||
colorAdjustPressure: {
|
||||
maxChannelDelta: 203,
|
||||
histogramSimilarity: 0.17,
|
||||
spatialRmse: 75.8,
|
||||
maxChannelDelta: 64,
|
||||
histogramSimilarity: 0.5,
|
||||
spatialRmse: 24,
|
||||
},
|
||||
curvesColorPressureChain: {
|
||||
maxChannelDelta: 203,
|
||||
histogramSimilarity: 0.18,
|
||||
spatialRmse: 75.5,
|
||||
maxChannelDelta: 96,
|
||||
histogramSimilarity: 0.35,
|
||||
spatialRmse: 36,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -402,10 +402,10 @@ function createEmptyTexture(width: number, height: number): FakeTexture {
|
||||
}
|
||||
|
||||
function inferShaderKind(source: string): ShaderKind {
|
||||
if (source.includes("uGamma")) {
|
||||
if (source.includes("uInvGamma") || source.includes("uRgbLut")) {
|
||||
return "curves";
|
||||
}
|
||||
if (source.includes("uColorShift")) {
|
||||
if (source.includes("uHueShift") || source.includes("uVibranceBoost")) {
|
||||
return "color-adjust";
|
||||
}
|
||||
if (source.includes("uExposureFactor")) {
|
||||
@@ -428,13 +428,65 @@ function toByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
|
||||
function sampleLutTexture(texture: FakeTexture | null, value: number): number {
|
||||
if (!texture) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const index = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
return texture.data[index * 4] / 255;
|
||||
}
|
||||
|
||||
function runCurvesShader(
|
||||
input: Uint8Array,
|
||||
uniforms: Map<string, number | [number, number, number]>,
|
||||
textures: {
|
||||
rgb: FakeTexture | null;
|
||||
red: FakeTexture | null;
|
||||
green: FakeTexture | null;
|
||||
blue: FakeTexture | null;
|
||||
},
|
||||
): Uint8Array {
|
||||
const output = new Uint8Array(input.length);
|
||||
const blackPoint = Number(uniforms.get("uBlackPoint") ?? 0);
|
||||
const whitePoint = Number(uniforms.get("uWhitePoint") ?? 255);
|
||||
const invGamma = Number(uniforms.get("uInvGamma") ?? 1);
|
||||
const channelMode = Number(uniforms.get("uChannelMode") ?? 0);
|
||||
const levelRange = Math.max(1, whitePoint - blackPoint);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.pow(Math.max(toNormalized(input[index]), 0), Math.max(gamma, 0.001));
|
||||
const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001));
|
||||
const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001));
|
||||
const mappedRed = Math.pow(
|
||||
Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
const mappedGreen = Math.pow(
|
||||
Math.max(Math.min(((input[index + 1] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
const mappedBlue = Math.pow(
|
||||
Math.max(Math.min(((input[index + 2] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
|
||||
const rgbRed = sampleLutTexture(textures.rgb, mappedRed);
|
||||
const rgbGreen = sampleLutTexture(textures.rgb, mappedGreen);
|
||||
const rgbBlue = sampleLutTexture(textures.rgb, mappedBlue);
|
||||
|
||||
let red = rgbRed;
|
||||
let green = rgbGreen;
|
||||
let blue = rgbBlue;
|
||||
|
||||
if (channelMode < 0.5) {
|
||||
red = sampleLutTexture(textures.red, rgbRed);
|
||||
green = sampleLutTexture(textures.green, rgbGreen);
|
||||
blue = sampleLutTexture(textures.blue, rgbBlue);
|
||||
} else if (channelMode < 1.5) {
|
||||
red = sampleLutTexture(textures.red, rgbRed);
|
||||
} else if (channelMode < 2.5) {
|
||||
green = sampleLutTexture(textures.green, rgbGreen);
|
||||
} else {
|
||||
blue = sampleLutTexture(textures.blue, rgbBlue);
|
||||
}
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
@@ -445,13 +497,98 @@ function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
|
||||
return output;
|
||||
}
|
||||
|
||||
function runColorAdjustShader(input: Uint8Array, shift: [number, number, number]): Uint8Array {
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
const l = (max + min) / 2;
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1));
|
||||
let h = 0;
|
||||
if (max === rn) {
|
||||
h = ((gn - bn) / delta) % 6;
|
||||
} else if (max === gn) {
|
||||
h = (bn - rn) / delta + 2;
|
||||
} else {
|
||||
h = (rn - gn) / delta + 4;
|
||||
}
|
||||
|
||||
h *= 60;
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let rp = 0;
|
||||
let gp = 0;
|
||||
let bp = 0;
|
||||
|
||||
if (h < 60) {
|
||||
rp = c;
|
||||
gp = x;
|
||||
} else if (h < 120) {
|
||||
rp = x;
|
||||
gp = c;
|
||||
} else if (h < 180) {
|
||||
gp = c;
|
||||
bp = x;
|
||||
} else if (h < 240) {
|
||||
gp = x;
|
||||
bp = c;
|
||||
} else if (h < 300) {
|
||||
rp = x;
|
||||
bp = c;
|
||||
} else {
|
||||
rp = c;
|
||||
bp = x;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.max(0, Math.min(255, Math.round((rp + m) * 255))),
|
||||
g: Math.max(0, Math.min(255, Math.round((gp + m) * 255))),
|
||||
b: Math.max(0, Math.min(255, Math.round((bp + m) * 255))),
|
||||
};
|
||||
}
|
||||
|
||||
function runColorAdjustShader(
|
||||
input: Uint8Array,
|
||||
uniforms: Map<string, number | [number, number, number]>,
|
||||
): Uint8Array {
|
||||
const output = new Uint8Array(input.length);
|
||||
const hueShift = Number(uniforms.get("uHueShift") ?? 0);
|
||||
const saturationFactor = Number(uniforms.get("uSaturationFactor") ?? 1);
|
||||
const luminanceShift = Number(uniforms.get("uLuminanceShift") ?? 0);
|
||||
const temperatureShift = Number(uniforms.get("uTemperatureShift") ?? 0);
|
||||
const tintShift = Number(uniforms.get("uTintShift") ?? 0);
|
||||
const vibranceBoost = Number(uniforms.get("uVibranceBoost") ?? 0);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0]));
|
||||
const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1]));
|
||||
const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2]));
|
||||
const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0);
|
||||
const shiftedHue = (hsl.h + hueShift + 360) % 360;
|
||||
const shiftedSaturation = Math.max(0, Math.min(1, hsl.s * saturationFactor));
|
||||
const shiftedLuminance = Math.max(0, Math.min(1, hsl.l + luminanceShift));
|
||||
const saturationDelta = (1 - hsl.s) * vibranceBoost;
|
||||
const vivid = hslToRgb(
|
||||
shiftedHue,
|
||||
Math.max(0, Math.min(1, shiftedSaturation + saturationDelta)),
|
||||
shiftedLuminance,
|
||||
);
|
||||
|
||||
const red = Math.max(0, Math.min(1, (vivid.r + temperatureShift) / 255));
|
||||
const green = Math.max(0, Math.min(1, (vivid.g + tintShift) / 255));
|
||||
const blue = Math.max(0, Math.min(1, (vivid.b - temperatureShift - tintShift * 0.3) / 255));
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
@@ -629,7 +766,8 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
let currentProgram: FakeProgram | null = null;
|
||||
let currentTexture: FakeTexture | null = null;
|
||||
let currentFramebuffer: FakeFramebuffer | null = null;
|
||||
let sourceTexture: FakeTexture | null = null;
|
||||
let activeTextureUnit = 0;
|
||||
const boundTextures = new Map<number, FakeTexture | null>();
|
||||
let drawWidth = 1;
|
||||
let drawHeight = 1;
|
||||
|
||||
@@ -693,9 +831,7 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
},
|
||||
bindTexture(_target: number, texture: FakeTexture | null) {
|
||||
currentTexture = texture;
|
||||
if (texture) {
|
||||
sourceTexture = texture;
|
||||
}
|
||||
boundTextures.set(activeTextureUnit, texture);
|
||||
},
|
||||
texParameteri() {},
|
||||
texImage2D(
|
||||
@@ -730,7 +866,9 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
currentTexture.height = height;
|
||||
currentTexture.data = new Uint8Array(width * height * 4);
|
||||
},
|
||||
activeTexture() {},
|
||||
activeTexture(textureUnit: number) {
|
||||
activeTextureUnit = textureUnit - glConstants.TEXTURE0;
|
||||
},
|
||||
getUniformLocation(program: FakeProgram, name: string) {
|
||||
return {
|
||||
program,
|
||||
@@ -774,22 +912,30 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
drawHeight = height;
|
||||
},
|
||||
drawArrays() {
|
||||
const sourceTexture = boundTextures.get(0) ?? null;
|
||||
if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) {
|
||||
throw new Error("Parity WebGL mock is missing required render state.");
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "curves") {
|
||||
const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1);
|
||||
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, gamma);
|
||||
const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1);
|
||||
const redUnit = Number(currentProgram.uniforms.get("uRedLut") ?? 2);
|
||||
const greenUnit = Number(currentProgram.uniforms.get("uGreenLut") ?? 3);
|
||||
const blueUnit = Number(currentProgram.uniforms.get("uBlueLut") ?? 4);
|
||||
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, currentProgram.uniforms, {
|
||||
rgb: boundTextures.get(rgbUnit) ?? null,
|
||||
red: boundTextures.get(redUnit) ?? null,
|
||||
green: boundTextures.get(greenUnit) ?? null,
|
||||
blue: boundTextures.get(blueUnit) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "color-adjust") {
|
||||
const colorShift = currentProgram.uniforms.get("uColorShift");
|
||||
const shift: [number, number, number] = Array.isArray(colorShift)
|
||||
? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0]
|
||||
: [0, 0, 0];
|
||||
currentFramebuffer.attachment.data = runColorAdjustShader(sourceTexture.data, shift);
|
||||
currentFramebuffer.attachment.data = runColorAdjustShader(
|
||||
sourceTexture.data,
|
||||
currentProgram.uniforms,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -828,7 +974,7 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
throw new Error("Parity WebGL mock has no framebuffer attachment to read from.");
|
||||
}
|
||||
|
||||
output.set(currentFramebuffer.attachment.data);
|
||||
output.set(currentFramebuffer.attachment.data.subarray(0, output.length));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,41 @@ function createCurvesStep(): PipelineStep {
|
||||
};
|
||||
}
|
||||
|
||||
function createCurvesPressureStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "curves-pressure-1",
|
||||
type: "curves",
|
||||
params: {
|
||||
channelMode: "master",
|
||||
levels: {
|
||||
blackPoint: 12,
|
||||
whitePoint: 232,
|
||||
gamma: 2.5,
|
||||
},
|
||||
points: {
|
||||
rgb: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 64, y: 52 },
|
||||
{ x: 196, y: 228 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
red: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
green: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
blue: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createColorAdjustStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "color-1",
|
||||
@@ -55,6 +90,23 @@ function createColorAdjustStep(): PipelineStep {
|
||||
};
|
||||
}
|
||||
|
||||
function createColorAdjustPressureStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "color-pressure-1",
|
||||
type: "color-adjust",
|
||||
params: {
|
||||
hsl: {
|
||||
hue: 48,
|
||||
saturation: 64,
|
||||
luminance: 18,
|
||||
},
|
||||
temperature: 24,
|
||||
tint: -28,
|
||||
vibrance: 52,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUnsupportedStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "light-1",
|
||||
@@ -158,7 +210,7 @@ describe("webgl backend poc", () => {
|
||||
texParameteri: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
activeTexture: vi.fn(),
|
||||
getUniformLocation: vi.fn(() => ({ uniform: true })),
|
||||
getUniformLocation: vi.fn((_program: unknown, name: string) => ({ uniform: true, name })),
|
||||
uniform1i: vi.fn(),
|
||||
uniform1f: vi.fn(),
|
||||
uniform3f: vi.fn(),
|
||||
@@ -405,7 +457,7 @@ describe("webgl backend poc", () => {
|
||||
.at(-1)?.index;
|
||||
|
||||
expect(lastBindBeforeDrawIndex).toBeTypeOf("number");
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toBe(sourceTexture);
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toStrictEqual(sourceTexture);
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture);
|
||||
});
|
||||
|
||||
@@ -464,6 +516,90 @@ describe("webgl backend poc", () => {
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(expect.anything(), 7);
|
||||
});
|
||||
|
||||
it("passes curves levels uniforms for non-default curves settings", async () => {
|
||||
const fakeGl = createFakeWebglContext({
|
||||
readbackPixels: new Uint8Array([11, 22, 33, 255]),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
|
||||
if (contextId === "webgl") {
|
||||
return fakeGl;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
const backend = createWebglPreviewBackend();
|
||||
|
||||
backend.runPreviewStep({
|
||||
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
|
||||
step: createCurvesPressureStep(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uBlackPoint" }),
|
||||
12,
|
||||
);
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uWhitePoint" }),
|
||||
232,
|
||||
);
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uInvGamma" }),
|
||||
0.4,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes hue, saturation, luminance, temperature, tint, and vibrance uniforms", async () => {
|
||||
const fakeGl = createFakeWebglContext({
|
||||
readbackPixels: new Uint8Array([11, 22, 33, 255]),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
|
||||
if (contextId === "webgl") {
|
||||
return fakeGl;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
const backend = createWebglPreviewBackend();
|
||||
|
||||
backend.runPreviewStep({
|
||||
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
|
||||
step: createColorAdjustPressureStep(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
const uniform1fCalls = vi.mocked(fakeGl.uniform1f).mock.calls;
|
||||
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uHueShift" }),
|
||||
48,
|
||||
);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uSaturationFactor" }),
|
||||
expect.closeTo(1.64, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uLuminanceShift" }),
|
||||
expect.closeTo(0.18, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uTemperatureShift" }),
|
||||
expect.closeTo(14.4, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uTintShift" }),
|
||||
expect.closeTo(-11.2, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uVibranceBoost" }),
|
||||
expect.closeTo(0.52, 5),
|
||||
]);
|
||||
});
|
||||
|
||||
it("downgrades compile/link failures to cpu with runtime_error reason", async () => {
|
||||
const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, createElement } 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";
|
||||
import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types";
|
||||
|
||||
type ParameterSliderProps = {
|
||||
@@ -110,23 +111,30 @@ describe("LightAdjustNode", () => {
|
||||
|
||||
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),
|
||||
createElement(
|
||||
CanvasGraphProvider as never,
|
||||
{
|
||||
nodes: [{ id: "light-1", type: "light-adjust", data }],
|
||||
edges: [],
|
||||
} as never,
|
||||
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 () => {
|
||||
@@ -183,4 +191,64 @@ describe("LightAdjustNode", () => {
|
||||
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
it("does not trigger a render-phase CanvasGraphProvider update while dragging sliders", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const staleData: LightAdjustData = {
|
||||
...DEFAULT_LIGHT_ADJUST_DATA,
|
||||
vignette: {
|
||||
...DEFAULT_LIGHT_ADJUST_DATA.vignette,
|
||||
},
|
||||
};
|
||||
|
||||
const renderNode = (data: LightAdjustData) =>
|
||||
root?.render(
|
||||
createElement(
|
||||
CanvasGraphProvider as never,
|
||||
{
|
||||
nodes: [{ id: "light-1", type: "light-adjust", data }],
|
||||
edges: [],
|
||||
} as never,
|
||||
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 sliderProps = parameterSliderState.latestProps;
|
||||
expect(sliderProps).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
sliderProps?.onChange(
|
||||
sliderProps.values.map((entry) =>
|
||||
entry.id === "brightness" ? { ...entry, value: 35 } : entry,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Cannot update a component (`CanvasGraphProvider`) while rendering a different component",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
95
tests/use-node-local-data-order.test.ts
Normal file
95
tests/use-node-local-data-order.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const canvasGraphMock = vi.hoisted(() => ({
|
||||
clearPreviewNodeDataOverride: vi.fn(),
|
||||
setPreviewNodeDataOverride: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraphPreviewOverrides: () => canvasGraphMock,
|
||||
}));
|
||||
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
|
||||
type AdjustmentData = {
|
||||
exposure: number;
|
||||
};
|
||||
|
||||
const latestHookRef: {
|
||||
current:
|
||||
| {
|
||||
updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void;
|
||||
}
|
||||
| null;
|
||||
} = { current: null };
|
||||
|
||||
function HookHarness() {
|
||||
const { updateLocalData } = useNodeLocalData<AdjustmentData>({
|
||||
nodeId: "node-1",
|
||||
data: { exposure: 0.2 },
|
||||
normalize: (value) => ({ ...(value as AdjustmentData) }),
|
||||
saveDelayMs: 1000,
|
||||
onSave: async () => undefined,
|
||||
debugLabel: "light-adjust",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHookRef.current = { updateLocalData };
|
||||
return () => {
|
||||
latestHookRef.current = null;
|
||||
};
|
||||
}, [updateLocalData]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("useNodeLocalData ordering", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
latestHookRef.current = null;
|
||||
canvasGraphMock.clearPreviewNodeDataOverride.mockReset();
|
||||
canvasGraphMock.setPreviewNodeDataOverride.mockReset();
|
||||
});
|
||||
|
||||
it("does not write preview overrides from inside the local state updater", async () => {
|
||||
let overrideWriteStack = "";
|
||||
|
||||
canvasGraphMock.setPreviewNodeDataOverride.mockImplementation(() => {
|
||||
overrideWriteStack = new Error().stack ?? "";
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHookRef.current?.updateLocalData((current) => ({
|
||||
...current,
|
||||
exposure: 0.8,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(overrideWriteStack).not.toContain("basicStateReducer");
|
||||
expect(overrideWriteStack).not.toContain("updateReducerImpl");
|
||||
});
|
||||
});
|
||||
@@ -67,16 +67,19 @@ function PreviewHarness({
|
||||
sourceUrl,
|
||||
steps,
|
||||
includeHistogram,
|
||||
debounceMs,
|
||||
}: {
|
||||
sourceUrl: string | null;
|
||||
steps: PipelineStep[];
|
||||
includeHistogram?: boolean;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
const { canvasRef, histogram, error, isRendering } = usePipelinePreview({
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth: 320,
|
||||
includeHistogram,
|
||||
debounceMs,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -453,6 +456,33 @@ describe("usePipelinePreview", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports a faster debounce override for local preview updates", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(PreviewHarness, {
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: createLightAdjustSteps(10),
|
||||
includeHistogram: false,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview histogram call sites", () => {
|
||||
@@ -475,7 +505,7 @@ describe("preview histogram call sites", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps histogram enabled for AdjustmentPreview", async () => {
|
||||
it("disables histogram for fast-path AdjustmentPreview", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
@@ -489,11 +519,78 @@ describe("preview histogram call sites", () => {
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({ nodes: [], edges: [] }),
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["light-1", { brightness: 10 }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: () => [],
|
||||
collectPipelineFromGraph: () => [
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
|
||||
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
|
||||
const AdjustmentPreview = adjustmentPreviewModule.default;
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(AdjustmentPreview, {
|
||||
nodeId: "light-1",
|
||||
nodeWidth: 320,
|
||||
currentType: "light-adjust",
|
||||
currentParams: { brightness: 10 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fast-path AdjustmentPreview when overrides belong to another pipeline", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
isRendering: false,
|
||||
hasSource: true,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.doMock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["other-node", { brightness: 10 }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: () => [
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
|
||||
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
|
||||
@@ -513,11 +610,72 @@ describe("preview histogram call sites", () => {
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeHistogram: true,
|
||||
debounceMs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests previews without histogram work in CompareSurface and fullscreen RenderNode", async () => {
|
||||
it("keeps histogram enabled for downstream AdjustmentPreview fast path", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
isRendering: false,
|
||||
hasSource: true,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.doMock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["upstream-node", { brightness: 10 }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: () => [
|
||||
{
|
||||
nodeId: "upstream-node",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
|
||||
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
|
||||
const AdjustmentPreview = adjustmentPreviewModule.default;
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(AdjustmentPreview, {
|
||||
nodeId: "light-1",
|
||||
nodeWidth: 320,
|
||||
currentType: "light-adjust",
|
||||
currentParams: { brightness: 20 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeHistogram: true,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests fast preview rendering without histogram work in CompareSurface and RenderNode", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
@@ -570,14 +728,29 @@ describe("preview histogram call sites", () => {
|
||||
useDebouncedCallback: (callback: () => void) => callback,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({ nodes: [], edges: [] }),
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([
|
||||
["compare-step", { brightness: 20 }],
|
||||
["render-1-pipeline", { format: "png" }],
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
resolveRenderPreviewInputFromGraph: () => ({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
findSourceNodeFromGraph: () => null,
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
@@ -616,7 +789,13 @@ describe("preview histogram call sites", () => {
|
||||
nodeWidth: 320,
|
||||
previewInput: {
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-step",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
preferPreview: true,
|
||||
}),
|
||||
@@ -641,16 +820,229 @@ describe("preview histogram call sites", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-step",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: null,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fast-path CompareSurface or RenderNode for unrelated overrides", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
isRendering: false,
|
||||
hasSource: true,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.doMock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
vi.doMock("convex/react", () => ({
|
||||
useMutation: () => vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("lucide-react", () => ({
|
||||
AlertCircle: () => null,
|
||||
ArrowDown: () => null,
|
||||
CheckCircle2: () => null,
|
||||
CloudUpload: () => null,
|
||||
Loader2: () => null,
|
||||
Maximize2: () => null,
|
||||
X: () => null,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
}));
|
||||
vi.doMock("@/components/canvas/nodes/adjustment-controls", () => ({
|
||||
SliderRow: () => null,
|
||||
}));
|
||||
vi.doMock("@/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.doMock("@/components/canvas/canvas-sync-context", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: vi.fn(async () => undefined),
|
||||
queueNodeResize: vi.fn(async () => undefined),
|
||||
status: { isOffline: false },
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/hooks/use-debounced-callback", () => ({
|
||||
useDebouncedCallback: (callback: () => void) => callback,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["unrelated-node", { format: "png" }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
resolveRenderPreviewInputFromGraph: ({ nodeId }: { nodeId: string }) => ({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: `${nodeId}-pipeline`,
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
findSourceNodeFromGraph: () => null,
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
}));
|
||||
vi.doMock("@/lib/image-formats", () => ({
|
||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||
}));
|
||||
vi.doMock("@/lib/image-pipeline/contracts", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/contracts")>(
|
||||
"@/lib/image-pipeline/contracts",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
hashPipeline: () => "pipeline-hash",
|
||||
};
|
||||
});
|
||||
vi.doMock("@/lib/image-pipeline/worker-client", () => ({
|
||||
isPipelineAbortError: () => false,
|
||||
renderFullWithWorkerFallback: vi.fn(),
|
||||
}));
|
||||
vi.doMock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
}));
|
||||
|
||||
const compareSurfaceModule = await import("@/components/canvas/nodes/compare-surface");
|
||||
const CompareSurface = compareSurfaceModule.default;
|
||||
const renderNodeModule = await import("@/components/canvas/nodes/render-node");
|
||||
const RenderNode = renderNodeModule.default;
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement("div", null,
|
||||
createElement(CompareSurface, {
|
||||
nodeWidth: 320,
|
||||
previewInput: {
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
preferPreview: true,
|
||||
}),
|
||||
createElement(RenderNode, {
|
||||
id: "render-1",
|
||||
data: {},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "render",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 320,
|
||||
height: 300,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
debounceMs: undefined,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
debounceMs: undefined,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: null,
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
debounceMs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user