import type { ReactElement, ReactNode } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GroupedSidebarList } from "@/lib/sidebar/apply-view"; import type { TaskSwitcherItem } from "./task-switcher-types"; const renderCounts = vi.hoisted(() => ({ groups: new Map(), nodes: new Map(), rows: new Map(), nestCandidateGetters: new Map TaskSwitcherItem[]) | undefined>(), })); function increment(counts: Map, key: string) { counts.set(key, (counts.get(key) ?? 0) + 1); } vi.mock("./task-item", () => ({ TaskItem: ({ title, onClick }: { title: string; onClick?: () => void }) => { increment(renderCounts.rows, title); return ; }, })); vi.mock("./task-switcher-context-menu", () => ({ TaskItemWithContextMenu: ({ task, children, getNestCandidateTasks, }: { task: TaskSwitcherItem; children: ReactElement; getNestCandidateTasks?: () => TaskSwitcherItem[]; }) => { renderCounts.nestCandidateGetters.set(task.id, getNestCandidateTasks); return children; }, })); vi.mock("./task-switcher-subtask-dnd", () => ({ SortableTaskLevel: ({ tasks, renderNode, }: { tasks: TaskSwitcherItem[]; renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => ReactNode; }) => <>{tasks.map((task) => renderNode(task, false))}, SortableTaskNode: ({ taskId, handle, nested, }: { taskId: string; handle: ReactNode; nested?: ReactNode; }) => { increment(renderCounts.nodes, taskId); return ( <> {handle} {nested} ); }, TaskTreeDndGroup: ({ groupTasks, children, }: { groupTasks: TaskSwitcherItem[]; children: (nestTargetIds: Set, activeDragId: string | null) => ReactNode; }) => { increment(renderCounts.groups, groupTasks[0]?.workflowId ?? "empty"); return <>{children(new Set(), null)}; }, NestDropZone: () => null, })); import { TaskSwitcher } from "./task-switcher"; const onSelectTask = vi.fn(); const onToggleSubtasks = vi.fn(); const TASK_A = "Task A"; const TASK_A_UPDATED = "Task A updated"; const TASK_B = "Task B"; const WORKFLOW_A = "Workflow A"; const WORKFLOW_B = "Workflow B"; function task(id: string, workflowId: string): TaskSwitcherItem { return { id, title: id, state: "IN_PROGRESS", workflowId, workflowName: workflowId }; } function groupedByWorkflow(tasks: TaskSwitcherItem[]): GroupedSidebarList { return { groups: tasks.map((item) => ({ key: item.workflowId!, label: item.workflowName!, tasks: [item], })), subTasksByParentId: new Map(), groupKey: "workflow", }; } function groupedTogether(tasks: TaskSwitcherItem[]): GroupedSidebarList { return { groups: [{ key: "__all__", label: "All", tasks }], subTasksByParentId: new Map(), groupKey: "none", }; } function groupedTrees( entries: Array<{ root: TaskSwitcherItem; child: TaskSwitcherItem }>, groupKey: "workflow" | "none", ): GroupedSidebarList { return { groups: groupKey === "workflow" ? entries.map(({ root }) => ({ key: root.workflowId!, label: root.workflowName!, tasks: [root], })) : [{ key: "__all__", label: "All", tasks: entries.map(({ root }) => root) }], subTasksByParentId: new Map(entries.map(({ root, child }) => [root.id, [child]])), groupKey, }; } function switcher(grouped: GroupedSidebarList, selectTask = onSelectTask) { return ( ); } beforeEach(() => { renderCounts.groups.clear(); renderCounts.nodes.clear(); renderCounts.rows.clear(); renderCounts.nestCandidateGetters.clear(); }); afterEach(() => cleanup()); describe("TaskSwitcher render stability", () => { it("does not rerender an unaffected expanded group when another group task updates", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_B); const initial = groupedByWorkflow([taskA, taskB]); const view = render(switcher(initial)); const updated = groupedByWorkflow([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]); view.rerender(switcher(updated)); expect(renderCounts.groups.get(WORKFLOW_B)).toBe(1); }); it("does not rerender an unaffected row beside an updated task", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_A); const initial = groupedTogether([taskA, taskB]); const view = render(switcher(initial)); const updated = groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]); view.rerender(switcher(updated)); expect({ updated: renderCounts.rows.get(TASK_A_UPDATED), unaffected: renderCounts.rows.get(TASK_B), }).toEqual({ updated: 1, unaffected: 1 }); }); it("does not rerender an unaffected row when task-derived handlers are recreated", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_A); const initial = groupedTogether([taskA, taskB]); const view = render(switcher(initial, vi.fn())); const updated = groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]); view.rerender(switcher(updated, vi.fn())); expect(renderCounts.rows.get(TASK_B)).toBe(1); }); it("routes a skipped row action to the latest committed handler", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_A); const initial = groupedTogether([taskA, taskB]); const view = render(switcher(initial, vi.fn())); const latestHandler = vi.fn(); view.rerender( switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]), latestHandler), ); fireEvent.click(screen.getByRole("button", { name: TASK_B })); expect(latestHandler).toHaveBeenCalledWith(TASK_B); }); it("gives a skipped row the latest nest candidates", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_A); const view = render(switcher(groupedTogether([taskA, taskB]))); view.rerender(switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]))); expect( renderCounts.nestCandidateGetters .get(TASK_B)?.() .map((item) => item.title), ).toEqual([TASK_A_UPDATED, TASK_B]); }); }); describe("TaskSwitcher nested render stability", () => { it("does not rerender an unaffected expanded group when another group subtask updates", () => { const parentA = task("Parent A", WORKFLOW_A); const childA = { ...task("Child A", WORKFLOW_A), parentTaskId: parentA.id }; const parentB = task("Parent B", WORKFLOW_B); const childB = { ...task("Child B", WORKFLOW_B), parentTaskId: parentB.id }; const initial = groupedTrees( [ { root: parentA, child: childA }, { root: parentB, child: childB }, ], "workflow", ); const view = render(switcher(initial)); const updated = groupedTrees( [ { root: { ...parentA }, child: { ...childA, title: "Child A updated" } }, { root: { ...parentB }, child: { ...childB } }, ], "workflow", ); view.rerender(switcher(updated)); expect(renderCounts.groups.get(WORKFLOW_B)).toBe(1); }); it("does not rerender an unaffected expandable row beside an updated subtask", () => { const parentA = task("Parent A", WORKFLOW_A); const childA = { ...task("Child A", WORKFLOW_A), parentTaskId: parentA.id }; const parentB = task("Parent B", WORKFLOW_A); const childB = { ...task("Child B", WORKFLOW_A), parentTaskId: parentB.id }; const initial = groupedTrees( [ { root: parentA, child: childA }, { root: parentB, child: childB }, ], "none", ); const view = render(switcher(initial)); const updated = groupedTrees( [ { root: { ...parentA }, child: { ...childA, title: "Child A updated" } }, { root: { ...parentB }, child: { ...childB } }, ], "none", ); view.rerender(switcher(updated)); expect(renderCounts.rows.get("Parent B")).toBe(1); }); it("does not rebuild an unaffected sibling tree node when a task updates", () => { const taskA = task(TASK_A, WORKFLOW_A); const taskB = task(TASK_B, WORKFLOW_A); const initial = groupedTogether([taskA, taskB]); const view = render(switcher(initial)); view.rerender(switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]))); expect(renderCounts.nodes.get(TASK_B)).toBe(1); }); });