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.
 
 

306 lines
8.6 KiB

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ReactFlowProvider } from "@xyflow/react";
import { GroupNode } from "@/components/nodes/GroupNode";
const mockUpdateGroup = vi.fn();
const mockDeleteGroup = vi.fn();
const mockOnNodesChange = vi.fn();
const mockRunGroup = vi.fn();
const mockCancelGroupTask = vi.fn();
const mockUseWorkflowStore = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
const state = mockUseWorkflowStore();
return selector ? selector(state) : state;
},
GROUP_COLORS: {
neutral: "#262626",
blue: "#1e3a5f",
green: "#1a3d2e",
purple: "#2d2458",
orange: "#3d2a1a",
red: "#3d1a1a",
},
}));
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
NodeToolbar: ({
children,
isVisible,
}: {
children: React.ReactNode;
isVisible?: boolean;
}) => (isVisible ? <div data-testid="node-toolbar">{children}</div> : null),
NodeResizer: ({
isVisible,
minWidth,
minHeight,
}: {
isVisible: boolean;
minWidth: number;
minHeight: number;
}) => (
<div
data-testid="node-resizer"
data-visible={isVisible}
data-min-width={minWidth}
data-min-height={minHeight}
/>
),
NodeResizeControl: ({
children,
position,
minWidth,
minHeight,
}: {
children: React.ReactNode;
position: string;
minWidth: number;
minHeight: number;
}) => (
<div
data-testid="node-resize-control"
data-position={position}
data-min-width={minWidth}
data-min-height={minHeight}
>
{children}
</div>
),
};
});
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,
isNbpInput: false,
};
const createNodeProps = (overrides = {}) => ({
id: "group-node-group-1",
type: "group" as const,
data: { groupId: "group-1" },
selected: false,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockUseWorkflowStore.mockReturnValue({
groups: { "group-1": defaultGroup },
nodes: [],
onNodesChange: mockOnNodesChange,
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
runGroup: mockRunGroup,
cancelGroupTask: mockCancelGroupTask,
runningGroupIds: new Set<string>(),
});
});
it("renders the group name and native resizer", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: true })} />
</TestWrapper>
);
expect(screen.getByText("Test Group")).toBeInTheDocument();
expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-visible", "true");
});
it("shows resize controls when hovering an unselected group", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-visible", "false");
fireEvent.mouseEnter(container.querySelector(".rounded-xl")!);
expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-visible", "true");
});
it("renders a bottom-right resize icon", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-position", "bottom-right");
expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-width", "200");
expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-height", "120");
expect(screen.getByTestId("group-resize-corner")).toHaveAttribute("title", "缩放分组");
});
it("keeps resize minimums large enough to contain grouped nodes", () => {
mockUseWorkflowStore.mockReturnValue({
groups: { "group-1": defaultGroup },
nodes: [
{
id: "node-1",
type: "prompt",
position: { x: 260, y: 140 },
style: { width: 180, height: 90 },
groupId: "group-1",
},
],
edges: [],
onNodesChange: mockOnNodesChange,
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
runGroup: mockRunGroup,
cancelGroupTask: mockCancelGroupTask,
runningGroupIds: new Set<string>(),
});
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-min-width", "460");
expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-min-height", "250");
expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-width", "460");
expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-height", "250");
});
it("returns null when group metadata is missing", () => {
mockUseWorkflowStore.mockReturnValue({
groups: {},
nodes: [],
onNodesChange: mockOnNodesChange,
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
runGroup: mockRunGroup,
cancelGroupTask: mockCancelGroupTask,
runningGroupIds: new Set<string>(),
});
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
expect(container.querySelector(".rounded-xl")).not.toBeInTheDocument();
});
it("updates the group name on double click and Enter", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
fireEvent.doubleClick(screen.getByText("Test Group"));
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("only shows the toolbar when the group is selected", () => {
const { rerender } = render(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: false })} />
</TestWrapper>
);
expect(screen.queryByTitle("颜色")).not.toBeInTheDocument();
rerender(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: true })} />
</TestWrapper>
);
expect(screen.getByTitle("颜色")).toBeInTheDocument();
});
it("changes group color from the toolbar", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: true })} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("颜色"));
fireEvent.click(screen.getByTitle("绿色"));
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { color: "green" });
});
it("applies a layout to grouped nodes", () => {
mockUseWorkflowStore.mockReturnValue({
groups: { "group-1": defaultGroup },
nodes: [
{
id: "node-1",
type: "prompt",
groupId: "group-1",
position: { x: 20, y: 40 },
data: {},
measured: { width: 100, height: 80 },
},
{
id: "node-2",
type: "prompt",
groupId: "group-1",
position: { x: 80, y: 10 },
data: {},
measured: { width: 100, height: 80 },
},
],
onNodesChange: mockOnNodesChange,
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
runGroup: mockRunGroup,
cancelGroupTask: mockCancelGroupTask,
runningGroupIds: new Set<string>(),
});
render(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: true })} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("布局"));
fireEvent.click(screen.getByText("水平排列"));
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { layout: "horizontal" });
expect(mockOnNodesChange).toHaveBeenCalledWith([
expect.objectContaining({ id: "node-1", type: "position" }),
expect.objectContaining({ id: "node-2", type: "position" }),
]);
});
it("ungroups from the toolbar", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps({ selected: true })} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("解组"));
expect(mockDeleteGroup).toHaveBeenCalledWith("group-1");
});
});