322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
// @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();
|
|
});
|
|
});
|