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(); vi.mock("@/lib/links", () => ({ 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), })); import { useTaskRemoval } from "./use-task-removal"; import { setRecentTasks } from "@/lib/recent-tasks"; type TaskRow = { id: string; primarySessionId: string | null }; type FakeState = { tasks: { activeTaskId: string | null; activeSessionId: string | null }; kanban: { tasks: TaskRow[] }; kanbanMulti: { snapshots: Record }; environmentIdBySessionId: Record; taskSessionsByTask: { itemsByTaskId: Record; loadedByTaskId: Record; loadingByTaskId: Record; }; setActiveTask: ReturnType; setActiveSession: ReturnType; setWorkflowSnapshot: ReturnType; setTaskSessionsForTask: ReturnType; setTaskSessionsLoading: ReturnType; }; function makeStore(init: { activeTaskId: string | null; activeSessionId?: string | null; remainingTasks: TaskRow[]; }): StoreApi & { getRecorded: () => FakeState } { const state: FakeState = { tasks: { activeTaskId: init.activeTaskId, activeSessionId: init.activeSessionId ?? null, }, kanban: { tasks: [] }, 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, setActiveSession: vi.fn() as ReturnType, setWorkflowSnapshot: vi.fn((wfId: string, snapshot: { tasks: TaskRow[] }) => { state.kanbanMulti.snapshots[wfId] = snapshot; }) as unknown as ReturnType, setTaskSessionsForTask: vi.fn() as ReturnType, setTaskSessionsLoading: vi.fn() as ReturnType, }; const api: StoreApi = { 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; return Object.assign(api, { getRecorded: () => state }) as StoreApi & { getRecorded: () => FakeState; }; } const nextTask: TaskRow = { id: "task-next", primarySessionId: "sess-next" }; const recentTaskId = "task-recent"; const recentSessionId = "sess-recent"; beforeEach(() => { vi.clearAllMocks(); 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: "2026-06-07T10:00:00Z", }, { taskId: "task-A", title: "Removed task", visitedAt: "2026-06-07T09:00:00Z", }, { taskId: "task-old", title: "Old task", visitedAt: "2026-06-06T10:00:00Z", }, ]); } function setRemovedTaskFirst() { setRecentTasks([ { taskId: "task-A", title: "Removed task", visitedAt: "2026-06-07T10:00:00Z", }, { taskId: recentTaskId, title: "Recent task", visitedAt: "2026-06-07T09:00:00Z", }, { taskId: "task-old", title: "Old task", visitedAt: "2026-06-06T10:00:00Z", }, ]); } 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 }), ); 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 }), ); 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 }), ); 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 }), ); 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 }), ); 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 / when no remaining tasks AND user 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 }), ); const removeResult = await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", wasActiveSessionId: "sess-A", }); expect(removeResult.switchedTaskId).toBeNull(); expect(hrefSetter).toHaveBeenCalledWith("/"); } finally { Object.defineProperty(window, "location", { configurable: true, value: originalLocation, }); } }); }); describe("useTaskRemoval — next task selection", () => { 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 }), ); 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 }), ); 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 }), ); 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); }); });