Enhance canvas functionality by adding media preview capabilities and image upload handling. Introduce compressed image previews during uploads, improve media library integration, and implement retry logic for bridge edge creation. Update dashboard to display media previews and optimize image node handling.
This commit is contained in:
@@ -62,6 +62,88 @@ describe("canvas connection policy", () => {
|
||||
).toBe("adjustment-source-invalid");
|
||||
});
|
||||
|
||||
it("allows image sources to crop", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "image",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows video sources to crop", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "video",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows ai-video sources to crop", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "ai-video",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows chained crop nodes", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "crop",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks unsupported crop sources", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "prompt",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBe("crop-source-invalid");
|
||||
});
|
||||
|
||||
it("limits crop nodes to one incoming connection", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "image",
|
||||
targetType: "crop",
|
||||
targetIncomingCount: 1,
|
||||
}),
|
||||
).toBe("crop-incoming-limit");
|
||||
});
|
||||
|
||||
it("allows crop output as render source", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "crop",
|
||||
targetType: "render",
|
||||
targetIncomingCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("describes unsupported crop source message", () => {
|
||||
expect(getCanvasConnectionValidationMessage("crop-source-invalid")).toBe(
|
||||
"Crop akzeptiert nur Bild-, Asset-, KI-Bild-, Video-, KI-Video-, Crop- oder Adjustment-Input.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes crop incoming limit", () => {
|
||||
expect(getCanvasConnectionValidationMessage("crop-incoming-limit")).toBe(
|
||||
"Crop-Nodes erlauben genau eine eingehende Verbindung.",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks ai-video as render source", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
@@ -148,7 +149,8 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("logs bridge payload details when bridge edge creation fails", async () => {
|
||||
it("logs bridge payload details when bridge edge creation retries are exhausted", async () => {
|
||||
vi.useFakeTimers();
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
@@ -200,9 +202,12 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[Canvas] bridge edge create failed",
|
||||
expect.objectContaining({
|
||||
@@ -214,7 +219,9 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
},
|
||||
error: bridgeError,
|
||||
attempt: 4,
|
||||
maxAttempts: 4,
|
||||
error: bridgeError.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
84
tests/crop-node-data-validation.test.ts
Normal file
84
tests/crop-node-data-validation.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_CROP_NODE_DATA,
|
||||
normalizeCropNodeData,
|
||||
} from "@/lib/image-pipeline/crop-node-data";
|
||||
|
||||
describe("crop node data validation", () => {
|
||||
it("normalizes and clamps crop rectangle data", () => {
|
||||
expect(
|
||||
normalizeCropNodeData({
|
||||
crop: {
|
||||
x: -0.2,
|
||||
y: 0.9,
|
||||
width: 0.8,
|
||||
height: 0.4,
|
||||
},
|
||||
resize: {
|
||||
mode: "custom",
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
fit: "cover",
|
||||
keepAspect: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
crop: {
|
||||
x: 0,
|
||||
y: 0.6,
|
||||
width: 0.8,
|
||||
height: 0.4,
|
||||
},
|
||||
resize: {
|
||||
mode: "custom",
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
fit: "cover",
|
||||
keepAspect: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to defaults for invalid values", () => {
|
||||
expect(
|
||||
normalizeCropNodeData({
|
||||
crop: {
|
||||
x: Number.NaN,
|
||||
y: "foo",
|
||||
width: 2,
|
||||
height: -1,
|
||||
},
|
||||
resize: {
|
||||
mode: "invalid",
|
||||
width: 0,
|
||||
height: Number.NaN,
|
||||
fit: "invalid",
|
||||
keepAspect: "invalid",
|
||||
},
|
||||
}),
|
||||
).toEqual(DEFAULT_CROP_NODE_DATA);
|
||||
});
|
||||
|
||||
it("rejects destructive payload fields", () => {
|
||||
expect(() =>
|
||||
normalizeCropNodeData(
|
||||
{
|
||||
...DEFAULT_CROP_NODE_DATA,
|
||||
storageId: "storage_123",
|
||||
},
|
||||
{ rejectDisallowedPayloadFields: true },
|
||||
),
|
||||
).toThrow("Crop node accepts parameter data only. 'storageId' is not allowed in data.");
|
||||
|
||||
expect(() =>
|
||||
normalizeCropNodeData(
|
||||
{
|
||||
...DEFAULT_CROP_NODE_DATA,
|
||||
imageData: "...",
|
||||
},
|
||||
{ rejectDisallowedPayloadFields: true },
|
||||
),
|
||||
).toThrow("Crop node accepts parameter data only. 'imageData' is not allowed in data.");
|
||||
});
|
||||
});
|
||||
321
tests/crop-node.test.ts
Normal file
321
tests/crop-node.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
// @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 type { CropNodeData } from "@/lib/image-pipeline/crop-node-data";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queueNodeDataUpdate: vi.fn(async () => undefined),
|
||||
setPreviewNodeDataOverride: vi.fn(),
|
||||
clearPreviewNodeDataOverride: vi.fn(),
|
||||
collectPipelineFromGraph: vi.fn(() => []),
|
||||
getSourceImageFromGraph: vi.fn(() => "https://cdn.example.com/source.png"),
|
||||
shouldFastPathPreviewPipeline: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Crop: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({ nodes: [], edges: [], previewNodeDataOverrides: {} }),
|
||||
useCanvasGraphPreviewOverrides: () => ({
|
||||
setPreviewNodeDataOverride: mocks.setPreviewNodeDataOverride,
|
||||
clearPreviewNodeDataOverride: mocks.clearPreviewNodeDataOverride,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: () => ({
|
||||
canvasRef: { current: null },
|
||||
hasSource: true,
|
||||
isRendering: false,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: mocks.collectPipelineFromGraph,
|
||||
getSourceImageFromGraph: mocks.getSourceImageFromGraph,
|
||||
shouldFastPathPreviewPipeline: mocks.shouldFastPathPreviewPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
SelectValue: () => null,
|
||||
}));
|
||||
|
||||
import CropNode from "@/components/canvas/nodes/crop-node";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type PointerInit = {
|
||||
pointerId?: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
function dispatchPointerEvent(target: Element, type: string, init: PointerInit) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: init.clientX,
|
||||
clientY: init.clientY,
|
||||
}) as MouseEvent & { pointerId?: number };
|
||||
event.pointerId = init.pointerId ?? 1;
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function getNumberInput(container: HTMLElement, labelKey: string): HTMLInputElement {
|
||||
const label = Array.from(container.querySelectorAll("label")).find((element) =>
|
||||
element.textContent?.includes(labelKey),
|
||||
);
|
||||
if (!(label instanceof HTMLLabelElement)) {
|
||||
throw new Error(`Label not found: ${labelKey}`);
|
||||
}
|
||||
const input = label.querySelector("input[type='number']");
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error(`Input not found for: ${labelKey}`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("CropNode", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.queueNodeDataUpdate.mockClear();
|
||||
mocks.setPreviewNodeDataOverride.mockClear();
|
||||
mocks.clearPreviewNodeDataOverride.mockClear();
|
||||
mocks.collectPipelineFromGraph.mockClear();
|
||||
mocks.collectPipelineFromGraph.mockReturnValue([]);
|
||||
|
||||
if (!("setPointerCapture" in HTMLElement.prototype)) {
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
}
|
||||
if (!("releasePointerCapture" in HTMLElement.prototype)) {
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
}
|
||||
vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLElement.prototype, "releasePointerCapture").mockImplementation(() => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderNode(data: CropNodeData) {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(CropNode, {
|
||||
id: "crop-1",
|
||||
data,
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "crop",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 320,
|
||||
height: 360,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setPreviewBounds() {
|
||||
const preview = container?.querySelector("[data-testid='crop-preview-area']");
|
||||
if (!(preview instanceof HTMLElement)) {
|
||||
throw new Error("Preview area not found");
|
||||
}
|
||||
vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
it("moves crop rect when dragging inside overlay", async () => {
|
||||
await renderNode({
|
||||
crop: { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
resize: { mode: "source", fit: "cover", keepAspect: false },
|
||||
});
|
||||
setPreviewBounds();
|
||||
|
||||
const overlay = container?.querySelector("[data-testid='crop-overlay']");
|
||||
if (!(overlay instanceof HTMLElement)) {
|
||||
throw new Error("Overlay not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(overlay, "pointerdown", { clientX: 40, clientY: 40 });
|
||||
dispatchPointerEvent(overlay, "pointermove", { clientX: 60, clientY: 60 });
|
||||
dispatchPointerEvent(overlay, "pointerup", { clientX: 60, clientY: 60 });
|
||||
});
|
||||
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.x").value).toBe("0.2");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.y").value).toBe("0.2");
|
||||
});
|
||||
|
||||
it("resizes crop rect from corner and edge handles", async () => {
|
||||
await renderNode({
|
||||
crop: { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
resize: { mode: "source", fit: "cover", keepAspect: false },
|
||||
});
|
||||
setPreviewBounds();
|
||||
|
||||
const eastHandle = container?.querySelector("[data-testid='crop-handle-e']");
|
||||
const southEastHandle = container?.querySelector("[data-testid='crop-handle-se']");
|
||||
if (!(eastHandle instanceof HTMLElement) || !(southEastHandle instanceof HTMLElement)) {
|
||||
throw new Error("Resize handles not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(eastHandle, "pointerdown", { clientX: 100, clientY: 80 });
|
||||
dispatchPointerEvent(eastHandle, "pointermove", { clientX: 140, clientY: 80 });
|
||||
dispatchPointerEvent(eastHandle, "pointerup", { clientX: 140, clientY: 80 });
|
||||
});
|
||||
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.width").value).toBe("0.6");
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(southEastHandle, "pointerdown", { clientX: 140, clientY: 140 });
|
||||
dispatchPointerEvent(southEastHandle, "pointermove", { clientX: 160, clientY: 180 });
|
||||
dispatchPointerEvent(southEastHandle, "pointerup", { clientX: 160, clientY: 180 });
|
||||
});
|
||||
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.width").value).toBe("0.7");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.height").value).toBe("0.6");
|
||||
});
|
||||
|
||||
it("preserves aspect ratio while resizing when keepAspect is enabled", async () => {
|
||||
await renderNode({
|
||||
crop: { x: 0.1, y: 0.1, width: 0.4, height: 0.2 },
|
||||
resize: { mode: "source", fit: "cover", keepAspect: true },
|
||||
});
|
||||
setPreviewBounds();
|
||||
|
||||
const southEastHandle = container?.querySelector("[data-testid='crop-handle-se']");
|
||||
if (!(southEastHandle instanceof HTMLElement)) {
|
||||
throw new Error("Corner handle not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(southEastHandle, "pointerdown", { clientX: 100, clientY: 60 });
|
||||
dispatchPointerEvent(southEastHandle, "pointermove", { clientX: 140, clientY: 60 });
|
||||
dispatchPointerEvent(southEastHandle, "pointerup", { clientX: 140, clientY: 60 });
|
||||
});
|
||||
|
||||
expect(Number(getNumberInput(container as HTMLElement, "adjustments.crop.fields.width").value)).toBeCloseTo(
|
||||
0.6,
|
||||
6,
|
||||
);
|
||||
expect(Number(getNumberInput(container as HTMLElement, "adjustments.crop.fields.height").value)).toBeCloseTo(
|
||||
0.3,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps drag operations to image bounds", async () => {
|
||||
await renderNode({
|
||||
crop: { x: 0.7, y: 0.7, width: 0.3, height: 0.3 },
|
||||
resize: { mode: "source", fit: "cover", keepAspect: false },
|
||||
});
|
||||
setPreviewBounds();
|
||||
|
||||
const overlay = container?.querySelector("[data-testid='crop-overlay']");
|
||||
if (!(overlay instanceof HTMLElement)) {
|
||||
throw new Error("Overlay not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(overlay, "pointerdown", { clientX: 150, clientY: 150 });
|
||||
dispatchPointerEvent(overlay, "pointermove", { clientX: -50, clientY: -50 });
|
||||
dispatchPointerEvent(overlay, "pointerup", { clientX: -50, clientY: -50 });
|
||||
});
|
||||
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.x").value).toBe("0");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.y").value).toBe("0");
|
||||
});
|
||||
|
||||
it("ignores drag starts outside overlay and handles", async () => {
|
||||
await renderNode({
|
||||
crop: { x: 0.2, y: 0.2, width: 0.4, height: 0.4 },
|
||||
resize: { mode: "source", fit: "cover", keepAspect: false },
|
||||
});
|
||||
setPreviewBounds();
|
||||
|
||||
const preview = container?.querySelector("[data-testid='crop-preview-area']");
|
||||
if (!(preview instanceof HTMLElement)) {
|
||||
throw new Error("Preview not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(preview, "pointerdown", { clientX: 10, clientY: 10 });
|
||||
dispatchPointerEvent(preview, "pointermove", { clientX: 120, clientY: 120 });
|
||||
dispatchPointerEvent(preview, "pointerup", { clientX: 120, clientY: 120 });
|
||||
});
|
||||
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.x").value).toBe("0.2");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.y").value).toBe("0.2");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.width").value).toBe("0.4");
|
||||
expect(getNumberInput(container as HTMLElement, "adjustments.crop.fields.height").value).toBe("0.4");
|
||||
expect(mocks.setPreviewNodeDataOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
149
tests/image-pipeline/geometry-transform.test.ts
Normal file
149
tests/image-pipeline/geometry-transform.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { normalizeCropResizeStepParams } from "@/lib/image-pipeline/adjustment-types";
|
||||
import { applyGeometryStepsToSource } from "@/lib/image-pipeline/geometry-transform";
|
||||
|
||||
describe("crop/resize normalization", () => {
|
||||
it("falls back to default full-frame crop when params are invalid", () => {
|
||||
expect(normalizeCropResizeStepParams(null)).toEqual({
|
||||
cropRect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
resize: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps normalized crop rect and rounds resize dimensions", () => {
|
||||
expect(
|
||||
normalizeCropResizeStepParams({
|
||||
cropRect: {
|
||||
x: -0.25,
|
||||
y: 0.2,
|
||||
width: 1.75,
|
||||
height: 0.5,
|
||||
},
|
||||
resize: {
|
||||
width: 99.7,
|
||||
height: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
cropRect: {
|
||||
x: 0,
|
||||
y: 0.2,
|
||||
width: 1,
|
||||
height: 0.5,
|
||||
},
|
||||
resize: {
|
||||
width: 100,
|
||||
height: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("geometry transform", () => {
|
||||
it("applies crop before tonal execution and updates output dimensions", () => {
|
||||
const contexts: Array<{ drawImage: ReturnType<typeof vi.fn> }> = [];
|
||||
const nativeCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName.toLowerCase() !== "canvas") {
|
||||
return nativeCreateElement(tagName);
|
||||
}
|
||||
|
||||
const context = {
|
||||
drawImage: vi.fn(),
|
||||
};
|
||||
contexts.push(context);
|
||||
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn().mockReturnValue(context),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
});
|
||||
|
||||
const source = { width: 4, height: 2 } as CanvasImageSource;
|
||||
|
||||
const result = applyGeometryStepsToSource({
|
||||
source,
|
||||
steps: [
|
||||
{
|
||||
nodeId: "crop-1",
|
||||
type: "crop",
|
||||
params: {
|
||||
cropRect: {
|
||||
x: 0.5,
|
||||
y: 0,
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.width).toBe(2);
|
||||
expect(result.height).toBe(2);
|
||||
expect(contexts).toHaveLength(2);
|
||||
expect(contexts[0]!.drawImage).toHaveBeenCalledWith(source, 0, 0, 4, 2);
|
||||
expect(contexts[1]!.drawImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 4, height: 2 }),
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies resize dimensions from crop params", () => {
|
||||
const nativeCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName.toLowerCase() !== "canvas") {
|
||||
return nativeCreateElement(tagName);
|
||||
}
|
||||
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
});
|
||||
|
||||
const source = { width: 4, height: 4 } as CanvasImageSource;
|
||||
|
||||
const result = applyGeometryStepsToSource({
|
||||
source,
|
||||
steps: [
|
||||
{
|
||||
nodeId: "crop-1",
|
||||
type: "crop",
|
||||
params: {
|
||||
cropRect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
resize: {
|
||||
width: 3,
|
||||
height: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.width).toBe(3);
|
||||
expect(result.height).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -298,4 +298,61 @@ describe("loadSourceBitmap", () => {
|
||||
await expect(loadSourceBitmap(sourceUrl)).resolves.toBe(secondBitmap);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("extracts the first decodable frame for video sources", async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: vi.fn().mockReturnValue("video/mp4"),
|
||||
},
|
||||
blob: vi.fn().mockResolvedValue(blob),
|
||||
};
|
||||
|
||||
const fakeVideo: Partial<HTMLVideoElement> & {
|
||||
onloadeddata: ((event: Event) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
load: () => void;
|
||||
} = {
|
||||
muted: false,
|
||||
playsInline: false,
|
||||
preload: "none",
|
||||
onloadeddata: null,
|
||||
onerror: null,
|
||||
load() {
|
||||
this.onloadeddata?.(new Event("loadeddata"));
|
||||
},
|
||||
pause: vi.fn(),
|
||||
removeAttribute: vi.fn(),
|
||||
};
|
||||
|
||||
const createObjectUrl = vi.fn().mockReturnValue("blob:video-source");
|
||||
const revokeObjectUrl = vi.fn();
|
||||
const nativeCreateElement = document.createElement.bind(document);
|
||||
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
Object.assign(URL, {
|
||||
createObjectURL: createObjectUrl,
|
||||
revokeObjectURL: revokeObjectUrl,
|
||||
}),
|
||||
);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName.toLowerCase() === "video") {
|
||||
return fakeVideo as HTMLVideoElement;
|
||||
}
|
||||
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
|
||||
const { loadSourceBitmap } = await importSubject();
|
||||
await expect(loadSourceBitmap("https://cdn.example.com/video.mp4")).resolves.toBe(bitmap);
|
||||
|
||||
expect(response.headers.get).toHaveBeenCalledWith("content-type");
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(blob);
|
||||
expect(createImageBitmap).toHaveBeenCalledWith(fakeVideo);
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:video-source");
|
||||
});
|
||||
});
|
||||
|
||||
92
tests/lib/canvas-render-preview.test.ts
Normal file
92
tests/lib/canvas-render-preview.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
describe("resolveRenderPreviewInputFromGraph", () => {
|
||||
it("includes crop in collected pipeline steps", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/source.png" },
|
||||
},
|
||||
{
|
||||
id: "crop-1",
|
||||
type: "crop",
|
||||
data: { cropRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.3 } },
|
||||
},
|
||||
{
|
||||
id: "render-1",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-1", target: "crop-1" },
|
||||
{ source: "crop-1", target: "render-1" },
|
||||
],
|
||||
);
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({
|
||||
nodeId: "render-1",
|
||||
graph,
|
||||
});
|
||||
|
||||
expect(preview.steps).toEqual([
|
||||
{
|
||||
nodeId: "crop-1",
|
||||
type: "crop",
|
||||
params: { cropRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.3 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives proxied pexels video source URL from mp4Url", () => {
|
||||
const mp4Url = "https://player.pexels.com/videos/example.mp4";
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "video-1",
|
||||
type: "video",
|
||||
data: { mp4Url },
|
||||
},
|
||||
{
|
||||
id: "render-1",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[{ source: "video-1", target: "render-1" }],
|
||||
);
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({ nodeId: "render-1", graph });
|
||||
|
||||
expect(preview.sourceUrl).toBe(`/api/pexels-video?u=${encodeURIComponent(mp4Url)}`);
|
||||
});
|
||||
|
||||
it("uses ai-video data.url as source URL when available", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "ai-video-1",
|
||||
type: "ai-video",
|
||||
data: { url: "https://cdn.example.com/generated-video.mp4" },
|
||||
},
|
||||
{
|
||||
id: "render-1",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[{ source: "ai-video-1", target: "render-1" }],
|
||||
);
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({ nodeId: "render-1", graph });
|
||||
|
||||
expect(preview.sourceUrl).toBe("https://cdn.example.com/generated-video.mp4");
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,20 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearDashboardSnapshotCache,
|
||||
emitDashboardSnapshotCacheInvalidationSignal,
|
||||
invalidateDashboardSnapshotForLastSignedInUser,
|
||||
readDashboardSnapshotCache,
|
||||
writeDashboardSnapshotCache,
|
||||
} from "@/lib/dashboard-snapshot-cache";
|
||||
|
||||
const USER_ID = "user-cache-test";
|
||||
const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user";
|
||||
const INVALIDATION_SIGNAL_KEY = "lemonspace.dashboard:snapshot:invalidate:v1";
|
||||
|
||||
describe("dashboard snapshot cache", () => {
|
||||
beforeEach(() => {
|
||||
const data = new Map<string, string>();
|
||||
const sessionData = new Map<string, string>();
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
@@ -22,11 +27,24 @@ describe("dashboard snapshot cache", () => {
|
||||
data.delete(key);
|
||||
},
|
||||
};
|
||||
const sessionStorageMock = {
|
||||
getItem: (key: string) => sessionData.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
sessionData.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
sessionData.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
value: sessionStorageMock,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
clearDashboardSnapshotCache(USER_ID);
|
||||
});
|
||||
@@ -70,4 +88,26 @@ describe("dashboard snapshot cache", () => {
|
||||
|
||||
expect(readDashboardSnapshotCache(USER_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("invalidates cache for the last signed-in user", () => {
|
||||
writeDashboardSnapshotCache(USER_ID, { generatedAt: 1 });
|
||||
window.sessionStorage.setItem(LAST_DASHBOARD_USER_KEY, USER_ID);
|
||||
|
||||
invalidateDashboardSnapshotForLastSignedInUser();
|
||||
|
||||
expect(readDashboardSnapshotCache(USER_ID)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(LAST_DASHBOARD_USER_KEY)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it("does not fail if no last dashboard user exists", () => {
|
||||
expect(() => invalidateDashboardSnapshotForLastSignedInUser()).not.toThrow();
|
||||
});
|
||||
|
||||
it("emits a localStorage invalidation signal", () => {
|
||||
emitDashboardSnapshotCacheInvalidationSignal();
|
||||
|
||||
const signal = window.localStorage.getItem(INVALIDATION_SIGNAL_KEY);
|
||||
expect(typeof signal).toBe("string");
|
||||
expect(Number(signal)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
155
tests/use-canvas-drop.test.ts
Normal file
155
tests/use-canvas-drop.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/* @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";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
const getImageDimensionsMock = vi.hoisted(() => vi.fn());
|
||||
const createCompressedImagePreviewMock = vi.hoisted(() => vi.fn());
|
||||
const invalidateDashboardSnapshotForLastSignedInUserMock = vi.hoisted(() => vi.fn());
|
||||
const emitDashboardSnapshotCacheInvalidationSignalMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/canvas/canvas-media-utils", () => ({
|
||||
getImageDimensions: getImageDimensionsMock,
|
||||
createCompressedImagePreview: createCompressedImagePreviewMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dashboard-snapshot-cache", () => ({
|
||||
invalidateDashboardSnapshotForLastSignedInUser:
|
||||
invalidateDashboardSnapshotForLastSignedInUserMock,
|
||||
emitDashboardSnapshotCacheInvalidationSignal:
|
||||
emitDashboardSnapshotCacheInvalidationSignalMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
|
||||
|
||||
const latestHandlers: {
|
||||
current: ReturnType<typeof useCanvasDrop> | null;
|
||||
} = { current: null };
|
||||
|
||||
type RunCreateNodeOnlineOnly = Parameters<typeof useCanvasDrop>[0]["runCreateNodeOnlineOnly"];
|
||||
|
||||
type HarnessProps = {
|
||||
runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly;
|
||||
};
|
||||
|
||||
function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) {
|
||||
const value = useCanvasDrop({
|
||||
canvasId: "canvas_1" as Id<"canvases">,
|
||||
isSyncOnline: true,
|
||||
t: (key: string) => key,
|
||||
edges: [],
|
||||
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
||||
generateUploadUrl: async () => "https://upload.example.com",
|
||||
registerUploadedImageMedia: async () => ({ ok: true }),
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly: async () => "node_split_1" as Id<"nodes">,
|
||||
notifyOfflineUnsupported: () => {},
|
||||
syncPendingMoveForClientRequest: async () => {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlers.current = value;
|
||||
return () => {
|
||||
latestHandlers.current = null;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("useCanvasDrop image upload path", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
latestHandlers.current = null;
|
||||
getImageDimensionsMock.mockReset();
|
||||
createCompressedImagePreviewMock.mockReset();
|
||||
invalidateDashboardSnapshotForLastSignedInUserMock.mockReset();
|
||||
emitDashboardSnapshotCacheInvalidationSignalMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("invalidates dashboard snapshot after successful dropped image upload", async () => {
|
||||
getImageDimensionsMock.mockResolvedValue({ width: 640, height: 480 });
|
||||
createCompressedImagePreviewMock.mockResolvedValue({
|
||||
blob: new Blob(["preview"], { type: "image/webp" }),
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "storage_1" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "preview_storage_1" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("crypto", {
|
||||
randomUUID: () => "client-request-id",
|
||||
});
|
||||
|
||||
const runCreateNodeOnlineOnly = vi
|
||||
.fn<HarnessProps["runCreateNodeOnlineOnly"]>()
|
||||
.mockResolvedValue("node_1" as Id<"nodes">);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
runCreateNodeOnlineOnly,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const file = new File(["file"], "drop.png", { type: "image/png" });
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlers.current?.onDrop({
|
||||
preventDefault: () => {},
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
dataTransfer: {
|
||||
getData: () => "",
|
||||
files: [file],
|
||||
},
|
||||
} as unknown as React.DragEvent);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(runCreateNodeOnlineOnly).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateDashboardSnapshotForLastSignedInUserMock).toHaveBeenCalledTimes(1);
|
||||
expect(emitDashboardSnapshotCacheInvalidationSignalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user