8d01756ec5
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
598 lines
21 KiB
TypeScript
598 lines
21 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { TaskPriority } from "@/lib/types/http";
|
|
import type { DisplayOrderTask } from "@/lib/kanban/task-order";
|
|
import {
|
|
multiSelectReducer,
|
|
INITIAL_STATE,
|
|
buildPipelineStepIndexOf,
|
|
filterIdsByPriorityFilter,
|
|
} from "./use-task-multi-select";
|
|
|
|
describe("multiSelectReducer", () => {
|
|
it("reset returns initial state", () => {
|
|
const dirty = {
|
|
selectedIds: new Set(["a", "b"]),
|
|
isMultiSelectEnabled: true,
|
|
isDeleting: true,
|
|
isArchiving: false,
|
|
anchorId: "a",
|
|
};
|
|
expect(multiSelectReducer(dirty, { type: "reset" })).toBe(INITIAL_STATE);
|
|
});
|
|
|
|
it("toggle_select adds a task", () => {
|
|
const next = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "t1" });
|
|
expect(next.selectedIds).toEqual(new Set(["t1"]));
|
|
});
|
|
|
|
it("toggle_select removes an already-selected task", () => {
|
|
const state = { ...INITIAL_STATE, selectedIds: new Set(["t1", "t2"]) };
|
|
const next = multiSelectReducer(state, { type: "toggle_select", taskId: "t1" });
|
|
expect(next.selectedIds).toEqual(new Set(["t2"]));
|
|
});
|
|
|
|
it("set_selected replaces the selection set", () => {
|
|
const state = { ...INITIAL_STATE, selectedIds: new Set(["old"]) };
|
|
const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["a", "b"]) });
|
|
expect(next.selectedIds).toEqual(new Set(["a", "b"]));
|
|
});
|
|
|
|
it("set_enabled controls isMultiSelectEnabled", () => {
|
|
const on = multiSelectReducer(INITIAL_STATE, { type: "set_enabled", value: true });
|
|
expect(on.isMultiSelectEnabled).toBe(true);
|
|
const off = multiSelectReducer(on, { type: "set_enabled", value: false });
|
|
expect(off.isMultiSelectEnabled).toBe(false);
|
|
});
|
|
|
|
describe("bulk operation state flags", () => {
|
|
it("set_deleting toggles isDeleting", () => {
|
|
const on = multiSelectReducer(INITIAL_STATE, { type: "set_deleting", value: true });
|
|
expect(on.isDeleting).toBe(true);
|
|
const off = multiSelectReducer(on, { type: "set_deleting", value: false });
|
|
expect(off.isDeleting).toBe(false);
|
|
});
|
|
|
|
it("set_archiving toggles isArchiving", () => {
|
|
const on = multiSelectReducer(INITIAL_STATE, { type: "set_archiving", value: true });
|
|
expect(on.isArchiving).toBe(true);
|
|
const off = multiSelectReducer(on, { type: "set_archiving", value: false });
|
|
expect(off.isArchiving).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("bulk action scenarios (reducer-level)", () => {
|
|
it("all succeed: selectedIds empty + enabled false", () => {
|
|
const state = {
|
|
...INITIAL_STATE,
|
|
selectedIds: new Set(["t1", "t2"]),
|
|
isMultiSelectEnabled: true,
|
|
};
|
|
// Simulate: set_selected with empty failed set, then set_enabled false
|
|
const afterSelect = multiSelectReducer(state, {
|
|
type: "set_selected",
|
|
ids: new Set<string>(),
|
|
});
|
|
const afterDisable = multiSelectReducer(afterSelect, { type: "set_enabled", value: false });
|
|
expect(afterDisable.selectedIds.size).toBe(0);
|
|
expect(afterDisable.isMultiSelectEnabled).toBe(false);
|
|
});
|
|
|
|
it("some fail: selectedIds contains failed IDs, enabled stays true", () => {
|
|
const state = {
|
|
...INITIAL_STATE,
|
|
selectedIds: new Set(["t1", "t2", "t3"]),
|
|
isMultiSelectEnabled: true,
|
|
};
|
|
// Simulate: set_selected with failed IDs only, no set_enabled call
|
|
const afterSelect = multiSelectReducer(state, {
|
|
type: "set_selected",
|
|
ids: new Set(["t2"]),
|
|
});
|
|
expect(afterSelect.selectedIds).toEqual(new Set(["t2"]));
|
|
expect(afterSelect.isMultiSelectEnabled).toBe(true);
|
|
});
|
|
|
|
it("all fail: selectedIds unchanged, enabled stays true", () => {
|
|
const state = {
|
|
...INITIAL_STATE,
|
|
selectedIds: new Set(["t1", "t2"]),
|
|
isMultiSelectEnabled: true,
|
|
};
|
|
const afterSelect = multiSelectReducer(state, {
|
|
type: "set_selected",
|
|
ids: new Set(["t1", "t2"]),
|
|
});
|
|
expect(afterSelect.selectedIds).toEqual(new Set(["t1", "t2"]));
|
|
expect(afterSelect.isMultiSelectEnabled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildPipelineStepIndexOf", () => {
|
|
const workflows = [{ id: "wf-1", name: "Workflow 1" }];
|
|
const snapshots = {
|
|
"wf-1": {
|
|
steps: [
|
|
{ id: "todo", position: 0 },
|
|
{ id: "review", position: 1 },
|
|
{ id: "done", position: 2 },
|
|
],
|
|
},
|
|
};
|
|
|
|
it("indexes steps by their displayed (position) order", () => {
|
|
const indexOf = buildPipelineStepIndexOf(workflows, snapshots, {});
|
|
expect(indexOf("todo")).toBe(0);
|
|
expect(indexOf("review")).toBe(1);
|
|
expect(indexOf("done")).toBe(2);
|
|
});
|
|
|
|
it("skips a hidden step when indexing the displayed order", () => {
|
|
const indexOf = buildPipelineStepIndexOf(workflows, snapshots, { "wf-1": ["review"] });
|
|
expect(indexOf("todo")).toBe(0);
|
|
expect(indexOf("done")).toBe(1);
|
|
expect(indexOf("review")).toBe(Infinity);
|
|
});
|
|
|
|
it("sorts an unknown or undefined step id last", () => {
|
|
const indexOf = buildPipelineStepIndexOf(workflows, snapshots, {});
|
|
expect(indexOf("unknown-step")).toBe(Infinity);
|
|
expect(indexOf(undefined)).toBe(Infinity);
|
|
});
|
|
|
|
it("indexes an explicitly selected hidden workflow", () => {
|
|
const hiddenWorkflows = [{ id: "improve", name: "Improve Kandev", hidden: true }];
|
|
const hiddenSnapshots = {
|
|
improve: { steps: [{ id: "improve-todo", position: 0 }] },
|
|
};
|
|
const indexOf = buildPipelineStepIndexOf(hiddenWorkflows, hiddenSnapshots, {}, "improve");
|
|
|
|
expect(indexOf("improve-todo")).toBe(0);
|
|
});
|
|
|
|
it("accumulates indices across workflows in swimlane render order, so a later workflow's first step never ties with an earlier workflow's first step", () => {
|
|
const multiWorkflows = [
|
|
{ id: "wf-1", name: "Workflow 1" },
|
|
{ id: "wf-2", name: "Workflow 2" },
|
|
];
|
|
const multiSnapshots = {
|
|
"wf-1": {
|
|
steps: [
|
|
{ id: "wf1-todo", position: 0 },
|
|
{ id: "wf1-done", position: 1 },
|
|
],
|
|
},
|
|
"wf-2": {
|
|
steps: [
|
|
{ id: "wf2-todo", position: 0 },
|
|
{ id: "wf2-done", position: 1 },
|
|
],
|
|
},
|
|
};
|
|
const indexOf = buildPipelineStepIndexOf(multiWorkflows, multiSnapshots, {});
|
|
expect(indexOf("wf1-todo")).toBe(0);
|
|
expect(indexOf("wf1-done")).toBe(1);
|
|
expect(indexOf("wf2-todo")).toBe(2);
|
|
expect(indexOf("wf2-done")).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("filterIdsByPriorityFilter", () => {
|
|
// STORED_INVALID_ID is AC-001.10's persistent unranked origin (a stored value
|
|
// outside the four tokens); "unranked" is the transient origin (absent
|
|
// field). Both must be excluded identically under a non-empty selection.
|
|
const STORED_INVALID_ID = "stored-invalid";
|
|
const taskById = new Map<string, DisplayOrderTask>([
|
|
["high", { id: "high", priority: "high" }],
|
|
["low", { id: "low", priority: "low" }],
|
|
["unranked", { id: "unranked" }],
|
|
[STORED_INVALID_ID, { id: STORED_INVALID_ID, priority: "urgent" as TaskPriority }],
|
|
]);
|
|
|
|
it("keeps every id when the priority filter is empty", () => {
|
|
expect(
|
|
filterIdsByPriorityFilter(["high", "low", "unranked", STORED_INVALID_ID], taskById, []),
|
|
).toEqual(["high", "low", "unranked", STORED_INVALID_ID]);
|
|
});
|
|
|
|
it("drops an id whose task's priority is outside the active selection", () => {
|
|
expect(filterIdsByPriorityFilter(["high", "low"], taskById, ["high"])).toEqual(["high"]);
|
|
});
|
|
|
|
it("drops an unranked task under a non-empty selection", () => {
|
|
expect(filterIdsByPriorityFilter(["high", "unranked"], taskById, ["high"])).toEqual(["high"]);
|
|
});
|
|
|
|
it("drops a persistent-origin unranked task (stored value outside the vocabulary) under a non-empty selection", () => {
|
|
expect(filterIdsByPriorityFilter(["high", STORED_INVALID_ID], taskById, ["high"])).toEqual([
|
|
"high",
|
|
]);
|
|
});
|
|
|
|
it("keeps an id whose task isn't in the map — visibility can't be determined, so it isn't excluded", () => {
|
|
expect(filterIdsByPriorityFilter(["ghost"], taskById, ["high"])).toEqual(["ghost"]);
|
|
});
|
|
});
|
|
|
|
const moveTaskById = vi.fn();
|
|
const moveTasks = vi.fn();
|
|
const deleteTaskById = vi.fn();
|
|
const archiveTaskById = vi.fn();
|
|
const runTaskRemovalBatch = vi.fn();
|
|
|
|
vi.mock("@/hooks/use-task-actions", () => ({
|
|
useTaskActions: () => ({
|
|
moveTaskById,
|
|
deleteTaskById,
|
|
archiveTaskById,
|
|
renameTaskById: vi.fn(),
|
|
}),
|
|
}));
|
|
vi.mock("@/hooks/use-task-workflow-move", () => ({
|
|
useTaskWorkflowMove: () => moveTasks,
|
|
}));
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => ({ isMobile: false }),
|
|
}));
|
|
vi.mock("@/hooks/use-task-removal", () => ({
|
|
useTaskRemovalSuccessNotifier: () => vi.fn(),
|
|
useTaskRemoval: () => ({ runTaskRemovalBatch }),
|
|
}));
|
|
|
|
// Stands in for coordinateTaskRemovalBatch: dispatches every request's
|
|
// `mutate()` synchronously (matching the real coordinator's Promise.allSettled
|
|
// call) so the eligibility-snapshot-at-invocation tests below still observe
|
|
// dispatch happening before their gates resolve.
|
|
function mockRunTaskRemovalBatchDefault() {
|
|
runTaskRemovalBatch.mockImplementation(
|
|
async (
|
|
_action: "delete" | "archive",
|
|
requests: Array<{ taskId: string; mutate: () => Promise<void> }>,
|
|
) => {
|
|
const results = await Promise.allSettled(requests.map((request) => request.mutate()));
|
|
return {
|
|
skipped: false,
|
|
operationToken: "removal-1",
|
|
switchedTaskId: null,
|
|
succeededTaskIds: requests
|
|
.filter((_, index) => results[index].status === "fulfilled")
|
|
.map((request) => request.taskId),
|
|
failedTaskIds: requests
|
|
.filter((_, index) => results[index].status === "rejected")
|
|
.map((request) => request.taskId),
|
|
errorsByTaskId: {},
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
function makeTask(id: string, priority?: TaskPriority) {
|
|
return {
|
|
id,
|
|
workflowId: "wf1",
|
|
workflowStepId: "step1",
|
|
title: id,
|
|
position: 0,
|
|
priority,
|
|
};
|
|
}
|
|
|
|
function makeStoreApi(
|
|
tasks: (ReturnType<typeof makeTask> & { createdAt?: string })[],
|
|
priorityFilterTokens: TaskPriority[] = [],
|
|
kanbanSort: "created_desc" | "priority_desc" = "created_desc",
|
|
) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let state: any = {
|
|
kanban: { workflowId: "wf1", steps: [], tasks: [] },
|
|
kanbanMulti: {
|
|
snapshots: {
|
|
wf1: {
|
|
workflowId: "wf1",
|
|
workflowName: "wf1",
|
|
steps: [{ id: "step1", position: 0 }],
|
|
tasks,
|
|
},
|
|
},
|
|
isLoading: false,
|
|
},
|
|
userSettings: {
|
|
kanbanSort,
|
|
kanbanPriorityFilterTokens: priorityFilterTokens,
|
|
kanbanViewMode: "kanban",
|
|
hiddenWorkflowStepIds: {},
|
|
},
|
|
};
|
|
state.hydrate = (patch: Partial<typeof state>) => {
|
|
state = { ...state, ...patch };
|
|
};
|
|
state.setWorkflowSnapshot = (wfId: string, snapshot: unknown) => {
|
|
state = {
|
|
...state,
|
|
kanbanMulti: {
|
|
...state.kanbanMulti,
|
|
snapshots: { ...state.kanbanMulti.snapshots, [wfId]: snapshot },
|
|
},
|
|
};
|
|
};
|
|
return { getState: () => state };
|
|
}
|
|
|
|
let storeApi: ReturnType<typeof makeStoreApi>;
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStoreApi: () => storeApi,
|
|
}));
|
|
|
|
describe("useTaskMultiSelect — bulk actions exclude priority-filtered-out tasks (AC-002.11)", () => {
|
|
beforeEach(() => {
|
|
moveTaskById.mockReset().mockResolvedValue(undefined);
|
|
moveTasks
|
|
.mockReset()
|
|
.mockImplementation(async (ids: string[], workflowId: string, targetStepId: string) => {
|
|
await Promise.all(
|
|
ids.map((id, position) =>
|
|
moveTaskById(id, {
|
|
workflow_id: workflowId,
|
|
workflow_step_id: targetStepId,
|
|
position,
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
deleteTaskById.mockReset().mockResolvedValue(undefined);
|
|
archiveTaskById.mockReset().mockResolvedValue(undefined);
|
|
runTaskRemovalBatch.mockReset();
|
|
mockRunTaskRemovalBatchDefault();
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("bulkDelete skips a selected task the active priority filter hides, without clearing it from the selection", async () => {
|
|
storeApi = makeStoreApi([makeTask("visible", "high"), makeTask("hidden", "low")], ["high"]);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("visible"));
|
|
act(() => result.current.toggleSelect("hidden"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkDelete();
|
|
});
|
|
|
|
expect(deleteTaskById).toHaveBeenCalledTimes(1);
|
|
expect(deleteTaskById).toHaveBeenCalledWith("visible", undefined);
|
|
expect(result.current.selectedIds.has("hidden")).toBe(true);
|
|
});
|
|
|
|
it("bulkMove skips a filtered-out selected task and doesn't consume a position index for it", async () => {
|
|
storeApi = makeStoreApi([makeTask("visible", "high"), makeTask("hidden", "low")], ["high"]);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("visible"));
|
|
act(() => result.current.toggleSelect("hidden"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkMove("step2");
|
|
});
|
|
|
|
expect(moveTaskById).toHaveBeenCalledTimes(1);
|
|
expect(moveTaskById).toHaveBeenCalledWith("visible", {
|
|
workflow_id: "wf1",
|
|
workflow_step_id: "step2",
|
|
position: 0,
|
|
});
|
|
});
|
|
|
|
it("bulkMove assigns positions in the live board sort order, not a fixed created-desc order (AC-002.10)", async () => {
|
|
// Under created_desc these would land oldest-last; under priority_desc a
|
|
// backward range selection must still land in priority-rank order.
|
|
storeApi = makeStoreApi(
|
|
[
|
|
{ ...makeTask("low", "low"), createdAt: "2026-01-03T00:00:00Z" },
|
|
{ ...makeTask("critical", "critical"), createdAt: "2026-01-01T00:00:00Z" },
|
|
{ ...makeTask("high", "high"), createdAt: "2026-01-02T00:00:00Z" },
|
|
],
|
|
[],
|
|
"priority_desc",
|
|
);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
// Select in reverse-of-priority order to prove the assigned index comes
|
|
// from the sort token, not selection order.
|
|
act(() => result.current.toggleSelect("low"));
|
|
act(() => result.current.toggleSelect("critical"));
|
|
act(() => result.current.toggleSelect("high"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkMove("step2");
|
|
});
|
|
|
|
expect(moveTaskById).toHaveBeenCalledTimes(3);
|
|
expect(moveTaskById).toHaveBeenNthCalledWith(1, "critical", {
|
|
workflow_id: "wf1",
|
|
workflow_step_id: "step2",
|
|
position: 0,
|
|
});
|
|
expect(moveTaskById).toHaveBeenNthCalledWith(2, "high", {
|
|
workflow_id: "wf1",
|
|
workflow_step_id: "step2",
|
|
position: 1,
|
|
});
|
|
expect(moveTaskById).toHaveBeenNthCalledWith(3, "low", {
|
|
workflow_id: "wf1",
|
|
workflow_step_id: "step2",
|
|
position: 2,
|
|
});
|
|
});
|
|
|
|
it("acts on every selected task when the priority filter is empty", async () => {
|
|
storeApi = makeStoreApi([makeTask("a", "high"), makeTask("b", "low")], []);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("a"));
|
|
act(() => result.current.toggleSelect("b"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkArchive();
|
|
});
|
|
|
|
expect(archiveTaskById).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// eligibleSelectedIds (lib.ts:176-187) reads the filter once, synchronously,
|
|
// before runBulkAction's only await point (Promise.allSettled) — so every
|
|
// `per(id)` dispatch below already happened by the time a test can mutate
|
|
// the store. These three tests pin that "snapshot at invocation, not at
|
|
// completion" contract for the in-flight window between dispatch and settle.
|
|
describe("useTaskMultiSelect — eligibility snapshot is frozen at invocation (AC-002.11)", () => {
|
|
beforeEach(() => {
|
|
moveTaskById.mockReset().mockResolvedValue(undefined);
|
|
moveTasks
|
|
.mockReset()
|
|
.mockImplementation(async (ids: string[], workflowId: string, targetStepId: string) => {
|
|
await Promise.all(
|
|
ids.map((id, position) =>
|
|
moveTaskById(id, {
|
|
workflow_id: workflowId,
|
|
workflow_step_id: targetStepId,
|
|
position,
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
deleteTaskById.mockReset().mockResolvedValue(undefined);
|
|
archiveTaskById.mockReset().mockResolvedValue(undefined);
|
|
runTaskRemovalBatch.mockReset();
|
|
mockRunTaskRemovalBatchDefault();
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
it("a task hidden by a filter change after invocation still gets acted on", async () => {
|
|
storeApi = makeStoreApi([makeTask("a", "high"), makeTask("b", "low")], []);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("a"));
|
|
act(() => result.current.toggleSelect("b"));
|
|
|
|
const gates = { a: deferred<void>(), b: deferred<void>() };
|
|
deleteTaskById.mockImplementation((id: string) => gates[id as "a" | "b"].promise);
|
|
|
|
let bulkDeletePromise: Promise<void> | undefined;
|
|
act(() => {
|
|
bulkDeletePromise = result.current.bulkDelete();
|
|
});
|
|
|
|
// Both dispatches already fired synchronously above; hiding "b" now must
|
|
// not undo the dispatch already made against the pre-mutation snapshot.
|
|
storeApi.getState().userSettings.kanbanPriorityFilterTokens = ["high"];
|
|
|
|
await act(async () => {
|
|
gates.a.resolve();
|
|
gates.b.resolve();
|
|
await bulkDeletePromise;
|
|
});
|
|
|
|
expect(deleteTaskById).toHaveBeenCalledTimes(2);
|
|
expect(deleteTaskById).toHaveBeenCalledWith("a", undefined);
|
|
expect(deleteTaskById).toHaveBeenCalledWith("b", undefined);
|
|
});
|
|
|
|
it("a task revealed after invocation isn't added to the acted-on set", async () => {
|
|
storeApi = makeStoreApi([makeTask("a", "high"), makeTask("c", "low")], ["high"]);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("a"));
|
|
act(() => result.current.toggleSelect("c"));
|
|
|
|
const gate = deferred<void>();
|
|
deleteTaskById.mockImplementation(() => gate.promise);
|
|
|
|
let bulkDeletePromise: Promise<void> | undefined;
|
|
act(() => {
|
|
bulkDeletePromise = result.current.bulkDelete();
|
|
});
|
|
|
|
// "c" was excluded from the invocation-time snapshot; revealing it now
|
|
// must not retroactively fold it into the dispatch already in flight.
|
|
storeApi.getState().userSettings.kanbanPriorityFilterTokens = [];
|
|
|
|
await act(async () => {
|
|
gate.resolve();
|
|
await bulkDeletePromise;
|
|
});
|
|
|
|
expect(deleteTaskById).toHaveBeenCalledTimes(1);
|
|
expect(deleteTaskById).toHaveBeenCalledWith("a", undefined);
|
|
expect(result.current.selectedIds.has("c")).toBe(true);
|
|
});
|
|
|
|
it("an invocation whose eligible snapshot is empty is a no-op", async () => {
|
|
storeApi = makeStoreApi([makeTask("c", "low")], ["high"]);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("c"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkDelete();
|
|
});
|
|
|
|
expect(deleteTaskById).not.toHaveBeenCalled();
|
|
expect(result.current.selectedIds.has("c")).toBe(true);
|
|
expect(result.current.isProcessing).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("useTaskMultiSelect — confirmation selection snapshots", () => {
|
|
beforeEach(() => {
|
|
moveTaskById.mockReset().mockResolvedValue(undefined);
|
|
moveTasks.mockReset();
|
|
deleteTaskById.mockReset().mockResolvedValue(undefined);
|
|
archiveTaskById.mockReset().mockResolvedValue(undefined);
|
|
runTaskRemovalBatch.mockReset();
|
|
mockRunTaskRemovalBatchDefault();
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("uses the confirmation snapshot for dispatch and keeps the excluded ids selected", async () => {
|
|
storeApi = makeStoreApi([makeTask("a", "high"), makeTask("b", "low")], []);
|
|
const { useTaskMultiSelect } = await import("./use-task-multi-select");
|
|
const { result } = renderHook(() => useTaskMultiSelect("wf1"));
|
|
|
|
act(() => result.current.toggleSelect("a"));
|
|
act(() => result.current.toggleSelect("b"));
|
|
|
|
await act(async () => {
|
|
await result.current.bulkDelete(undefined, {
|
|
allIds: ["a", "b"],
|
|
eligibleIds: ["a"],
|
|
});
|
|
});
|
|
|
|
expect(deleteTaskById).toHaveBeenCalledTimes(1);
|
|
expect(deleteTaskById).toHaveBeenCalledWith("a", undefined);
|
|
expect(result.current.selectedIds).toEqual(new Set(["b"]));
|
|
});
|
|
});
|