import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Settings, Task } from "@fusion/core"; import { RoutingTab } from "../RoutingTab"; import * as api from "../../api"; vi.mock("lucide-react", () => ({ X: () => , })); vi.mock("../../api", async () => { const actual = await vi.importActual("../../api"); return { ...actual, fetchNodes: vi.fn(), updateTask: vi.fn(), }; }); const mockFetchNodes = api.fetchNodes as ReturnType; const mockUpdateTask = api.updateTask as ReturnType; const MOCK_NODES = [ { id: "node-a", name: "Alpha", type: "local", status: "online", maxConcurrent: 2, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "node-b", name: "Beta", type: "remote", status: "offline", maxConcurrent: 4, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "node-c", name: "Gamma", type: "remote", status: "connecting", maxConcurrent: 6, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "node-d", name: "Delta", type: "remote", status: "error", maxConcurrent: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ]; function makeTask(overrides: Partial = {}): Task { return { id: "FN-001", description: "Routing test task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, }; } type RoutingSettings = Settings & { defaultNodeId?: string; unavailableNodePolicy?: "block" | "fallback-local"; }; function makeSettings(overrides: Partial = {}): RoutingSettings { return { maxConcurrent: 2, maxWorktrees: 2, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true, ...overrides, }; } describe("RoutingTab", () => { const addToast = vi.fn(); const onTaskUpdated = vi.fn(); beforeEach(() => { vi.clearAllMocks(); mockFetchNodes.mockResolvedValue(MOCK_NODES); mockUpdateTask.mockImplementation(async (_id: string, updates: { nodeId?: string | null }) => makeTask({ nodeId: updates.nodeId ?? undefined }), ); }); it("renders loading state initially by disabling selector while nodes are loading", async () => { mockFetchNodes.mockReturnValue(new Promise(() => {})); render(); expect(screen.getByLabelText("Select execution node")).toBeDisabled(); }); it("renders routing summary after loading", async () => { render(); expect(await screen.findByText("Routing Summary")).toBeInTheDocument(); expect(screen.getByText("Effective node")).toBeInTheDocument(); expect(screen.getByText("Routing source")).toBeInTheDocument(); }); it("renders routing source as per-task override", async () => { render( , ); expect(await screen.findByText("Per-task override")).toBeInTheDocument(); expect(screen.getByText(/Effective node/i)).toBeInTheDocument(); expect(screen.getAllByText("Alpha (local)")[0]).toBeInTheDocument(); expect(document.querySelector(".status-dot--online")).toBeInTheDocument(); }); it("renders routing source as project default", async () => { render(); expect(await screen.findByText("Project default")).toBeInTheDocument(); }); it("renders routing source as no routing when no override or project default exists", async () => { render(); expect(await screen.findByText("No routing")).toBeInTheDocument(); }); it("displays effective node for per-task override", async () => { render(); expect(await screen.findByText("Alpha (local)")).toBeInTheDocument(); }); it("displays effective node for project default", async () => { render(); expect(await screen.findByText("Beta (remote)")).toBeInTheDocument(); }); it("displays unknown effective node ID gracefully when node is unavailable", async () => { render(); expect(await screen.findByText("unknown-node (node unavailable or unknown)")).toBeInTheDocument(); }); it("node override selector shows available nodes", async () => { render(); expect(await screen.findByRole("option", { name: "Alpha (local) — online" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "Gamma (remote) — connecting" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "Delta (remote) — error" })).toBeInTheDocument(); }); it("renders routing summary with project default", async () => { render( , ); expect(await screen.findByText("Project default")).toBeInTheDocument(); expect(screen.getByText(/Effective node/i)).toBeInTheDocument(); expect(screen.getByText("Unhealthy")).toBeInTheDocument(); }); it("renders NodeHealthDot in routing summary for effective node", async () => { render( , ); await screen.findByText("Alpha (local)"); expect(document.querySelector(".status-dot--online")).toBeInTheDocument(); }); it("renders NodeHealthDot and Unhealthy badge for offline effective node", async () => { render( , ); await screen.findByText("Unhealthy"); expect(document.querySelector(".status-dot--offline")).toBeInTheDocument(); }); it("renders no-routing summary when no override or project default exists", async () => { render(); expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument(); expect(screen.getByText("No routing")).toBeInTheDocument(); }); it("disables node selector for in-progress tasks", async () => { render(); const selector = await screen.findByLabelText("Select execution node"); expect(selector).toBeDisabled(); expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument(); }); it.each(["executing", "merging-fix"] as const)("disables node selector for active task status %s", async (status) => { render(); const selector = await screen.findByLabelText("Select execution node"); expect(selector).toBeDisabled(); expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument(); }); it("enables node selector for non-active tasks", async () => { render(); const selector = await screen.findByLabelText("Select execution node"); await waitFor(() => { expect(selector).toBeEnabled(); }); }); it("selecting a node calls updateTask with selected nodeId and success toast", async () => { const user = userEvent.setup(); render( , ); const selector = await screen.findByLabelText("Select execution node"); await user.selectOptions(selector, "node-a"); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: "node-a" })); expect(addToast).toHaveBeenCalledWith("Node override updated", "success"); }); }); it("clear override button calls updateTask with nodeId null", async () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole("button", { name: "Clear override" })); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: null })); }); }); it.each([ ["block", "Block execution"], ["fallback-local", "Fall back to local"], ] as const)("displays unavailable-node policy: %s", async (policy, label) => { render(); expect(await screen.findByText(label)).toBeInTheDocument(); }); it("displays unavailable-node policy as not configured when omitted", async () => { render(); expect(await screen.findByText("Not configured")).toBeInTheDocument(); }); it("shows error state when fetchNodes fails", async () => { mockFetchNodes.mockRejectedValue(new Error("Failed to load nodes from API")); render(); expect(await screen.findByText("Failed to load nodes from API")).toBeInTheDocument(); }); it("calls onTaskUpdated callback after successful update", async () => { const updated = makeTask({ nodeId: "node-c" }); mockUpdateTask.mockResolvedValueOnce(updated); const user = userEvent.setup(); render( , ); await user.selectOptions(await screen.findByLabelText("Select execution node"), "node-c"); await waitFor(() => { expect(onTaskUpdated).toHaveBeenCalledWith(updated); }); }); it("shows error toast when updateTask fails", async () => { mockUpdateTask.mockRejectedValueOnce(new Error("Save failed")); const user = userEvent.setup(); render(); await user.selectOptions(await screen.findByLabelText("Select execution node"), "node-a"); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Save failed", "error"); }); }); });