Files
fusion/packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.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

538 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
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";
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("Model favorites persistence", () => {
it("persists provider favorite toggle to global settings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
// When provider is favorited, the optgroup header shows "Remove" button
const removeButton = within(portal).queryByRole("button", { name: "Remove anthropic from favorites" });
expect(removeButton).not.toBeNull();
fireEvent.click(removeButton!);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: [],
favoriteModels: [],
});
});
it("persists model favorite toggle to global settings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
// When model is favorited, it appears as a pinned row with "Remove" button
// There may be duplicates (in pinned row + provider group), use first one
const removeButtons = within(portal).queryAllByRole("button", { name: "Remove Claude Sonnet 4.5 from favorites" });
expect(removeButtons.length).toBeGreaterThan(0);
fireEvent.click(removeButtons[0]);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: [],
favoriteModels: [],
});
});
it("adds provider to favorites", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" });
fireEvent.click(addButton);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
});
it("rolls back local favorite state when updateGlobalSettings fails", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockRejectedValueOnce(new Error("Network error"));
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" });
fireEvent.click(removeButton);
// Optimistic state should immediately show unfavorited UI.
expect(within(portal).getByRole("button", { name: "Add anthropic to favorites" })).toBeTruthy();
// The API call is fire-and-forget; rollback runs in the rejected-promise catch microtask.
await waitFor(() => {
expect(api.updateGlobalSettings).toHaveBeenCalled();
});
// Re-query until rollback flushes and favorited UI is restored.
await waitFor(() => {
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]');
expect(portalAfterRollback).not.toBeNull();
expect(within(portalAfterRollback as HTMLElement).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
});
});
});
});
describe("getSessionTabId", () => {
it("creates and persists a per-tab id in sessionStorage", () => {
window.sessionStorage.clear();
const first = getSessionTabId();
const second = getSessionTabId();
expect(first).toBeTruthy();
expect(second).toBe(first);
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
});
});
describe("useSessionLock", () => {
beforeEach(() => {
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
});
it("acquires on mount and releases on unmount", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const { unmount } = renderHook(() => useSessionLock("session-1"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
unmount();
await waitFor(() => {
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
});
it("exposes locked state and allows taking control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
const { result } = renderHook(() => useSessionLock("session-2"));
await waitFor(() => {
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
});
await act(async () => {
await result.current.takeControl();
});
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
expect(result.current.isLockedByOther).toBe(false);
expect(result.current.currentHolder).toBeNull();
});
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const sendBeaconSpy = vi.fn(() => true);
vi.stubGlobal("navigator", {
...window.navigator,
sendBeacon: sendBeaconSpy,
} as Navigator);
const { result } = renderHook(() => useSessionLock("session-3"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
});
const source = MockEventSource.instances[0];
expect(source).toBeDefined();
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-other",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-self",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(false);
act(() => {
window.dispatchEvent(new Event("beforeunload"));
});
expect(sendBeaconSpy).toHaveBeenCalledWith(
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
);
});
describe("Mobile keyboard behavior (FN-3337)", () => {
beforeEach(() => {
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
});
it("applies keyboard CSS variables when keyboard is open on mobile", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 300,
viewportHeight: 400,
viewportOffsetTop: 50,
keyboardOpen: true,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toContain("--keyboard-overlap");
expect(modal!.getAttribute("style")).toContain("--vv-height");
expect(modal!.getAttribute("style")).toContain("--vv-offset-top");
});
it("does not apply keyboard CSS variables when keyboard is closed", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toBeNull();
});
it("does not apply keyboard CSS variables on desktop", () => {
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toBeNull();
});
});
});