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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user