224 lines
7.4 KiB
TypeScript
224 lines
7.4 KiB
TypeScript
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { useChangeWorkflow } from "@/hooks/domains/kanban/use-change-workflow";
|
|
import { ChangeWorkflowDialog } from "./change-workflow-dialog";
|
|
|
|
const CURRENT_AGENT_LABEL = "Current agent";
|
|
const CURRENT_PROFILE_ID = "profile-current";
|
|
const SUBMIT_BUTTON_TEST_ID = "change-workflow-submit";
|
|
|
|
const { useChangeWorkflowMock, responsiveMock } = vi.hoisted(() => ({
|
|
useChangeWorkflowMock: vi.fn(),
|
|
responsiveMock: { isMobile: false, isFinePointer: true },
|
|
}));
|
|
|
|
vi.mock("@/hooks/domains/kanban/use-change-workflow", () => ({
|
|
useChangeWorkflow: useChangeWorkflowMock,
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => responsiveMock,
|
|
}));
|
|
|
|
vi.mock("@/components/task/workflow-move-preview-footer", () => ({
|
|
WorkflowMovePreviewFooter: () => <div data-testid="workflow-entry-preview" />,
|
|
}));
|
|
|
|
type HookState = ReturnType<typeof useChangeWorkflow>;
|
|
|
|
function makeHookState(): HookState {
|
|
const task = {
|
|
id: "task-1",
|
|
title: "Keep the thread",
|
|
workflow_id: "source-workflow",
|
|
workflow_step_id: "source-step",
|
|
updated_at: "2026-09-22T12:00:00Z",
|
|
workflow_agent_overrides: {
|
|
workflow_id: "source-workflow",
|
|
steps: [{ source_profile_id: "profile-old", replacement_profile_id: "profile-retired" }],
|
|
},
|
|
} as unknown as HookState["task"];
|
|
const step = (
|
|
id: string,
|
|
name: string,
|
|
sessionTarget?: { kind: "initial" } | { kind: "step"; step_id: string },
|
|
) => ({
|
|
id,
|
|
name,
|
|
position: id === "build" ? 0 : 1,
|
|
color: "#abcdef",
|
|
session_target: sessionTarget,
|
|
});
|
|
return {
|
|
task,
|
|
taskStatus: "success",
|
|
taskError: null,
|
|
workflows: [],
|
|
workflowsStatus: "success",
|
|
workflowsError: null,
|
|
destinations: [{ id: "destination", name: "Product", workspace_id: "workspace-1" }],
|
|
selectedWorkflowId: "destination",
|
|
selectedWorkflow: { id: "destination", name: "Product", workspace_id: "workspace-1" },
|
|
changeWorkflow: vi.fn(),
|
|
snapshot: {
|
|
workflow: { id: "destination", name: "Product" },
|
|
steps: [
|
|
step("build", "Build"),
|
|
step("review", "Review", { kind: "step", step_id: "build" }),
|
|
step("wrap-up", "Wrap-up", { kind: "initial" }),
|
|
],
|
|
tasks: [],
|
|
} as unknown as HookState["snapshot"],
|
|
snapshotStatus: "success",
|
|
snapshotError: null,
|
|
retrySnapshot: vi.fn(),
|
|
profiles: [],
|
|
profilesStatus: "success",
|
|
profilesError: null,
|
|
profileOptions: [
|
|
{
|
|
value: CURRENT_PROFILE_ID,
|
|
label: CURRENT_AGENT_LABEL,
|
|
renderLabel: () => CURRENT_AGENT_LABEL,
|
|
},
|
|
],
|
|
rows: [
|
|
{
|
|
sourceProfileId: CURRENT_PROFILE_ID,
|
|
sourceLabel: CURRENT_AGENT_LABEL,
|
|
sourceAgentName: "agent",
|
|
stepIds: ["build", "review"],
|
|
stepNames: ["Build", "Review"],
|
|
replacementProfileId: "profile-retired",
|
|
replacementLabel: "Retired agent",
|
|
replacementAvailable: false,
|
|
},
|
|
],
|
|
setOverride: vi.fn(),
|
|
overrides: {},
|
|
selectedStepId: "build",
|
|
setSelectedStepId: vi.fn(),
|
|
sourceWorkflowName: "Backlog",
|
|
sourceStepName: "Todo",
|
|
sourceChanged: false,
|
|
uncertainResult: false,
|
|
submitError: null,
|
|
sourceProfileErrorId: undefined,
|
|
isSubmitting: false,
|
|
canSubmit: false,
|
|
submit: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
retryWorkflows: vi.fn(),
|
|
retryProfiles: vi.fn(),
|
|
workflowChange: {
|
|
expected_workflow_id: "source-workflow",
|
|
expected_step_id: "source-step",
|
|
expected_updated_at: "2026-09-22T12:00:00Z",
|
|
agent_overrides: {},
|
|
},
|
|
} as unknown as HookState;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
responsiveMock.isMobile = false;
|
|
responsiveMock.isFinePointer = true;
|
|
useChangeWorkflowMock.mockReset();
|
|
useChangeWorkflowMock.mockReturnValue(makeHookState());
|
|
});
|
|
|
|
describe("ChangeWorkflowDialog", () => {
|
|
it("shows the destination form, task-scoped agent mapping, and conversation relationships", () => {
|
|
render(
|
|
<ChangeWorkflowDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
taskId="task-1"
|
|
workspaceId="workspace-1"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("dialog", { name: "Change workflow..." })).toBeTruthy();
|
|
expect(screen.getByText("Backlog · Todo")).toBeTruthy();
|
|
expect(screen.getByTestId("change-workflow-destination")).toBeTruthy();
|
|
expect(screen.getByTestId("change-workflow-step")).toBeTruthy();
|
|
expect(
|
|
screen.getByText(
|
|
"Changing workflows replaces this task's existing workflow agent overrides.",
|
|
),
|
|
).toBeTruthy();
|
|
expect(screen.getByText("Review uses the conversation from Build.")).toBeTruthy();
|
|
expect(screen.getByText("Wrap-up uses the initial conversation.")).toBeTruthy();
|
|
expect(screen.getByTestId("workflow-entry-preview")).toBeTruthy();
|
|
expect(screen.getByRole("alert").textContent).toContain("This profile is unavailable");
|
|
expect((screen.getByTestId(SUBMIT_BUTTON_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it("uses touch sizing on coarse-pointer tablets and always discloses override replacement", () => {
|
|
responsiveMock.isFinePointer = false;
|
|
const state = makeHookState();
|
|
state.task = { ...state.task!, workflow_agent_overrides: undefined };
|
|
useChangeWorkflowMock.mockReturnValue(state);
|
|
render(
|
|
<ChangeWorkflowDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
taskId="task-1"
|
|
workspaceId="workspace-1"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("dialog", { name: "Change workflow..." })).toBeTruthy();
|
|
expect(screen.queryByTestId("change-workflow-drawer")).toBeNull();
|
|
expect(screen.getByTestId("change-workflow-close").className).toContain("size-11");
|
|
expect(screen.getByTestId(SUBMIT_BUTTON_TEST_ID).className).toContain("min-h-12");
|
|
expect(
|
|
screen.getByText(
|
|
"Changing workflows replaces this task's existing workflow agent overrides.",
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("resets a replacement to the workflow profile and returns cancel to the caller", () => {
|
|
const state = makeHookState();
|
|
useChangeWorkflowMock.mockReturnValue(state);
|
|
const onOpenChange = vi.fn();
|
|
render(
|
|
<ChangeWorkflowDialog
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
taskId="task-1"
|
|
workspaceId="workspace-1"
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId(`change-workflow-profile-reset-${CURRENT_PROFILE_ID}`));
|
|
expect(state.setOverride).toHaveBeenCalledWith(CURRENT_PROFILE_ID, CURRENT_PROFILE_ID);
|
|
fireEvent.click(screen.getByTestId("change-workflow-cancel"));
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("uses a full-height phone surface with one scroll area and safe-area action space", () => {
|
|
responsiveMock.isMobile = true;
|
|
render(
|
|
<ChangeWorkflowDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
taskId="task-1"
|
|
workspaceId="workspace-1"
|
|
/>,
|
|
);
|
|
|
|
const drawer = screen.getByTestId("change-workflow-drawer");
|
|
const body = screen.getByTestId("change-workflow-scroll");
|
|
const footer = screen.getByTestId(SUBMIT_BUTTON_TEST_ID).parentElement;
|
|
expect(drawer.className).toContain("100dvh");
|
|
expect(body.className).toContain("overflow-y-auto");
|
|
expect(footer?.className).toContain("safe-area-inset-bottom");
|
|
expect(screen.getByTestId(SUBMIT_BUTTON_TEST_ID).className).toContain("min-h-12");
|
|
});
|
|
});
|