Files
lemonspace_app/components/canvas/__tests__/use-canvas-drop.test.tsx

539 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Edge as RFEdge } from "@xyflow/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
import {
emitDashboardSnapshotCacheInvalidationSignal,
invalidateDashboardSnapshotForLastSignedInUser,
} from "@/lib/dashboard-snapshot-cache";
import { toast } from "@/lib/toast";
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils";
vi.mock("@/lib/toast", () => ({
toast: {
error: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@/components/canvas/canvas-media-utils", () => ({
getImageDimensions: vi.fn(async () => ({ width: 1600, height: 900 })),
createCompressedImagePreview: vi.fn(async () => ({
blob: new Blob(["preview"], { type: "image/webp" }),
width: 640,
height: 360,
})),
}));
vi.mock("@/lib/dashboard-snapshot-cache", () => ({
invalidateDashboardSnapshotForLastSignedInUser: vi.fn(),
emitDashboardSnapshotCacheInvalidationSignal: vi.fn(),
}));
const latestHandlersRef: {
current: ReturnType<typeof useCanvasDrop> | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
type HookHarnessProps = {
isSyncOnline?: boolean;
generateUploadUrl?: ReturnType<typeof vi.fn>;
registerUploadedImageMedia?: ReturnType<typeof vi.fn>;
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
edges?: RFEdge[];
};
function HookHarness({
isSyncOnline = true,
generateUploadUrl = vi.fn(async () => "https://upload.test"),
registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const })),
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
notifyOfflineUnsupported = vi.fn(),
syncPendingMoveForClientRequest = vi.fn(async () => undefined),
screenToFlowPosition = (position) => position,
edges = [],
}: HookHarnessProps) {
const handlers = useCanvasDrop({
canvasId: asCanvasId("canvas-1"),
isSyncOnline,
t: ((key: string) => key) as (key: string) => string,
edges,
screenToFlowPosition,
generateUploadUrl,
registerUploadedImageMedia,
runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported,
syncPendingMoveForClientRequest,
});
useEffect(() => {
latestHandlersRef.current = handlers;
}, [handlers]);
return null;
}
describe("useCanvasDrop", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ storageId: "storage-1" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ storageId: "preview-storage-1" }),
}),
);
vi.stubGlobal("crypto", {
randomUUID: vi.fn(() => "req-1"),
});
});
afterEach(async () => {
latestHandlersRef.current = null;
vi.clearAllMocks();
consoleErrorSpy.mockRestore();
vi.unstubAllGlobals();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("creates a node from a raw sidebar node type drop", async () => {
const runCreateNodeOnlineOnly = vi.fn(async () => "node-1");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 120,
clientY: 340,
dataTransfer: {
getData: vi.fn((type: string) =>
type === CANVAS_NODE_DND_MIME ? "image" : "",
),
files: [],
},
} as unknown as React.DragEvent);
});
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({
canvasId: "canvas-1",
type: "image",
positionX: 120,
positionY: 340,
width: NODE_DEFAULTS.image.width,
height: NODE_DEFAULTS.image.height,
data: {
...NODE_DEFAULTS.image.data,
canvasId: "canvas-1",
},
clientRequestId: "req-1",
});
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-1");
});
it("creates an image node from a dropped image file", async () => {
const generateUploadUrl = vi.fn(async () => "https://upload.test");
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
generateUploadUrl={generateUploadUrl}
registerUploadedImageMedia={registerUploadedImageMedia}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 240,
clientY: 180,
dataTransfer: {
getData: vi.fn(() => ""),
files: [file],
},
} as unknown as React.DragEvent);
});
expect(generateUploadUrl).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenNthCalledWith(1, "https://upload.test", {
method: "POST",
headers: { "Content-Type": "image/png" },
body: file,
});
expect(fetch).toHaveBeenNthCalledWith(2, "https://upload.test", {
method: "POST",
headers: { "Content-Type": "image/webp" },
body: expect.any(Blob),
});
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({
canvasId: "canvas-1",
type: "image",
positionX: 240,
positionY: 180,
width: NODE_DEFAULTS.image.width,
height: NODE_DEFAULTS.image.height,
data: {
storageId: "storage-1",
previewStorageId: "preview-storage-1",
filename: "photo.png",
mimeType: "image/png",
width: 1600,
height: 900,
previewWidth: 640,
previewHeight: 360,
canvasId: "canvas-1",
},
clientRequestId: "req-1",
});
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith(
"req-1",
"node-image",
);
expect(registerUploadedImageMedia).toHaveBeenCalledWith({
canvasId: "canvas-1",
nodeId: "node-image",
storageId: "storage-1",
filename: "photo.png",
mimeType: "image/png",
width: 1600,
height: 900,
});
expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1);
expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1);
});
it("registers dropped image media when node creation fails", async () => {
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
const runCreateNodeOnlineOnly = vi.fn(async () => {
throw new Error("create failed");
});
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
registerUploadedImageMedia={registerUploadedImageMedia}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 240,
clientY: 180,
dataTransfer: {
getData: vi.fn(() => ""),
files: [file],
},
} as unknown as React.DragEvent);
});
await act(async () => {
await Promise.resolve();
});
expect(syncPendingMoveForClientRequest).not.toHaveBeenCalled();
expect(registerUploadedImageMedia).toHaveBeenCalledWith({
canvasId: "canvas-1",
storageId: "storage-1",
filename: "photo.png",
mimeType: "image/png",
width: 1600,
height: 900,
});
expect(registerUploadedImageMedia).not.toHaveBeenCalledWith(
expect.objectContaining({ nodeId: expect.anything() }),
);
expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1);
expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1);
});
it("creates a node from a JSON payload drop", async () => {
const runCreateNodeOnlineOnly = vi.fn(async () => "node-video");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 90,
clientY: 75,
dataTransfer: {
getData: vi.fn((type: string) =>
type === CANVAS_NODE_DND_MIME
? JSON.stringify({
type: "video",
data: {
assetId: "asset-42",
label: "Clip",
},
})
: "",
),
files: [],
},
} as unknown as React.DragEvent);
});
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({
canvasId: "canvas-1",
type: "video",
positionX: 90,
positionY: 75,
width: NODE_DEFAULTS.video.width,
height: NODE_DEFAULTS.video.height,
data: {
...NODE_DEFAULTS.video.data,
assetId: "asset-42",
label: "Clip",
canvasId: "canvas-1",
},
clientRequestId: "req-1",
});
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
});
it("continues with original upload when preview generation fails", async () => {
vi.mocked(createCompressedImagePreview).mockRejectedValueOnce(
new Error("preview failed"),
);
const generateUploadUrl = vi.fn(async () => "https://upload.test");
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
generateUploadUrl={generateUploadUrl}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 20,
clientY: 10,
dataTransfer: {
getData: vi.fn(() => ""),
files: [file],
},
} as unknown as React.DragEvent);
});
expect(generateUploadUrl).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(1);
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
storageId: "storage-1",
}),
}),
);
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({
previewStorageId: expect.anything(),
}),
}),
);
});
it("splits an intersected persisted edge for sidebar node drops", async () => {
const runCreateNodeOnlineOnly = vi.fn(async () => "node-note");
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const edgeContainer = document.createElement("g");
edgeContainer.classList.add("react-flow__edge");
edgeContainer.setAttribute("data-id", "edge-a");
const interaction = document.createElement("path");
interaction.classList.add("react-flow__edge-interaction");
edgeContainer.appendChild(interaction);
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => [interaction]),
configurable: true,
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
edges={[{ id: "edge-a", source: "node-1", target: "node-2" } as RFEdge]}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 120,
clientY: 340,
dataTransfer: {
getData: vi.fn((type: string) =>
type === CANVAS_NODE_DND_MIME ? "note" : "",
),
files: [],
},
} as unknown as React.DragEvent);
});
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({
canvasId: "canvas-1",
type: "note",
positionX: 120,
positionY: 340,
width: NODE_DEFAULTS.note.width,
height: NODE_DEFAULTS.note.height,
data: {
...NODE_DEFAULTS.note.data,
canvasId: "canvas-1",
},
splitEdgeId: "edge-a",
newNodeTargetHandle: undefined,
newNodeSourceHandle: undefined,
splitSourceHandle: undefined,
splitTargetHandle: undefined,
clientRequestId: "req-1",
});
expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled();
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-note");
});
it("shows an upload failure toast when the dropped file upload fails", async () => {
const generateUploadUrl = vi.fn(async () => "https://upload.test");
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
json: async () => ({ storageId: "storage-1" }),
})),
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
generateUploadUrl={generateUploadUrl}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 240,
clientY: 180,
dataTransfer: {
getData: vi.fn(() => ""),
files: [file],
},
} as unknown as React.DragEvent);
});
expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled();
expect(syncPendingMoveForClientRequest).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to upload dropped file:",
expect.any(Error),
);
expect(toast.error).toHaveBeenCalledWith("canvas.uploadFailed", "Upload failed");
});
});