import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { useRoadmaps } from "../useRoadmaps"; import * as api from "../../api"; // Mock the API module vi.mock("../../api", () => ({ fetchRoadmaps: vi.fn(), fetchRoadmap: vi.fn(), createRoadmap: vi.fn(), updateRoadmap: vi.fn(), deleteRoadmap: vi.fn(), createRoadmapMilestone: vi.fn(), updateRoadmapMilestone: vi.fn(), deleteRoadmapMilestone: vi.fn(), createRoadmapFeature: vi.fn(), updateRoadmapFeature: vi.fn(), deleteRoadmapFeature: vi.fn(), reorderRoadmapMilestones: vi.fn(), reorderRoadmapFeatures: vi.fn(), moveRoadmapFeature: vi.fn(), generateMilestoneSuggestions: vi.fn(), generateFeatureSuggestions: vi.fn(), fetchRoadmapHandoff: vi.fn(), })); const mockRoadmaps = [ { id: "RM-001", title: "Q2 Roadmap", description: "Q2 product roadmap", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "RM-002", title: "Q3 Roadmap", description: "Q3 product roadmap", createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]; const mockRoadmapHierarchy: import("@fusion/core").RoadmapWithHierarchy = { id: "RM-001", title: "Q2 Roadmap", description: "Q2 product roadmap", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", milestones: [ { id: "RMS-001", roadmapId: "RM-001", title: "Milestone 1", description: "First milestone", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", features: [ { id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", description: "First feature", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ], }, { id: "RMS-002", roadmapId: "RM-001", title: "Milestone 2", description: "Second milestone", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", features: [], }, ], }; describe("useRoadmaps", () => { beforeEach(() => { vi.clearAllMocks(); (api.fetchRoadmaps as ReturnType).mockResolvedValue(mockRoadmaps); (api.fetchRoadmap as ReturnType).mockResolvedValue(mockRoadmapHierarchy); }); afterEach(() => { vi.restoreAllMocks(); }); it("initializes with empty state and fetches roadmaps on mount", async () => { const { result } = renderHook(() => useRoadmaps()); // Initially loading expect(result.current.loading).toBe(true); expect(result.current.roadmaps).toEqual([]); expect(result.current.selectedRoadmapId).toBeNull(); // Wait for fetch to complete await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.roadmaps).toEqual(mockRoadmaps); expect(api.fetchRoadmaps).toHaveBeenCalledWith(undefined); }); it("fetches roadmaps with projectId when provided", async () => { const { result } = renderHook(() => useRoadmaps({ projectId: "proj_abc" })); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(api.fetchRoadmaps).toHaveBeenCalledWith("proj_abc"); expect(result.current.roadmaps).toEqual(mockRoadmaps); }); it("clears selection and refetches when projectId changes", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj_abc" } } ); // Select a roadmap result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Change project rerender({ projectId: "proj_xyz" }); // Selection should be cleared expect(result.current.selectedRoadmapId).toBeNull(); expect(api.fetchRoadmaps).toHaveBeenLastCalledWith("proj_xyz"); }); it("selects a roadmap and fetches its data", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); expect(api.fetchRoadmap).toHaveBeenCalledWith("RM-001", undefined); expect(result.current.selectedRoadmap).toEqual(mockRoadmapHierarchy); expect(result.current.milestones).toEqual(mockRoadmapHierarchy.milestones); }); it("creates a new roadmap and refreshes the list", async () => { const newRoadmap = { id: "RM-003", title: "New Roadmap", description: "A new roadmap", createdAt: "2026-01-03T00:00:00.000Z", updatedAt: "2026-01-03T00:00:00.000Z", }; (api.createRoadmap as ReturnType).mockResolvedValue(newRoadmap); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const onSuccess = vi.fn(); await result.current.createRoadmap({ title: "New Roadmap", description: "A new roadmap" }, { onSuccess }); expect(api.createRoadmap).toHaveBeenCalledWith( { title: "New Roadmap", description: "A new roadmap" }, undefined ); await waitFor(() => { expect(result.current.roadmaps).toContainEqual(newRoadmap); }); expect(onSuccess).toHaveBeenCalled(); }); it("updates a roadmap and refreshes the list", async () => { const updatedRoadmap = { ...mockRoadmaps[0], title: "Updated Title" }; (api.updateRoadmap as ReturnType).mockResolvedValue(updatedRoadmap); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const onSuccess = vi.fn(); await result.current.updateRoadmap("RM-001", { title: "Updated Title" }, { onSuccess }); expect(api.updateRoadmap).toHaveBeenCalledWith("RM-001", { title: "Updated Title" }, undefined); await waitFor(() => { expect(result.current.roadmaps.find((r) => r.id === "RM-001")?.title).toBe("Updated Title"); }); expect(onSuccess).toHaveBeenCalled(); }); it("deletes a roadmap and removes it from the list", async () => { (api.deleteRoadmap as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const onSuccess = vi.fn(); await result.current.deleteRoadmap("RM-001", { onSuccess }); expect(api.deleteRoadmap).toHaveBeenCalledWith("RM-001", undefined); await waitFor(() => { expect(result.current.roadmaps.find((r) => r.id === "RM-001")).toBeUndefined(); }); expect(onSuccess).toHaveBeenCalled(); }); it("clears selected roadmap when deleting the selected roadmap", async () => { (api.deleteRoadmap as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.deleteRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBeNull(); }); expect(result.current.selectedRoadmap).toBeNull(); }); it("creates a milestone in the selected roadmap", async () => { const newMilestone = { id: "RMS-003", roadmapId: "RM-001", title: "New Milestone", description: "A new milestone", orderIndex: 2, createdAt: "2026-01-03T00:00:00.000Z", updatedAt: "2026-01-03T00:00:00.000Z", }; (api.createRoadmapMilestone as ReturnType).mockResolvedValue(newMilestone); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.createMilestone({ title: "New Milestone", description: "A new milestone" }, { onSuccess }); expect(api.createRoadmapMilestone).toHaveBeenCalledWith( "RM-001", { title: "New Milestone", description: "A new milestone" }, undefined ); expect(onSuccess).toHaveBeenCalled(); // Should trigger refresh expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("throws error when creating milestone without selected roadmap", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const onError = vi.fn(); await expect( result.current.createMilestone({ title: "New Milestone" }, { onError }) ).rejects.toThrow("No roadmap selected"); expect(onError).toHaveBeenCalled(); }); it("updates a milestone and refreshes", async () => { const updatedMilestone = { ...mockRoadmapHierarchy.milestones[0], title: "Updated Milestone" }; (api.updateRoadmapMilestone as ReturnType).mockResolvedValue(updatedMilestone); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.updateMilestone("RMS-001", { title: "Updated Milestone" }, { onSuccess }); expect(api.updateRoadmapMilestone).toHaveBeenCalledWith("RMS-001", { title: "Updated Milestone" }, undefined); expect(onSuccess).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("deletes a milestone and removes it from state", async () => { (api.deleteRoadmapMilestone as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.deleteMilestone("RMS-001", { onSuccess }); expect(api.deleteRoadmapMilestone).toHaveBeenCalledWith("RMS-001", undefined); expect(onSuccess).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("creates a feature in a milestone", async () => { const newFeature = { id: "RF-002", milestoneId: "RMS-001", title: "New Feature", description: "A new feature", orderIndex: 1, createdAt: "2026-01-03T00:00:00.000Z", updatedAt: "2026-01-03T00:00:00.000Z", }; (api.createRoadmapFeature as ReturnType).mockResolvedValue(newFeature); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.createFeature("RMS-001", { title: "New Feature", description: "A new feature" }, { onSuccess }); expect(api.createRoadmapFeature).toHaveBeenCalledWith( "RMS-001", { title: "New Feature", description: "A new feature" }, undefined ); expect(onSuccess).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("updates a feature and refreshes", async () => { const updatedFeature = { ...mockRoadmapHierarchy.milestones[0].features[0], title: "Updated Feature", }; (api.updateRoadmapFeature as ReturnType).mockResolvedValue(updatedFeature); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.updateFeature("RF-001", { title: "Updated Feature" }, { onSuccess }); expect(api.updateRoadmapFeature).toHaveBeenCalledWith("RF-001", { title: "Updated Feature" }, undefined); expect(onSuccess).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("deletes a feature and refreshes", async () => { (api.deleteRoadmapFeature as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onSuccess = vi.fn(); await result.current.deleteFeature("RF-001", { onSuccess }); expect(api.deleteRoadmapFeature).toHaveBeenCalledWith("RF-001", undefined); expect(onSuccess).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("surfaces error state when fetch fails", async () => { const fetchError = new Error("Network error"); (api.fetchRoadmaps as ReturnType).mockRejectedValue(fetchError); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.error).toEqual(fetchError); }); it("calls onError callback when CRUD operation fails", async () => { const apiError = new Error("API error"); (api.createRoadmap as ReturnType).mockRejectedValue(apiError); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const onError = vi.fn(); await expect( result.current.createRoadmap({ title: "Test" }, { onError }) ).rejects.toThrow("API error"); expect(onError).toHaveBeenCalledWith(apiError); }); it("refreshes roadmaps and selected roadmap", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.refresh(); expect(api.fetchRoadmaps).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalledWith("RM-001", undefined); }); describe("reorderMilestones", () => { it("reorders milestones and refreshes", async () => { (api.reorderRoadmapMilestones as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Reorder milestones: swap RMS-001 and RMS-002 await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"]); expect(api.reorderRoadmapMilestones).toHaveBeenCalledWith( "RM-001", ["RMS-002", "RMS-001"], undefined ); // Should refresh to get server state expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("rolls back on failure and calls onError", async () => { const reorderError = new Error("Reorder failed"); (api.reorderRoadmapMilestones as ReturnType).mockRejectedValue(reorderError); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const initialMilestones = result.current.milestones; const onError = vi.fn(); try { await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"], { onError }); } catch { // Expected to throw } expect(onError).toHaveBeenCalledWith(reorderError); // State should be rolled back expect(result.current.milestones).toEqual(initialMilestones); }); it("sends correct payload shape for reorder", async () => { (api.reorderRoadmapMilestones as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.reorderMilestones("RM-001", ["RMS-001", "RMS-002"]); // Verify the payload shape expect(api.reorderRoadmapMilestones).toHaveBeenCalledTimes(1); const call = (api.reorderRoadmapMilestones as ReturnType).mock.calls[0]; expect(call[0]).toBe("RM-001"); expect(Array.isArray(call[1])).toBe(true); expect(call[1]).toHaveLength(2); }); }); describe("reorderFeatures", () => { it("reorders features within a milestone with optimistic update", async () => { // This test requires multiple features to meaningfully test reordering // We'll create a custom hierarchy with multiple features const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = { ...mockRoadmapHierarchy, milestones: [ { ...mockRoadmapHierarchy.milestones[0], features: [ { id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }, { id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }, ], }, mockRoadmapHierarchy.milestones[1], ], }; (api.fetchRoadmap as ReturnType).mockResolvedValue(multiFeatureHierarchy); (api.reorderRoadmapFeatures as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Reorder features in RMS-001: swap RF-001 and RF-002 await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"]); expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith( "RMS-001", ["RF-002", "RF-001"], undefined ); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("rolls back on failure", async () => { // This test requires multiple features const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = { ...mockRoadmapHierarchy, milestones: [ { ...mockRoadmapHierarchy.milestones[0], features: [ { id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }, { id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }, ], }, mockRoadmapHierarchy.milestones[1], ], }; (api.fetchRoadmap as ReturnType).mockResolvedValue(multiFeatureHierarchy); const reorderError = new Error("Feature reorder failed"); (api.reorderRoadmapFeatures as ReturnType).mockRejectedValue(reorderError); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const initialFeatures = result.current.featuresByMilestoneId["RMS-001"]; const onError = vi.fn(); try { await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError }); } catch { // Expected to throw } expect(onError).toHaveBeenCalledWith(reorderError); expect(result.current.featuresByMilestoneId["RMS-001"]).toEqual(initialFeatures); }); }); describe("moveFeature", () => { it("moves a feature to a different milestone", async () => { (api.moveRoadmapFeature as ReturnType).mockResolvedValue(undefined); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Move RF-001 from RMS-001 to RMS-002 at index 0 await result.current.moveFeature("RF-001", "RMS-002", 0); expect(api.moveRoadmapFeature).toHaveBeenCalledWith( "RF-001", "RMS-002", 0, undefined ); expect(api.fetchRoadmap).toHaveBeenCalled(); }); it("rolls back on failure", async () => { const moveError = new Error("Move failed"); (api.moveRoadmapFeature as ReturnType).mockRejectedValue(moveError); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const initialFeaturesByMilestoneId = result.current.featuresByMilestoneId; const onError = vi.fn(); try { await result.current.moveFeature("RF-001", "RMS-002", 0, { onError }); } catch { // Expected to throw } expect(onError).toHaveBeenCalledWith(moveError); expect(result.current.featuresByMilestoneId).toEqual(initialFeaturesByMilestoneId); }); it("throws when feature not found", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); const onError = vi.fn(); await expect( result.current.moveFeature("NONEXISTENT", "RMS-002", 0, { onError }) ).rejects.toThrow("Feature not found"); }); }); describe("Feature suggestions", () => { it("generates feature suggestions for a milestone with stable draft IDs", async () => { const mockSuggestions = [ { title: "Feature 1", description: "Description 1" }, { title: "Feature 2", description: "Description 2" }, ]; (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: mockSuggestions, }); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.generateFeatureSuggestions("RMS-001", { count: 5 }); await waitFor(() => { const suggestions = result.current.featureSuggestionsByMilestoneId["RMS-001"]; expect(suggestions).toHaveLength(2); expect(suggestions![0].title).toBe("Feature 1"); expect(suggestions![1].title).toBe("Feature 2"); // Verify stable draft IDs exist expect(suggestions![0].id).toBeDefined(); expect(suggestions![1].id).toBeDefined(); expect(suggestions![0].id).not.toBe(suggestions![1].id); }); expect(api.generateFeatureSuggestions).toHaveBeenCalledWith( "RMS-001", { count: 5 }, undefined ); }); it("generates feature suggestions with prompt", async () => { (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Auth Feature" }], }); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); await result.current.generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 }); expect(api.generateFeatureSuggestions).toHaveBeenCalledWith( "RMS-001", { prompt: "Focus on auth", count: 3 }, undefined ); }); it("editing a draft changes the persisted value after accept-one", async () => { const mockFeature = { id: "RF-NEW", milestoneId: "RMS-001", title: "Edited Feature Title", description: "Edited description", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; (api.createRoadmapFeature as ReturnType).mockResolvedValue(mockFeature); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Generate suggestions (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Original Title", description: "Original description" }], }); await result.current.generateFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1); }); // Get the draft ID const draftId = result.current.featureSuggestionsByMilestoneId["RMS-001"][0].id; // Edit the draft result.current.updateFeatureSuggestionDraft("RMS-001", draftId, { title: "Edited Feature Title", description: "Edited description", }); // Verify the draft is updated await waitFor(() => { const suggestion = result.current.featureSuggestionsByMilestoneId["RMS-001"][0]; expect(suggestion.title).toBe("Edited Feature Title"); expect(suggestion.description).toBe("Edited description"); }); // Accept the suggestion - should use the edited values await result.current.acceptFeatureSuggestion("RMS-001", draftId); // API should be called with the edited values expect(api.createRoadmapFeature).toHaveBeenCalledWith( "RMS-001", { title: "Edited Feature Title", description: "Edited description" }, undefined ); }); it("mixed edited drafts persist in the same order on accept-all", async () => { const mockFeatures = [ { id: "RF-NEW-1", milestoneId: "RMS-001", title: "Edited Title 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "RF-NEW-2", milestoneId: "RMS-001", title: "Title 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ]; (api.createRoadmapFeature as ReturnType) .mockResolvedValueOnce(mockFeatures[0]) .mockResolvedValueOnce(mockFeatures[1]); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: [ { title: "Original Title 1" }, { title: "Original Title 2" }, ], }); await result.current.generateFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2); }); // Get draft IDs const suggestions = result.current.featureSuggestionsByMilestoneId["RMS-001"]; const draftId1 = suggestions[0].id; const draftId2 = suggestions[1].id; // Edit the first draft result.current.updateFeatureSuggestionDraft("RMS-001", draftId1, { title: "Edited Title 1", }); // Accept all await result.current.acceptAllFeatureSuggestions("RMS-001"); // Verify sequential calls with edited value for first suggestion expect(api.createRoadmapFeature).toHaveBeenCalledTimes(2); expect(api.createRoadmapFeature).toHaveBeenNthCalledWith( 1, "RMS-001", { title: "Edited Title 1", description: undefined }, undefined ); expect(api.createRoadmapFeature).toHaveBeenNthCalledWith( 2, "RMS-001", { title: "Original Title 2", description: undefined }, undefined ); }); it("clears feature suggestions for a milestone", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }], }); await result.current.generateFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2); }); // Clear suggestions result.current.clearFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined(); }); }); it("is isolated per milestone", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions for milestone 1 (api.generateFeatureSuggestions as ReturnType).mockResolvedValueOnce({ suggestions: [{ title: "MS1 Feature" }], }); await result.current.generateFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1); }); // Set suggestions for milestone 2 (api.generateFeatureSuggestions as ReturnType).mockResolvedValueOnce({ suggestions: [{ title: "MS2 Feature" }], }); await result.current.generateFeatureSuggestions("RMS-002"); await waitFor(() => { // Verify suggestions are isolated expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1); expect(result.current.featureSuggestionsByMilestoneId["RMS-001"][0].title).toBe("MS1 Feature"); expect(result.current.featureSuggestionsByMilestoneId["RMS-002"]).toHaveLength(1); expect(result.current.featureSuggestionsByMilestoneId["RMS-002"][0].title).toBe("MS2 Feature"); }); }); it("returns correct loading state for feature suggestions", async () => { let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void; (api.generateFeatureSuggestions as ReturnType).mockImplementation(() => { return new Promise((resolve) => { resolveGenerate = resolve; }); }); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Start generating const generatePromise = result.current.generateFeatureSuggestions("RMS-001"); // Wait for loading state to update await waitFor(() => { expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(true); }); // Complete generation resolveGenerate!({ suggestions: [{ title: "Feature" }] }); await generatePromise; await waitFor(() => { // Check loading state is false expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(false); }); }); it("clears feature suggestions when project changes", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateFeatureSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Feature" }], }); await result.current.generateFeatureSuggestions("RMS-001"); await waitFor(() => { expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1); }); // Change project rerender({ projectId: "proj-2" }); // Suggestions should be cleared expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined(); }); it("stale async suggestion responses are ignored after project change", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set up a slow-responding mock let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void; (api.generateFeatureSuggestions as ReturnType).mockImplementation(() => { return new Promise((resolve) => { resolveGenerate = resolve; }); }); // Start generating const generatePromise = result.current.generateFeatureSuggestions("RMS-001"); // Change project before the promise resolves rerender({ projectId: "proj-2" }); // Resolve the promise - should be ignored resolveGenerate!({ suggestions: [{ title: "Stale Feature" }] }); await generatePromise; // Suggestions should NOT be set for the old project // (Since we're now in project "proj-2", the stale response should be ignored) expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined(); }); }); describe("Milestone suggestions", () => { it("generates milestone suggestions with stable draft IDs", async () => { const mockSuggestions = [ { title: "Milestone 1", description: "Description 1" }, { title: "Milestone 2", description: "Description 2" }, ]; (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: mockSuggestions, }); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.generateMilestoneSuggestions("Build an app", 5); await waitFor(() => { const suggestions = result.current.milestoneSuggestions; expect(suggestions).toHaveLength(2); expect(suggestions[0].title).toBe("Milestone 1"); expect(suggestions[1].title).toBe("Milestone 2"); // Verify stable draft IDs exist expect(suggestions[0].id).toBeDefined(); expect(suggestions[1].id).toBeDefined(); expect(suggestions[0].id).not.toBe(suggestions[1].id); }); }); it("editing a draft changes the persisted value after accept-one", async () => { const mockMilestone = { id: "RMS-NEW", roadmapId: "RM-001", title: "Edited Milestone Title", description: "Edited description", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; (api.createRoadmapMilestone as ReturnType).mockResolvedValue(mockMilestone); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Generate suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Original Title", description: "Original description" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(1); }); // Get the draft ID const draftId = result.current.milestoneSuggestions[0].id; // Edit the draft result.current.updateMilestoneSuggestionDraft(draftId, { title: "Edited Milestone Title", description: "Edited description", }); // Verify the draft is updated await waitFor(() => { const suggestion = result.current.milestoneSuggestions[0]; expect(suggestion.title).toBe("Edited Milestone Title"); expect(suggestion.description).toBe("Edited description"); }); // Accept the suggestion - should use the edited values await result.current.acceptMilestoneSuggestion(draftId); // API should be called with the edited values expect(api.createRoadmapMilestone).toHaveBeenCalledWith( "RM-001", { title: "Edited Milestone Title", description: "Edited description" }, undefined ); }); it("mixed edited drafts persist in the same order on accept-all", async () => { const mockMilestones = [ { id: "RMS-NEW-1", roadmapId: "RM-001", title: "Edited Title 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "RMS-NEW-2", roadmapId: "RM-001", title: "Title 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ]; (api.createRoadmapMilestone as ReturnType) .mockResolvedValueOnce(mockMilestones[0]) .mockResolvedValueOnce(mockMilestones[1]); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [ { title: "Original Title 1" }, { title: "Original Title 2" }, ], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(2); }); // Get draft IDs const suggestions = result.current.milestoneSuggestions; const draftId1 = suggestions[0].id; const draftId2 = suggestions[1].id; // Edit the first draft result.current.updateMilestoneSuggestionDraft(draftId1, { title: "Edited Title 1", }); // Accept all await result.current.acceptAllMilestoneSuggestions(); // Verify sequential calls with edited value for first suggestion expect(api.createRoadmapMilestone).toHaveBeenCalledTimes(2); expect(api.createRoadmapMilestone).toHaveBeenNthCalledWith( 1, "RM-001", { title: "Edited Title 1", description: undefined }, undefined ); expect(api.createRoadmapMilestone).toHaveBeenNthCalledWith( 2, "RM-001", { title: "Original Title 2", description: undefined }, undefined ); }); it("clearing drafts removes only draft state (not already persisted milestones)", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Suggestion 1" }, { title: "Suggestion 2" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(2); expect(result.current.milestones).toHaveLength(2); // Existing milestones from mock }); // Clear suggestions result.current.clearMilestoneSuggestions(); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(0); // Existing milestones should still be there expect(result.current.milestones).toHaveLength(2); }); }); it("stale async suggestion responses are ignored after project change", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set up a slow-responding mock let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void; (api.generateMilestoneSuggestions as ReturnType).mockImplementation(() => { return new Promise((resolve) => { resolveGenerate = resolve; }); }); // Start generating const generatePromise = result.current.generateMilestoneSuggestions("Build something", 5); // Change project before the promise resolves rerender({ projectId: "proj-2" }); // Resolve the promise - should be ignored resolveGenerate!({ suggestions: [{ title: "Stale Milestone" }] }); await generatePromise; // Suggestions should NOT be set for the old project expect(result.current.milestoneSuggestions).toHaveLength(0); }); it("prevents acceptance of empty title", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Generate suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Valid Title" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(1); }); const draftId = result.current.milestoneSuggestions[0].id; // Edit to make title empty result.current.updateMilestoneSuggestionDraft(draftId, { title: "", }); // Try to accept - should fail const onError = vi.fn(); await expect( result.current.acceptMilestoneSuggestion(draftId, { onError }) ).rejects.toThrow("Title cannot be empty"); expect(onError).toHaveBeenCalled(); }); it("prevents acceptance of whitespace-only title", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Generate suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Valid Title" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(1); }); const draftId = result.current.milestoneSuggestions[0].id; // Edit to make title whitespace-only result.current.updateMilestoneSuggestionDraft(draftId, { title: " ", }); // Try to accept - should fail const onError = vi.fn(); await expect( result.current.acceptMilestoneSuggestion(draftId, { onError }) ).rejects.toThrow("Title cannot be empty"); expect(onError).toHaveBeenCalled(); }); it("accepts a single milestone suggestion by draftId", async () => { const mockMilestone = { id: "RMS-NEW", roadmapId: "RM-001", title: "New Milestone", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; (api.createRoadmapMilestone as ReturnType).mockResolvedValue(mockMilestone); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Generate suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "New Milestone", description: "Description" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(1); }); const draftId = result.current.milestoneSuggestions[0].id; // Accept the suggestion await result.current.acceptMilestoneSuggestion(draftId); expect(api.createRoadmapMilestone).toHaveBeenCalledWith( "RM-001", { title: "New Milestone", description: "Description" }, undefined ); }); it("clears milestone suggestions when project changes", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Set suggestions (api.generateMilestoneSuggestions as ReturnType).mockResolvedValue({ suggestions: [{ title: "Milestone" }], }); await result.current.generateMilestoneSuggestions("Build something", 5); await waitFor(() => { expect(result.current.milestoneSuggestions).toHaveLength(1); }); // Change project rerender({ projectId: "proj-2" }); // Suggestions should be cleared expect(result.current.milestoneSuggestions).toHaveLength(0); }); }); describe("Handoff / Export", () => { const mockHandoffPayload = { mission: { sourceRoadmapId: "RM-001", title: "Q2 Roadmap", description: "Q2 product roadmap", milestones: [ { sourceMilestoneId: "RMS-001", title: "Milestone 1", description: "First milestone", orderIndex: 0, features: [ { sourceFeatureId: "RF-001", title: "Feature 1", description: "First feature", orderIndex: 0 }, ], }, ], }, features: [ { source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "Q2 Roadmap", milestoneTitle: "Milestone 1", milestoneOrderIndex: 0, featureOrderIndex: 0 }, title: "Feature 1", description: "First feature", }, ], }; beforeEach(() => { // Reset fetchHandoff mock for each test (api.fetchRoadmapHandoff as ReturnType).mockReset(); }); it("fetches handoff payload for a roadmap", async () => { (api.fetchRoadmapHandoff as ReturnType).mockResolvedValue(mockHandoffPayload); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state from previous tests result.current.clearHandoff(); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); await result.current.fetchHandoff("RM-001"); await waitFor(() => { expect(result.current.handoffPayload).toEqual(mockHandoffPayload); expect(result.current.isFetchingHandoff).toBe(false); expect(result.current.handoffError).toBeNull(); }); }); it("handles fetchHandoff error", async () => { (api.fetchRoadmapHandoff as ReturnType).mockRejectedValueOnce( new Error("Failed to fetch handoff") ); const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state result.current.clearHandoff(); await result.current.fetchHandoff("RM-001"); await waitFor(() => { expect(result.current.handoffError).toBeDefined(); expect(result.current.handoffPayload).toBeNull(); expect(result.current.isFetchingHandoff).toBe(false); }); }); it("clears handoff state with clearHandoff", async () => { (api.fetchRoadmapHandoff as ReturnType).mockResolvedValue(mockHandoffPayload); const { result, rerender } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state result.current.clearHandoff(); await result.current.fetchHandoff("RM-001"); await waitFor(() => { expect(result.current.handoffPayload).toEqual(mockHandoffPayload); }); // Call clearHandoff and rerender to get fresh state result.current.clearHandoff(); rerender(); expect(result.current.handoffPayload).toBeNull(); expect(result.current.handoffError).toBeNull(); expect(result.current.isFetchingHandoff).toBe(false); }); it("clears handoff payload when project changes", async () => { (api.fetchRoadmapHandoff as ReturnType).mockResolvedValue(mockHandoffPayload); const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state result.current.clearHandoff(); await result.current.fetchHandoff("RM-001"); await waitFor(() => { expect(result.current.handoffPayload).toEqual(mockHandoffPayload); }); // Change project rerender({ projectId: "proj-2" }); // Handoff should be cleared expect(result.current.handoffPayload).toBeNull(); expect(result.current.handoffError).toBeNull(); }); it("sends correct projectId when fetching handoff", async () => { (api.fetchRoadmapHandoff as ReturnType).mockResolvedValue(mockHandoffPayload); const { result } = renderHook(() => useRoadmaps({ projectId: "proj-test" })); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state result.current.clearHandoff(); await result.current.fetchHandoff("RM-001"); await waitFor(() => { expect(api.fetchRoadmapHandoff).toHaveBeenCalledWith("RM-001", "proj-test"); }); }); it("does not set stale handoff response after project change", async () => { const { result, rerender } = renderHook( ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }), { initialProps: { projectId: "proj-1" } } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); // Clear any stale handoff state result.current.clearHandoff(); // Start fetch but don't resolve yet let resolveHandoff: ((value: typeof mockHandoffPayload) => void) | null = null; (api.fetchRoadmapHandoff as ReturnType).mockImplementationOnce(() => { return new Promise((resolve) => { resolveHandoff = resolve; }); }); const fetchPromise = result.current.fetchHandoff("RM-001"); // Change project before promise resolves rerender({ projectId: "proj-2" }); // Resolve the promise if (resolveHandoff) { resolveHandoff(mockHandoffPayload); } await fetchPromise; // Handoff should NOT be set because we're in a different project now expect(result.current.handoffPayload).toBeNull(); }); }); describe("No-op suppression", () => { it("skips API call when reordering features to same order", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // Try to reorder with same order as current const currentFeatureIds = result.current.featuresByMilestoneId["RMS-001"]?.map((f) => f.id) || []; await result.current.reorderFeatures("RMS-001", currentFeatureIds); // API should NOT have been called expect(api.reorderRoadmapFeatures).not.toHaveBeenCalled(); }); it("skips API call when moving feature to same position in same milestone", async () => { const { result } = renderHook(() => useRoadmaps()); await waitFor(() => { expect(result.current.loading).toBe(false); }); result.current.selectRoadmap("RM-001"); await waitFor(() => { expect(result.current.selectedRoadmapId).toBe("RM-001"); }); // The feature RF-001 is already at index 0, try to move it to index 0 await result.current.moveFeature("RF-001", "RMS-001", 0); // API should NOT have been called expect(api.moveRoadmapFeature).not.toHaveBeenCalled(); }); }); });