5105ac224c
* feat: add anchored range selection to multi-select reducer * feat: cmd+click and shift+click multi-select on kanban board * feat: add cmd/shift multi-select to sidebar task list * test: e2e coverage for cmd/shift multi-select * fix: harden multi-select selection lifecycle from review feedback * test: scope multi-select e2e helpers to active containers * fix: add sidebar multi-select hook tests and tighten bulk-action lifecycle * fix: prune hidden sidebar selections and order bulk moves by view * test: cover visible-order bulk move and anchor realignment * fix: harden sidebar bulk-move scope and keyboard selection parity * fix: order kanban bulk moves by board position * test: cover multi-select helpers and fix stale-id sort fallback * fix: order kanban card context-menu moves by board position * feat: sidebar multi-select shows only bulk-valid actions with bulk pin and delete * fix: harden multi-select bulk ordering, selection clearing, and a11y * fix: gate kanban card move-to-step for mixed-workflow selections * fix: unpin fully pinned sidebar selections * refactor: keep sidebar task components under limits * test: restore sidebar selection console spy safely
166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import { ToastProvider } from "@/components/toast-provider";
|
|
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
|
import type { GroupedSidebarList } from "@/lib/sidebar/apply-view";
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
function Providers({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<StateProvider>
|
|
<ToastProvider>{children}</ToastProvider>
|
|
</StateProvider>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<Providers>
|
|
<TaskSwitcher
|
|
grouped={grouped()}
|
|
activeTaskId={null}
|
|
selectedTaskId={null}
|
|
onSelectTask={vi.fn()}
|
|
onToggleSubtasks={vi.fn()}
|
|
collapsedSubtaskParentIds={collapsedSubtaskParentIds}
|
|
/>
|
|
</Providers>,
|
|
);
|
|
}
|
|
|
|
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(
|
|
<Providers>
|
|
<TaskSwitcher
|
|
grouped={{
|
|
groups: [{ key: "wf1", label: "Workflow 1", tasks: [ROOT] }],
|
|
subTasksByParentId: grouped().subTasksByParentId,
|
|
}}
|
|
activeTaskId={null}
|
|
selectedTaskId={null}
|
|
onSelectTask={vi.fn()}
|
|
onToggleSubtasks={vi.fn()}
|
|
collapsedSubtaskParentIds={[]}
|
|
/>
|
|
</Providers>,
|
|
);
|
|
// 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(
|
|
<Providers>
|
|
<TaskSwitcher
|
|
grouped={grouped()}
|
|
activeTaskId={null}
|
|
selectedTaskId={null}
|
|
onSelectTask={vi.fn()}
|
|
onToggleSubtasks={vi.fn()}
|
|
collapsedSubtaskParentIds={[]}
|
|
/>
|
|
</Providers>,
|
|
);
|
|
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(
|
|
<Providers>
|
|
<TaskSwitcher
|
|
grouped={{
|
|
groups: [{ key: "__all__", label: "All", tasks: [TASK_A, TASK_B] }],
|
|
subTasksByParentId: new Map(),
|
|
}}
|
|
activeTaskId={null}
|
|
selectedTaskId={null}
|
|
onSelectTask={vi.fn()}
|
|
onBulkPin={vi.fn()}
|
|
pinnedTaskIds={[TASK_A.id, TASK_B.id]}
|
|
selectedTaskIds={new Set([TASK_A.id, TASK_B.id])}
|
|
/>
|
|
</Providers>,
|
|
);
|
|
|
|
fireEvent.contextMenu(screen.getByText(TASK_A.title));
|
|
|
|
expect(screen.getByText("Unpin 2 tasks")).toBeTruthy();
|
|
});
|
|
});
|