Files
kandev/apps/web/components/task/workflow-move-proceed-button.test.tsx
2026-09-19 21:26:36 +01:00

524 lines
17 KiB
TypeScript

import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import {
useWorkflowMoveLongPress,
WORKFLOW_MOVE_LONG_PRESS_MS,
WorkflowMoveProceedButton,
} from "./workflow-move-proceed-button";
const { previewMock, previewRevisionMock, TERRA_MODEL } = vi.hoisted(() => {
const TERRA_MODEL = "gpt-5.6-terra";
return {
TERRA_MODEL,
previewMock: vi.fn().mockResolvedValue({
outcome: "create_new",
model: {
before: { known: false },
after: { known: true, label: TERRA_MODEL },
after_source: "profile",
},
changes: [],
context_reset: false,
source_disposition: "keep",
dispatch: "prompt",
}),
previewRevisionMock: vi.fn(() => "revision-1"),
};
});
vi.mock("@/lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api")>()),
previewWorkflowMove: previewMock,
}));
vi.mock("@/hooks/domains/kanban/use-workflow-move-preview-revision", () => ({
useWorkflowMovePreviewRevision: () => previewRevisionMock(),
}));
const touchMocks = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => touchMocks.enabled,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
agentProfiles: { items: [] },
availableAgents: { items: [], loading: false, loaded: true, tools: [] },
}),
}));
afterEach(() => {
cleanup();
vi.useRealTimers();
touchMocks.enabled = false;
previewRevisionMock.mockReturnValue("revision-1");
previewMock.mockResolvedValue({
outcome: "create_new",
model: {
before: { known: false },
after: { known: true, label: TERRA_MODEL },
after_source: "profile",
},
changes: [],
context_reset: false,
source_disposition: "keep",
dispatch: "prompt",
});
});
beforeEach(() => {
vi.useFakeTimers();
});
const INSTRUCTIONS_TEST_ID = "workflow-move-instructions";
const SUBMIT_TEST_ID = "workflow-move-submit";
const PROCEED_TEST_ID = "proceed-next-step";
const OPTIONS_TEST_ID = "workflow-move-options";
const HOVER_OPTIONS_TEST_ID = `${PROCEED_TEST_ID}-options`;
const HOVER_OPEN_DELAY_MS = 120;
function renderProceed(onProceed = vi.fn()) {
render(
<TooltipProvider>
<WorkflowMoveProceedButton
nextStepName="Review"
onProceed={onProceed}
isMoving={false}
testId={PROCEED_TEST_ID}
/>
</TooltipProvider>,
);
return onProceed;
}
function touchPointer(overrides: Record<string, unknown> = {}) {
return {
pointerType: "touch",
button: 0,
clientX: 20,
clientY: 20,
...overrides,
} as never;
}
function openHoverForm() {
const proceed = screen.getByTestId(PROCEED_TEST_ID);
act(() => {
fireEvent.mouseEnter(proceed);
vi.advanceTimersByTime(HOVER_OPEN_DELAY_MS);
});
return screen.getByTestId(HOVER_OPTIONS_TEST_ID);
}
describe("WorkflowMoveProceedButton", () => {
it("moves directly on a short desktop click", () => {
const onProceed = renderProceed();
fireEvent.click(screen.getByTestId(PROCEED_TEST_ID));
expect(onProceed).toHaveBeenCalledWith(undefined);
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
});
it("does not render a redundant options sidecar beside the direct action", () => {
const onProceed = renderProceed();
expect(screen.queryByTestId(`${PROCEED_TEST_ID}-options-trigger`)).toBeNull();
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
expect(onProceed).not.toHaveBeenCalled();
});
it("reveals the anchored options form on a desktop hover", () => {
renderProceed();
openHoverForm();
expect(screen.getByTestId(HOVER_OPTIONS_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("workflow-move-skip-step-prompt")).toBeTruthy();
expect(screen.getByTestId(INSTRUCTIONS_TEST_ID)).toBeTruthy();
});
it("submits the hovered draft as one-shot entry options", async () => {
const onProceed = vi.fn().mockResolvedValue(true);
renderProceed(onProceed);
openHoverForm();
fireEvent.change(screen.getByTestId(INSTRUCTIONS_TEST_ID), {
target: { value: "create the PR ready for review" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await act(async () => {});
expect(onProceed).toHaveBeenCalledWith({
instructions: "create the PR ready for review",
});
// A successful optioned move closes the hovered form.
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
});
it("clears a successful desktop options draft before the next target", async () => {
const onProceed = vi.fn().mockResolvedValue(true);
const { rerender } = render(
<TooltipProvider>
<WorkflowMoveProceedButton
nextStepName="Review"
onProceed={onProceed}
isMoving={false}
testId={PROCEED_TEST_ID}
/>
</TooltipProvider>,
);
openHoverForm();
fireEvent.change(screen.getByTestId(INSTRUCTIONS_TEST_ID), {
target: { value: "only for Review" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await act(async () => {});
rerender(
<TooltipProvider>
<WorkflowMoveProceedButton
nextStepName="QA"
onProceed={onProceed}
isMoving={false}
testId={PROCEED_TEST_ID}
/>
</TooltipProvider>,
);
openHoverForm();
expect((screen.getByTestId(INSTRUCTIONS_TEST_ID) as HTMLTextAreaElement).value).toBe("");
});
it("keeps the hovered form and its draft open when the move fails", async () => {
const onProceed = vi.fn().mockResolvedValue(false);
renderProceed(onProceed);
openHoverForm();
fireEvent.change(screen.getByTestId(INSTRUCTIONS_TEST_ID), {
target: { value: "retry after capacity opens" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await act(async () => {});
expect(onProceed).toHaveBeenCalledOnce();
expect(screen.getByTestId(HOVER_OPTIONS_TEST_ID)).toBeTruthy();
expect((screen.getByTestId(INSTRUCTIONS_TEST_ID) as HTMLTextAreaElement).value).toBe(
"retry after capacity opens",
);
});
it("does not reveal the options form from a short click", () => {
renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.pointerDown(proceed, { pointerType: "mouse", button: 0, clientX: 20, clientY: 20 });
fireEvent.pointerUp(proceed, { pointerType: "mouse", button: 0, clientX: 20, clientY: 20 });
fireEvent.click(proceed);
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
});
it("closes the hovered form after the pointer leaves both the button and the form", () => {
renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
openHoverForm();
act(() => {
fireEvent.mouseLeave(proceed);
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
});
});
describe("WorkflowMoveProceedButton label interaction", () => {
it("keeps the hovered form open when the skip-prompt label receives focus", () => {
renderProceed();
openHoverForm();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
const skipCheckbox = screen.getByTestId("workflow-move-skip-step-prompt");
const skipLabel = screen.getByText("Skip the step prompt").closest("label");
expect(skipLabel?.htmlFor).toBe(skipCheckbox.id);
fireEvent.blur(proceed, { relatedTarget: skipCheckbox });
fireEvent.focus(skipCheckbox);
fireEvent.click(screen.getByText("Skip the step prompt"));
act(() => vi.advanceTimersByTime(200));
expect(skipCheckbox.getAttribute("aria-checked")).toBe("true");
expect(screen.getByTestId(HOVER_OPTIONS_TEST_ID)).toBeTruthy();
});
});
describe("WorkflowMoveProceedButton — coarse pointer", () => {
it("opens the Drawer after a long press and suppresses its duplicate click", () => {
touchMocks.enabled = true;
const onProceed = renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.pointerDown(proceed, touchPointer());
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
expect(screen.getByTestId(OPTIONS_TEST_ID)).toBeTruthy();
fireEvent.pointerUp(proceed, touchPointer());
fireEvent.click(proceed);
expect(onProceed).not.toHaveBeenCalled();
});
it("suppresses a long-press release retargeted to the Drawer submit action", () => {
touchMocks.enabled = true;
const onProceed = renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.pointerDown(proceed, touchPointer());
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
expect(onProceed).not.toHaveBeenCalled();
});
it("moves directly on a coarse-pointer short tap", () => {
touchMocks.enabled = true;
const onProceed = renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.pointerDown(proceed, touchPointer());
fireEvent.pointerUp(proceed, touchPointer());
fireEvent.click(proceed);
expect(onProceed).toHaveBeenCalledWith(undefined);
expect(screen.queryByTestId(OPTIONS_TEST_ID)).toBeNull();
});
it("keeps the desktop control compact and gives touch input a larger hitbox", () => {
renderProceed();
expect(screen.getByTestId(PROCEED_TEST_ID).className).toContain("h-6");
cleanup();
touchMocks.enabled = true;
renderProceed();
expect(screen.getByTestId(PROCEED_TEST_ID).className).toContain("min-h-11");
});
});
describe("WorkflowMoveProceedButton — in-flight guarding", () => {
it("suppresses rapid duplicate clicks before the move state re-renders", () => {
const onProceed = renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.click(proceed);
fireEvent.click(proceed);
expect(onProceed).toHaveBeenCalledTimes(1);
});
it("keeps direct and options actions disabled while a move is in flight", () => {
const onProceed = vi.fn();
render(
<TooltipProvider>
<WorkflowMoveProceedButton
nextStepName="Review"
onProceed={onProceed}
isMoving
testId={PROCEED_TEST_ID}
/>
</TooltipProvider>,
);
const proceed = screen.getByTestId(PROCEED_TEST_ID);
expect((proceed as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(proceed);
expect(onProceed).not.toHaveBeenCalled();
});
it("closes the hovered options form on Escape", async () => {
renderProceed();
const form = openHoverForm();
fireEvent.keyDown(form, { key: "Escape" });
act(() => vi.advanceTimersByTime(10));
await act(async () => {});
expect(screen.queryByTestId(HOVER_OPTIONS_TEST_ID)).toBeNull();
});
it("returns focus to the direct button after closing a long-press Drawer", () => {
touchMocks.enabled = true;
const onProceed = renderProceed();
const proceed = screen.getByTestId(PROCEED_TEST_ID);
fireEvent.pointerDown(proceed, touchPointer());
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
fireEvent.pointerUp(proceed, touchPointer());
fireEvent.click(proceed);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
act(() => vi.runOnlyPendingTimers());
expect(onProceed).not.toHaveBeenCalled();
expect(document.activeElement).toBe(proceed);
});
});
describe("useWorkflowMoveLongPress", () => {
type PointerHandlers = ReturnType<typeof useWorkflowMoveLongPress>["pointerHandlers"];
it.each([
["pointerup", (handlers: PointerHandlers) => handlers.onPointerUp(touchPointer())],
["pointercancel", (handlers: PointerHandlers) => handlers.onPointerCancel(touchPointer())],
])("cancels before the threshold on %s", (_name, cancel) => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useWorkflowMoveLongPress(onLongPress));
act(() => {
result.current.pointerHandlers.onPointerDown(touchPointer());
cancel(result.current.pointerHandlers);
vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS);
});
expect(onLongPress).not.toHaveBeenCalled();
});
it("cancels when movement passes the slop without preventing scrolling", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useWorkflowMoveLongPress(onLongPress));
const moveEvent = touchPointer({ clientX: 31, clientY: 20 });
act(() => {
result.current.pointerHandlers.onPointerDown(touchPointer());
result.current.pointerHandlers.onPointerMove(moveEvent);
vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS);
});
expect(onLongPress).not.toHaveBeenCalled();
});
it("cancels an in-flight timer when unmounted", () => {
const onLongPress = vi.fn();
const { result, unmount } = renderHook(() => useWorkflowMoveLongPress(onLongPress));
act(() => result.current.pointerHandlers.onPointerDown(touchPointer()));
unmount();
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
expect(onLongPress).not.toHaveBeenCalled();
});
it("marks one synthetic click as handled after a completed long press", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useWorkflowMoveLongPress(onLongPress));
act(() => {
result.current.pointerHandlers.onPointerDown(touchPointer());
vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS);
});
expect(onLongPress).toHaveBeenCalledOnce();
expect(result.current.consumePendingClick()).toBe(true);
expect(result.current.consumePendingClick()).toBe(false);
});
});
describe("proceed preview footer", () => {
it.each([false, true])("previews the current options on touch=%s", async (touch) => {
touchMocks.enabled = touch;
previewMock.mockClear();
render(
<TooltipProvider>
<WorkflowMoveProceedButton
nextStepName="Review"
onProceed={vi.fn()}
isMoving={false}
testId={PROCEED_TEST_ID}
previewTarget={{ taskId: "task-1", workflowId: "workflow-1", workflowStepId: "review" }}
/>
</TooltipProvider>,
);
expect(previewMock).not.toHaveBeenCalled();
if (touch) {
fireEvent.pointerDown(screen.getByTestId(PROCEED_TEST_ID), touchPointer());
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
fireEvent.pointerUp(screen.getByTestId(PROCEED_TEST_ID), touchPointer());
fireEvent.click(screen.getByTestId(PROCEED_TEST_ID));
} else openHoverForm();
await act(() => vi.advanceTimersByTimeAsync(200));
expect(screen.getByTestId("workflow-move-preview").textContent).toContain(TERRA_MODEL);
fireEvent.click(screen.getByTestId("workflow-move-skip-step-prompt"));
await act(() => vi.advanceTimersByTimeAsync(200));
expect(previewMock).toHaveBeenLastCalledWith(
"task-1",
{
workflow_id: "workflow-1",
workflow_step_id: "review",
entry_options: { skip_step_prompt: true },
},
expect.anything(),
);
});
it.each([false, true])("refreshes planned to actual model text on touch=%s", async (touch) => {
touchMocks.enabled = touch;
previewMock.mockReset();
previewMock.mockResolvedValueOnce({
outcome: "create_new",
model: {
before: { known: false },
after: { known: true, label: TERRA_MODEL },
after_source: "profile",
},
changes: [],
context_reset: false,
source_disposition: "keep",
dispatch: "prompt",
});
previewMock.mockResolvedValueOnce({
outcome: "reuse_current",
model: {
before: { known: true, label: TERRA_MODEL },
after: { known: true, label: TERRA_MODEL },
after_source: "override",
},
changes: [],
context_reset: false,
source_disposition: "keep",
dispatch: "prompt",
});
const props = {
nextStepName: "Review",
onProceed: vi.fn(),
isMoving: false,
testId: PROCEED_TEST_ID,
previewTarget: { taskId: "task-1", workflowId: "workflow-1", workflowStepId: "review" },
} as const;
const { rerender } = render(
<TooltipProvider>
<WorkflowMoveProceedButton {...props} />
</TooltipProvider>,
);
if (touch) {
fireEvent.pointerDown(screen.getByTestId(PROCEED_TEST_ID), touchPointer());
act(() => vi.advanceTimersByTime(WORKFLOW_MOVE_LONG_PRESS_MS));
fireEvent.pointerUp(screen.getByTestId(PROCEED_TEST_ID), touchPointer());
fireEvent.click(screen.getByTestId(PROCEED_TEST_ID));
} else {
openHoverForm();
}
await act(() => vi.advanceTimersByTimeAsync(200));
expect(screen.getByTestId("workflow-move-preview").textContent).toContain(TERRA_MODEL);
previewRevisionMock.mockReturnValue("revision-2");
rerender(
<TooltipProvider>
<WorkflowMoveProceedButton {...props} />
</TooltipProvider>,
);
await act(() => vi.advanceTimersByTimeAsync(200));
expect(screen.getByTestId("workflow-move-preview").textContent).toContain("override retained");
});
});