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 ?
{children}
: null), NodeResizer: ({ isVisible, minWidth, minHeight, }: { isVisible: boolean; minWidth: number; minHeight: number; }) => (
), NodeResizeControl: ({ children, position, minWidth, minHeight, }: { children: React.ReactNode; position: string; minWidth: number; minHeight: number; }) => (
{children}
), }; }); function TestWrapper({ children }: { children: React.ReactNode }) { return {children}; } 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(), }); }); it("renders the group name and native resizer", () => { render( ); 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( ); 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( ); 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(), }); render( ); 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(), }); const { container } = render( ); expect(container.querySelector(".rounded-xl")).not.toBeInTheDocument(); }); it("updates the group name on double click and Enter", () => { render( ); 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( ); expect(screen.queryByTitle("颜色")).not.toBeInTheDocument(); rerender( ); expect(screen.getByTitle("颜色")).toBeInTheDocument(); }); it("changes group color from the toolbar", () => { render( ); 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(), }); render( ); 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( ); fireEvent.click(screen.getByTitle("解组")); expect(mockDeleteGroup).toHaveBeenCalledWith("group-1"); }); });