diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx
new file mode 100644
index 00000000..c5851137
--- /dev/null
+++ b/src/components/__tests__/ConnectionDropMenu.test.tsx
@@ -0,0 +1,253 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ConnectionDropMenu } from "@/components/ConnectionDropMenu";
+
+describe("ConnectionDropMenu", () => {
+ const mockOnSelect = vi.fn();
+ const mockOnClose = vi.fn();
+
+ const defaultProps = {
+ position: { x: 100, y: 200 },
+ handleType: "image" as const,
+ connectionType: "source" as const,
+ onSelect: mockOnSelect,
+ onClose: mockOnClose,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Basic Rendering", () => {
+ it("should render at the specified position", () => {
+ render();
+
+ const menu = document.querySelector(".fixed");
+ expect(menu).toBeInTheDocument();
+ expect(menu).toHaveStyle({ left: "100px", top: "200px" });
+ });
+
+ it("should render the header with handle type", () => {
+ render();
+
+ expect(screen.getByText("Add image node")).toBeInTheDocument();
+ });
+
+ it("should render keyboard shortcuts hint", () => {
+ render();
+
+ expect(screen.getByText("navigate")).toBeInTheDocument();
+ expect(screen.getByText("select")).toBeInTheDocument();
+ });
+
+ it("should not render when handleType is null", () => {
+ render();
+
+ expect(screen.queryByText(/Add .* node/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Node Type Filtering - Source Connection (from output handle)", () => {
+ it("should show image-accepting nodes when dragging from image output", () => {
+ render();
+
+ expect(screen.getByText("Annotate")).toBeInTheDocument();
+ expect(screen.getByText("Generate Image")).toBeInTheDocument();
+ expect(screen.getByText("Generate Video")).toBeInTheDocument();
+ expect(screen.getByText("Split Grid Node")).toBeInTheDocument();
+ expect(screen.getByText("Split Grid Now")).toBeInTheDocument();
+ expect(screen.getByText("Output")).toBeInTheDocument();
+ });
+
+ it("should show text-accepting nodes when dragging from text output", () => {
+ render();
+
+ expect(screen.getByText("Generate Image")).toBeInTheDocument();
+ expect(screen.getByText("Generate Video")).toBeInTheDocument();
+ expect(screen.getByText("LLM Generate")).toBeInTheDocument();
+ // Should NOT show image-only nodes
+ expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
+ expect(screen.queryByText("Output")).not.toBeInTheDocument();
+ });
+
+ it("should show video-accepting nodes when dragging from video output", () => {
+ render();
+
+ expect(screen.getByText("Generate Video")).toBeInTheDocument();
+ expect(screen.getByText("Output")).toBeInTheDocument();
+ // Should NOT show image/text-only nodes
+ expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
+ expect(screen.queryByText("LLM Generate")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Node Type Filtering - Target Connection (from input handle)", () => {
+ it("should show image-producing nodes when dragging from image input", () => {
+ render();
+
+ expect(screen.getByText("Image Input")).toBeInTheDocument();
+ expect(screen.getByText("Annotate")).toBeInTheDocument();
+ expect(screen.getByText("Generate Image")).toBeInTheDocument();
+ // Should NOT show text nodes
+ expect(screen.queryByText("Prompt")).not.toBeInTheDocument();
+ });
+
+ it("should show text-producing nodes when dragging from text input", () => {
+ render();
+
+ expect(screen.getByText("Prompt")).toBeInTheDocument();
+ expect(screen.getByText("LLM Generate")).toBeInTheDocument();
+ // Should NOT show image-only nodes
+ expect(screen.queryByText("Image Input")).not.toBeInTheDocument();
+ expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
+ });
+
+ it("should show video-producing nodes when dragging from video input", () => {
+ render();
+
+ expect(screen.getByText("Generate Video")).toBeInTheDocument();
+ // Should NOT show other nodes
+ expect(screen.queryByText("Image Input")).not.toBeInTheDocument();
+ expect(screen.queryByText("Prompt")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Menu Item Click", () => {
+ it("should call onSelect with node type when menu item is clicked", () => {
+ render();
+
+ const annotateButton = screen.getByText("Annotate");
+ fireEvent.click(annotateButton);
+
+ expect(mockOnSelect).toHaveBeenCalledWith({ type: "annotation", isAction: false });
+ });
+
+ it("should call onSelect with isAction true for action items", () => {
+ render();
+
+ const splitNowButton = screen.getByText("Split Grid Now");
+ fireEvent.click(splitNowButton);
+
+ expect(mockOnSelect).toHaveBeenCalledWith({ type: "splitGridImmediate", isAction: true });
+ });
+
+ it("should call onSelect with nanoBanana type for Generate Image", () => {
+ render();
+
+ const generateButton = screen.getByText("Generate Image");
+ fireEvent.click(generateButton);
+
+ expect(mockOnSelect).toHaveBeenCalledWith({ type: "nanoBanana", isAction: false });
+ });
+ });
+
+ describe("Escape Key", () => {
+ it("should call onClose when Escape key is pressed", () => {
+ render();
+
+ fireEvent.keyDown(document, { key: "Escape" });
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+
+ describe("Click Outside", () => {
+ it("should call onClose when clicking outside the menu", () => {
+ const { container } = render(
+
+ );
+
+ const outside = screen.getByTestId("outside");
+ fireEvent.mouseDown(outside);
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it("should not call onClose when clicking inside the menu", () => {
+ render();
+
+ const menuItem = screen.getByText("Annotate");
+ fireEvent.mouseDown(menuItem);
+
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Keyboard Navigation", () => {
+ it("should navigate down with ArrowDown key", () => {
+ render();
+
+ // First item should be highlighted by default
+ const firstButton = screen.getByText("Annotate").closest("button");
+ expect(firstButton).toHaveClass("bg-neutral-700");
+
+ // Press ArrowDown
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+
+ // Second item should now be highlighted
+ const secondButton = screen.getByText("Generate Image").closest("button");
+ expect(secondButton).toHaveClass("bg-neutral-700");
+ });
+
+ it("should navigate up with ArrowUp key", () => {
+ render();
+
+ // Press ArrowUp to go to last item (wrapping)
+ fireEvent.keyDown(document, { key: "ArrowUp" });
+
+ // Last item should now be highlighted
+ const lastButton = screen.getByText("Output").closest("button");
+ expect(lastButton).toHaveClass("bg-neutral-700");
+ });
+
+ it("should select item with Enter key", () => {
+ render();
+
+ // Press Enter on first item
+ fireEvent.keyDown(document, { key: "Enter" });
+
+ expect(mockOnSelect).toHaveBeenCalledWith({ type: "annotation", isAction: false });
+ });
+
+ it("should select navigated item with Enter key", () => {
+ render();
+
+ // Navigate to second item
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ // Press Enter
+ fireEvent.keyDown(document, { key: "Enter" });
+
+ expect(mockOnSelect).toHaveBeenCalledWith({ type: "nanoBanana", isAction: false });
+ });
+
+ it("should wrap around when navigating past last item", () => {
+ render();
+
+ // Text options: nanoBanana, generateVideo, llmGenerate (3 items)
+ // Navigate down 3 times to wrap to first
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+
+ // Should be back on first item
+ const firstButton = screen.getByText("Generate Image").closest("button");
+ expect(firstButton).toHaveClass("bg-neutral-700");
+ });
+ });
+
+ describe("Mouse Hover", () => {
+ it("should highlight item on mouse enter", () => {
+ render();
+
+ const secondItem = screen.getByText("Generate Image");
+ fireEvent.mouseEnter(secondItem);
+
+ // Second item should now be highlighted
+ const secondButton = secondItem.closest("button");
+ expect(secondButton).toHaveClass("bg-neutral-700");
+ });
+ });
+});