Files
fusion/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx
gsxdsm a460a9bbc0 fix(plugins,dashboard): the dependency graph drew every card with the LEGACY lane vocabulary (#3029)
## The third producer of unflagged cards — the one a host-side fix could
not reach

#3025 fixed the two producers that go through `renderTaskCard`.
`GraphTaskNode` is a third: it imports `TaskCard` **directly** through
the plugin's interop shim, so that fix bypassed it and every role helper
inside a graph card kept reading the legacy ids.

The same component also called the stuck predicate without its flags:

```ts
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);   // no columnFlags
```

so `isWipColumnRole` fell back to the literal and **no card in the graph
could ever be stuck on a renamed board**. Because `isStuck` gates
`isActive`, a wedged card rendered with the **active** styling — the
graph reported *"running"* about a task that had not moved in hours,
while the main board showed the same card as stuck.

That asymmetry between two views of one task is the defect, and it is
what the new test pins.

## One cause, so one fix

Both symptoms came from the same gap: `PluginDashboardViewContext`
exposed `tasks` and nothing about the board's vocabulary. It now carries
`columnFlagsByTaskId` — the same per-task map `renderTaskCard` already
uses, **two lines away in the same object literal**.

## I filed this twice as blocked on a public-API change. It was not.

```
packages/dashboard                        @fusion/dashboard                        private: true
packages/plugin-sdk                       @fusion/plugin-sdk                       private: true
plugins/fusion-plugin-dependency-graph    @fusion-plugin-examples/dependency-graph private: true
```

No published surface anywhere in the path — three in-repo private
packages and a hand-written `.d.ts`. **#3026 landed the general form of
that mistake while I was still making it**: a deferral's stated blocker
is a claim, and mine decayed unchecked until I finally measured it.

## Two type decisions worth reviewing

- **`Partial<TraitFlags>`** in the plugin-facing type, not the
dashboard's `ExecutorColumnFlags` — that module's own header restricts
it to `@fusion/core` and `react` imports so external plugin builds can
consume it. Same runtime object either way.
- **`MainContentProps.columnFlagsByTaskId` widened** from `{complete,
archived, intake, hold}` to the flags the map really carries. It is
built from `workflow.columns.find(...).flags`, so the four-flag
declaration was a narrower view than the value — and `countsTowardWip`,
which every wip predicate needs, was invisible through it. That narrow
type is why threading this looked impossible at first.

Absent still means legacy, matching how the host treats remote rows and
off-board columns: the degraded answer is the documented literal, never
*"this board has no wip lane"*.

## Revert proof

Dropping the 4th argument:

```
AssertionError: expected 'graph-task-node graph-task-node--acti…' not to contain 'graph-task-node--active'
      Tests  1 failed | 26 passed (27)
```

The paired case (a fresh legacy `in-progress` card still reads active)
passes both ways by design — it guards against over-detection, so I am
not counting it as coverage.

The gate agrees independently:
`plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx: 1 -> 0`,
baseline re-recorded 16 → 15 in the same commit.

## Verification (measured)

- plugin suite — **185 passed / 20 files**
- dashboard `dashboard/` + `plugins/` suites — **48 passed / 6 files**
- `tsc --noEmit` clean in both packages; `pnpm lint` clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (15, none
added), `check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: all three packages are `private: true`.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:30:47 -07:00

426 lines
17 KiB
TypeScript

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";
/*
FNXC:DependencyGraphTests 2026-07-08-13:10:
GraphTaskNode renders the REAL TaskCard (to verify prop pass-through), and TaskCard now renders RuntimeFallbackBadge which calls the dashboard's useToast() hook. This file has no ToastProvider, so mock useToast the same way the dashboard's own TaskCard.test.tsx does to avoid "useToast must be used within ToastProvider".
*/
/*
FNXC:DependencyGraphTests 2026-07-18-04:35:
RuntimeFallbackBadge uses useOptionalToast (soft-fail path). Mock both exports so
GraphTaskNode suites stay provider-free.
*/
vi.mock("@fusion/dashboard/app/hooks/useToast", () => ({
useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }),
useOptionalToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }),
}));
function createTask(overrides: Partial<Task> = {}): 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<string, string>(),
};
}
afterEach(() => {
vi.useRealTimers();
cleanup();
});
describe("GraphTaskNode", () => {
it("renders a TaskCard and passes core props through", () => {
const props = createProps(createTask());
const { container } = render(<GraphTaskNode {...props} style={{ left: 10, top: 20 }} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} isSelected={true} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...props} />);
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(<GraphTaskNode {...highlightedProps} isHighlighted={true} />);
expect(screen.getByTestId("graph-task-node-FN-HL").className).toContain("graph-task-node--highlighted");
unmount();
render(<GraphTaskNode {...neutralProps} />);
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(
<div>
<TaskCard {...props} disableDrag={true} />
<GraphTaskNode {...props} />
</div>,
);
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);
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-15:30:
THE INVARIANT: a stalled card in the board's OWN wip lane reads as stuck, not as healthily running.
`isTaskStuck` was called without its `columnFlags` argument, so `isWipColumnRole` fell back to the
literal `in-progress`. On a renamed board no graph card could ever be stuck — and because `isStuck`
gates `isActive`, a wedged card rendered with the ACTIVE styling instead: the graph said "running"
about a task that had not moved in hours, while the main board showed the same card as stuck.
That asymmetry between two views of one task is the whole defect, and it is what this pins.
Reverted (the 4th argument dropped, or the flags not threaded from the host context), the first case
gets the `--active` class back and fails.
*/
describe("stuck detection on a renamed board", () => {
const STALE_MS = 120_000;
function stalledCard() {
return createTask({
column: "building",
status: "executing",
updatedAt: new Date(Date.now() - STALE_MS).toISOString(),
} as Partial<Task>);
}
it("treats a stalled card in a RENAMED wip lane as stuck, not active", () => {
const props = createProps(stalledCard());
render(<GraphTaskNode {...props} taskColumnFlags={{ countsTowardWip: true }} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--active");
});
it("still reads a legacy in-progress card as active when it is fresh", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", updatedAt: new Date().toISOString() } as Partial<Task>));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).toContain("graph-task-node--active");
});
});