Files
lemonspace_app/tests/crop-node.test.ts

323 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" },
useConnection: () => ({ inProgress: false }),
}));
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();
});
});