import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; import { createTaskLinkSelectAction } from "./task-switcher-context-menu"; import type { GroupedSidebarList } from "@/lib/sidebar/apply-view"; afterEach(() => cleanup()); function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } function item(id: string, parentTaskId?: string): TaskSwitcherItem { return { id, title: id, state: "IN_PROGRESS", parentTaskId }; } // root → child → grandchild (depth 2) const ROOT = item("Root"); const CHILD = item("Child", "Root"); const GRANDCHILD = item("Grandchild", "Child"); const TASK_A = item("Task A"); const TASK_B = item("Task B"); function grouped(): GroupedSidebarList { return { groups: [{ key: "__all__", label: "All", tasks: [ROOT] }], subTasksByParentId: new Map([ ["Root", [CHILD]], ["Child", [GRANDCHILD]], ]), }; } function renderSwitcher(collapsedSubtaskParentIds: string[] = []) { return render( , ); } function blockDepth(container: HTMLElement, taskId: string): string | null { return ( container .querySelector(`[data-testid='sortable-task-block'][data-task-id='${taskId}']`) ?.getAttribute("data-depth") ?? null ); } describe("TaskSwitcher — nested subtasks beyond depth 1", () => { it("renders the full tree (root, child, grandchild)", () => { renderSwitcher(); expect(screen.queryByText("Root")).not.toBeNull(); expect(screen.queryByText("Child")).not.toBeNull(); expect(screen.queryByText("Grandchild")).not.toBeNull(); }); it("tags each row with its tree depth", () => { const { container } = renderSwitcher(); expect(blockDepth(container, "Root")).toBe("0"); expect(blockDepth(container, "Child")).toBe("1"); expect(blockDepth(container, "Grandchild")).toBe("2"); }); it("collapsing a mid-level parent hides its whole subtree", () => { renderSwitcher(["Child"]); expect(screen.queryByText("Root")).not.toBeNull(); expect(screen.queryByText("Child")).not.toBeNull(); // Grandchild lives under the collapsed Child, so it must not render. expect(screen.queryByText("Grandchild")).toBeNull(); }); it("group header counts the whole subtree, not just direct children", () => { renderSwitcher(); // Header only shows for >1 group or a non-default key. Force it by using a // keyed group instead of the implicit __all__ bucket. cleanup(); render( , ); // Root + Child + Grandchild = 3 expect(screen.queryByText("3")).not.toBeNull(); }); it("parent subtask toggle counts all descendants, not just direct children", () => { const { container } = renderSwitcher(); const rootToggle = container.querySelector( "[data-testid='sidebar-subtask-toggle'][data-task-id='Root']", ); expect(rootToggle).not.toBeNull(); // Root → Child → Grandchild: badge should reflect 2 hidden rows, not 1. expect(rootToggle!.textContent).toContain("2"); }); it("omits grab cursor on nested rows when subtask reorder is disabled", () => { const { container } = render( , ); for (const taskId of ["Child", "Grandchild"]) { const handle = container.querySelector( `[data-testid='sortable-task-block'][data-task-id='${taskId}'] > [data-testid='sortable-task-handle']`, ); expect(handle).not.toBeNull(); expect(handle!.className).not.toContain("cursor-grab"); } }); }); describe("TaskSwitcher — bulk pin menu", () => { it("offers bulk unpin when every selected root task is pinned", () => { render( , ); fireEvent.contextMenu(screen.getByText(TASK_A.title)); expect(screen.getByText("Unpin 2 tasks")).toBeTruthy(); }); }); describe("TaskSwitcher — external issue link menu", () => { it("offers configured external issue providers from the task context menu", async () => { render( [0]>)} /> , ); fireEvent.contextMenu(screen.getByText(TASK_A.title)); const linkTrigger = screen.getByText("Link"); fireEvent.focus(linkTrigger); fireEvent.keyDown(linkTrigger, { key: "ArrowRight" }); await waitFor(() => { expect(screen.getByText("Jira Ticket")).toBeTruthy(); expect(screen.getByText("Linear Issue")).toBeTruthy(); expect(screen.getByText("Sentry Issue")).toBeTruthy(); }); }); it("passes the visible task title to external issue link handlers", () => { const archivedTask: TaskSwitcherItem = { id: "archived-task", title: "Archived task title", isArchived: true, }; const closeMenu = vi.fn(); const onLinkJiraTicket = vi.fn(); createTaskLinkSelectAction(archivedTask, onLinkJiraTicket, closeMenu)?.(); expect(onLinkJiraTicket).toHaveBeenCalledWith(archivedTask.id, archivedTask.title); expect(closeMenu).toHaveBeenCalledOnce(); }); });