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
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");
|
|
});
|
|
});
|
|
|