You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
468 lines
14 KiB
468 lines
14 KiB
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { GroupNode } from "@/components/nodes/GroupNode";
|
|
import { ReactFlowProvider } from "@xyflow/react";
|
|
|
|
// Mock the workflow store
|
|
const mockUpdateGroup = vi.fn();
|
|
const mockDeleteGroup = vi.fn();
|
|
const mockMoveGroupNodes = vi.fn();
|
|
const mockUseWorkflowStore = vi.fn();
|
|
|
|
// Mock GROUP_COLORS
|
|
const mockGroupColors: Record<string, string> = {
|
|
neutral: "#262626",
|
|
blue: "#1e3a5f",
|
|
green: "#1a3d2e",
|
|
purple: "#2d2458",
|
|
orange: "#3d2a1a",
|
|
red: "#3d1a1a",
|
|
};
|
|
|
|
vi.mock("@/store/workflowStore", () => ({
|
|
useWorkflowStore: () => mockUseWorkflowStore(),
|
|
GROUP_COLORS: {
|
|
neutral: "#262626",
|
|
blue: "#1e3a5f",
|
|
green: "#1a3d2e",
|
|
purple: "#2d2458",
|
|
orange: "#3d2a1a",
|
|
red: "#3d1a1a",
|
|
},
|
|
}));
|
|
|
|
// Mock useReactFlow
|
|
vi.mock("@xyflow/react", async () => {
|
|
const actual = await vi.importActual("@xyflow/react");
|
|
return {
|
|
...actual,
|
|
useReactFlow: () => ({
|
|
getNodes: vi.fn(() => []),
|
|
setNodes: vi.fn(),
|
|
}),
|
|
NodeResizer: ({ isVisible, children }: { isVisible: boolean; children?: React.ReactNode }) => (
|
|
<div data-testid="node-resizer" data-visible={isVisible}>
|
|
{children}
|
|
</div>
|
|
),
|
|
};
|
|
});
|
|
|
|
// Wrapper component for React Flow context
|
|
function TestWrapper({ children }: { children: React.ReactNode }) {
|
|
return <ReactFlowProvider>{children}</ReactFlowProvider>;
|
|
}
|
|
|
|
describe("GroupNode", () => {
|
|
const defaultGroup = {
|
|
id: "group-1",
|
|
name: "Test Group",
|
|
color: "blue" as const,
|
|
position: { x: 0, y: 0 },
|
|
size: { width: 300, height: 200 },
|
|
locked: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default mock implementation
|
|
mockUseWorkflowStore.mockReturnValue({
|
|
groups: { "group-1": defaultGroup },
|
|
updateGroup: mockUpdateGroup,
|
|
deleteGroup: mockDeleteGroup,
|
|
moveGroupNodes: mockMoveGroupNodes,
|
|
});
|
|
});
|
|
|
|
const createNodeProps = (data: { groupId: string } = { groupId: "group-1" }) => ({
|
|
id: "group-node-1",
|
|
type: "group" as const,
|
|
data,
|
|
selected: false,
|
|
});
|
|
|
|
describe("Basic Rendering", () => {
|
|
it("should render the group name", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
expect(screen.getByText("Test Group")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should apply group color to background", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const groupContainer = container.querySelector(".rounded-xl");
|
|
expect(groupContainer).toBeInTheDocument();
|
|
// Browser converts hex to rgb, so check for the RGB values instead
|
|
// #1e3a5f = rgb(30, 58, 95)
|
|
expect(groupContainer?.getAttribute("style")).toContain("rgb(30, 58, 95)");
|
|
});
|
|
|
|
it("should render color picker button", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const colorButton = container.querySelector('button[title="Change color"]');
|
|
expect(colorButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render delete button", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const deleteButton = container.querySelector('button[title="Delete group"]');
|
|
expect(deleteButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("should return null when group not found", () => {
|
|
mockUseWorkflowStore.mockReturnValue({
|
|
groups: {},
|
|
updateGroup: mockUpdateGroup,
|
|
deleteGroup: mockDeleteGroup,
|
|
moveGroupNodes: mockMoveGroupNodes,
|
|
});
|
|
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps({ groupId: "non-existent" })} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Should render nothing
|
|
expect(container.querySelector(".rounded-xl")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Group Name Editing", () => {
|
|
it("should enter edit mode when name is clicked", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Input should be visible
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
expect(input).toBeInTheDocument();
|
|
});
|
|
|
|
it("should update group name on Enter key", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click to edit
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Change value and press Enter
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
fireEvent.change(input, { target: { value: "New Name" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { name: "New Name" });
|
|
});
|
|
|
|
it("should cancel editing on Escape key", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click to edit
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Change value and press Escape
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
fireEvent.change(input, { target: { value: "Changed" } });
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
|
|
// Should revert and exit edit mode
|
|
expect(mockUpdateGroup).not.toHaveBeenCalled();
|
|
expect(screen.getByText("Test Group")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should update group name on blur", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click to edit
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Change value and blur
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
fireEvent.change(input, { target: { value: "Blurred Name" } });
|
|
fireEvent.blur(input);
|
|
|
|
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { name: "Blurred Name" });
|
|
});
|
|
|
|
it("should not update if name is unchanged", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click to edit
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Press Enter without changing
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(mockUpdateGroup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not update if name is empty", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click to edit
|
|
const nameSpan = screen.getByText("Test Group");
|
|
fireEvent.click(nameSpan);
|
|
|
|
// Clear value and blur
|
|
const input = screen.getByDisplayValue("Test Group");
|
|
fireEvent.change(input, { target: { value: " " } });
|
|
fireEvent.blur(input);
|
|
|
|
expect(mockUpdateGroup).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Color Picker", () => {
|
|
it("should open color picker when color button is clicked", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const colorButton = container.querySelector('button[title="Change color"]');
|
|
fireEvent.click(colorButton!);
|
|
|
|
// Color options should be visible (6 colors)
|
|
const colorOptions = container.querySelectorAll('.grid.grid-cols-4 button');
|
|
expect(colorOptions.length).toBe(6);
|
|
});
|
|
|
|
it("should change group color when color option is clicked", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Open color picker
|
|
const colorButton = container.querySelector('button[title="Change color"]');
|
|
fireEvent.click(colorButton!);
|
|
|
|
// Click on green color option
|
|
const greenOption = container.querySelector('button[title="Green"]');
|
|
fireEvent.click(greenOption!);
|
|
|
|
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { color: "green" });
|
|
});
|
|
|
|
it("should close color picker after selecting a color", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Open color picker
|
|
const colorButton = container.querySelector('button[title="Change color"]');
|
|
fireEvent.click(colorButton!);
|
|
|
|
// Select a color
|
|
const greenOption = container.querySelector('button[title="Green"]');
|
|
fireEvent.click(greenOption!);
|
|
|
|
// Color picker should be closed
|
|
const colorOptions = container.querySelectorAll('.grid.grid-cols-4 button');
|
|
expect(colorOptions.length).toBe(0);
|
|
});
|
|
|
|
it("should highlight current color in picker", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Open color picker
|
|
const colorButton = container.querySelector('button[title="Change color"]');
|
|
fireEvent.click(colorButton!);
|
|
|
|
// Blue option should have highlight styles (current color)
|
|
const blueOption = container.querySelector('button[title="Blue"]');
|
|
expect(blueOption).toHaveClass("border-white");
|
|
});
|
|
});
|
|
|
|
describe("Delete Functionality", () => {
|
|
it("should call deleteGroup when delete button is clicked", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const deleteButton = container.querySelector('button[title="Delete group"]');
|
|
fireEvent.click(deleteButton!);
|
|
|
|
expect(mockDeleteGroup).toHaveBeenCalledWith("group-1");
|
|
});
|
|
});
|
|
|
|
describe("Node Resizer", () => {
|
|
it("should show resizer when selected", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} selected={true} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const resizer = screen.getByTestId("node-resizer");
|
|
expect(resizer).toHaveAttribute("data-visible", "true");
|
|
});
|
|
|
|
it("should hide resizer when not selected", () => {
|
|
render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} selected={false} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const resizer = screen.getByTestId("node-resizer");
|
|
expect(resizer).toHaveAttribute("data-visible", "false");
|
|
});
|
|
});
|
|
|
|
describe("Header Drag", () => {
|
|
it("should have cursor-grab class on header", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const header = container.querySelector(".cursor-grab");
|
|
expect(header).toBeInTheDocument();
|
|
});
|
|
|
|
it("should not start drag when clicking on buttons", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
// Click on delete button
|
|
const deleteButton = container.querySelector('button[title="Delete group"]');
|
|
fireEvent.mouseDown(deleteButton!);
|
|
|
|
// moveGroupNodes should not be called
|
|
expect(mockMoveGroupNodes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should start drag when clicking on header background", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const header = container.querySelector(".cursor-grab");
|
|
fireEvent.mouseDown(header!, { clientX: 100, clientY: 100 });
|
|
|
|
// Move mouse
|
|
fireEvent.mouseMove(window, { clientX: 120, clientY: 120 });
|
|
|
|
// moveGroupNodes should be called with delta
|
|
expect(mockMoveGroupNodes).toHaveBeenCalledWith("group-1", { x: 20, y: 20 });
|
|
});
|
|
|
|
it("should stop drag on mouseup", () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const header = container.querySelector(".cursor-grab");
|
|
fireEvent.mouseDown(header!, { clientX: 100, clientY: 100 });
|
|
fireEvent.mouseMove(window, { clientX: 120, clientY: 120 });
|
|
fireEvent.mouseUp(window);
|
|
|
|
// Clear the mock
|
|
mockMoveGroupNodes.mockClear();
|
|
|
|
// Move mouse again - should not trigger moveGroupNodes
|
|
fireEvent.mouseMove(window, { clientX: 140, clientY: 140 });
|
|
expect(mockMoveGroupNodes).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Different Group Colors", () => {
|
|
// Map color names to RGB values (browser converts hex to rgb)
|
|
it.each([
|
|
["neutral", "rgb(38, 38, 38)"],
|
|
["blue", "rgb(30, 58, 95)"],
|
|
["green", "rgb(26, 61, 46)"],
|
|
["purple", "rgb(45, 36, 88)"],
|
|
["orange", "rgb(61, 42, 26)"],
|
|
["red", "rgb(61, 26, 26)"],
|
|
])("should render with %s color", (colorName, colorRgb) => {
|
|
mockUseWorkflowStore.mockReturnValue({
|
|
groups: {
|
|
"group-1": { ...defaultGroup, color: colorName }
|
|
},
|
|
updateGroup: mockUpdateGroup,
|
|
deleteGroup: mockDeleteGroup,
|
|
moveGroupNodes: mockMoveGroupNodes,
|
|
});
|
|
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<GroupNode {...createNodeProps()} />
|
|
</TestWrapper>
|
|
);
|
|
|
|
const groupContainer = container.querySelector(".rounded-xl");
|
|
// Check that container has inline style with the color (browser converts hex to rgb)
|
|
expect(groupContainer?.getAttribute("style")).toContain(colorRgb);
|
|
});
|
|
});
|
|
});
|
|
|