import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { Task } from "@fusion/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TaskCard } from "@fusion/dashboard/app/components/TaskCard"; import { GraphTaskNode } from "../GraphTaskNode"; function createTask(overrides: Partial = {}): Task { return { id: "FN-TEST", description: "Task description", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], ...overrides, } as Task; } function createProps(task: Task) { return { task, position: { x: 0, y: 0 }, scale: 1, onNodePositionChange: vi.fn(), onNodeDragStateChange: vi.fn(), projectId: "proj-1", onOpenDetail: vi.fn(), addToast: vi.fn(), onUpdateTask: vi.fn(), onArchiveTask: vi.fn(), onUnarchiveTask: vi.fn(), onDeleteTask: vi.fn(), onRetryTask: vi.fn(), onOpenDetailWithTab: vi.fn(), onMoveTask: vi.fn(), onOpenMission: vi.fn(), taskStuckTimeoutMs: 60_000, lastFetchTimeMs: Date.now(), workflowStepNameLookup: new Map(), }; } afterEach(() => { vi.useRealTimers(); cleanup(); }); describe("GraphTaskNode", () => { it("renders a TaskCard and passes core props through", () => { const props = createProps(createTask()); const { container } = render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); expect(node).toBeTruthy(); expect(container.querySelector(".card-title")?.textContent).toContain("Task description"); expect(node.getAttribute("draggable")).toBe("false"); expect(container.querySelector(".card")?.getAttribute("draggable")).toBe("false"); }); it("shows active indicator with capitalized status for in-progress executing tasks", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [{ name: "step one", status: "in-progress" }], }), ); const { container } = render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); expect(node.className).toContain("graph-task-node--active"); expect(container.querySelector(".card")?.className).toContain("agent-active"); expect(screen.getByText("Executing")).toBeTruthy(); expect(container.querySelector(".graph-task-active-indicator")).toBeTruthy(); }); it("defaults indicator text to Executing when status is missing on in-progress tasks", () => { const props = createProps(createTask({ column: "in-progress", status: undefined })); const { container } = render(); expect(container.querySelector(".graph-task-active-indicator")).toBeTruthy(); expect(screen.getByText("Executing")).toBeTruthy(); }); it("applies in-review visual class and does not apply active class for in-review tasks", () => { const props = createProps(createTask({ column: "in-review", status: "idle" })); const { container } = render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy(); expect(node.className).toContain("graph-task-node--in-review"); expect(node.className).not.toContain("graph-task-node--active"); }); it.each(["todo", "triage", "in-progress"] as const)("does not apply in-review class for %s tasks", (column) => { const props = createProps(createTask({ column, status: column === "in-progress" ? "executing" : "idle" })); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--in-review"); }); it("does not render active indicator for paused in-progress tasks", () => { const props = createProps(createTask({ column: "in-progress", status: "executing", paused: true })); const { container } = render(); expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy(); }); it("does not render active indicator for failed in-progress tasks", () => { const props = createProps(createTask({ column: "in-progress", status: "failed" })); const { container } = render(); expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy(); }); it("sets current-step attribute for active task when current step is valid", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [ { name: "step one", status: "done" }, { name: "step two", status: "done" }, { name: "step three", status: "in-progress" }, ], currentStep: 2, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("2"); }); it("sets current-step attribute to zero when first step is active", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [{ name: "step one", status: "in-progress" }], currentStep: 0, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("0"); }); it("omits current-step attribute when current step is out of bounds", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [{ name: "step one", status: "in-progress" }], currentStep: 10, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false); }); it("omits current-step attribute when current step is negative", () => { const props = createProps(createTask({ column: "in-progress", status: "executing", steps: [], currentStep: -1 })); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false); }); it("omits current-step attribute when current step is undefined", () => { const props = createProps(createTask({ column: "in-progress", status: "executing", steps: [], currentStep: undefined })); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false); }); it("does not set current-step for non-active tasks", () => { const props = createProps( createTask({ column: "todo", status: "queued", steps: [{ name: "step one", status: "in-progress" }], currentStep: 0, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false); }); it("sets current-step to native step index when workflow steps are present", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [ { id: "native-1", name: "native one", status: "done" }, { id: "native-2", name: "native two", status: "in-progress" }, ], enabledWorkflowSteps: ["wf-1"], currentStep: 1, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("1"); }); it("does not set current-step when native step list is empty even with workflow steps", () => { const props = createProps( createTask({ column: "in-progress", status: "executing", steps: [], enabledWorkflowSteps: ["wf-1"], currentStep: 0, }), ); render(); expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false); }); it("double-clicking card opens task detail exactly once", () => { const props = createProps(createTask()); const { container } = render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); fireEvent.doubleClick(node); expect(props.onOpenDetail).toHaveBeenCalledTimes(1); expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" })); }); it("touch double-tap opens task detail exactly once", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const props = createProps(createTask()); render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); vi.advanceTimersByTime(120); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 }); expect(props.onOpenDetail).toHaveBeenCalledTimes(1); expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" })); }); it("touch taps outside the double-tap window do not open task detail", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const props = createProps(createTask()); render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); vi.advanceTimersByTime(320); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 }); expect(props.onOpenDetail).not.toHaveBeenCalled(); }); it("touch drag gestures do not open task detail on pointer up", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const props = createProps(createTask()); render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 }); vi.advanceTimersByTime(120); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 }); fireEvent.pointerMove(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 }); expect(props.onOpenDetail).not.toHaveBeenCalled(); }); it("mouse pointer taps do not trigger the touch double-tap path", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); const props = createProps(createTask()); render(); const node = screen.getByTestId("graph-task-node-FN-TEST"); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 }); vi.advanceTimersByTime(120); fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 }); fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 }); expect(props.onOpenDetail).not.toHaveBeenCalled(); }); it("single click on active indicator surface does not open task detail", () => { const props = createProps(createTask({ column: "in-progress", status: "executing" })); const { container } = render(); const indicator = container.querySelector(".graph-task-active-indicator"); expect(indicator).toBeTruthy(); fireEvent.click(indicator!); expect(props.onOpenDetail).not.toHaveBeenCalled(); }); it("applies highlighted class only when requested", () => { const highlightedProps = createProps(createTask({ id: "FN-HL" })); const neutralProps = createProps(createTask({ id: "FN-NEUTRAL" })); const { unmount } = render(); expect(screen.getByTestId("graph-task-node-FN-HL").className).toContain("graph-task-node--highlighted"); unmount(); render(); const neutral = screen.getByTestId("graph-task-node-FN-NEUTRAL"); expect(neutral.className).not.toContain("graph-task-node--highlighted"); expect(neutral.className).not.toContain("graph-task-node--dimmed"); }); it("renders the same TaskCard structure as board usage", () => { const task = createTask({ id: "FN-SAME", column: "in-progress", status: "executing", error: "Execution failed", missionId: "M-1", sourceType: "automation", sourceAgentId: "agent-1", steps: [{ name: "sync", status: "in-progress" }], currentStep: 0, }); const props = createProps(task); const { container } = render(
, ); const cards = container.querySelectorAll(".card"); expect(cards.length).toBe(2); const [boardCard, graphCard] = cards; const selectors = [ ".card-id", ".card-title", ".card-status-badge", ".card-step-dot", ".card-step-name", ".card-progress", ".card-progress-fill", ".card-error", ".card-mission-badge", ".card-provider-icons", ".card-agent-badge", ]; for (const selector of selectors) { expect(Boolean(boardCard.querySelector(selector))).toBe(Boolean(graphCard.querySelector(selector))); } expect(Boolean(boardCard.querySelector(".card-error"))).toBe(Boolean(graphCard.querySelector(".card-error"))); expect(boardCard.querySelector(".card-id")?.textContent).toBe(graphCard.querySelector(".card-id")?.textContent); expect(boardCard.querySelector(".card-title")?.textContent).toBe(graphCard.querySelector(".card-title")?.textContent); }); });