Files
fusion/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx
Fusion efdc6c4625 feat(FN-4139): add swipe-back navigation to chat, mission, and planning flo
Implements swipe-back gesture support for mobile navigation across ChatView, MissionManager, and PlanningModeModal, wired through a shared navigation history context and covered by a comprehensive test suite (~548 lines of swipe-back tests). A small fixup ensures swipe-back behaves correctly when la

Fusion-Task-Id: FN-4139
2026-05-12 17:04:33 -07:00

609 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
import * as api from "../../api";
import { PlanningModeModal } from "../PlanningModeModal";
import { TaskDetailModal } from "../TaskDetailModal";
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
import { useSessionLock } from "../../hooks/useSessionLock";
import { getSessionTabId } from "../../utils/getSessionTabId";
import type { MergeResult } from "@fusion/core";
import {
mockStartPlanning,
mockStartPlanningStreaming,
mockCreatePlanningDraft,
mockConnectPlanningStream,
mockRespondToPlanning,
mockRetryPlanningSession,
mockCancelPlanning,
mockStopPlanningGeneration,
mockUpdatePlanningSessionDraft,
mockCreateTaskFromPlanning,
mockStartPlanningBreakdown,
mockCreateTasksFromPlanning,
mockFetchAiSession,
mockParseConversationHistory,
mockFetchModels,
mockAcquireSessionLock,
mockReleaseSessionLock,
mockForceAcquireSessionLock,
mockUploadAttachment,
mockDeleteAttachment,
mockUpdateTask,
mockPauseTask,
mockUnpauseTask,
mockFetchTaskDetail,
mockRequestSpecRevision,
mockApprovePlan,
mockRejectPlan,
mockRefineTask,
mockFetchAiSessions,
mockConfirm,
mockUseViewportMode,
mockUseMobileKeyboard,
mockTasks,
mockModels,
mockQuestion,
mockSummary,
mockTaskDetail,
MockEventSource,
getMediaBlocks,
mockViewport,
} from "./PlanningModeModal.test-helpers";
vi.mock("../../api", () => ({
startPlanning: (...args: any[]) => mockStartPlanning(...args),
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
updateTask: (...args: any[]) => mockUpdateTask(...args),
pauseTask: (...args: any[]) => mockPauseTask(...args),
unpauseTask: (...args: any[]) => mockUnpauseTask(...args),
fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args),
requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args),
approvePlan: (...args: any[]) => mockApprovePlan(...args),
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
refineTask: (...args: any[]) => mockRefineTask(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchModels: (...args: any[]) => mockFetchModels(...args),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockUseViewportMode(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
}));
describe("PlanningModeModal", () => {
const mockOnClose = vi.fn();
const mockOnTaskCreated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
// Default to desktop viewport; mobile-specific tests override per-test.
mockViewport("desktop");
// Default mock for streaming
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
// Server's createDraftSession always returns the placeholder title; the
// real summarized title only arrives later via blur/close summarize or
// when the session transitions out of draft. Mirror that in the mock so
// the sidebar render rule (preview while title === placeholder) behaves
// realistically in tests.
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null);
mockFetchAiSessions.mockResolvedValue([]);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
resolvedPlanningProvider: "openai",
resolvedPlanningModelId: "gpt-4o",
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
mockStopPlanningGeneration.mockResolvedValue({ success: true });
// Default: simulate receiving a question after a brief delay
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
});
describe("Initial view", () => {
it("renders the initial input view when open", () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByText("Planning Mode")).toBeDefined();
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined();
expect(container.querySelector(".planning-modal-body")).not.toBeNull();
expect(container.querySelector(".planning-modal-body")?.classList.contains("modal-body")).toBe(false);
expect(container.querySelector(".planning-examples-label")?.textContent).toBe("Try an example:");
});
it("does not render when closed", () => {
render(
<PlanningModeModal
isOpen={false}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.queryByText("Planning Mode")).toBeNull();
});
it("mobile close path blurs focused input and resets viewport scroll", () => {
mockViewport("mobile");
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
const rafSpy = vi
.spyOn(window, "requestAnimationFrame")
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/) as HTMLTextAreaElement;
act(() => {
textarea.focus();
});
const blurSpy = vi.spyOn(textarea, "blur");
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Close" }));
});
expect(blurSpy).toHaveBeenCalledTimes(1);
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
expect(rafSpy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("hides send to background button in initial state", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.queryByLabelText("Send to background")).toBeNull();
});
it("enables start button when text is entered", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const startButton = screen.getByText("Start Planning");
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Test plan" } });
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
});
it("shows example chips", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
});
it("renders planning model dropdown in initial view", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByRole("button", { name: "Advanced planning settings" })).toBeDefined();
expect(screen.queryByRole("button", { name: "Planning Model" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
const modelTrigger = screen.getByRole("button", { name: "Planning Model" });
expect(modelTrigger).toBeDefined();
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
});
});
it("shows resolved default model badge and switches to override badge when selected", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeDefined();
});
it("passes selected planning model to startPlanningStreaming", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
}, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
});
});
it("keeps advanced disclosure collapsed by default and reveals controls when expanded", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const disclosureButton = screen.getByRole("button", { name: "Advanced planning settings" });
expect(disclosureButton).toBeDefined();
const disclosure = disclosureButton.closest(".onboarding-disclosure");
expect(disclosure).not.toBeNull();
const disclosureScope = within(disclosure as HTMLElement);
expect(disclosureButton.getAttribute("aria-expanded")).toBe("false");
expect(disclosureScope.queryByRole("button", { name: "Planning Model" })).toBeNull();
expect(disclosureScope.queryByText(/Selects which model runs the planning interview/)).toBeNull();
fireEvent.click(disclosureButton);
expect(disclosureButton.getAttribute("aria-expanded")).toBe("true");
expect(disclosureScope.getByRole("button", { name: "Planning Model" })).toBeDefined();
await waitFor(() => {
expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined();
});
expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined();
expect(disclosureScope.getByText(/Plan size sets default interview depth/)).toBeDefined();
expect(disclosureScope.getByRole("button", { name: "Small" })).toBeDefined();
expect(disclosureScope.getByRole("button", { name: "Medium" }).getAttribute("aria-pressed")).toBe("true");
expect(disclosureScope.getByRole("button", { name: "Large" })).toBeDefined();
expect(disclosureScope.getByLabelText("Questions")).toBeDefined();
});
it("updates selected depth and sends custom question count", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Large" }));
fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } });
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "large",
customQuestionCount: 7,
}, undefined);
});
});
it("calls startPlanningStreaming without model override when none selected", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
});
});
it("auto-creates a draft after typing and reuses it when starting", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan" } });
await waitFor(() => {
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
});
expect(mockCreatePlanningDraft).toHaveBeenCalledWith(
"Build a detailed auth system plan",
undefined,
undefined,
);
// Sidebar shows the inputPayload-derived preview for draft rows so
// multiple drafts are distinguishable, not the placeholder title that
// createDraftSession returns. The text also appears in the textarea
// value, so scope the query to the sidebar item title element.
const sidebarItem = document.querySelector(".planning-sidebar-item-title");
expect(sidebarItem?.textContent).toBe("Build a detailed auth system plan");
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan with extras" } });
await new Promise((resolve) => setTimeout(resolve, 350));
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
"Build a detailed auth system plan with extras",
undefined,
undefined,
{
planningDepth: "medium",
customQuestionCount: undefined,
},
"draft-123",
);
});
});
it("auto-starts planning when initialPlan prop is provided", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
initialPlan="Build a login system from new task dialog"
/>
);
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
// Should transition to question view
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
});
it("sets initial plan text in textarea when initialPlan prop is provided", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
initialPlan="Pre-filled plan from new task"
/>
);
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
});
});
describe("modal height constraint regression", () => {
it("desktop planning modal max-height accounts for overlay padding", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const modal = container.querySelector(".planning-modal");
expect(modal).toBeTruthy();
const { loadAllAppCss } = await import("../../test/cssFixture");
const css = loadAllAppCss();
const blockMatch = css.match(
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
);
expect(blockMatch).toBeTruthy();
const maxHeightValue = blockMatch![1].trim();
expect(maxHeightValue).toContain("calc(");
expect(maxHeightValue).toContain("100dvh");
expect(maxHeightValue).toContain("--overlay-padding-top");
});
it("uses planning-scoped disclosure overrides to remove inherited content indent", async () => {
const { loadAllAppCssBaseOnly } = await import("../../test/cssFixture");
const css = loadAllAppCssBaseOnly();
const blockMatch = css.match(
/\.planning-advanced-disclosure\s+\.onboarding-disclosure-content\s*\{[^}]*\}/,
);
expect(blockMatch).toBeTruthy();
expect(blockMatch![0]).toContain("padding-inline-start: 0;");
expect(blockMatch![0]).toContain("justify-content: center;");
});
it("keeps mobile question view top spacing compact", async () => {
const { loadAllAppCss } = await import("../../test/cssFixture");
const css = loadAllAppCss();
const mobileBlocks = getMediaBlocks(css, "@media (max-width: 768px)");
const mobileCss = mobileBlocks.join("\n");
expect(mobileCss).toContain(".planning-question-scroll");
expect(mobileCss).toContain("padding-top: var(--space-sm);");
expect(mobileCss).toContain("gap: var(--space-md);");
});
});
});