import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MultiSelectToolbar } from "@/components/MultiSelectToolbar";
import { ReactFlowProvider } from "@xyflow/react";
// Mock JSZip
vi.mock("jszip", () => ({
default: vi.fn().mockImplementation(() => ({
file: vi.fn(),
generateAsync: vi.fn().mockResolvedValue(new Blob()),
})),
}));
// Mock the workflow store
const mockOnNodesChange = vi.fn();
const mockCreateGroup = vi.fn();
const mockRemoveNodesFromGroup = vi.fn();
const mockUseWorkflowStore = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
if (selector) {
return mockUseWorkflowStore(selector);
}
return mockUseWorkflowStore((s: unknown) => s);
},
}));
// Mock useReactFlow
const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 }));
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getViewport: mockGetViewport,
}),
};
});
// Wrapper component for React Flow context
function TestWrapper({ children }: { children: React.ReactNode }) {
return {children};
}
// Create mock nodes for testing
const createMockNode = (id: string, overrides = {}) => ({
id,
type: "prompt",
position: { x: 100, y: 100 },
data: {},
selected: true,
measured: { width: 220, height: 200 },
...overrides,
});
// Default store state factory
const createDefaultState = (overrides = {}) => ({
nodes: [],
onNodesChange: mockOnNodesChange,
createGroup: mockCreateGroup,
removeNodesFromGroup: mockRemoveNodesFromGroup,
...overrides,
});
describe("MultiSelectToolbar", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation - no nodes selected
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState());
});
});
describe("Visibility", () => {
it("should not render when no nodes are selected", () => {
const { container } = render(
);
expect(container.firstChild).toBeNull();
});
it("should not render when only one node is selected", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [createMockNode("node-1")],
}));
});
const { container } = render(
);
expect(container.firstChild).toBeNull();
});
it("should render when two or more nodes are selected", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 300, y: 0 } }),
],
}));
});
render(
);
expect(screen.getByTitle("Stack horizontally (H)")).toBeInTheDocument();
});
it("should not render when nodes are selected but less than 2", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1"),
createMockNode("node-2", { selected: false }),
],
}));
});
const { container } = render(
);
expect(container.firstChild).toBeNull();
});
});
describe("Basic Rendering", () => {
beforeEach(() => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 300, y: 0 } }),
],
}));
});
});
it("should render stack horizontally button", () => {
render(
);
expect(screen.getByTitle("Stack horizontally (H)")).toBeInTheDocument();
});
it("should render stack vertically button", () => {
render(
);
expect(screen.getByTitle("Stack vertically (V)")).toBeInTheDocument();
});
it("should render arrange as grid button", () => {
render(
);
expect(screen.getByTitle("Arrange as grid (G)")).toBeInTheDocument();
});
it("should render create group button when nodes are not in a group", () => {
render(
);
expect(screen.getByTitle("Create group")).toBeInTheDocument();
});
it("should render download images button", () => {
render(
);
expect(screen.getByTitle("Download images as ZIP")).toBeInTheDocument();
});
});
describe("Stack Horizontally", () => {
it("should call onNodesChange when stack horizontally button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 50 } }),
createMockNode("node-2", { position: { x: 300, y: 0 } }),
],
}));
});
render(
);
const stackHorizontalButton = screen.getByTitle("Stack horizontally (H)");
fireEvent.click(stackHorizontalButton);
// Should be called for each node
expect(mockOnNodesChange).toHaveBeenCalled();
});
it("should position nodes from left to right based on their original x position", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 500, y: 100 } }),
createMockNode("node-2", { position: { x: 100, y: 50 } }),
],
}));
});
render(
);
fireEvent.click(screen.getByTitle("Stack horizontally (H)"));
// First call should position node-2 (at x=100, so it comes first)
expect(mockOnNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "position",
id: "node-2",
position: expect.objectContaining({ y: 50 }), // Aligned to topmost y
}),
]);
});
});
describe("Stack Vertically", () => {
it("should call onNodesChange when stack vertically button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 50, y: 0 } }),
createMockNode("node-2", { position: { x: 0, y: 300 } }),
],
}));
});
render(
);
const stackVerticalButton = screen.getByTitle("Stack vertically (V)");
fireEvent.click(stackVerticalButton);
expect(mockOnNodesChange).toHaveBeenCalled();
});
it("should position nodes from top to bottom based on their original y position", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 100, y: 500 } }),
createMockNode("node-2", { position: { x: 50, y: 100 } }),
],
}));
});
render(
);
fireEvent.click(screen.getByTitle("Stack vertically (V)"));
// First call should position node-2 (at y=100, so it comes first)
expect(mockOnNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "position",
id: "node-2",
position: expect.objectContaining({ x: 50 }), // Aligned to leftmost x
}),
]);
});
});
describe("Arrange as Grid", () => {
it("should call onNodesChange when arrange as grid button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 100, y: 100 } }),
createMockNode("node-3", { position: { x: 200, y: 200 } }),
createMockNode("node-4", { position: { x: 300, y: 300 } }),
],
}));
});
render(
);
const gridButton = screen.getByTitle("Arrange as grid (G)");
fireEvent.click(gridButton);
expect(mockOnNodesChange).toHaveBeenCalled();
});
it("should arrange nodes in a grid pattern", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 500, y: 0 } }),
createMockNode("node-3", { position: { x: 0, y: 500 } }),
createMockNode("node-4", { position: { x: 500, y: 500 } }),
],
}));
});
render(
);
fireEvent.click(screen.getByTitle("Arrange as grid (G)"));
// With 4 nodes, should create a 2x2 grid
expect(mockOnNodesChange).toHaveBeenCalled();
});
});
describe("Create Group", () => {
it("should call createGroup with selected node IDs when create group button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 300, y: 0 } }),
],
}));
});
render(
);
const createGroupButton = screen.getByTitle("Create group");
fireEvent.click(createGroupButton);
expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]);
});
it("should show ungroup button when selected nodes are in a group", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }),
createMockNode("node-2", { position: { x: 300, y: 0 }, groupId: "group-1" }),
],
}));
});
render(
);
expect(screen.getByTitle("Remove from group")).toBeInTheDocument();
expect(screen.queryByTitle("Create group")).not.toBeInTheDocument();
});
it("should call removeNodesFromGroup when ungroup button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }),
createMockNode("node-2", { position: { x: 300, y: 0 }, groupId: "group-1" }),
],
}));
});
render(
);
const ungroupButton = screen.getByTitle("Remove from group");
fireEvent.click(ungroupButton);
expect(mockRemoveNodesFromGroup).toHaveBeenCalledWith(["node-1", "node-2"]);
});
it("should show ungroup button when at least one selected node is in a group", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }),
createMockNode("node-2", { position: { x: 300, y: 0 } }), // Not in a group
],
}));
});
render(
);
expect(screen.getByTitle("Remove from group")).toBeInTheDocument();
});
});
describe("Download Images", () => {
it("should render download images button", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 300, y: 0 } }),
],
}));
});
render(
);
expect(screen.getByTitle("Download images as ZIP")).toBeInTheDocument();
});
it("should not download when no images are available", async () => {
// Mock URL.createObjectURL and URL.revokeObjectURL
const createObjectURLSpy = vi.fn();
const revokeObjectURLSpy = vi.fn();
global.URL.createObjectURL = createObjectURLSpy;
global.URL.revokeObjectURL = revokeObjectURLSpy;
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { type: "prompt", position: { x: 0, y: 0 }, data: { prompt: "test" } }),
createMockNode("node-2", { type: "prompt", position: { x: 300, y: 0 }, data: { prompt: "test2" } }),
],
}));
});
render(
);
const downloadButton = screen.getByTitle("Download images as ZIP");
fireEvent.click(downloadButton);
// Should not create object URL when no images
expect(createObjectURLSpy).not.toHaveBeenCalled();
});
});
describe("Toolbar Position", () => {
it("should position toolbar based on selected nodes bounding box", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 100, y: 200 } }),
createMockNode("node-2", { position: { x: 400, y: 200 } }),
],
}));
});
const { container } = render(
);
const toolbar = container.firstChild as HTMLElement;
expect(toolbar).toHaveStyle({ transform: "translateX(-50%)" });
});
it("should account for viewport zoom in positioning", () => {
mockGetViewport.mockReturnValue({ x: 100, y: 50, zoom: 2 });
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("node-1", { position: { x: 0, y: 0 } }),
createMockNode("node-2", { position: { x: 200, y: 0 } }),
],
}));
});
const { container } = render(
);
const toolbar = container.firstChild as HTMLElement;
expect(toolbar).toBeInTheDocument();
});
});
});