Files
kandev/apps/web/hooks/use-task-removal.test.ts
T
2026-08-13 11:26:02 +01:00

683 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import type { StoreApi } from "zustand";
const replaceTaskUrlMock = vi.fn();
const performLayoutSwitchMock = vi.fn();
const listTaskSessionsMock = vi.fn();
const fetchTaskMock = vi.fn();
const OVERVIEW_URL = "/?home=overview";
vi.mock("@/lib/links", () => ({
linkToTaskOverview: () => "/?home=overview",
replaceTaskUrl: (...args: unknown[]) => replaceTaskUrlMock(...args),
}));
vi.mock("@/lib/state/dockview-store", () => ({
performLayoutSwitch: (...args: unknown[]) => performLayoutSwitchMock(...args),
}));
vi.mock("@/lib/api", () => ({
listTaskSessions: (...args: unknown[]) => listTaskSessionsMock(...args),
fetchTask: (...args: unknown[]) => fetchTaskMock(...args),
}));
import { useTaskRemoval, selectNextTaskAfterRemoval } from "./use-task-removal";
import { setRecentTasks } from "@/lib/recent-tasks";
type TaskRow = { id: string; primarySessionId: string | null; parentTaskId?: string | null };
const CASCADE_PARENT: TaskRow = { id: "task-parent", primarySessionId: "sess-parent" };
const CASCADE_CHILD: TaskRow = {
id: "task-child",
primarySessionId: "sess-child",
parentTaskId: CASCADE_PARENT.id,
};
const CASCADE_DEEP_CHILD: TaskRow = {
id: "task-deep-child",
primarySessionId: "sess-deep-child",
parentTaskId: CASCADE_CHILD.id,
};
const RECENT_TASK_VISITED_AT = "2026-06-07T10:00:00Z";
const REMOVED_TASK_VISITED_AT = "2026-06-07T09:00:00Z";
const OLD_TASK_VISITED_AT = "2026-06-06T10:00:00Z";
const CASCADE_CHILD_VISITED_AT = "2026-06-07T12:00:00Z";
const CASCADE_DEEP_CHILD_VISITED_AT = "2026-06-07T11:00:00Z";
type FakeState = {
tasks: { activeTaskId: string | null; activeSessionId: string | null };
kanban: { tasks: TaskRow[] };
kanbanMulti: { snapshots: Record<string, { tasks: TaskRow[] }> };
environmentIdBySessionId: Record<string, string>;
taskSessionsByTask: {
itemsByTaskId: Record<string, never[]>;
loadedByTaskId: Record<string, boolean>;
loadingByTaskId: Record<string, boolean>;
};
setActiveTask: ReturnType<typeof vi.fn>;
setActiveSession: ReturnType<typeof vi.fn>;
setWorkflowSnapshot: ReturnType<typeof vi.fn>;
setTaskSessionsForTask: ReturnType<typeof vi.fn>;
setTaskSessionsLoading: ReturnType<typeof vi.fn>;
};
function makeStore(init: {
activeTaskId: string | null;
activeSessionId?: string | null;
remainingTasks: TaskRow[];
canonicalTasks?: TaskRow[];
}): StoreApi<FakeState> & { getRecorded: () => FakeState } {
const state: FakeState = {
tasks: {
activeTaskId: init.activeTaskId,
activeSessionId: init.activeSessionId ?? null,
},
kanban: { tasks: init.canonicalTasks ?? [] },
kanbanMulti: {
snapshots: { "wf-1": { tasks: init.remainingTasks } },
},
environmentIdBySessionId: { "sess-next": "env-next", "sess-A": "env-A" },
taskSessionsByTask: {
itemsByTaskId: {},
loadedByTaskId: {},
loadingByTaskId: {},
},
setActiveTask: vi.fn() as ReturnType<typeof vi.fn>,
setActiveSession: vi.fn() as ReturnType<typeof vi.fn>,
setWorkflowSnapshot: vi.fn((wfId: string, snapshot: { tasks: TaskRow[] }) => {
state.kanbanMulti.snapshots[wfId] = snapshot;
}) as unknown as ReturnType<typeof vi.fn>,
setTaskSessionsForTask: vi.fn() as ReturnType<typeof vi.fn>,
setTaskSessionsLoading: vi.fn() as ReturnType<typeof vi.fn>,
};
const api: StoreApi<FakeState> = {
getState: () => state,
setState: (updater: unknown) => {
const next =
typeof updater === "function"
? (updater as (s: FakeState) => FakeState)(state)
: (updater as FakeState);
Object.assign(state, next);
},
subscribe: () => () => {},
getInitialState: () => state,
} as unknown as StoreApi<FakeState>;
return Object.assign(api, { getRecorded: () => state }) as StoreApi<FakeState> & {
getRecorded: () => FakeState;
};
}
function mockLocation() {
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: {
get href() {
return "";
},
set href(value: string) {
hrefSetter(value);
},
},
});
return {
hrefSetter,
restore: () =>
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
}),
};
}
const nextTask: TaskRow = { id: "task-next", primarySessionId: "sess-next" };
const recentTaskId = "task-recent";
const recentSessionId = "sess-recent";
beforeEach(() => {
vi.clearAllMocks();
fetchTaskMock.mockResolvedValue({ archived_at: null });
window.localStorage.clear();
});
function makeStoreWithOldAndRecentTasks(activeTaskId: string | null) {
const oldTask = { id: "task-old", primarySessionId: "sess-old" };
const recentTask = { id: recentTaskId, primarySessionId: recentSessionId };
const store = makeStore({
activeTaskId,
activeSessionId: "sess-A",
remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }, oldTask, recentTask],
});
store.getRecorded().environmentIdBySessionId = {
...store.getRecorded().environmentIdBySessionId,
"sess-old": "env-old",
[recentSessionId]: "env-recent",
};
return store;
}
function setRecentTaskFirst() {
setRecentTasks([
{
taskId: recentTaskId,
title: "Recent task",
visitedAt: RECENT_TASK_VISITED_AT,
},
{
taskId: "task-A",
title: "Removed task",
visitedAt: REMOVED_TASK_VISITED_AT,
},
{
taskId: "task-old",
title: "Old task",
visitedAt: OLD_TASK_VISITED_AT,
},
]);
}
function setRemovedTaskFirst() {
setRecentTasks([
{
taskId: "task-A",
title: "Removed task",
visitedAt: RECENT_TASK_VISITED_AT,
},
{
taskId: recentTaskId,
title: "Recent task",
visitedAt: REMOVED_TASK_VISITED_AT,
},
{
taskId: "task-old",
title: "Old task",
visitedAt: OLD_TASK_VISITED_AT,
},
]);
}
describe("useTaskRemoval — switch guard (current store wins)", () => {
it("switches to next task when activeTaskId === taskId (user still on removed task)", async () => {
const store = makeStore({
activeTaskId: "task-A",
activeSessionId: "sess-A",
remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }, nextTask],
});
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith("task-next", "sess-next");
expect(replaceTaskUrlMock).toHaveBeenCalledWith("task-next");
});
it("does NOT switch when user manually moved to a different task during in-flight archive", async () => {
const store = makeStore({
activeTaskId: "task-B",
activeSessionId: "sess-B",
remainingTasks: [{ id: "task-B", primarySessionId: "sess-B" }, nextTask],
});
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(store.getRecorded().setActiveTask).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
});
});
describe("useTaskRemoval — switch guard (WS-clear fallback)", () => {
it("switches when WS cleared activeTaskId AND wasActiveTaskId matches removed task", async () => {
const store = makeStore({
activeTaskId: null,
activeSessionId: null,
remainingTasks: [nextTask],
});
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith("task-next", "sess-next");
expect(replaceTaskUrlMock).toHaveBeenCalledWith("task-next");
});
it("does NOT switch when no opts provided and WS already cleared activeTaskId", async () => {
const store = makeStore({
activeTaskId: null,
activeSessionId: null,
remainingTasks: [nextTask],
});
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A");
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(store.getRecorded().setActiveTask).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
});
it("does NOT switch when activeTaskId is null AND wasActiveTaskId does not match removed task", async () => {
const store = makeStore({
activeTaskId: null,
activeSessionId: null,
remainingTasks: [nextTask],
});
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-B",
wasActiveSessionId: "sess-B",
});
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(store.getRecorded().setActiveTask).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
});
it("redirects to the explicit overview when no remaining tasks AND user is still on removed task", async () => {
const store = makeStore({
activeTaskId: "task-A",
activeSessionId: "sess-A",
remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }],
});
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: {
get href() {
return "";
},
set href(value: string) {
hrefSetter(value);
},
},
});
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removeResult = await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(removeResult.switchedTaskId).toBeNull();
expect(hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
});
describe("useTaskRemoval — next task selection", () => {
it("redirects home when a stale snapshot candidate is absent from the canonical board", async () => {
const staleTask = { id: "task-stale", primarySessionId: "sess-stale" };
const store = makeStore({
activeTaskId: "task-A",
activeSessionId: "sess-A",
remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }, staleTask],
canonicalTasks: [],
});
fetchTaskMock.mockRejectedValueOnce(new Error("task not found"));
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: {
get href() {
return "";
},
set href(value: string) {
hrefSetter(value);
},
},
});
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removal = await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(removal.switchedTaskId).toBeNull();
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
expect(hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
it("switches to the most recent remaining task instead of the first snapshot task", async () => {
const store = makeStoreWithOldAndRecentTasks("task-A");
setRecentTaskFirst();
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
recentTaskId,
recentSessionId,
);
expect(replaceTaskUrlMock).toHaveBeenCalledWith(recentTaskId);
});
it("skips the removed active task when it is first in recent history", async () => {
const store = makeStoreWithOldAndRecentTasks("task-A");
setRemovedTaskFirst();
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
recentTaskId,
recentSessionId,
);
expect(replaceTaskUrlMock).toHaveBeenCalledWith(recentTaskId);
});
it("can switch before removing the task from board state", async () => {
const store = makeStoreWithOldAndRecentTasks("task-A");
setRemovedTaskFirst();
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard("task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
switchOnly: true,
});
expect(store.getRecorded().setWorkflowSnapshot).not.toHaveBeenCalled();
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
recentTaskId,
recentSessionId,
);
expect(replaceTaskUrlMock).toHaveBeenCalledWith(recentTaskId);
});
});
describe("useTaskRemoval — cascade tree exclusion", () => {
it("skips a cascade tree collected from workflow and canonical projections", async () => {
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD, CASCADE_DEEP_CHILD, unrelated],
canonicalTasks: [
CASCADE_DEEP_CHILD,
{ ...CASCADE_CHILD, parentTaskId: CASCADE_DEEP_CHILD.id },
],
});
setRecentTasks([
{ taskId: CASCADE_CHILD.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
{
taskId: CASCADE_DEEP_CHILD.id,
title: "Deep child",
visitedAt: CASCADE_DEEP_CHILD_VISITED_AT,
},
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
unrelated.id,
unrelated.primarySessionId,
);
});
it("prefers the workflow snapshot when canonical ancestry is stale", async () => {
const detachedChild = { ...CASCADE_CHILD, parentTaskId: null };
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, detachedChild, unrelated],
canonicalTasks: [CASCADE_CHILD],
});
setRecentTasks([
{ taskId: detachedChild.id, title: "Detached child", visitedAt: CASCADE_CHILD_VISITED_AT },
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
detachedChild.id,
detachedChild.primarySessionId,
);
});
it("can still switch to an active child when cascade exclusion is not requested", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
setRecentTasks([
{ taskId: CASCADE_CHILD.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
CASCADE_CHILD.id,
CASCADE_CHILD.primarySessionId,
);
});
});
describe("useTaskRemoval — cascade no-candidate transition", () => {
it("does not navigate home when switch-only mode has no safe candidate", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removal = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(removal.switchedTaskId).toBeNull();
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(location.hrefSetter).not.toHaveBeenCalled();
} finally {
location.restore();
}
});
it("opens the overview after final cascade cleanup when no safe task remains", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removal = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
excludeTaskTree: true,
} as never);
expect(removal.switchedTaskId).toBeNull();
expect(location.hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
location.restore();
}
});
it("retains the original cascade tree for cleanup after cache pruning", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const initialRemoval = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(initialRemoval.excludedTaskIds).toEqual(
new Set([CASCADE_PARENT.id, CASCADE_CHILD.id]),
);
store.getRecorded().kanbanMulti.snapshots["wf-1"].tasks = [
{ ...CASCADE_CHILD, parentTaskId: null },
];
store.getRecorded().kanban.tasks = [];
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
excludeTaskTree: true,
excludedTaskIds: initialRemoval.excludedTaskIds,
} as never);
expect(store.getRecorded().kanbanMulti.snapshots["wf-1"].tasks).toEqual([]);
expect(location.hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
location.restore();
}
});
});
describe("selectNextTaskAfterRemoval — stale-candidate rejection", () => {
const select = selectNextTaskAfterRemoval as unknown as (
remaining: TaskRow[],
removedTaskId: string,
isLive: (taskId: string) => Promise<boolean>,
excludedTaskIds?: ReadonlySet<string>,
) => Promise<TaskRow | null>;
it("skips every excluded descendant while preserving recent-task order", async () => {
const child: TaskRow = { ...CASCADE_CHILD, parentTaskId: null };
const deepChild: TaskRow = { ...CASCADE_DEEP_CHILD, parentTaskId: null };
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
setRecentTasks([
{ taskId: child.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
{ taskId: deepChild.id, title: "Deep child", visitedAt: CASCADE_DEEP_CHILD_VISITED_AT },
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
await expect(
select(
[child, deepChild, unrelated],
CASCADE_PARENT.id,
async () => true,
new Set([CASCADE_PARENT.id, child.id, deepChild.id]),
),
).resolves.toBe(unrelated);
});
it("returns null (redirect home) when the only remaining candidate is not a live board member", async () => {
const stale: TaskRow = { id: "task-A-stale", primarySessionId: "sess-A" };
await expect(select([stale], "task-B", async () => false)).resolves.toBeNull();
});
it("returns the candidate when it is a live board member", async () => {
const live: TaskRow = { id: "task-A-live", primarySessionId: "sess-A" };
await expect(select([live], "task-B", async () => true)).resolves.toBe(live);
});
});