feat(FN-1675): merge fusion/fn-1675

This commit is contained in:
gsxdsm
2026-04-15 18:23:05 -07:00
parent 20c138731c
commit cff03f11a3
9 changed files with 974 additions and 27 deletions

View File

@@ -21,6 +21,7 @@ vi.mock("../../api", () => ({
moveRoadmapFeature: vi.fn(),
generateMilestoneSuggestions: vi.fn(),
generateFeatureSuggestions: vi.fn(),
fetchRoadmapHandoff: vi.fn(),
}));
const mockRoadmaps = [
@@ -555,7 +556,24 @@ describe("useRoadmaps", () => {
});
describe("reorderFeatures", () => {
it("reorders features within a milestone", async () => {
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<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const { result } = renderHook(() => useRoadmaps());
@@ -569,18 +587,34 @@ describe("useRoadmaps", () => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Reorder features in RMS-001: already has RF-001 at orderIndex 0
await result.current.reorderFeatures("RMS-001", ["RF-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-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<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
const reorderError = new Error("Feature reorder failed");
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
@@ -599,7 +633,7 @@ describe("useRoadmaps", () => {
const onError = vi.fn();
try {
await result.current.reorderFeatures("RMS-001", ["RF-001"], { onError });
await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError });
} catch {
// Expected to throw
}
@@ -1461,4 +1495,239 @@ describe("useRoadmaps", () => {
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<typeof vi.fn>).mockReset();
});
it("fetches handoff payload for a roadmap", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
});

View File

@@ -277,7 +277,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const newRoadmap = await api.createRoadmap(input, projectId);
const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
setRoadmaps((prev) => [...prev, newRoadmap]);
opts?.onSuccess?.();
} catch (err) {
@@ -285,7 +285,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [projectId]);
}, []); // No dependencies needed - uses refs
const updateRoadmap = useCallback(async (
roadmapId: string,
@@ -293,7 +293,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmap(roadmapId, updates, projectId);
const updated = await api.updateRoadmap(roadmapId, updates, projectIdRef.current);
setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
@@ -304,14 +304,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [projectId]);
}, []); // No dependencies needed - uses refs
const deleteRoadmap = useCallback(async (
roadmapId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmap(roadmapId, projectId);
await api.deleteRoadmap(roadmapId, projectIdRef.current);
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmapId(null);
@@ -325,7 +325,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [projectId]);
}, []); // No dependencies needed - uses refs
const selectRoadmap = useCallback((roadmapId: string | null) => {
setSelectedRoadmapId(roadmapId);
@@ -364,7 +364,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectId);
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectIdRef.current);
setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -375,14 +375,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
const deleteMilestone = useCallback(async (
milestoneId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmapMilestone(milestoneId, projectId);
await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
setFeaturesByMilestoneId((prev) => {
const updated = { ...prev };
@@ -398,7 +398,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
// Feature CRUD
const createFeature = useCallback(async (
@@ -407,7 +407,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectId);
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
setFeaturesByMilestoneId((prev) => ({
...prev,
[milestoneId]: [...(prev[milestoneId] || []), newFeature],
@@ -421,7 +421,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
const updateFeature = useCallback(async (
featureId: string,
@@ -429,7 +429,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmapFeature(featureId, updates, projectId);
const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) {
@@ -446,14 +446,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
const deleteFeature = useCallback(async (
featureId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmapFeature(featureId, projectId);
await api.deleteRoadmapFeature(featureId, projectIdRef.current);
setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) {
@@ -470,7 +470,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
// Milestone ordering
const reorderMilestones = useCallback(async (
@@ -490,7 +490,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
setMilestones(reordered);
try {
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectId);
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
// Refresh to get server state
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -503,7 +503,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
// Feature ordering
const reorderFeatures = useCallback(async (
@@ -511,9 +511,16 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
orderedFeatureIds: string[],
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// No-op suppression: if IDs are already in the same order, skip API call
const currentFeatures = featuresByMilestoneIdRef.current[milestoneId] || [];
const currentIds = currentFeatures.map((f) => f.id);
if (JSON.stringify(currentIds) === JSON.stringify(orderedFeatureIds)) {
opts?.onSuccess?.();
return;
}
// Save snapshot for rollback
const snapshot = featuresByMilestoneIdRef.current;
const currentFeatures = snapshot[milestoneId] || [];
// Optimistic update
const reordered = orderedFeatureIds
@@ -527,7 +534,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
}));
try {
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectId);
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
// Refresh to get server state
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -540,7 +547,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
}, [fetchSelectedRoadmap]); // Uses refs internally
const moveFeature = useCallback(async (
featureId: string,
@@ -566,6 +573,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
throw error;
}
// No-op suppression: if already at target position in same milestone, skip
if (sourceMilestoneId === targetMilestoneId) {
const currentFeatures = snapshot[sourceMilestoneId] || [];
const clampedIndex = Math.max(0, Math.min(targetIndex, currentFeatures.length - 1));
const currentIndex = currentFeatures.findIndex((f) => f.id === featureId);
if (currentIndex === clampedIndex) {
opts?.onSuccess?.();
return;
}
}
// Optimistic update
const sourceFeatures = snapshot[sourceMilestoneId] || [];
const targetFeatures = snapshot[targetMilestoneId] || [];