443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
|
|
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
|
|
const pointerState = vi.hoisted(() => ({ isFinePointer: false, isMobile: false }));
|
|
const CONFIRM_TEST_ID = "archive-task-confirm";
|
|
const INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
|
|
const CLEANUP_EFFECTS_TEST_ID = "task-cleanup-effects";
|
|
const CLEANUP_NOTES_TEST_ID = "task-cleanup-notes";
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
getSubtaskCount: (...args: unknown[]) => getSubtaskCountMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => pointerState,
|
|
}));
|
|
|
|
import { TaskArchiveConfirmation } from "./task-archive-confirmation";
|
|
|
|
afterEach(cleanup);
|
|
beforeEach(() => {
|
|
pointerState.isMobile = false;
|
|
pointerState.isFinePointer = false;
|
|
getSubtaskCountMock.mockReset();
|
|
});
|
|
|
|
describe("TaskArchiveConfirmation phone surfaces", () => {
|
|
it.each([false, true])("uses a stable phone sheet even with a fine pointer: %s", async (fine) => {
|
|
pointerState.isMobile = true;
|
|
pointerState.isFinePointer = fine;
|
|
const count = deferredSubtaskCount();
|
|
getSubtaskCountMock.mockReturnValue(count.promise);
|
|
const onConfirm = vi.fn();
|
|
renderConfirmation(onConfirm);
|
|
const sheet = screen.getByRole("dialog", { name: /Archive task/ });
|
|
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
|
|
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
|
|
await act(async () => count.resolve({ count: 2 }));
|
|
expect(screen.getByRole("dialog", { name: /Archive task/ })).toBe(sheet);
|
|
const cascade = screen.getByRole("checkbox");
|
|
expect(cascade.getAttribute("data-state")).toBe("unchecked");
|
|
fireEvent.click(cascade);
|
|
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
|
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: true }));
|
|
expect(onConfirm).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps forced phone archive in a bottom sheet and cancels delayed classification", async () => {
|
|
pointerState.isMobile = true;
|
|
const count = deferredSubtaskCount();
|
|
getSubtaskCountMock.mockReturnValue(count.promise);
|
|
const onConfirm = vi.fn();
|
|
const onOpenChange = vi.fn();
|
|
renderConfirmation(onConfirm, onOpenChange, true);
|
|
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
await act(async () => count.resolve({ count: 3 }));
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
expect(onConfirm).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function ConfirmationHarness({
|
|
onConfirm,
|
|
onOpenChange,
|
|
forceDialog = false,
|
|
renderInline,
|
|
}: {
|
|
onConfirm: () => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
forceDialog?: boolean;
|
|
renderInline?: (content: ReactNode) => ReactNode;
|
|
}) {
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
const [open, setOpen] = useState(true);
|
|
return (
|
|
<>
|
|
<button
|
|
ref={anchorRef}
|
|
type="button"
|
|
data-testid="archive-anchor"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Archive source
|
|
</button>
|
|
<button type="button" data-testid="outside-action">
|
|
Outside action
|
|
</button>
|
|
<TaskArchiveConfirmation
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
onOpenChange(next);
|
|
}}
|
|
anchorRef={anchorRef}
|
|
taskId="task-1"
|
|
taskTitle="Task One"
|
|
executorType="worktree"
|
|
onConfirm={onConfirm}
|
|
confirmTestId={CONFIRM_TEST_ID}
|
|
forceDialog={forceDialog}
|
|
inline={Boolean(renderInline)}
|
|
renderInline={renderInline}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AnchorLifecycleHarness({
|
|
showAnchor,
|
|
onOpenChange,
|
|
}: {
|
|
showAnchor: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(true);
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
const handleOpenChange = useCallback(
|
|
(nextOpen: boolean) => {
|
|
setOpen(nextOpen);
|
|
onOpenChange(nextOpen);
|
|
},
|
|
[onOpenChange],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{showAnchor ? (
|
|
<button ref={anchorRef} type="button" data-testid="archive-anchor">
|
|
Archive source
|
|
</button>
|
|
) : null}
|
|
<TaskArchiveConfirmation
|
|
open={open}
|
|
onOpenChange={handleOpenChange}
|
|
anchorRef={anchorRef}
|
|
taskId="task-1"
|
|
taskTitle="Task One"
|
|
executorType="worktree"
|
|
onConfirm={vi.fn()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function renderConfirmation(onConfirm = vi.fn(), onOpenChange = vi.fn(), forceDialog = false) {
|
|
return render(
|
|
<StateProvider>
|
|
<ConfirmationHarness
|
|
onConfirm={onConfirm}
|
|
onOpenChange={onOpenChange}
|
|
forceDialog={forceDialog}
|
|
/>
|
|
</StateProvider>,
|
|
);
|
|
}
|
|
|
|
function deferredSubtaskCount() {
|
|
let resolve: (result: { count: number }) => void = () => {};
|
|
const promise = new Promise<{ count: number }>((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("TaskArchiveConfirmation pending dismissal", () => {
|
|
it("dismisses the hidden desktop request on Escape and restores trigger focus", async () => {
|
|
pointerState.isFinePointer = true;
|
|
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
|
const onOpenChange = vi.fn();
|
|
|
|
renderConfirmation(vi.fn(), onOpenChange);
|
|
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
|
|
|
|
const outsideAction = screen.getByTestId("outside-action");
|
|
outsideAction.focus();
|
|
fireEvent.keyDown(outsideAction, { key: "Escape" });
|
|
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
expect(document.activeElement).toBe(screen.getByTestId("archive-anchor"));
|
|
});
|
|
|
|
it("dismisses the hidden desktop request on outside pointer intent", async () => {
|
|
pointerState.isFinePointer = true;
|
|
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
|
const onOpenChange = vi.fn();
|
|
|
|
renderConfirmation(vi.fn(), onOpenChange);
|
|
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
|
|
|
|
fireEvent.pointerDown(screen.getByTestId("outside-action"));
|
|
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("dismisses the hidden desktop request when its anchor disappears", async () => {
|
|
pointerState.isFinePointer = true;
|
|
const deferredCount = deferredSubtaskCount();
|
|
getSubtaskCountMock.mockReturnValue(deferredCount.promise);
|
|
const onOpenChange = vi.fn();
|
|
const renderHarness = (showAnchor: boolean) => (
|
|
<StateProvider>
|
|
<AnchorLifecycleHarness showAnchor={showAnchor} onOpenChange={onOpenChange} />
|
|
</StateProvider>
|
|
);
|
|
const view = render(renderHarness(true));
|
|
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
|
|
|
|
view.rerender(renderHarness(false));
|
|
await act(async () => deferredCount.resolve({ count: 2 }));
|
|
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
});
|
|
|
|
describe("TaskArchiveConfirmation inline surface", () => {
|
|
// @covers AC-TASKS-THREADS-ACTIONS-004.2
|
|
it.each([0, 2])(
|
|
"wraps only simple inline confirmation, not the %s-descendant dialog",
|
|
async (count) => {
|
|
getSubtaskCountMock.mockResolvedValue({ count });
|
|
render(
|
|
<StateProvider>
|
|
<ConfirmationHarness
|
|
onConfirm={vi.fn()}
|
|
onOpenChange={vi.fn()}
|
|
renderInline={(content) => <div data-testid="inline-surface">{content}</div>}
|
|
/>
|
|
</StateProvider>,
|
|
);
|
|
if (count === 0) {
|
|
const surface = await screen.findByTestId("inline-surface");
|
|
expect(await within(surface).findByTestId(CONFIRM_TEST_ID)).toBeTruthy();
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
} else {
|
|
expect(await screen.findByRole("alertdialog")).toBeTruthy();
|
|
expect(screen.queryByTestId("inline-surface")).toBeNull();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("TaskArchiveConfirmation classification", () => {
|
|
it("does not expose an archive action while descendant classification is pending", () => {
|
|
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
|
|
|
renderConfirmation();
|
|
|
|
expect(screen.queryByTestId(CONFIRM_TEST_ID)).toBeNull();
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
});
|
|
|
|
// @covers AC-TASKS-CONFIRMATION-SURFACE-002.4
|
|
it("waits for desktop classification before showing only the cascade dialog", async () => {
|
|
pointerState.isFinePointer = true;
|
|
const deferredCount = deferredSubtaskCount();
|
|
getSubtaskCountMock.mockReturnValue(deferredCount.promise);
|
|
|
|
renderConfirmation();
|
|
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
|
|
|
|
expect(screen.queryByTestId("task-archive-confirm-popover")).toBeNull();
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
expect(screen.queryByTestId(CONFIRM_TEST_ID)).toBeNull();
|
|
|
|
await act(async () => deferredCount.resolve({ count: 2 }));
|
|
|
|
expect(await screen.findByRole("alertdialog")).toBeTruthy();
|
|
expect(screen.getByTestId("archive-cascade-checkbox")).toBeTruthy();
|
|
expect(screen.queryByTestId("task-archive-confirm-popover")).toBeNull();
|
|
});
|
|
|
|
it("uses touch-sized local actions after a resolved zero-descendant result", async () => {
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
|
|
renderConfirmation();
|
|
|
|
const confirmation = await screen.findByTestId(INLINE_CONFIRMATION_TEST_ID);
|
|
expect(confirmation).toBeTruthy();
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
expect(screen.getByTestId(CONFIRM_TEST_ID).className).toContain("h-11");
|
|
});
|
|
|
|
// @covers AC-UI-TASK-CLEANUP-CONFIRMATION-001.8
|
|
it("uses the contained dialog when a coarse-pointer caller forces it", async () => {
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
const onConfirm = vi.fn();
|
|
const onOpenChange = vi.fn();
|
|
|
|
renderConfirmation(onConfirm, onOpenChange, true);
|
|
|
|
const dialog = await screen.findByRole("alertdialog", { name: /Archive task/ });
|
|
expect(dialog).toBeTruthy();
|
|
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
|
|
expect(screen.getByTestId("task-confirmation-outcome").textContent).toContain("Task One");
|
|
|
|
const archive = screen.getByTestId(CONFIRM_TEST_ID);
|
|
expect(archive.className).toContain("min-h-11");
|
|
expect(archive.className).toContain("w-full");
|
|
expect(archive.getAttribute("data-variant")).toBe("default");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
|
|
fireEvent.click(screen.getByTestId("archive-anchor"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(false),
|
|
);
|
|
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
|
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: false }));
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("keeps the forced dialog archive action disabled while classification is pending", async () => {
|
|
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
|
|
|
renderConfirmation(vi.fn(), vi.fn(), true);
|
|
|
|
const dialog = await screen.findByRole("alertdialog", { name: /Archive task/ });
|
|
expect(dialog).toBeTruthy();
|
|
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
|
|
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("uses the same semantic cleanup effect list and supporting notes as the dialog", async () => {
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
|
|
renderConfirmation();
|
|
|
|
await screen.findByTestId(INLINE_CONFIRMATION_TEST_ID);
|
|
expect(screen.getByTestId(CLEANUP_EFFECTS_TEST_ID).getAttribute("role")).toBe("list");
|
|
expect(
|
|
screen.getByTestId(CLEANUP_EFFECTS_TEST_ID).querySelectorAll('[role="listitem"]'),
|
|
).toHaveLength(2);
|
|
expect(screen.getByTestId(CLEANUP_NOTES_TEST_ID).tagName).toBe("SPAN");
|
|
expect(screen.queryByText(/Are you sure/i)).toBeNull();
|
|
});
|
|
|
|
it("keeps descendants on the cascade dialog branch", async () => {
|
|
getSubtaskCountMock.mockResolvedValue({ count: 2 });
|
|
|
|
renderConfirmation();
|
|
|
|
const dialog = await screen.findByRole("alertdialog");
|
|
expect(dialog).toBeTruthy();
|
|
expect(screen.getByTestId("archive-cascade-checkbox")).toBeTruthy();
|
|
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("keeps an unknown classification on the safe dialog branch", async () => {
|
|
getSubtaskCountMock.mockRejectedValue(new Error("classification unavailable"));
|
|
|
|
renderConfirmation();
|
|
|
|
expect(await screen.findByRole("alertdialog")).toBeTruthy();
|
|
expect(screen.queryByTestId("archive-cascade-checkbox")).toBeNull();
|
|
});
|
|
|
|
it("closes the local surface before invoking the archive callback", async () => {
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
let closedBeforeConfirm = false;
|
|
const onConfirm = vi.fn(() => {
|
|
closedBeforeConfirm = screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID) === null;
|
|
});
|
|
|
|
renderConfirmation(onConfirm);
|
|
fireEvent.click(await screen.findByTestId(CONFIRM_TEST_ID));
|
|
|
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
|
expect(closedBeforeConfirm).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("TaskArchiveConfirmation cleanup copy", () => {
|
|
it("uses the same semantic cleanup effect list in the fine-pointer popover", async () => {
|
|
pointerState.isFinePointer = true;
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
|
|
renderConfirmation();
|
|
|
|
await screen.findByTestId("task-archive-confirm-popover");
|
|
expect(screen.getByTestId(CLEANUP_EFFECTS_TEST_ID).getAttribute("role")).toBe("list");
|
|
expect(
|
|
screen.getByTestId(CLEANUP_EFFECTS_TEST_ID).querySelectorAll('[role="listitem"]'),
|
|
).toHaveLength(2);
|
|
expect(screen.getByTestId(CLEANUP_NOTES_TEST_ID).tagName).toBe("SPAN");
|
|
});
|
|
});
|
|
|
|
describe("TaskArchiveConfirmation focus return", () => {
|
|
it("returns focus to the trigger when a confirmed archive fails", async () => {
|
|
pointerState.isFinePointer = false;
|
|
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
|
const onConfirm = vi.fn(() => {
|
|
const failure = Promise.reject(new Error("archive failed"));
|
|
void failure.catch(() => undefined);
|
|
return failure;
|
|
});
|
|
|
|
function FocusHarness() {
|
|
const [open, setOpen] = useState(true);
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
return (
|
|
<>
|
|
<button ref={anchorRef} type="button" data-testid="archive-focus-anchor">
|
|
Archive source
|
|
</button>
|
|
<TaskArchiveConfirmation
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
anchorRef={anchorRef}
|
|
focusReturnRef={anchorRef}
|
|
restoreFocusOnConfirm
|
|
taskId="task-1"
|
|
taskTitle="Task One"
|
|
executorType="worktree"
|
|
forceDialog
|
|
onConfirm={onConfirm}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
render(
|
|
<StateProvider>
|
|
<FocusHarness />
|
|
</StateProvider>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId(CONFIRM_TEST_ID));
|
|
|
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
|
await waitFor(() =>
|
|
expect(document.activeElement).toBe(screen.getByTestId("archive-focus-anchor")),
|
|
);
|
|
});
|
|
});
|