Files
kandev/apps/web/components/task/task-management-surface.test.tsx
T
2026-09-25 10:30:33 +01:00

207 lines
7.0 KiB
TypeScript

import { useRef } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useStore } from "zustand";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createAppStore, type AppState } from "@/lib/state/store";
import { useTaskManagementFlow } from "@/hooks/use-task-management-flow";
import { TaskManagementSurface } from "./task-management-surface";
const api = vi.hoisted(() => ({
linkTaskIssue: vi.fn(),
archiveTask: vi.fn(),
bulkMoveSelectedTasks: vi.fn(),
toast: vi.fn(),
getSubtaskCount: vi.fn(),
}));
const pointer = vi.hoisted(() => ({ isMobile: true, isFinePointer: false }));
const ISSUE_INPUT = "task-github-issue-input";
let store: ReturnType<typeof createAppStore>;
let flow: ReturnType<typeof useTaskManagementFlow>;
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => store,
useAppStore: (selector: (state: AppState) => unknown) => useStore(store, selector),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: api.toast }) }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => pointer,
}));
vi.mock("@/lib/api/domains/github-api", async (original) => ({
...(await original<typeof import("@/lib/api/domains/github-api")>()),
linkTaskIssue: api.linkTaskIssue,
}));
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
getSubtaskCount: api.getSubtaskCount,
archiveTask: api.archiveTask,
bulkMoveSelectedTasks: api.bulkMoveSelectedTasks,
}));
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
pointer.isMobile = true;
pointer.isFinePointer = false;
api.getSubtaskCount.mockResolvedValue({ count: 0 });
api.bulkMoveSelectedTasks.mockResolvedValue({ moved_count: 1 });
store = createAppStore();
store.setState((state) => ({
workspaces: { ...state.workspaces, activeId: "workspace" },
workflows: {
...state.workflows,
items: [{ id: "workflow", name: "Flow", workspaceId: "workspace" }],
},
kanbanMulti: {
...state.kanbanMulti,
snapshots: {
workflow: {
workflowId: "workflow",
workflowName: "Flow",
steps: [],
tasks: ["A", "B"].map((id) => ({
id,
title: id,
workflowId: "workflow",
workflowStepId: "step",
position: 0,
})),
},
},
},
}));
});
function Harness({ showAnchor = true }: { showAnchor?: boolean }) {
flow = useTaskManagementFlow();
const anchorRef = useRef<HTMLButtonElement>(null);
const fallbackRef = useRef<HTMLButtonElement>(null);
return (
<>
{showAnchor && (
<button ref={anchorRef} onClick={() => flow.open("A")}>
Open A
</button>
)}
<button ref={fallbackRef}>Surviving thread</button>
<TaskManagementSurface
flow={flow}
point={{ x: 0, y: 0 }}
anchorRef={anchorRef}
focusReturnRef={fallbackRef}
onReturnFocus={() => {}}
/>
</>
);
}
function openIssue() {
fireEvent.click(screen.getByRole("button", { name: "Link" }));
fireEvent.click(screen.getByRole("button", { name: "GitHub Issue" }));
}
// @covers AC-TASKS-THREADS-ACTIONS-002.2, AC-TASKS-THREADS-ACTIONS-002.5
it("a late link result for A cannot close or populate a newer link flow for B", async () => {
let release!: () => void;
api.linkTaskIssue.mockReturnValueOnce(
new Promise<void>((resolve) => {
release = resolve;
}),
);
render(<Harness />);
fireEvent.click(screen.getByText("Open A"));
openIssue();
fireEvent.change(screen.getByTestId(ISSUE_INPUT), {
target: { value: "https://github.com/test/repo/issues/1" },
});
act(() => store.getState().setActiveSession("B", "session-B"));
fireEvent.click(screen.getByTestId("task-github-issue-submit"));
expect(api.linkTaskIssue).toHaveBeenCalledWith("A", {
issue: "https://github.com/test/repo/issues/1",
});
act(() => flow.open("B"));
openIssue();
expect((screen.getByTestId(ISSUE_INPUT) as HTMLInputElement).value).toBe("");
await act(async () => release());
expect((screen.getByTestId(ISSUE_INPUT) as HTMLInputElement).disabled).toBe(false);
expect(flow.identity?.taskId).toBe("B");
});
// @covers AC-TASKS-THREADS-ACTIONS-002.3
it("discarding an inaccessible target also discards its provider form state", async () => {
render(<Harness />);
fireEvent.click(screen.getByText("Open A"));
openIssue();
act(() =>
store.setState((state) => ({ workspaces: { ...state.workspaces, activeId: "elsewhere" } })),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
act(() =>
store.setState((state) => ({ workspaces: { ...state.workspaces, activeId: "workspace" } })),
);
act(() => flow.open("B"));
fireEvent.click(screen.getByRole("button", { name: "Link" }));
fireEvent.click(screen.getByRole("button", { name: "GitHub Pull Request" }));
expect(screen.queryByTestId(ISSUE_INPUT)).toBeNull();
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
// @covers AC-TASKS-THREADS-ACTIONS-002.3, AC-TASKS-THREADS-ACTIONS-004.6
it("keeps a pending archive for A when only its header is filtered out", async () => {
pointer.isMobile = false;
pointer.isFinePointer = true;
let release!: (value: { count: number }) => void;
api.getSubtaskCount.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const view = render(<Harness />);
fireEvent.click(screen.getByText("Open A"));
act(() => flow.setStage("archive"));
await waitFor(() => expect(api.getSubtaskCount).toHaveBeenCalledWith("A"));
view.rerender(<Harness showAnchor={false} />);
expect(flow.stage).toBe("archive");
await act(async () => release({ count: 0 }));
expect(await screen.findByTestId("archive-task-confirm")).toBeTruthy();
expect(flow.getTarget()?.id).toBe("A");
});
// @covers AC-TASKS-THREADS-ACTIONS-001.3, AC-TASKS-THREADS-ACTIONS-002.2
it("allows moves within the current hidden workflow without offering other hidden workflows", async () => {
store.setState((state) => ({
workflows: {
...state.workflows,
items: ["workflow", "other-hidden"].map((id) => ({
id,
name: id,
workspaceId: "workspace",
hidden: true,
})),
},
kanbanMulti: {
...state.kanbanMulti,
snapshots: {
workflow: {
...state.kanbanMulti.snapshots.workflow,
steps: ["step", "next"].map((id, position) => ({
id,
title: id,
position,
color: "",
})),
},
},
},
}));
render(<Harness />);
fireEvent.click(screen.getByText("Open A"));
expect(screen.queryByRole("button", { name: "Change workflow..." })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Move to" }));
fireEvent.click(screen.getByRole("button", { name: "next" }));
await waitFor(() =>
expect(api.bulkMoveSelectedTasks).toHaveBeenCalledWith({
task_ids: ["A"],
target_workflow_id: "workflow",
target_step_id: "next",
}),
);
});