Files
kandev/apps/web/components/task/task-archive-confirmation.test.tsx
T
2026-09-12 14:58:49 +02:00

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")),
);
});
});