fix(FN-782): fix footer status bar count derivation and sync with board view
- Share task list data between footer ExecutorStatusBar and board view to ensure consistent counts - Refactor MissionManager component with improved structure and readability - Refactor useExecutorStats hook and simplify test coverage - Update ExecutorStatusBar component and tests for accurate count display - Consolidate and clean up dashboard styles.css - Update footer status bar documentation in README
This commit is contained in:
@@ -598,7 +598,7 @@ function AppInner() {
|
||||
/>
|
||||
{renderMainContent()}
|
||||
{viewMode === "project" && currentProject && (
|
||||
<ExecutorStatusBar projectId={currentProject.id} />
|
||||
<ExecutorStatusBar tasks={tasks} projectId={currentProject.id} />
|
||||
)}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -1833,7 +1833,8 @@ export type ExecutorState = "idle" | "running" | "paused";
|
||||
/** Aggregated executor statistics for the status bar.
|
||||
*
|
||||
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount)
|
||||
* are derived client-side from the tasks array to avoid duplication.
|
||||
* are derived client-side from the same tasks array shared with the board, ensuring
|
||||
* the footer counts always match the column counts displayed on screen.
|
||||
* The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and
|
||||
* lastActivityAt from the activity log.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { Activity, AlertTriangle, Clock, Pause, Play, Zap } from "lucide-react";
|
||||
import { useExecutorStats } from "../hooks/useExecutorStats";
|
||||
import type { ExecutorState } from "../api";
|
||||
|
||||
interface ExecutorStatusBarProps {
|
||||
/** Task list (shared with the board to keep counts in sync) */
|
||||
tasks: Task[];
|
||||
/** Project ID for fetching project-specific stats */
|
||||
projectId?: string;
|
||||
}
|
||||
@@ -55,8 +58,8 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
|
||||
* - Executor state badge (idle/running/paused)
|
||||
* - Last activity timestamp
|
||||
*/
|
||||
export function ExecutorStatusBar({ projectId }: ExecutorStatusBarProps) {
|
||||
const { stats, loading, error } = useExecutorStats(projectId);
|
||||
export function ExecutorStatusBar({ tasks, projectId }: ExecutorStatusBarProps) {
|
||||
const { stats, loading, error } = useExecutorStats(tasks, projectId);
|
||||
|
||||
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { ExecutorStats } from "../../api";
|
||||
|
||||
const mockUseExecutorStats = useExecutorStats as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** Minimal empty task list used by tests that mock the hook. */
|
||||
const emptyTasks: any[] = [];
|
||||
|
||||
describe("ExecutorStatusBar", () => {
|
||||
const defaultStats: ExecutorStats = {
|
||||
runningTaskCount: 2,
|
||||
@@ -41,7 +44,7 @@ describe("ExecutorStatusBar", () => {
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders all stat segments", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Running");
|
||||
@@ -51,14 +54,14 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
it("displays running task count", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("displays max concurrent count", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("/");
|
||||
@@ -66,28 +69,28 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
it("displays blocked task count", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("displays queued task count", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("5");
|
||||
});
|
||||
|
||||
it("displays in-review count", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
it("does not show stuck tasks segment when count is 0", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.queryByText("Stuck")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -100,7 +103,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Stuck");
|
||||
@@ -110,7 +113,7 @@ describe("ExecutorStatusBar", () => {
|
||||
|
||||
describe("executor state", () => {
|
||||
it("shows Running state with running executorState", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
const stateElement = statusBar.querySelector(".executor-status-bar__state");
|
||||
@@ -125,7 +128,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
const stateElement = statusBar.querySelector(".executor-status-bar__state");
|
||||
@@ -140,7 +143,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
const stateElement = statusBar.querySelector(".executor-status-bar__state");
|
||||
@@ -148,7 +151,7 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
it("applies running class when executor is running", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveClass("executor-status-bar--running");
|
||||
@@ -162,7 +165,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).not.toHaveClass("executor-status-bar--running");
|
||||
@@ -176,7 +179,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).not.toHaveClass("executor-status-bar--running");
|
||||
@@ -192,7 +195,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Loading...");
|
||||
@@ -200,7 +203,7 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
it("does not show loading text when not loading", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -213,7 +216,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -228,7 +231,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Failed to fetch stats");
|
||||
@@ -243,7 +246,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
// The error bar shouldn't have the running segment
|
||||
@@ -253,19 +256,19 @@ describe("ExecutorStatusBar", () => {
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("has role status", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has aria-label", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Executor status");
|
||||
});
|
||||
|
||||
it("applies warning class to blocked count when blocked tasks exist", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
// Get the status bar and look for the blocked count element
|
||||
const statusBar = screen.getByRole("status");
|
||||
@@ -281,7 +284,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
const stuckSegment = statusBar.querySelector(".executor-status-bar__segment--stuck");
|
||||
@@ -289,7 +292,7 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
it("applies active class to running indicator when tasks are running", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveClass("executor-status-bar--running");
|
||||
@@ -298,7 +301,7 @@ describe("ExecutorStatusBar", () => {
|
||||
|
||||
describe("visual states", () => {
|
||||
it("shows warning styling when blocked tasks exist", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const blockedCount = screen.getByText("1");
|
||||
expect(blockedCount).toHaveClass("executor-status-bar__count--warning");
|
||||
@@ -312,7 +315,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const counts = screen.queryAllByText("0");
|
||||
// First one is running count which shouldn't have warning
|
||||
@@ -321,16 +324,17 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
describe("project context", () => {
|
||||
it("passes projectId to useExecutorStats when provided", () => {
|
||||
render(<ExecutorStatusBar projectId="proj_abc123" />);
|
||||
it("passes tasks and projectId to useExecutorStats when provided", () => {
|
||||
const tasks: any[] = [{ id: "FN-001" }];
|
||||
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
|
||||
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith("proj_abc123");
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123");
|
||||
});
|
||||
|
||||
it("passes undefined to useExecutorStats when projectId not provided", () => {
|
||||
render(<ExecutorStatusBar />);
|
||||
it("passes tasks and undefined to useExecutorStats when projectId not provided", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(undefined);
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,7 +349,7 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.getByText("2m ago")).toBeInTheDocument();
|
||||
});
|
||||
@@ -358,9 +362,39 @@ describe("ExecutorStatusBar", () => {
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar />);
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(screen.getByText("no activity")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("board-sync regression", () => {
|
||||
it("requires tasks prop — does not fetch its own task list", () => {
|
||||
// This test verifies the component receives tasks from its parent
|
||||
// rather than creating its own useTasks instance, which was the
|
||||
// root cause of the footer/board count mismatch.
|
||||
const tasks: any[] = [{ id: "FN-001" }];
|
||||
render(<ExecutorStatusBar tasks={tasks} />);
|
||||
|
||||
// useExecutorStats receives the tasks array as first argument
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined);
|
||||
});
|
||||
|
||||
it("renders stuck segment with correct count when stuck tasks detected", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: { ...defaultStats, stuckTaskCount: 3, runningTaskCount: 2 },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Stuck");
|
||||
const stuckCount = statusBar.querySelector(".executor-status-bar__segment--stuck .executor-status-bar__count");
|
||||
expect(stuckCount).toHaveTextContent("3");
|
||||
expect(stuckCount).toHaveClass("executor-status-bar__count--error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useExecutorStats } from "./useExecutorStats";
|
||||
import * as apiModule from "../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -13,23 +13,6 @@ vi.mock("../api", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useTasks hook
|
||||
vi.mock("./useTasks", () => ({
|
||||
useTasks: vi.fn(() => ({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useExecutorStats", () => {
|
||||
const mockFetchExecutorStats = apiModule.fetchExecutorStats as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -50,24 +33,8 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("initial state", () => {
|
||||
it("returns initial stats with zero counts when tasks array is empty", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
|
||||
// Advance timers to let the initial fetch complete
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
@@ -80,22 +47,7 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("uses maxConcurrent from API", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -105,22 +57,7 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("uses lastActivityAt from API", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -132,27 +69,13 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("task count derivations", () => {
|
||||
it("counts tasks in in-progress column as runningTaskCount", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "in-progress"),
|
||||
createMockTask("FN-002", "in-progress"),
|
||||
createMockTask("FN-003", "in-progress"),
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -162,26 +85,12 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("counts tasks in todo column as queuedTaskCount", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "todo"),
|
||||
createMockTask("FN-002", "todo"),
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -191,27 +100,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("counts tasks in in-review column as inReviewCount", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "in-review"),
|
||||
createMockTask("FN-002", "in-review"),
|
||||
createMockTask("FN-003", "in-review"),
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -221,27 +116,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("counts tasks with blockedBy set as blockedTaskCount", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "todo"), blockedBy: ["FN-000"] },
|
||||
{ ...createMockTask("FN-001", "todo"), blockedBy: "FN-000" },
|
||||
{ ...createMockTask("FN-002", "todo") }, // no blockedBy
|
||||
{ ...createMockTask("FN-003", "todo"), blockedBy: ["FN-001", "FN-002"] },
|
||||
{ ...createMockTask("FN-003", "todo"), blockedBy: "FN-002" },
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -251,27 +132,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("does not count tasks without blockedBy as blocked", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "todo"),
|
||||
createMockTask("FN-002", "todo"),
|
||||
createMockTask("FN-003", "todo"),
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -280,27 +147,13 @@ describe("useExecutorStats", () => {
|
||||
expect(result.current.stats.blockedTaskCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count tasks with empty blockedBy array as blocked", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
it("does not count tasks with empty blockedBy string as blocked", async () => {
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "todo"), blockedBy: [] },
|
||||
{ ...createMockTask("FN-002", "todo"), blockedBy: [] },
|
||||
{ ...createMockTask("FN-001", "todo"), blockedBy: "" },
|
||||
{ ...createMockTask("FN-002", "todo"), blockedBy: "" },
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -312,28 +165,14 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("stuck task detection", () => {
|
||||
it("detects tasks in in-progress with no activity for > 10 minutes as stuck", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
// Set updatedAt to 11 minutes ago
|
||||
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
|
||||
{ ...createMockTask("FN-002", "in-progress") }, // just updated
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -343,27 +182,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("does not count non-in-progress tasks as stuck even if old", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
// Set updatedAt to 11 minutes ago for a todo task
|
||||
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "todo"), updatedAt: elevenMinutesAgo },
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -373,27 +198,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("does not count recent in-progress tasks as stuck", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
// Set updatedAt to 5 minutes ago
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "in-progress"), updatedAt: fiveMinutesAgo },
|
||||
];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -405,27 +216,13 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("executor state derivation", () => {
|
||||
it("returns 'idle' when globalPause is true", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: true,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -435,27 +232,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("returns 'idle' when enginePaused is true and runningTaskCount is 0", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: true,
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -465,28 +248,14 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("returns 'paused' when enginePaused is true and runningTaskCount > 0", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [createMockTask("FN-001", "in-progress")];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: true,
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -496,28 +265,14 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("returns 'running' when globalPause is false, enginePaused is false, and runningTaskCount > 0", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const tasks: Task[] = [createMockTask("FN-001", "in-progress")];
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks,
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -527,27 +282,13 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("returns 'idle' when no tasks are running and not paused", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -558,69 +299,61 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
describe("project context", () => {
|
||||
it("passes projectId to useTasks when provided", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const mockUseTasks = vi.mocked(useTasks);
|
||||
mockUseTasks.mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
it("passes projectId to fetchExecutorStats when provided", async () => {
|
||||
renderHook(() => useExecutorStats([], "proj_abc123"));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
renderHook(() => useExecutorStats("proj_abc123"));
|
||||
|
||||
expect(mockUseTasks).toHaveBeenCalledWith({ projectId: "proj_abc123" });
|
||||
expect(mockFetchExecutorStats).toHaveBeenCalledWith("proj_abc123");
|
||||
});
|
||||
|
||||
it("passes no options to useTasks when projectId is not provided", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
const mockUseTasks = vi.mocked(useTasks);
|
||||
mockUseTasks.mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
it("passes undefined to fetchExecutorStats when projectId is not provided", async () => {
|
||||
renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
renderHook(() => useExecutorStats());
|
||||
expect(mockFetchExecutorStats).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUseTasks).toHaveBeenCalledWith(undefined);
|
||||
describe("reactive task updates", () => {
|
||||
it("reflects new task counts when tasks change", async () => {
|
||||
const initialTasks: Task[] = [
|
||||
createMockTask("FN-001", "todo"),
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tasks }) => useExecutorStats(tasks),
|
||||
{ initialProps: { tasks: initialTasks } }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.stats.queuedTaskCount).toBe(1);
|
||||
expect(result.current.stats.runningTaskCount).toBe(0);
|
||||
|
||||
// Simulate task moving from todo to in-progress
|
||||
const updatedTasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "in-progress") },
|
||||
createMockTask("FN-002", "todo"),
|
||||
];
|
||||
|
||||
rerender({ tasks: updatedTasks });
|
||||
|
||||
expect(result.current.stats.queuedTaskCount).toBe(1);
|
||||
expect(result.current.stats.runningTaskCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh function", () => {
|
||||
it("manually refreshes stats", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -646,23 +379,9 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("error handling", () => {
|
||||
it("sets error state when API call fails", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -672,23 +391,9 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
it("clears error on successful refresh", async () => {
|
||||
const { useTasks } = await import("./useTasks");
|
||||
vi.mocked(useTasks).mockReturnValue({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats());
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -709,6 +414,111 @@ describe("useExecutorStats", () => {
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("board-sync regression", () => {
|
||||
it("derives counts from the same tasks array the board uses", async () => {
|
||||
// Simulate a full board: 2 triage, 3 todo (1 blocked), 2 in-progress, 1 in-review, 4 done
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "triage"),
|
||||
createMockTask("FN-002", "triage"),
|
||||
createMockTask("FN-003", "todo"),
|
||||
{ ...createMockTask("FN-004", "todo"), blockedBy: "FN-010" },
|
||||
createMockTask("FN-005", "todo"),
|
||||
createMockTask("FN-006", "in-progress"),
|
||||
createMockTask("FN-007", "in-progress"),
|
||||
createMockTask("FN-008", "in-review"),
|
||||
createMockTask("FN-009", "done"),
|
||||
createMockTask("FN-010", "done"),
|
||||
createMockTask("FN-011", "done"),
|
||||
createMockTask("FN-012", "done"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
// These must match the column counts shown on the board
|
||||
expect(result.current.stats.runningTaskCount).toBe(2); // in-progress
|
||||
expect(result.current.stats.queuedTaskCount).toBe(3); // todo
|
||||
expect(result.current.stats.inReviewCount).toBe(1); // in-review
|
||||
expect(result.current.stats.blockedTaskCount).toBe(1); // blockedBy set
|
||||
expect(result.current.stats.stuckTaskCount).toBe(0); // all recent
|
||||
|
||||
// Verify the executor state reflects running tasks
|
||||
expect(result.current.stats.executorState).toBe("running");
|
||||
});
|
||||
|
||||
it("does not count triage, done, or archived tasks in any footer metric", async () => {
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "triage"),
|
||||
createMockTask("FN-002", "done"),
|
||||
createMockTask("FN-003", "archived"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.stats.runningTaskCount).toBe(0);
|
||||
expect(result.current.stats.queuedTaskCount).toBe(0);
|
||||
expect(result.current.stats.inReviewCount).toBe(0);
|
||||
expect(result.current.stats.blockedTaskCount).toBe(0);
|
||||
expect(result.current.stats.stuckTaskCount).toBe(0);
|
||||
});
|
||||
|
||||
it("updates counts immediately when tasks array reference changes", async () => {
|
||||
const initialTasks: Task[] = [
|
||||
createMockTask("FN-001", "todo"),
|
||||
createMockTask("FN-002", "todo"),
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tasks }) => useExecutorStats(tasks),
|
||||
{ initialProps: { tasks: initialTasks } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.stats.queuedTaskCount).toBe(2);
|
||||
|
||||
// Move FN-001 to in-progress, add a new todo
|
||||
const updatedTasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "in-progress") },
|
||||
createMockTask("FN-002", "todo"),
|
||||
createMockTask("FN-003", "todo"),
|
||||
];
|
||||
|
||||
rerender({ tasks: updatedTasks });
|
||||
|
||||
// Should reflect immediately without waiting for polling
|
||||
expect(result.current.stats.queuedTaskCount).toBe(2);
|
||||
expect(result.current.stats.runningTaskCount).toBe(1);
|
||||
});
|
||||
|
||||
it("uses string blockedBy matching the real Task type", async () => {
|
||||
// Regression: blockedBy is string | undefined, not string[]
|
||||
const tasks: Task[] = [
|
||||
{ ...createMockTask("FN-001", "todo"), blockedBy: "FN-099" }, // string, not array
|
||||
{ ...createMockTask("FN-002", "in-progress"), blockedBy: "FN-098" },
|
||||
{ ...createMockTask("FN-003", "todo") }, // no blockedBy
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
// Both FN-001 and FN-002 have blockedBy set
|
||||
expect(result.current.stats.blockedTaskCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockTask(id: string, column: Task["column"]): Task {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { useTasks } from "./useTasks";
|
||||
import { fetchExecutorStats } from "../api";
|
||||
import type { ExecutorStats, ExecutorState } from "../api";
|
||||
|
||||
@@ -105,15 +104,15 @@ function deriveStatsFromTasks(tasks: Task[]): Pick<
|
||||
/**
|
||||
* Hook for aggregating executor statistics for the status bar.
|
||||
*
|
||||
* - Uses `useTasks` to get task list for column-based counts
|
||||
* - Receives the shared task list directly (same instance used by the board)
|
||||
* so footer counts always match the board state
|
||||
* - Polls `/api/executor/stats` every 5 seconds for executor state
|
||||
* - Derives blockedTaskCount from tasks with blockedBy field set
|
||||
* - Derives stuckTaskCount by checking if any "in-progress" task has updatedAt > 10 minutes ago
|
||||
* - Derives executorState from globalPause and enginePaused flags
|
||||
* - Returns ExecutorStats object with reactive updates
|
||||
*/
|
||||
export function useExecutorStats(projectId?: string): UseExecutorStatsResult {
|
||||
const { tasks } = useTasks(projectId ? { projectId } : undefined);
|
||||
export function useExecutorStats(tasks: Task[], projectId?: string): UseExecutorStatsResult {
|
||||
|
||||
const [apiData, setApiData] = useState<{
|
||||
globalPause: boolean;
|
||||
|
||||
Reference in New Issue
Block a user