511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import type { QueuedMessage } from "@/lib/state/slices/session/types";
|
|
import type { EntityReference } from "@/lib/types/entity-reference";
|
|
import { entityReferenceMarkdown } from "@/lib/entity-references/message-references";
|
|
import { QueueEntryNotFoundError } from "@/lib/api/domains/queue-api";
|
|
import { toast } from "sonner";
|
|
|
|
const useQueueMock = vi.fn();
|
|
|
|
vi.mock("@/hooks/domains/session/use-queue", () => ({
|
|
useQueue: (sessionId: string | null) => useQueueMock(sessionId),
|
|
}));
|
|
|
|
vi.mock("@kandev/ui/tooltip", () => ({
|
|
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
// Mock Radix Collapsible because the real primitive pulls in a React global the
|
|
// jsdom test environment doesn't provide. We just need open/closed behavior to
|
|
// drive the assertions — the close-animation path is exercised in E2E.
|
|
vi.mock("@kandev/ui/collapsible", () => {
|
|
const Collapsible = ({ open, children }: { open: boolean; children: ReactNode }) =>
|
|
open ? <div data-collapsible-open="true">{children}</div> : null;
|
|
const CollapsibleContent = ({ children }: { children: ReactNode }) => <>{children}</>;
|
|
return { Collapsible, CollapsibleContent };
|
|
});
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { error: vi.fn(), success: vi.fn() },
|
|
}));
|
|
|
|
import { QueueAffordance } from "./queued-ghost-list";
|
|
|
|
const SESSION_ID = "sess-1";
|
|
const CHIP_ID = "queue-chip";
|
|
const PANEL_ID = "queued-ghost-list";
|
|
const QUEUED_BY_USER = "user";
|
|
const MERGE_BUTTON_ID = "queue-entry-merge";
|
|
const EDIT_BUTTON_ID = "queue-entry-edit";
|
|
const REMOVE_BUTTON_ID = "queue-entry-remove";
|
|
const SEND_NOW_BUTTON_ID = "queue-entry-send-now";
|
|
|
|
function entry(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
|
|
return {
|
|
id: "q-1",
|
|
session_id: "sess-1",
|
|
task_id: "task-1",
|
|
content: "hello world",
|
|
plan_mode: false,
|
|
queued_at: "2026-05-18T00:00:00Z",
|
|
queued_by: QUEUED_BY_USER,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function taskReference(): EntityReference {
|
|
return {
|
|
version: 1,
|
|
ref: "mention:v1:kandev:task:workspace-1:task-1",
|
|
provider: "kandev",
|
|
kind: "task",
|
|
id: "task-1",
|
|
key: "KAN-1",
|
|
title: "Fix authentication",
|
|
url: "/t/task-1",
|
|
scope: "workspace-1",
|
|
};
|
|
}
|
|
|
|
function queueState(entries: QueuedMessage[], extra: Partial<ReturnType<typeof baseState>> = {}) {
|
|
return { ...baseState(entries), ...extra };
|
|
}
|
|
|
|
function baseState(entries: QueuedMessage[]) {
|
|
return {
|
|
entries,
|
|
count: entries.length,
|
|
max: 10,
|
|
isFull: false,
|
|
isLoading: false,
|
|
queue: vi.fn(async () => {}),
|
|
clearAll: vi.fn(async () => {}),
|
|
drainNext: vi.fn(async () => {}),
|
|
editEntry: vi.fn(async () => {}),
|
|
removeEntry: vi.fn(async () => {}),
|
|
mergeEntry: vi.fn(async () => {}),
|
|
sendEntryNow: vi.fn(async () => {}),
|
|
sendAllNow: vi.fn(async () => {}),
|
|
cancellationPending: false,
|
|
refetch: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
const CHILD = <div data-testid="child-marker">input</div>;
|
|
|
|
function renderQueue(node: ReactNode) {
|
|
return render(<StateProvider>{node}</StateProvider>);
|
|
}
|
|
|
|
function pressQueueEscape(): ReturnType<typeof vi.fn> {
|
|
const outerEscapeHandler = vi.fn();
|
|
document.addEventListener("keydown", outerEscapeHandler);
|
|
try {
|
|
fireEvent.keyDown(screen.getByTestId("queue-close"), { key: "Escape" });
|
|
} finally {
|
|
document.removeEventListener("keydown", outerEscapeHandler);
|
|
}
|
|
return outerEscapeHandler;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useQueueMock.mockReset();
|
|
vi.mocked(toast.error).mockClear();
|
|
vi.mocked(toast.success).mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe("QueueAffordance", () => {
|
|
it("renders only children when there are no queued entries", () => {
|
|
useQueueMock.mockReturnValue(queueState([]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
expect(screen.getByTestId("child-marker")).toBeTruthy();
|
|
expect(screen.queryByTestId(CHIP_ID)).toBeNull();
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
});
|
|
|
|
it("renders only children when sessionId is null", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(<QueueAffordance sessionId={null}>{CHILD}</QueueAffordance>);
|
|
expect(screen.getByTestId("child-marker")).toBeTruthy();
|
|
expect(screen.queryByTestId(CHIP_ID)).toBeNull();
|
|
});
|
|
|
|
it("shows a collapsed chip with the queue count when entries exist", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry(), entry({ id: "q-2", content: "second" })]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
const chip = screen.getByTestId(CHIP_ID);
|
|
expect(chip.textContent).toContain("2 queued");
|
|
expect(chip.getAttribute("aria-label")).toContain("click to expand");
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
});
|
|
|
|
it("marks the chip as full when isFull is true", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()], { isFull: true, max: 1 }));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
const chip = screen.getByTestId(CHIP_ID);
|
|
expect(chip.getAttribute("data-full")).toBe("true");
|
|
expect(chip.textContent).toContain("full");
|
|
});
|
|
|
|
it("clicking the chip swaps it for the expanded panel", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.getByTestId(PANEL_ID)).toBeTruthy();
|
|
// While the panel is open the chip is hidden — its info is duplicated in
|
|
// the panel header, so we collapse via the X close button or Escape.
|
|
expect(screen.queryByTestId(CHIP_ID)).toBeNull();
|
|
});
|
|
|
|
it("clicking the X close button in the panel collapses the panel", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getByTestId("queue-close"));
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
});
|
|
|
|
it("auto-collapses the panel when the queue drains to zero", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
const { rerender } = render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.getByTestId(PANEL_ID)).toBeTruthy();
|
|
useQueueMock.mockReturnValue(queueState([]));
|
|
rerender(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
expect(screen.queryByTestId(CHIP_ID)).toBeNull();
|
|
});
|
|
|
|
it("auto-collapses the panel when sessionId changes", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
const { rerender } = render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.getByTestId(PANEL_ID)).toBeTruthy();
|
|
rerender(<QueueAffordance sessionId="sess-2">{CHILD}</QueueAffordance>);
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
});
|
|
|
|
it("clear-all button invokes clearAll from the queue hook", () => {
|
|
const state = queueState([entry()]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getByTestId("queue-clear-all"));
|
|
expect(state.clearAll).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows a run-next action when manual drain is available", () => {
|
|
const state = queueState([entry()]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(
|
|
<QueueAffordance sessionId={SESSION_ID} canDrain>
|
|
{CHILD}
|
|
</QueueAffordance>,
|
|
);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getByTestId("queue-drain-next"));
|
|
expect(state.drainNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance Send Now", () => {
|
|
it("sends the whole visible queue from the header", () => {
|
|
const state = queueState([entry(), entry({ id: "q-2", content: "second" })]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getByTestId("queue-send-now"));
|
|
expect(state.sendAllNow).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sends the selected row now", () => {
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "first" }),
|
|
entry({ id: "q-2", content: "second" }),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getAllByTestId(SEND_NOW_BUTTON_ID)[1]);
|
|
expect(state.sendEntryNow).toHaveBeenCalledWith("q-2");
|
|
});
|
|
|
|
it("disables row and header Send Now while cancellation is pending", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()], { cancellationPending: true }));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect((screen.getByTestId("queue-send-now") as HTMLButtonElement).disabled).toBe(true);
|
|
expect((screen.getByTestId(SEND_NOW_BUTTON_ID) as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance busy controls", () => {
|
|
it("hides the run-next action while the agent is busy", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(
|
|
<QueueAffordance sessionId={SESSION_ID} canDrain={false}>
|
|
{CHILD}
|
|
</QueueAffordance>,
|
|
);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.queryByTestId("queue-drain-next")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance positions", () => {
|
|
it("compacts displayed positions when persisted queue positions contain gaps", () => {
|
|
const initialEntries = [
|
|
entry({ id: "q-1", position: 1 }),
|
|
entry({ id: "q-2", position: 2 }),
|
|
entry({ id: "q-3", position: 3 }),
|
|
];
|
|
useQueueMock.mockReturnValue(queueState(initialEntries));
|
|
const { rerender } = render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
|
|
useQueueMock.mockReturnValue(queueState([initialEntries[0], initialEntries[2]]));
|
|
rerender(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
expect(screen.getByLabelText("Position #1")).toBeTruthy();
|
|
expect(screen.getByLabelText("Position #2")).toBeTruthy();
|
|
expect(screen.queryByLabelText("Position #3")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance Escape handling", () => {
|
|
it("collapses an open panel without reaching an outer dialog", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
const outerEscapeHandler = pressQueueEscape();
|
|
expect(screen.queryByTestId(PANEL_ID)).toBeNull();
|
|
expect(screen.getByTestId(CHIP_ID)).toBeTruthy();
|
|
expect(outerEscapeHandler).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance — renderStatusBar prop", () => {
|
|
it("calls renderStatusBar with null when there are no queued entries", () => {
|
|
useQueueMock.mockReturnValue(queueState([]));
|
|
const renderStatusBar = vi.fn(() => <div data-testid="status-bar-slot" />);
|
|
render(
|
|
<QueueAffordance sessionId={SESSION_ID} renderStatusBar={renderStatusBar}>
|
|
{CHILD}
|
|
</QueueAffordance>,
|
|
);
|
|
expect(renderStatusBar).toHaveBeenCalledWith(null);
|
|
expect(screen.getByTestId("status-bar-slot")).toBeTruthy();
|
|
});
|
|
|
|
it("calls renderStatusBar with a chip node when entries exist and panel is closed", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
const renderStatusBar = vi.fn((chip) => <div data-testid="status-bar-slot">{chip}</div>);
|
|
render(
|
|
<QueueAffordance sessionId={SESSION_ID} renderStatusBar={renderStatusBar}>
|
|
{CHILD}
|
|
</QueueAffordance>,
|
|
);
|
|
const callArg = renderStatusBar.mock.calls[0][0];
|
|
expect(callArg).not.toBeNull();
|
|
// The chip lives inside the caller-supplied slot, not as a separate
|
|
// inline element above the input.
|
|
const slot = screen.getByTestId("status-bar-slot");
|
|
expect(slot.contains(screen.getByTestId(CHIP_ID))).toBe(true);
|
|
});
|
|
|
|
it("calls renderStatusBar with null when entries exist and the panel is open", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
const renderStatusBar = vi.fn((chip) => <div data-testid="status-bar-slot">{chip}</div>);
|
|
render(
|
|
<QueueAffordance sessionId={SESSION_ID} renderStatusBar={renderStatusBar}>
|
|
{CHILD}
|
|
</QueueAffordance>,
|
|
);
|
|
// Open the panel — the chip should drop out of the status bar.
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.getByTestId(PANEL_ID)).toBeTruthy();
|
|
const lastCall = renderStatusBar.mock.calls.at(-1);
|
|
expect(lastCall?.[0]).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance — provenance actions", () => {
|
|
it("offers Remove for every visible origin and Edit only for user rows", () => {
|
|
const state = queueState([
|
|
entry({ id: "q-user", queued_by: "user" }),
|
|
entry({ id: "q-agent", queued_by: "agent" }),
|
|
entry({
|
|
id: "q-workflow",
|
|
queued_by: "workflow",
|
|
metadata: { workflow_message: true, workflow_step_name: "Review" },
|
|
}),
|
|
entry({ id: "q-server", queued_by: "server" }),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
|
|
expect(screen.getByTestId("workflow-message-badge").textContent).toContain("Review");
|
|
expect(screen.getAllByTestId(REMOVE_BUTTON_ID)).toHaveLength(4);
|
|
expect(screen.getAllByTestId(EDIT_BUTTON_ID)).toHaveLength(1);
|
|
|
|
fireEvent.click(screen.getAllByTestId(REMOVE_BUTTON_ID)[1]);
|
|
expect(state.removeEntry).toHaveBeenCalledWith("q-agent");
|
|
});
|
|
|
|
it("keeps one queue scroll owner and touch-sizes clear and close controls", () => {
|
|
useQueueMock.mockReturnValue(queueState([entry()]));
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
|
|
const panel = screen.getByTestId(PANEL_ID);
|
|
expect(panel.querySelectorAll(".overflow-y-auto")).toHaveLength(1);
|
|
for (const testId of ["queue-clear-all", "queue-close"]) {
|
|
expect(screen.getByTestId(testId).className).toContain("[@media(pointer:coarse)]:h-11");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance entity-reference edits", () => {
|
|
it("forwards an explicit empty replacement through the queue edit callback", async () => {
|
|
const reference = taskReference();
|
|
const state = queueState([
|
|
entry({
|
|
content: entityReferenceMarkdown(reference),
|
|
metadata: { entity_references: [reference] },
|
|
}),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getByTitle("Edit queued message"));
|
|
fireEvent.change(screen.getByTestId("queue-edit-textarea"), {
|
|
target: { value: "reference removed" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(state.editEntry).toHaveBeenCalledWith("q-1", "reference removed", undefined, []),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("QueueAffordance merge wiring", () => {
|
|
it("shows a merge control on the second row and calls mergeEntry with its id", () => {
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
|
entry({ id: "q-2", content: "second", queued_by: QUEUED_BY_USER }),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
|
|
const mergeButtons = screen.getAllByTestId(MERGE_BUTTON_ID);
|
|
// Only the second row merges (the head row has nothing above it).
|
|
expect(mergeButtons).toHaveLength(1);
|
|
fireEvent.click(mergeButtons[0]);
|
|
expect(state.mergeEntry).toHaveBeenCalledWith("q-2");
|
|
});
|
|
|
|
it("hides the merge control when the rows have mismatched sender kinds", () => {
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "agent", queued_by: "agent" }),
|
|
entry({ id: "q-2", content: "user", queued_by: QUEUED_BY_USER }),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
renderQueue(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.queryByTestId(MERGE_BUTTON_ID)).toBeNull();
|
|
});
|
|
|
|
it("hides the merge control for agent rows from different sender tasks", () => {
|
|
const state = queueState([
|
|
entry({
|
|
id: "q-1",
|
|
content: "agent a",
|
|
queued_by: "agent",
|
|
metadata: { sender_task_id: "task-7" },
|
|
}),
|
|
entry({
|
|
id: "q-2",
|
|
content: "agent b",
|
|
queued_by: "agent",
|
|
metadata: { sender_task_id: "task-8" },
|
|
}),
|
|
]);
|
|
useQueueMock.mockReturnValue(state);
|
|
renderQueue(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
expect(screen.queryByTestId(MERGE_BUTTON_ID)).toBeNull();
|
|
});
|
|
|
|
it("toasts an error when the merge fails", async () => {
|
|
const { toast } = await import("sonner");
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
|
entry({ id: "q-2", content: "second", queued_by: QUEUED_BY_USER }),
|
|
]);
|
|
state.mergeEntry = vi.fn(async () => {
|
|
throw new Error("boom");
|
|
});
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getAllByTestId(MERGE_BUTTON_ID)[0]);
|
|
await waitFor(() =>
|
|
expect(toast.error).toHaveBeenCalledWith("Failed to merge queued messages."),
|
|
);
|
|
});
|
|
|
|
it("does not toast on a drain race (QueueEntryNotFoundError)", async () => {
|
|
const { toast } = await import("sonner");
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
|
entry({ id: "q-2", content: "second", queued_by: QUEUED_BY_USER }),
|
|
]);
|
|
state.mergeEntry = vi.fn(async () => {
|
|
throw new QueueEntryNotFoundError();
|
|
});
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
fireEvent.click(screen.getAllByTestId(MERGE_BUTTON_ID)[0]);
|
|
await waitFor(() => expect(state.mergeEntry).toHaveBeenCalledTimes(1));
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serializes rapid double-clicks on the same merge row", async () => {
|
|
const state = queueState([
|
|
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
|
entry({ id: "q-2", content: "second", queued_by: QUEUED_BY_USER }),
|
|
]);
|
|
state.mergeEntry = vi.fn(async () => {});
|
|
useQueueMock.mockReturnValue(state);
|
|
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
|
|
|
fireEvent.click(screen.getByTestId(CHIP_ID));
|
|
const button = screen.getAllByTestId(MERGE_BUTTON_ID)[0];
|
|
fireEvent.click(button);
|
|
fireEvent.click(button);
|
|
await waitFor(() => expect(state.mergeEntry).toHaveBeenCalledTimes(1));
|
|
});
|
|
});
|