FN-6029: restore board drop-check regression coverage

Restore Board drag-drop pre-check coverage by extracting the decision logic into a reusable helper.

- extract Board canDropTask rejection logic into a shared helper used by the component
- replace Lane-mocking coverage with direct helper tests for workflow, column, and capacity branches
- add regression cases for missing workflows, default-workflow fallback, same-column WIP drops, and cross-workflow occupancy handling

Files changed:
 packages/dashboard/app/components/Board.tsx        |  37 +--
 .../__tests__/Board.canDropTask.test.tsx           | 261 ++++++++++++---------
 .../dashboard/app/components/boardCanDropTask.ts   |  56 +++++
 3 files changed, 218 insertions(+), 136 deletions(-)

Fusion-Task-Id: FN-6029

Fusion-Task-Lineage: cd71bbc5-0f97-49c3-9eef-0dd8221341c0
This commit is contained in:
gsxdsm
2026-06-08 11:55:35 -07:00
parent be0140c1b4
commit e57db86a5c
3 changed files with 219 additions and 137 deletions

View File

@@ -11,6 +11,7 @@ import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
interface BoardProps { interface BoardProps {
tasks: Task[]; tasks: Task[];
@@ -418,32 +419,16 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata. // Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a // Cross-lane drag → workflow-mismatch. Deterministic rejections return a
// messageKey (no-move); null = allowed. // messageKey (no-move); null = allowed.
const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => { const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => (
if (!boardWorkflows) return null; getBoardCanDropTaskRejection({
const sourceTask = tasks.find((t) => t.id === taskId); boardWorkflows,
if (!sourceTask) return null; tasks,
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId; maxConcurrent,
// Cross-lane drag never switches workflows (R17). taskId,
if (sourceWorkflowId !== laneWorkflowId) { targetColumnId,
return "board.rejection.workflowMismatch"; laneWorkflowId,
} })
const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId); ), [boardWorkflows, tasks, maxConcurrent]);
if (!workflow) return null;
const targetCol = workflow.columns.find((c) => c.id === targetColumnId);
if (!targetCol) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetCol.flags.countsTowardWip) {
const occupants = tasks.filter(
(t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}, [boardWorkflows, tasks, maxConcurrent]);
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`, // FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`

View File

@@ -1,58 +1,18 @@
// FN-1416: Board-level coverage of the canDropTask drag pre-check (R17). // FN-1416/FN-6029: Board-level coverage of the canDropTask drag pre-check (R17).
// //
// canDropTask is an internal Board closure passed down to <Lane>. Board.tsx is // Board now passes one-argument per-column wrappers directly to <Column> in the
// being edited by another agent, so rather than touch it (or its existing // selected-workflow rendering path, while <Lane> still adapts the canonical
// test), this file mocks <Lane> to CAPTURE the real canDropTask closure Board // three-argument decision for multi-lane rendering. These tests exercise the
// constructs, then drives the three rejection branches plus the allowed case: // pure Board decision seam directly so unrendered-column branches (especially
// - cross-workflow drag → "board.rejection.workflowMismatch" // unknownColumn) remain covered without stale Lane mocking.
// - unknown target column in the lane → "board.rejection.unknownColumn"
// - full wip column (>= maxConcurrent) → "board.rejection.capacityExhausted"
// - valid same-lane, under-capacity drop → null (allowed)
//
// This exercises the production closure (not a copy), so a regression in any
// branch fails here.
import React from "react"; import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act } from "@testing-library/react";
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
import { Board } from "../Board"; import type { BoardWorkflowsPayload } from "../../api";
import { getBoardCanDropTaskRejection } from "../boardCanDropTask";
vi.mock("../../hooks/useBatchBadgeFetch", () => ({ const DEFAULT_WORKFLOW = "builtin:coding";
useBatchBadgeFetch: vi.fn(() => ({ const CUSTOM_WORKFLOW = "WF-001";
fetchBatch: vi.fn(),
isLoading: false,
lastFetchTime: null,
getBatchData: vi.fn(),
})),
}));
const fetchBoardWorkflowsMock = vi.fn();
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
// Don't pull in the full Column tree from the mocked Lane.
vi.mock("../Column", () => ({ Column: () => <div /> }));
// Capture the canDropTask closure Board passes to each Lane.
type CanDrop = (taskId: string, targetColumnId: string, workflowId: string) => string | null;
let capturedCanDropTask: CanDrop | null = null;
vi.mock("../Lane", () => ({
Lane: (props: { canDropTask: CanDrop }) => {
capturedCanDropTask = props.canDropTask;
return <section data-testid="lane" />;
},
}));
const DEFAULT_LANE = "builtin:coding";
const CUSTOM_LANE = "WF-001";
// builtin:coding columns (in-progress counts toward wip; todo does not). // builtin:coding columns (in-progress counts toward wip; todo does not).
const defaultColumns = [ const defaultColumns = [
@@ -86,86 +46,167 @@ function makeTask(id: string, column: string): Task {
} as unknown as Task; } as unknown as Task;
} }
function boardProps(overrides: Record<string, unknown> = {}) { function boardWorkflows(taskWorkflowIds: Record<string, string> = {}): BoardWorkflowsPayload {
return { return {
tasks: [] as Task[], flagEnabled: true,
maxConcurrent: 2, defaultWorkflowId: DEFAULT_WORKFLOW,
onMoveTask: () => Promise.resolve({} as never), workflows: [
onOpenDetail: () => {}, { id: DEFAULT_WORKFLOW, name: "Coding", columns: defaultColumns },
addToast: () => {}, { id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns },
onQuickCreate: () => Promise.resolve({} as never), ],
onNewTask: () => {}, taskWorkflowIds,
autoMerge: true,
onToggleAutoMerge: () => {},
globalPaused: false,
...overrides,
}; };
} }
/** Render Board flag-ON with the given tasks and wait for canDropTask capture. */ function canDrop({
async function renderAndCapture(tasks: Task[], taskWorkflowIds: Record<string, string>) { workflows = boardWorkflows(),
fetchBoardWorkflowsMock.mockResolvedValue({ tasks,
flagEnabled: true, maxConcurrent = 2,
defaultWorkflowId: DEFAULT_LANE, taskId = "FN-1",
workflows: [ targetColumnId,
{ id: DEFAULT_LANE, name: "Coding", columns: defaultColumns }, laneWorkflowId = DEFAULT_WORKFLOW,
{ id: CUSTOM_LANE, name: "Custom", columns: customColumns }, }: {
], workflows?: BoardWorkflowsPayload | null | undefined;
taskWorkflowIds, tasks: Task[];
maxConcurrent?: number;
taskId?: string;
targetColumnId: string;
laneWorkflowId?: string;
}) {
return getBoardCanDropTaskRejection({
boardWorkflows: workflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
}); });
await act(async () => {
const props = boardProps({ tasks }) as unknown as React.ComponentProps<typeof Board>;
render(<Board {...props} />);
await Promise.resolve();
});
expect(capturedCanDropTask).toBeTypeOf("function");
return capturedCanDropTask!;
} }
describe("Board canDropTask pre-check (FN-1416)", () => { describe("Board canDropTask pre-check (FN-1416/FN-6029)", () => {
beforeEach(() => { it("cross-workflow drag returns workflowMismatch", () => {
capturedCanDropTask = null;
fetchBoardWorkflowsMock.mockReset();
try { window.localStorage.clear(); } catch { /* jsdom */ }
});
it("cross-workflow drag → workflowMismatch", async () => {
// FN-1 lives in the default lane; dragging it into the custom lane crosses
// workflows (R17 never switches a card's workflow via drag).
const tasks = [makeTask("FN-1", "todo")]; const tasks = [makeTask("FN-1", "todo")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
expect(canDrop("FN-1", "c-run", CUSTOM_LANE)).toBe("board.rejection.workflowMismatch"); expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks,
targetColumnId: "c-run",
laneWorkflowId: CUSTOM_WORKFLOW,
})).toBe("board.rejection.workflowMismatch");
}); });
it("unknown target column in the lane → unknownColumn", async () => { it("unknown target column in the source workflow returns unknownColumn", () => {
const tasks = [makeTask("FN-1", "todo")]; const tasks = [makeTask("FN-1", "todo")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
expect(canDrop("FN-1", "does-not-exist", DEFAULT_LANE)).toBe("board.rejection.unknownColumn"); expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks,
targetColumnId: "does-not-exist",
})).toBe("board.rejection.unknownColumn");
}); });
it("full wip column (occupants >= maxConcurrent) → capacityExhausted", async () => { it("full wip column returns capacityExhausted", () => {
// maxConcurrent: 2; two cards already occupy in-progress in the default lane. // maxConcurrent: 2; two cards already occupy in-progress in the default
// Dragging a third (from todo) into in-progress must reject on capacity. // workflow. Dragging a third from todo into in-progress must reject.
const tasks = [ const tasks = [
makeTask("FN-1", "todo"), makeTask("FN-1", "todo"),
makeTask("FN-2", "in-progress"), makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"), makeTask("FN-3", "in-progress"),
]; ];
const canDrop = await renderAndCapture(tasks, {
"FN-1": DEFAULT_LANE, expect(canDrop({
"FN-2": DEFAULT_LANE, workflows: boardWorkflows({
"FN-3": DEFAULT_LANE, "FN-1": DEFAULT_WORKFLOW,
}); "FN-2": DEFAULT_WORKFLOW,
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBe("board.rejection.capacityExhausted"); "FN-3": DEFAULT_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBe("board.rejection.capacityExhausted");
}); });
it("valid same-lane drop under capacity → allowed (null)", async () => { it("valid same-workflow drops under capacity return null", () => {
// One free in-progress slot (maxConcurrent 2, one occupant); moving FN-1 from
// todo into in-progress in its own lane is permitted.
const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")]; const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE, "FN-2": DEFAULT_LANE }); const workflows = boardWorkflows({ "FN-1": DEFAULT_WORKFLOW, "FN-2": DEFAULT_WORKFLOW });
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBeNull();
// Dropping into a non-wip column (todo → in-review) is also allowed. expect(canDrop({ workflows, tasks, targetColumnId: "in-progress" })).toBeNull();
expect(canDrop("FN-1", "in-review", DEFAULT_LANE)).toBeNull(); expect(canDrop({ workflows, tasks, targetColumnId: "in-review" })).toBeNull();
});
it("returns null when boardWorkflows is undefined", () => {
expect(canDrop({
workflows: undefined,
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "in-progress",
})).toBeNull();
});
it("returns null when the source task is missing", () => {
expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks: [makeTask("FN-2", "todo")],
taskId: "FN-1",
targetColumnId: "in-progress",
})).toBeNull();
});
it("returns null when the source workflow is missing from boardWorkflows", () => {
expect(canDrop({
workflows: {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW,
workflows: [{ id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns }],
taskWorkflowIds: { "FN-1": DEFAULT_WORKFLOW },
},
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "in-progress",
})).toBeNull();
});
it("falls back to the default workflow when a task has no workflow id", () => {
expect(canDrop({
workflows: boardWorkflows({}),
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "does-not-exist",
laneWorkflowId: DEFAULT_WORKFLOW,
})).toBe("board.rejection.unknownColumn");
});
it("allows same-column wip drops even when the column is at capacity", () => {
const tasks = [
makeTask("FN-1", "in-progress"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
];
expect(canDrop({
workflows: boardWorkflows({
"FN-1": DEFAULT_WORKFLOW,
"FN-2": DEFAULT_WORKFLOW,
"FN-3": DEFAULT_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBeNull();
});
it("does not count occupants from other workflows toward this workflow's capacity", () => {
const tasks = [
makeTask("FN-1", "todo"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
makeTask("FN-4", "in-progress"),
];
expect(canDrop({
workflows: boardWorkflows({
"FN-1": DEFAULT_WORKFLOW,
"FN-2": DEFAULT_WORKFLOW,
"FN-3": CUSTOM_WORKFLOW,
"FN-4": CUSTOM_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBeNull();
}); });
}); });

View File

@@ -0,0 +1,56 @@
import type { Task } from "@fusion/core";
import type { BoardWorkflowsPayload } from "../api";
export interface BoardCanDropTaskInput {
boardWorkflows: BoardWorkflowsPayload | null | undefined;
tasks: Task[];
maxConcurrent: number;
taskId: string;
targetColumnId: string;
laneWorkflowId: string;
}
/**
* Canonical Board drag pre-check (R17). Deterministic rejections return a stable
* i18n message key; `null` means the pre-check allows the drop or cannot decide.
*/
export function getBoardCanDropTaskRejection({
boardWorkflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
}: BoardCanDropTaskInput): string | null {
if (!boardWorkflows) return null;
const sourceTask = tasks.find((task) => task.id === taskId);
if (!sourceTask) return null;
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
// Cross-lane drag never switches workflows (R17).
if (sourceWorkflowId !== laneWorkflowId) {
return "board.rejection.workflowMismatch";
}
const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === laneWorkflowId);
if (!workflow) return null;
const targetColumn = workflow.columns.find((column) => column.id === targetColumnId);
if (!targetColumn) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetColumn.flags.countsTowardWip) {
const occupants = tasks.filter(
(task) => task.column === targetColumnId
&& (boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}