@runfusion/fusion 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/bin.js +27921 -21003
  2. package/dist/client/assets/AgentDetailView-BwJaLqZh.css +1 -0
  3. package/dist/client/assets/AgentDetailView-ZbHEbYRT.js +18 -0
  4. package/dist/client/assets/AgentsView-B3jYk8Kt.js +29 -0
  5. package/dist/client/assets/{AgentsView-DSGQWObq.css → AgentsView-CV3vm7Qk.css} +1 -1
  6. package/dist/client/assets/ChatView-DhPkiEGs.js +1 -0
  7. package/dist/client/assets/ChatView-DwJAd5G1.css +1 -0
  8. package/dist/client/assets/{DevServerView-C9lzHrcT.js → DevServerView-DyGDEiBP.js} +1 -1
  9. package/dist/client/assets/{DirectoryPicker-aVdFaV37.js → DirectoryPicker-D5UIeIl6.js} +1 -1
  10. package/dist/client/assets/{DocumentsView-DIpg3NSP.js → DocumentsView-DNHu1T8K.js} +1 -1
  11. package/dist/client/assets/{DocumentsView-BrhyOdeE.css → DocumentsView-gv4zG3aT.css} +1 -1
  12. package/dist/client/assets/EvalsView-CUNJ1TLc.css +1 -0
  13. package/dist/client/assets/EvalsView-CpRobtDi.js +1 -0
  14. package/dist/client/assets/{agentSkills-DDHJnrkn.css → ExperimentalAgentOnboardingModal-B-APN_lM.css} +1 -1
  15. package/dist/client/assets/ExperimentalAgentOnboardingModal-DOY_oZi7.js +499 -0
  16. package/dist/client/assets/InsightsView-B0J4mhzV.css +1 -0
  17. package/dist/client/assets/InsightsView-vp0RE8Mg.js +11 -0
  18. package/dist/client/assets/MemoryView-PSc5lGJt.js +2 -0
  19. package/dist/client/assets/MemoryView-zaXewZzi.css +1 -0
  20. package/dist/client/assets/NodesView-DMj6HGeC.js +14 -0
  21. package/dist/client/assets/NodesView-DT4pXowv.css +1 -0
  22. package/dist/client/assets/{PiExtensionsManager-Buopv-jb.js → PiExtensionsManager-DL_QcN56.js} +2 -2
  23. package/dist/client/assets/PluginManager-BtYKm8IT.js +1 -0
  24. package/dist/client/assets/PluginManager-DtRQXia5.css +1 -0
  25. package/dist/client/assets/{ResearchView-_BHXUv2j.js → ResearchView-BhWqfdV0.js} +1 -1
  26. package/dist/client/assets/SettingsModal-BAgB4_AR.js +31 -0
  27. package/dist/client/assets/SettingsModal-CUCyaAyE.js +1 -0
  28. package/dist/client/assets/SettingsModal-DzsLquBu.css +1 -0
  29. package/dist/client/assets/SetupWizardModal-BKscasuh.js +1 -0
  30. package/dist/client/assets/{SkillsView-hDpTBdFT.js → SkillsView-BdELqTy7.js} +1 -1
  31. package/dist/client/assets/TodoView-Cx9cVhq7.css +1 -0
  32. package/dist/client/assets/TodoView-DFNGBDNV.js +6 -0
  33. package/dist/client/assets/{folder-open-usZkXdq2.js → folder-open-k1xmUMyr.js} +1 -1
  34. package/dist/client/assets/index-Qq2JOOWx.css +1 -0
  35. package/dist/client/assets/index-TFYXEVpn.js +692 -0
  36. package/dist/client/assets/projectDetection-G3XuxD2X.js +1 -0
  37. package/dist/client/assets/{star-BAT_ObKE.js → star-ne32r3Y4.js} +1 -1
  38. package/dist/client/assets/{upload-BC2YKNEV.js → upload-MS-2Gx53.js} +1 -1
  39. package/dist/client/assets/{users-Dkd4rtrN.js → users-C519GSjH.js} +1 -1
  40. package/dist/client/index.html +12 -20
  41. package/dist/client/theme-data.css +106 -0
  42. package/dist/client/version.json +1 -1
  43. package/dist/droid-cli/package.json +1 -1
  44. package/dist/extension.js +15395 -9935
  45. package/dist/pi-claude-cli/package.json +1 -1
  46. package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +216 -0
  47. package/dist/plugins/fusion-plugin-cursor-runtime/manifest.json +6 -0
  48. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +11 -0
  49. package/dist/plugins/fusion-plugin-dependency-graph/bundled.js +30 -0
  50. package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +1 -1
  51. package/dist/plugins/fusion-plugin-dependency-graph/package.json +3 -26
  52. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +136684 -0
  53. package/dist/plugins/fusion-plugin-droid-runtime/manifest.json +13 -0
  54. package/dist/plugins/fusion-plugin-droid-runtime/mcp-schema-server.cjs +49 -0
  55. package/dist/plugins/fusion-plugin-droid-runtime/package.json +11 -0
  56. package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +68 -71
  57. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  58. package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +137 -53
  59. package/dist/plugins/fusion-plugin-openclaw-runtime/mcp-schema-server.cjs +59 -0
  60. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  61. package/dist/plugins/fusion-plugin-paperclip-runtime/bundled.js +155 -109
  62. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  63. package/dist/plugins/fusion-plugin-reports/manifest.json +33 -0
  64. package/dist/plugins/fusion-plugin-reports/package.json +26 -0
  65. package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +51 -0
  66. package/dist/plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts +166 -0
  67. package/dist/plugins/fusion-plugin-reports/src/__tests__/settings.test.ts +157 -0
  68. package/dist/plugins/fusion-plugin-reports/src/index.ts +87 -0
  69. package/dist/plugins/fusion-plugin-reports/src/report-schema.ts +38 -0
  70. package/dist/plugins/fusion-plugin-reports/src/review-panel.ts +294 -0
  71. package/dist/plugins/fusion-plugin-reports/src/review-types.ts +75 -0
  72. package/dist/plugins/fusion-plugin-reports/src/settings.ts +105 -0
  73. package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-schema.test.ts +66 -0
  74. package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-store.test.ts +177 -0
  75. package/dist/plugins/fusion-plugin-reports/src/store/report-store.ts +341 -0
  76. package/dist/plugins/fusion-plugin-reports/src/store/report-types.ts +77 -0
  77. package/dist/plugins/fusion-plugin-roadmap/manifest.json +16 -0
  78. package/dist/plugins/fusion-plugin-roadmap/package.json +48 -0
  79. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +101 -0
  80. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +92 -0
  81. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +48 -0
  82. package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +31 -0
  83. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +1299 -0
  84. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +2559 -0
  85. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +1144 -0
  86. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +1756 -0
  87. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +70 -0
  88. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +7 -0
  89. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +1 -0
  90. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +8 -0
  91. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +1188 -0
  92. package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +20 -0
  93. package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +6 -0
  94. package/dist/plugins/fusion-plugin-roadmap/src/index.ts +74 -0
  95. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +1 -0
  96. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +41 -0
  97. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +15 -0
  98. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +15 -0
  99. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +283 -0
  100. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +1 -0
  101. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +21 -0
  102. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +1 -0
  103. package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +310 -0
  104. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +5 -0
  105. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +1 -0
  106. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +361 -0
  107. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +1 -0
  108. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +408 -0
  109. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +68 -0
  110. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +1 -0
  111. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +300 -0
  112. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +1 -0
  113. package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +381 -0
  114. package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +3 -0
  115. package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +1 -0
  116. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +445 -0
  117. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +334 -0
  118. package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +1318 -0
  119. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +163 -0
  120. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +37 -0
  121. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +1 -0
  122. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +188 -0
  123. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +1 -0
  124. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +311 -0
  125. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +299 -0
  126. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +1 -0
  127. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +765 -0
  128. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +1 -0
  129. package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +1001 -0
  130. package/dist/plugins/fusion-plugin-whatsapp-chat/manifest.json +8 -0
  131. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +34 -0
  132. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +99 -0
  133. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +145 -0
  134. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +216 -0
  135. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/reply.test.ts +52 -0
  136. package/dist/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +89 -0
  137. package/dist/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +253 -0
  138. package/dist/plugins/fusion-plugin-whatsapp-chat/src/index.ts +262 -0
  139. package/dist/plugins/fusion-plugin-whatsapp-chat/src/qrcode.d.ts +1 -0
  140. package/dist/plugins/fusion-plugin-whatsapp-chat/src/reply.ts +37 -0
  141. package/package.json +2 -2
  142. package/skill/fusion/SKILL.md +2 -2
  143. package/skill/fusion/references/engine-tools.md +3 -0
  144. package/skill/fusion/references/extension-tools.md +39 -0
  145. package/skill/fusion/references/fusion-capabilities.md +3 -0
  146. package/dist/client/assets/AgentDetailView-C1XceMgi.js +0 -18
  147. package/dist/client/assets/AgentDetailView-CeO_1MK7.css +0 -1
  148. package/dist/client/assets/AgentsView-Deh125ss.js +0 -527
  149. package/dist/client/assets/ChatView-7D_RQDqT.js +0 -1
  150. package/dist/client/assets/InsightsView-AWo5o_81.css +0 -1
  151. package/dist/client/assets/InsightsView-jKjEFAx_.js +0 -11
  152. package/dist/client/assets/MemoryView-DiajLXby.css +0 -1
  153. package/dist/client/assets/MemoryView-nXlTqebk.js +0 -2
  154. package/dist/client/assets/NodesView-Di2SvOhg.js +0 -14
  155. package/dist/client/assets/NodesView-fXqDk9ur.css +0 -1
  156. package/dist/client/assets/PluginManager-B9-NbQ8f.js +0 -1
  157. package/dist/client/assets/PluginManager-C1DbPaar.css +0 -1
  158. package/dist/client/assets/RoadmapsView-DHWjUoc8.js +0 -6
  159. package/dist/client/assets/RoadmapsView-DdGlfuu-.css +0 -1
  160. package/dist/client/assets/SettingsModal-C89Ikhfm.js +0 -1
  161. package/dist/client/assets/SettingsModal-DHitIpsa.css +0 -1
  162. package/dist/client/assets/SettingsModal-DR_yirvK.js +0 -31
  163. package/dist/client/assets/SetupWizardModal-BtDMY9pa.js +0 -1
  164. package/dist/client/assets/agentSkills-B-w5wFHh.js +0 -1
  165. package/dist/client/assets/index-Bc6ZdGMz.css +0 -1
  166. package/dist/client/assets/index-D__RMku8.js +0 -694
  167. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +0 -141
  168. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +0 -428
  169. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +0 -261
  170. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +0 -41
  171. package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +0 -25
  172. package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +0 -22
@@ -0,0 +1,1756 @@
1
+ /* @vitest-environment jsdom */
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { useRoadmaps } from "../useRoadmaps";
5
+ import * as api from "../api";
6
+
7
+ // Mock the API module
8
+ vi.mock("../api", () => ({
9
+ fetchRoadmaps: vi.fn(),
10
+ fetchRoadmap: vi.fn(),
11
+ createRoadmap: vi.fn(),
12
+ updateRoadmap: vi.fn(),
13
+ deleteRoadmap: vi.fn(),
14
+ createRoadmapMilestone: vi.fn(),
15
+ updateRoadmapMilestone: vi.fn(),
16
+ deleteRoadmapMilestone: vi.fn(),
17
+ createRoadmapFeature: vi.fn(),
18
+ updateRoadmapFeature: vi.fn(),
19
+ deleteRoadmapFeature: vi.fn(),
20
+ reorderRoadmapMilestones: vi.fn(),
21
+ reorderRoadmapFeatures: vi.fn(),
22
+ moveRoadmapFeature: vi.fn(),
23
+ generateMilestoneSuggestions: vi.fn(),
24
+ generateFeatureSuggestions: vi.fn(),
25
+ fetchRoadmapHandoff: vi.fn(),
26
+ }));
27
+
28
+ const mockRoadmaps = [
29
+ {
30
+ id: "RM-001",
31
+ title: "Q2 Roadmap",
32
+ description: "Q2 product roadmap",
33
+ createdAt: "2026-01-01T00:00:00.000Z",
34
+ updatedAt: "2026-01-01T00:00:00.000Z",
35
+ },
36
+ {
37
+ id: "RM-002",
38
+ title: "Q3 Roadmap",
39
+ description: "Q3 product roadmap",
40
+ createdAt: "2026-01-02T00:00:00.000Z",
41
+ updatedAt: "2026-01-02T00:00:00.000Z",
42
+ },
43
+ ];
44
+
45
+ const mockRoadmapHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
46
+ id: "RM-001",
47
+ title: "Q2 Roadmap",
48
+ description: "Q2 product roadmap",
49
+ createdAt: "2026-01-01T00:00:00.000Z",
50
+ updatedAt: "2026-01-01T00:00:00.000Z",
51
+ milestones: [
52
+ {
53
+ id: "RMS-001",
54
+ roadmapId: "RM-001",
55
+ title: "Milestone 1",
56
+ description: "First milestone",
57
+ orderIndex: 0,
58
+ createdAt: "2026-01-01T00:00:00.000Z",
59
+ updatedAt: "2026-01-01T00:00:00.000Z",
60
+ features: [
61
+ {
62
+ id: "RF-001",
63
+ milestoneId: "RMS-001",
64
+ title: "Feature 1",
65
+ description: "First feature",
66
+ orderIndex: 0,
67
+ createdAt: "2026-01-01T00:00:00.000Z",
68
+ updatedAt: "2026-01-01T00:00:00.000Z",
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ id: "RMS-002",
74
+ roadmapId: "RM-001",
75
+ title: "Milestone 2",
76
+ description: "Second milestone",
77
+ orderIndex: 1,
78
+ createdAt: "2026-01-01T00:00:00.000Z",
79
+ updatedAt: "2026-01-01T00:00:00.000Z",
80
+ features: [],
81
+ },
82
+ ],
83
+ };
84
+
85
+ describe("useRoadmaps", () => {
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ (api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmaps);
89
+ (api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmapHierarchy);
90
+ });
91
+
92
+ afterEach(() => {
93
+ vi.restoreAllMocks();
94
+ });
95
+
96
+ it("initializes with empty state and fetches roadmaps on mount", async () => {
97
+ const { result } = renderHook(() => useRoadmaps());
98
+
99
+ // Initially loading
100
+ expect(result.current.loading).toBe(true);
101
+ expect(result.current.roadmaps).toEqual([]);
102
+ expect(result.current.selectedRoadmapId).toBeNull();
103
+
104
+ // Wait for fetch to complete
105
+ await waitFor(() => {
106
+ expect(result.current.loading).toBe(false);
107
+ });
108
+
109
+ expect(result.current.roadmaps).toEqual(mockRoadmaps);
110
+ expect(api.fetchRoadmaps).toHaveBeenCalledWith(undefined);
111
+ });
112
+
113
+ it("fetches roadmaps with projectId when provided", async () => {
114
+ const { result } = renderHook(() => useRoadmaps({ projectId: "proj_abc" }));
115
+
116
+ await waitFor(() => {
117
+ expect(result.current.loading).toBe(false);
118
+ });
119
+
120
+ expect(api.fetchRoadmaps).toHaveBeenCalledWith("proj_abc");
121
+ expect(result.current.roadmaps).toEqual(mockRoadmaps);
122
+ });
123
+
124
+ it("clears selection and refetches when projectId changes", async () => {
125
+ const { result, rerender } = renderHook(
126
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
127
+ { initialProps: { projectId: "proj_abc" } }
128
+ );
129
+
130
+ // Select a roadmap
131
+ result.current.selectRoadmap("RM-001");
132
+ await waitFor(() => {
133
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
134
+ });
135
+
136
+ // Change project
137
+ rerender({ projectId: "proj_xyz" });
138
+
139
+ // Selection should be cleared
140
+ expect(result.current.selectedRoadmapId).toBeNull();
141
+ expect(api.fetchRoadmaps).toHaveBeenLastCalledWith("proj_xyz");
142
+ });
143
+
144
+ it("selects a roadmap and fetches its data", async () => {
145
+ const { result } = renderHook(() => useRoadmaps());
146
+
147
+ await waitFor(() => {
148
+ expect(result.current.loading).toBe(false);
149
+ });
150
+
151
+ result.current.selectRoadmap("RM-001");
152
+
153
+ await waitFor(() => {
154
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
155
+ });
156
+
157
+ expect(api.fetchRoadmap).toHaveBeenCalledWith("RM-001", undefined);
158
+ expect(result.current.selectedRoadmap).toEqual(mockRoadmapHierarchy);
159
+ expect(result.current.milestones).toEqual(mockRoadmapHierarchy.milestones);
160
+ });
161
+
162
+ it("creates a new roadmap and refreshes the list", async () => {
163
+ const newRoadmap = {
164
+ id: "RM-003",
165
+ title: "New Roadmap",
166
+ description: "A new roadmap",
167
+ createdAt: "2026-01-03T00:00:00.000Z",
168
+ updatedAt: "2026-01-03T00:00:00.000Z",
169
+ };
170
+ (api.createRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(newRoadmap);
171
+
172
+ const { result } = renderHook(() => useRoadmaps());
173
+
174
+ await waitFor(() => {
175
+ expect(result.current.loading).toBe(false);
176
+ });
177
+
178
+ const onSuccess = vi.fn();
179
+ await result.current.createRoadmap({ title: "New Roadmap", description: "A new roadmap" }, { onSuccess });
180
+
181
+ expect(api.createRoadmap).toHaveBeenCalledWith(
182
+ { title: "New Roadmap", description: "A new roadmap" },
183
+ undefined
184
+ );
185
+ await waitFor(() => {
186
+ expect(result.current.roadmaps).toContainEqual(newRoadmap);
187
+ });
188
+ expect(onSuccess).toHaveBeenCalled();
189
+ });
190
+
191
+ it("updates a roadmap and refreshes the list", async () => {
192
+ const updatedRoadmap = { ...mockRoadmaps[0], title: "Updated Title" };
193
+ (api.updateRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(updatedRoadmap);
194
+
195
+ const { result } = renderHook(() => useRoadmaps());
196
+
197
+ await waitFor(() => {
198
+ expect(result.current.loading).toBe(false);
199
+ });
200
+
201
+ const onSuccess = vi.fn();
202
+ await result.current.updateRoadmap("RM-001", { title: "Updated Title" }, { onSuccess });
203
+
204
+ expect(api.updateRoadmap).toHaveBeenCalledWith("RM-001", { title: "Updated Title" }, undefined);
205
+ await waitFor(() => {
206
+ expect(result.current.roadmaps.find((r) => r.id === "RM-001")?.title).toBe("Updated Title");
207
+ });
208
+ expect(onSuccess).toHaveBeenCalled();
209
+ });
210
+
211
+ it("updates selectedRoadmap when updating the selected roadmap", async () => {
212
+ const updatedRoadmap = { ...mockRoadmaps[0], title: "Updated Title" };
213
+ (api.updateRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(updatedRoadmap);
214
+
215
+ const { result } = renderHook(() => useRoadmaps());
216
+
217
+ await waitFor(() => {
218
+ expect(result.current.loading).toBe(false);
219
+ });
220
+
221
+ result.current.selectRoadmap("RM-001");
222
+
223
+ await waitFor(() => {
224
+ expect(result.current.selectedRoadmap?.title).toBe("Q2 Roadmap");
225
+ });
226
+
227
+ await result.current.updateRoadmap("RM-001", { title: "Updated Title" });
228
+
229
+ await waitFor(() => {
230
+ expect(result.current.selectedRoadmap?.title).toBe("Updated Title");
231
+ });
232
+ });
233
+
234
+ it("deletes a roadmap and removes it from the list", async () => {
235
+ (api.deleteRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
236
+
237
+ const { result } = renderHook(() => useRoadmaps());
238
+
239
+ await waitFor(() => {
240
+ expect(result.current.loading).toBe(false);
241
+ });
242
+
243
+ const onSuccess = vi.fn();
244
+ await result.current.deleteRoadmap("RM-001", { onSuccess });
245
+
246
+ expect(api.deleteRoadmap).toHaveBeenCalledWith("RM-001", undefined);
247
+ await waitFor(() => {
248
+ expect(result.current.roadmaps.find((r) => r.id === "RM-001")).toBeUndefined();
249
+ });
250
+ expect(onSuccess).toHaveBeenCalled();
251
+ });
252
+
253
+ it("clears selected roadmap when deleting the selected roadmap", async () => {
254
+ (api.deleteRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
255
+
256
+ const { result } = renderHook(() => useRoadmaps());
257
+
258
+ await waitFor(() => {
259
+ expect(result.current.loading).toBe(false);
260
+ });
261
+
262
+ result.current.selectRoadmap("RM-001");
263
+ await waitFor(() => {
264
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
265
+ });
266
+
267
+ await result.current.deleteRoadmap("RM-001");
268
+
269
+ await waitFor(() => {
270
+ expect(result.current.selectedRoadmapId).toBeNull();
271
+ });
272
+ expect(result.current.selectedRoadmap).toBeNull();
273
+ });
274
+
275
+ it("creates a milestone in the selected roadmap", async () => {
276
+ const newMilestone = {
277
+ id: "RMS-003",
278
+ roadmapId: "RM-001",
279
+ title: "New Milestone",
280
+ description: "A new milestone",
281
+ orderIndex: 2,
282
+ createdAt: "2026-01-03T00:00:00.000Z",
283
+ updatedAt: "2026-01-03T00:00:00.000Z",
284
+ };
285
+ (api.createRoadmapMilestone as ReturnType<typeof vi.fn>).mockResolvedValue(newMilestone);
286
+
287
+ const { result } = renderHook(() => useRoadmaps());
288
+
289
+ await waitFor(() => {
290
+ expect(result.current.loading).toBe(false);
291
+ });
292
+
293
+ result.current.selectRoadmap("RM-001");
294
+ await waitFor(() => {
295
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
296
+ });
297
+
298
+ const onSuccess = vi.fn();
299
+ await result.current.createMilestone({ title: "New Milestone", description: "A new milestone" }, { onSuccess });
300
+
301
+ expect(api.createRoadmapMilestone).toHaveBeenCalledWith(
302
+ "RM-001",
303
+ { title: "New Milestone", description: "A new milestone" },
304
+ undefined
305
+ );
306
+ expect(onSuccess).toHaveBeenCalled();
307
+ // Should trigger refresh
308
+ expect(api.fetchRoadmap).toHaveBeenCalled();
309
+ });
310
+
311
+ it("throws error when creating milestone without selected roadmap", async () => {
312
+ const { result } = renderHook(() => useRoadmaps());
313
+
314
+ await waitFor(() => {
315
+ expect(result.current.loading).toBe(false);
316
+ });
317
+
318
+ const onError = vi.fn();
319
+ await expect(
320
+ result.current.createMilestone({ title: "New Milestone" }, { onError })
321
+ ).rejects.toThrow("No roadmap selected");
322
+ expect(onError).toHaveBeenCalled();
323
+ });
324
+
325
+ it("updates a milestone and refreshes", async () => {
326
+ const updatedMilestone = { ...mockRoadmapHierarchy.milestones[0], title: "Updated Milestone" };
327
+ (api.updateRoadmapMilestone as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMilestone);
328
+
329
+ const { result } = renderHook(() => useRoadmaps());
330
+
331
+ await waitFor(() => {
332
+ expect(result.current.loading).toBe(false);
333
+ });
334
+
335
+ result.current.selectRoadmap("RM-001");
336
+ await waitFor(() => {
337
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
338
+ });
339
+
340
+ const onSuccess = vi.fn();
341
+ await result.current.updateMilestone("RMS-001", { title: "Updated Milestone" }, { onSuccess });
342
+
343
+ expect(api.updateRoadmapMilestone).toHaveBeenCalledWith("RMS-001", { title: "Updated Milestone" }, undefined);
344
+ expect(onSuccess).toHaveBeenCalled();
345
+ expect(api.fetchRoadmap).toHaveBeenCalled();
346
+ });
347
+
348
+ it("deletes a milestone and removes it from state", async () => {
349
+ (api.deleteRoadmapMilestone as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
350
+
351
+ const { result } = renderHook(() => useRoadmaps());
352
+
353
+ await waitFor(() => {
354
+ expect(result.current.loading).toBe(false);
355
+ });
356
+
357
+ result.current.selectRoadmap("RM-001");
358
+ await waitFor(() => {
359
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
360
+ });
361
+
362
+ const onSuccess = vi.fn();
363
+ await result.current.deleteMilestone("RMS-001", { onSuccess });
364
+
365
+ expect(api.deleteRoadmapMilestone).toHaveBeenCalledWith("RMS-001", undefined);
366
+ expect(onSuccess).toHaveBeenCalled();
367
+ expect(api.fetchRoadmap).toHaveBeenCalled();
368
+ });
369
+
370
+ it("creates a feature in a milestone", async () => {
371
+ const newFeature = {
372
+ id: "RF-002",
373
+ milestoneId: "RMS-001",
374
+ title: "New Feature",
375
+ description: "A new feature",
376
+ orderIndex: 1,
377
+ createdAt: "2026-01-03T00:00:00.000Z",
378
+ updatedAt: "2026-01-03T00:00:00.000Z",
379
+ };
380
+ (api.createRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(newFeature);
381
+
382
+ const { result } = renderHook(() => useRoadmaps());
383
+
384
+ await waitFor(() => {
385
+ expect(result.current.loading).toBe(false);
386
+ });
387
+
388
+ result.current.selectRoadmap("RM-001");
389
+ await waitFor(() => {
390
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
391
+ });
392
+
393
+ const onSuccess = vi.fn();
394
+ await result.current.createFeature("RMS-001", { title: "New Feature", description: "A new feature" }, { onSuccess });
395
+
396
+ expect(api.createRoadmapFeature).toHaveBeenCalledWith(
397
+ "RMS-001",
398
+ { title: "New Feature", description: "A new feature" },
399
+ undefined
400
+ );
401
+ expect(onSuccess).toHaveBeenCalled();
402
+ expect(api.fetchRoadmap).toHaveBeenCalled();
403
+ });
404
+
405
+ it("updates a feature and refreshes", async () => {
406
+ const updatedFeature = {
407
+ ...mockRoadmapHierarchy.milestones[0].features[0],
408
+ title: "Updated Feature",
409
+ };
410
+ (api.updateRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(updatedFeature);
411
+
412
+ const { result } = renderHook(() => useRoadmaps());
413
+
414
+ await waitFor(() => {
415
+ expect(result.current.loading).toBe(false);
416
+ });
417
+
418
+ result.current.selectRoadmap("RM-001");
419
+ await waitFor(() => {
420
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
421
+ });
422
+
423
+ const onSuccess = vi.fn();
424
+ await result.current.updateFeature("RF-001", { title: "Updated Feature" }, { onSuccess });
425
+
426
+ expect(api.updateRoadmapFeature).toHaveBeenCalledWith("RF-001", { title: "Updated Feature" }, undefined);
427
+ expect(onSuccess).toHaveBeenCalled();
428
+ expect(api.fetchRoadmap).toHaveBeenCalled();
429
+ });
430
+
431
+ it("deletes a feature and refreshes", async () => {
432
+ (api.deleteRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
433
+
434
+ const { result } = renderHook(() => useRoadmaps());
435
+
436
+ await waitFor(() => {
437
+ expect(result.current.loading).toBe(false);
438
+ });
439
+
440
+ result.current.selectRoadmap("RM-001");
441
+ await waitFor(() => {
442
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
443
+ });
444
+
445
+ const onSuccess = vi.fn();
446
+ await result.current.deleteFeature("RF-001", { onSuccess });
447
+
448
+ expect(api.deleteRoadmapFeature).toHaveBeenCalledWith("RF-001", undefined);
449
+ expect(onSuccess).toHaveBeenCalled();
450
+ expect(api.fetchRoadmap).toHaveBeenCalled();
451
+ });
452
+
453
+ it("surfaces error state when fetch fails", async () => {
454
+ const fetchError = new Error("Network error");
455
+ (api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockRejectedValue(fetchError);
456
+
457
+ const { result } = renderHook(() => useRoadmaps());
458
+
459
+ await waitFor(() => {
460
+ expect(result.current.loading).toBe(false);
461
+ });
462
+
463
+ expect(result.current.error).toEqual(fetchError);
464
+ });
465
+
466
+ it("calls onError callback when CRUD operation fails", async () => {
467
+ const apiError = new Error("API error");
468
+ (api.createRoadmap as ReturnType<typeof vi.fn>).mockRejectedValue(apiError);
469
+
470
+ const { result } = renderHook(() => useRoadmaps());
471
+
472
+ await waitFor(() => {
473
+ expect(result.current.loading).toBe(false);
474
+ });
475
+
476
+ const onError = vi.fn();
477
+ await expect(
478
+ result.current.createRoadmap({ title: "Test" }, { onError })
479
+ ).rejects.toThrow("API error");
480
+ expect(onError).toHaveBeenCalledWith(apiError);
481
+ });
482
+
483
+ it("refreshes roadmaps and selected roadmap", async () => {
484
+ const { result } = renderHook(() => useRoadmaps());
485
+
486
+ await waitFor(() => {
487
+ expect(result.current.loading).toBe(false);
488
+ });
489
+
490
+ result.current.selectRoadmap("RM-001");
491
+ await waitFor(() => {
492
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
493
+ });
494
+
495
+ await result.current.refresh();
496
+
497
+ expect(api.fetchRoadmaps).toHaveBeenCalled();
498
+ expect(api.fetchRoadmap).toHaveBeenCalledWith("RM-001", undefined);
499
+ });
500
+
501
+ describe("reorderMilestones", () => {
502
+ it("reorders milestones and refreshes", async () => {
503
+ (api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
504
+
505
+ const { result } = renderHook(() => useRoadmaps());
506
+
507
+ await waitFor(() => {
508
+ expect(result.current.loading).toBe(false);
509
+ });
510
+
511
+ result.current.selectRoadmap("RM-001");
512
+ await waitFor(() => {
513
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
514
+ });
515
+
516
+ // Reorder milestones: swap RMS-001 and RMS-002
517
+ await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"]);
518
+
519
+ expect(api.reorderRoadmapMilestones).toHaveBeenCalledWith(
520
+ "RM-001",
521
+ ["RMS-002", "RMS-001"],
522
+ undefined
523
+ );
524
+ // Should refresh to get server state
525
+ expect(api.fetchRoadmap).toHaveBeenCalled();
526
+ });
527
+
528
+ it("rolls back on failure and calls onError", async () => {
529
+ const reorderError = new Error("Reorder failed");
530
+ (api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
531
+
532
+ const { result } = renderHook(() => useRoadmaps());
533
+
534
+ await waitFor(() => {
535
+ expect(result.current.loading).toBe(false);
536
+ });
537
+
538
+ result.current.selectRoadmap("RM-001");
539
+ await waitFor(() => {
540
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
541
+ });
542
+
543
+ const initialMilestones = result.current.milestones;
544
+ const onError = vi.fn();
545
+
546
+ try {
547
+ await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"], { onError });
548
+ } catch {
549
+ // Expected to throw
550
+ }
551
+
552
+ expect(onError).toHaveBeenCalledWith(reorderError);
553
+ // State should be rolled back
554
+ expect(result.current.milestones).toEqual(initialMilestones);
555
+ });
556
+
557
+ it("sends correct payload shape for reorder", async () => {
558
+ (api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
559
+
560
+ const { result } = renderHook(() => useRoadmaps());
561
+
562
+ await waitFor(() => {
563
+ expect(result.current.loading).toBe(false);
564
+ });
565
+
566
+ result.current.selectRoadmap("RM-001");
567
+ await waitFor(() => {
568
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
569
+ });
570
+
571
+ await result.current.reorderMilestones("RM-001", ["RMS-001", "RMS-002"]);
572
+
573
+ // Verify the payload shape
574
+ expect(api.reorderRoadmapMilestones).toHaveBeenCalledTimes(1);
575
+ const call = (api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mock.calls[0];
576
+ expect(call[0]).toBe("RM-001");
577
+ expect(Array.isArray(call[1])).toBe(true);
578
+ expect(call[1]).toHaveLength(2);
579
+ });
580
+ });
581
+
582
+ describe("reorderFeatures", () => {
583
+ it("reorders features within a milestone with optimistic update", async () => {
584
+ // This test requires multiple features to meaningfully test reordering
585
+ // We'll create a custom hierarchy with multiple features
586
+ const multiFeatureHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
587
+ ...mockRoadmapHierarchy,
588
+ milestones: [
589
+ {
590
+ ...mockRoadmapHierarchy.milestones[0],
591
+ features: [
592
+ { 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" },
593
+ { 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" },
594
+ ],
595
+ },
596
+ mockRoadmapHierarchy.milestones[1],
597
+ ],
598
+ };
599
+
600
+ (api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
601
+ (api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
602
+
603
+ const { result } = renderHook(() => useRoadmaps());
604
+
605
+ await waitFor(() => {
606
+ expect(result.current.loading).toBe(false);
607
+ });
608
+
609
+ result.current.selectRoadmap("RM-001");
610
+ await waitFor(() => {
611
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
612
+ });
613
+
614
+ // Reorder features in RMS-001: swap RF-001 and RF-002
615
+ await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"]);
616
+
617
+ expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith(
618
+ "RMS-001",
619
+ ["RF-002", "RF-001"],
620
+ undefined
621
+ );
622
+ expect(api.fetchRoadmap).toHaveBeenCalled();
623
+ });
624
+
625
+ it("rolls back on failure", async () => {
626
+ // This test requires multiple features
627
+ const multiFeatureHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
628
+ ...mockRoadmapHierarchy,
629
+ milestones: [
630
+ {
631
+ ...mockRoadmapHierarchy.milestones[0],
632
+ features: [
633
+ { 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" },
634
+ { 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" },
635
+ ],
636
+ },
637
+ mockRoadmapHierarchy.milestones[1],
638
+ ],
639
+ };
640
+
641
+ (api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
642
+ const reorderError = new Error("Feature reorder failed");
643
+ (api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
644
+
645
+ const { result } = renderHook(() => useRoadmaps());
646
+
647
+ await waitFor(() => {
648
+ expect(result.current.loading).toBe(false);
649
+ });
650
+
651
+ result.current.selectRoadmap("RM-001");
652
+ await waitFor(() => {
653
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
654
+ });
655
+
656
+ const initialFeatures = result.current.featuresByMilestoneId["RMS-001"];
657
+ const onError = vi.fn();
658
+
659
+ try {
660
+ await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError });
661
+ } catch {
662
+ // Expected to throw
663
+ }
664
+
665
+ expect(onError).toHaveBeenCalledWith(reorderError);
666
+ expect(result.current.featuresByMilestoneId["RMS-001"]).toEqual(initialFeatures);
667
+ });
668
+ });
669
+
670
+ describe("moveFeature", () => {
671
+ it("moves a feature to a different milestone", async () => {
672
+ (api.moveRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
673
+
674
+ const { result } = renderHook(() => useRoadmaps());
675
+
676
+ await waitFor(() => {
677
+ expect(result.current.loading).toBe(false);
678
+ });
679
+
680
+ result.current.selectRoadmap("RM-001");
681
+ await waitFor(() => {
682
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
683
+ });
684
+
685
+ // Move RF-001 from RMS-001 to RMS-002 at index 0
686
+ await result.current.moveFeature("RF-001", "RMS-002", 0);
687
+
688
+ expect(api.moveRoadmapFeature).toHaveBeenCalledWith(
689
+ "RF-001",
690
+ "RMS-002",
691
+ 0,
692
+ undefined
693
+ );
694
+ expect(api.fetchRoadmap).toHaveBeenCalled();
695
+ });
696
+
697
+ it("rolls back on failure", async () => {
698
+ const moveError = new Error("Move failed");
699
+ (api.moveRoadmapFeature as ReturnType<typeof vi.fn>).mockRejectedValue(moveError);
700
+
701
+ const { result } = renderHook(() => useRoadmaps());
702
+
703
+ await waitFor(() => {
704
+ expect(result.current.loading).toBe(false);
705
+ });
706
+
707
+ result.current.selectRoadmap("RM-001");
708
+ await waitFor(() => {
709
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
710
+ });
711
+
712
+ const initialFeaturesByMilestoneId = result.current.featuresByMilestoneId;
713
+ const onError = vi.fn();
714
+
715
+ try {
716
+ await result.current.moveFeature("RF-001", "RMS-002", 0, { onError });
717
+ } catch {
718
+ // Expected to throw
719
+ }
720
+
721
+ expect(onError).toHaveBeenCalledWith(moveError);
722
+ expect(result.current.featuresByMilestoneId).toEqual(initialFeaturesByMilestoneId);
723
+ });
724
+
725
+ it("throws when feature not found", async () => {
726
+ const { result } = renderHook(() => useRoadmaps());
727
+
728
+ await waitFor(() => {
729
+ expect(result.current.loading).toBe(false);
730
+ });
731
+
732
+ result.current.selectRoadmap("RM-001");
733
+ await waitFor(() => {
734
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
735
+ });
736
+
737
+ const onError = vi.fn();
738
+ await expect(
739
+ result.current.moveFeature("NONEXISTENT", "RMS-002", 0, { onError })
740
+ ).rejects.toThrow("Feature not found");
741
+ });
742
+ });
743
+
744
+ describe("Feature suggestions", () => {
745
+ it("generates feature suggestions for a milestone with stable draft IDs", async () => {
746
+ const mockSuggestions = [
747
+ { title: "Feature 1", description: "Description 1" },
748
+ { title: "Feature 2", description: "Description 2" },
749
+ ];
750
+
751
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
752
+ suggestions: mockSuggestions,
753
+ });
754
+
755
+ const { result } = renderHook(() => useRoadmaps());
756
+
757
+ await waitFor(() => {
758
+ expect(result.current.loading).toBe(false);
759
+ });
760
+
761
+ result.current.selectRoadmap("RM-001");
762
+ await waitFor(() => {
763
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
764
+ });
765
+
766
+ await result.current.generateFeatureSuggestions("RMS-001", { count: 5 });
767
+
768
+ await waitFor(() => {
769
+ const suggestions = result.current.featureSuggestionsByMilestoneId["RMS-001"];
770
+ expect(suggestions).toHaveLength(2);
771
+ expect(suggestions![0].title).toBe("Feature 1");
772
+ expect(suggestions![1].title).toBe("Feature 2");
773
+ // Verify stable draft IDs exist
774
+ expect(suggestions![0].id).toBeDefined();
775
+ expect(suggestions![1].id).toBeDefined();
776
+ expect(suggestions![0].id).not.toBe(suggestions![1].id);
777
+ });
778
+
779
+ expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
780
+ "RMS-001",
781
+ { count: 5 },
782
+ undefined
783
+ );
784
+ });
785
+
786
+ it("generates feature suggestions with prompt", async () => {
787
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
788
+ suggestions: [{ title: "Auth Feature" }],
789
+ });
790
+
791
+ const { result } = renderHook(() => useRoadmaps());
792
+
793
+ await waitFor(() => {
794
+ expect(result.current.loading).toBe(false);
795
+ });
796
+
797
+ await result.current.generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
798
+
799
+ expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
800
+ "RMS-001",
801
+ { prompt: "Focus on auth", count: 3 },
802
+ undefined
803
+ );
804
+ });
805
+
806
+ it("editing a draft changes the persisted value after accept-one", async () => {
807
+ const mockFeature = {
808
+ id: "RF-NEW",
809
+ milestoneId: "RMS-001",
810
+ title: "Edited Feature Title",
811
+ description: "Edited description",
812
+ orderIndex: 0,
813
+ createdAt: "2026-01-01T00:00:00.000Z",
814
+ updatedAt: "2026-01-01T00:00:00.000Z",
815
+ };
816
+
817
+ (api.createRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(mockFeature);
818
+
819
+ const { result } = renderHook(() => useRoadmaps());
820
+
821
+ await waitFor(() => {
822
+ expect(result.current.loading).toBe(false);
823
+ });
824
+
825
+ result.current.selectRoadmap("RM-001");
826
+ await waitFor(() => {
827
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
828
+ });
829
+
830
+ // Generate suggestions
831
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
832
+ suggestions: [{ title: "Original Title", description: "Original description" }],
833
+ });
834
+
835
+ await result.current.generateFeatureSuggestions("RMS-001");
836
+
837
+ await waitFor(() => {
838
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
839
+ });
840
+
841
+ // Get the draft ID
842
+ const draftId = result.current.featureSuggestionsByMilestoneId["RMS-001"][0].id;
843
+
844
+ // Edit the draft
845
+ result.current.updateFeatureSuggestionDraft("RMS-001", draftId, {
846
+ title: "Edited Feature Title",
847
+ description: "Edited description",
848
+ });
849
+
850
+ // Verify the draft is updated
851
+ await waitFor(() => {
852
+ const suggestion = result.current.featureSuggestionsByMilestoneId["RMS-001"][0];
853
+ expect(suggestion.title).toBe("Edited Feature Title");
854
+ expect(suggestion.description).toBe("Edited description");
855
+ });
856
+
857
+ // Accept the suggestion - should use the edited values
858
+ await result.current.acceptFeatureSuggestion("RMS-001", draftId);
859
+
860
+ // API should be called with the edited values
861
+ expect(api.createRoadmapFeature).toHaveBeenCalledWith(
862
+ "RMS-001",
863
+ { title: "Edited Feature Title", description: "Edited description" },
864
+ undefined
865
+ );
866
+ });
867
+
868
+ it("mixed edited drafts persist in the same order on accept-all", async () => {
869
+ const mockFeatures = [
870
+ {
871
+ id: "RF-NEW-1",
872
+ milestoneId: "RMS-001",
873
+ title: "Edited Title 1",
874
+ orderIndex: 0,
875
+ createdAt: "2026-01-01T00:00:00.000Z",
876
+ updatedAt: "2026-01-01T00:00:00.000Z",
877
+ },
878
+ {
879
+ id: "RF-NEW-2",
880
+ milestoneId: "RMS-001",
881
+ title: "Title 2",
882
+ orderIndex: 1,
883
+ createdAt: "2026-01-01T00:00:00.000Z",
884
+ updatedAt: "2026-01-01T00:00:00.000Z",
885
+ },
886
+ ];
887
+
888
+ (api.createRoadmapFeature as ReturnType<typeof vi.fn>)
889
+ .mockResolvedValueOnce(mockFeatures[0])
890
+ .mockResolvedValueOnce(mockFeatures[1]);
891
+
892
+ const { result } = renderHook(() => useRoadmaps());
893
+
894
+ await waitFor(() => {
895
+ expect(result.current.loading).toBe(false);
896
+ });
897
+
898
+ result.current.selectRoadmap("RM-001");
899
+ await waitFor(() => {
900
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
901
+ });
902
+
903
+ // Set suggestions
904
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
905
+ suggestions: [
906
+ { title: "Original Title 1" },
907
+ { title: "Original Title 2" },
908
+ ],
909
+ });
910
+
911
+ await result.current.generateFeatureSuggestions("RMS-001");
912
+
913
+ await waitFor(() => {
914
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
915
+ });
916
+
917
+ // Get draft IDs
918
+ const suggestions = result.current.featureSuggestionsByMilestoneId["RMS-001"];
919
+ const draftId1 = suggestions[0].id;
920
+ const draftId2 = suggestions[1].id;
921
+
922
+ // Edit the first draft
923
+ result.current.updateFeatureSuggestionDraft("RMS-001", draftId1, {
924
+ title: "Edited Title 1",
925
+ });
926
+
927
+ // Accept all
928
+ await result.current.acceptAllFeatureSuggestions("RMS-001");
929
+
930
+ // Verify sequential calls with edited value for first suggestion
931
+ expect(api.createRoadmapFeature).toHaveBeenCalledTimes(2);
932
+ expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
933
+ 1,
934
+ "RMS-001",
935
+ { title: "Edited Title 1", description: undefined },
936
+ undefined
937
+ );
938
+ expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
939
+ 2,
940
+ "RMS-001",
941
+ { title: "Original Title 2", description: undefined },
942
+ undefined
943
+ );
944
+ });
945
+
946
+ it("clears feature suggestions for a milestone", async () => {
947
+ const { result } = renderHook(() => useRoadmaps());
948
+
949
+ await waitFor(() => {
950
+ expect(result.current.loading).toBe(false);
951
+ });
952
+
953
+ result.current.selectRoadmap("RM-001");
954
+ await waitFor(() => {
955
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
956
+ });
957
+
958
+ // Set suggestions
959
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
960
+ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }],
961
+ });
962
+
963
+ await result.current.generateFeatureSuggestions("RMS-001");
964
+
965
+ await waitFor(() => {
966
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
967
+ });
968
+
969
+ // Clear suggestions
970
+ result.current.clearFeatureSuggestions("RMS-001");
971
+
972
+ await waitFor(() => {
973
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
974
+ });
975
+ });
976
+
977
+ it("is isolated per milestone", async () => {
978
+ const { result } = renderHook(() => useRoadmaps());
979
+
980
+ await waitFor(() => {
981
+ expect(result.current.loading).toBe(false);
982
+ });
983
+
984
+ result.current.selectRoadmap("RM-001");
985
+ await waitFor(() => {
986
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
987
+ });
988
+
989
+ // Set suggestions for milestone 1
990
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
991
+ suggestions: [{ title: "MS1 Feature" }],
992
+ });
993
+
994
+ await result.current.generateFeatureSuggestions("RMS-001");
995
+
996
+ await waitFor(() => {
997
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
998
+ });
999
+
1000
+ // Set suggestions for milestone 2
1001
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
1002
+ suggestions: [{ title: "MS2 Feature" }],
1003
+ });
1004
+
1005
+ await result.current.generateFeatureSuggestions("RMS-002");
1006
+
1007
+ await waitFor(() => {
1008
+ // Verify suggestions are isolated
1009
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
1010
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"][0].title).toBe("MS1 Feature");
1011
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-002"]).toHaveLength(1);
1012
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-002"][0].title).toBe("MS2 Feature");
1013
+ });
1014
+ });
1015
+
1016
+ it("returns correct loading state for feature suggestions", async () => {
1017
+ let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void;
1018
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockImplementation(() => {
1019
+ return new Promise((resolve) => {
1020
+ resolveGenerate = resolve;
1021
+ });
1022
+ });
1023
+
1024
+ const { result } = renderHook(() => useRoadmaps());
1025
+
1026
+ await waitFor(() => {
1027
+ expect(result.current.loading).toBe(false);
1028
+ });
1029
+
1030
+ // Start generating
1031
+ const generatePromise = result.current.generateFeatureSuggestions("RMS-001");
1032
+
1033
+ // Wait for loading state to update
1034
+ await waitFor(() => {
1035
+ expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(true);
1036
+ });
1037
+
1038
+ // Complete generation
1039
+ resolveGenerate!({ suggestions: [{ title: "Feature" }] });
1040
+ await generatePromise;
1041
+
1042
+ await waitFor(() => {
1043
+ // Check loading state is false
1044
+ expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(false);
1045
+ });
1046
+ });
1047
+
1048
+ it("clears feature suggestions when project changes", async () => {
1049
+ const { result, rerender } = renderHook(
1050
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1051
+ { initialProps: { projectId: "proj-1" } }
1052
+ );
1053
+
1054
+ await waitFor(() => {
1055
+ expect(result.current.loading).toBe(false);
1056
+ });
1057
+
1058
+ result.current.selectRoadmap("RM-001");
1059
+ await waitFor(() => {
1060
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1061
+ });
1062
+
1063
+ // Set suggestions
1064
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1065
+ suggestions: [{ title: "Feature" }],
1066
+ });
1067
+
1068
+ await result.current.generateFeatureSuggestions("RMS-001");
1069
+
1070
+ await waitFor(() => {
1071
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
1072
+ });
1073
+
1074
+ // Change project
1075
+ rerender({ projectId: "proj-2" });
1076
+
1077
+ // Suggestions should be cleared
1078
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
1079
+ });
1080
+
1081
+ it("stale async suggestion responses are ignored after project change", async () => {
1082
+ const { result, rerender } = renderHook(
1083
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1084
+ { initialProps: { projectId: "proj-1" } }
1085
+ );
1086
+
1087
+ await waitFor(() => {
1088
+ expect(result.current.loading).toBe(false);
1089
+ });
1090
+
1091
+ result.current.selectRoadmap("RM-001");
1092
+ await waitFor(() => {
1093
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1094
+ });
1095
+
1096
+ // Set up a slow-responding mock
1097
+ let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void;
1098
+ (api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockImplementation(() => {
1099
+ return new Promise((resolve) => {
1100
+ resolveGenerate = resolve;
1101
+ });
1102
+ });
1103
+
1104
+ // Start generating
1105
+ const generatePromise = result.current.generateFeatureSuggestions("RMS-001");
1106
+
1107
+ // Change project before the promise resolves
1108
+ rerender({ projectId: "proj-2" });
1109
+
1110
+ // Resolve the promise - should be ignored
1111
+ resolveGenerate!({ suggestions: [{ title: "Stale Feature" }] });
1112
+ await generatePromise;
1113
+
1114
+ // Suggestions should NOT be set for the old project
1115
+ // (Since we're now in project "proj-2", the stale response should be ignored)
1116
+ expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
1117
+ });
1118
+ });
1119
+
1120
+ describe("Milestone suggestions", () => {
1121
+ it("generates milestone suggestions with stable draft IDs", async () => {
1122
+ const mockSuggestions = [
1123
+ { title: "Milestone 1", description: "Description 1" },
1124
+ { title: "Milestone 2", description: "Description 2" },
1125
+ ];
1126
+
1127
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1128
+ suggestions: mockSuggestions,
1129
+ });
1130
+
1131
+ const { result } = renderHook(() => useRoadmaps());
1132
+
1133
+ await waitFor(() => {
1134
+ expect(result.current.loading).toBe(false);
1135
+ });
1136
+
1137
+ result.current.selectRoadmap("RM-001");
1138
+ await waitFor(() => {
1139
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1140
+ });
1141
+
1142
+ await result.current.generateMilestoneSuggestions("Build an app", 5);
1143
+
1144
+ await waitFor(() => {
1145
+ const suggestions = result.current.milestoneSuggestions;
1146
+ expect(suggestions).toHaveLength(2);
1147
+ expect(suggestions[0].title).toBe("Milestone 1");
1148
+ expect(suggestions[1].title).toBe("Milestone 2");
1149
+ // Verify stable draft IDs exist
1150
+ expect(suggestions[0].id).toBeDefined();
1151
+ expect(suggestions[1].id).toBeDefined();
1152
+ expect(suggestions[0].id).not.toBe(suggestions[1].id);
1153
+ });
1154
+ });
1155
+
1156
+ it("editing a draft changes the persisted value after accept-one", async () => {
1157
+ const mockMilestone = {
1158
+ id: "RMS-NEW",
1159
+ roadmapId: "RM-001",
1160
+ title: "Edited Milestone Title",
1161
+ description: "Edited description",
1162
+ orderIndex: 0,
1163
+ createdAt: "2026-01-01T00:00:00.000Z",
1164
+ updatedAt: "2026-01-01T00:00:00.000Z",
1165
+ };
1166
+
1167
+ (api.createRoadmapMilestone as ReturnType<typeof vi.fn>).mockResolvedValue(mockMilestone);
1168
+
1169
+ const { result } = renderHook(() => useRoadmaps());
1170
+
1171
+ await waitFor(() => {
1172
+ expect(result.current.loading).toBe(false);
1173
+ });
1174
+
1175
+ result.current.selectRoadmap("RM-001");
1176
+ await waitFor(() => {
1177
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1178
+ });
1179
+
1180
+ // Generate suggestions
1181
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1182
+ suggestions: [{ title: "Original Title", description: "Original description" }],
1183
+ });
1184
+
1185
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1186
+
1187
+ await waitFor(() => {
1188
+ expect(result.current.milestoneSuggestions).toHaveLength(1);
1189
+ });
1190
+
1191
+ // Get the draft ID
1192
+ const draftId = result.current.milestoneSuggestions[0].id;
1193
+
1194
+ // Edit the draft
1195
+ result.current.updateMilestoneSuggestionDraft(draftId, {
1196
+ title: "Edited Milestone Title",
1197
+ description: "Edited description",
1198
+ });
1199
+
1200
+ // Verify the draft is updated
1201
+ await waitFor(() => {
1202
+ const suggestion = result.current.milestoneSuggestions[0];
1203
+ expect(suggestion.title).toBe("Edited Milestone Title");
1204
+ expect(suggestion.description).toBe("Edited description");
1205
+ });
1206
+
1207
+ // Accept the suggestion - should use the edited values
1208
+ await result.current.acceptMilestoneSuggestion(draftId);
1209
+
1210
+ // API should be called with the edited values
1211
+ expect(api.createRoadmapMilestone).toHaveBeenCalledWith(
1212
+ "RM-001",
1213
+ { title: "Edited Milestone Title", description: "Edited description" },
1214
+ undefined
1215
+ );
1216
+ });
1217
+
1218
+ it("mixed edited drafts persist in the same order on accept-all", async () => {
1219
+ const mockMilestones = [
1220
+ {
1221
+ id: "RMS-NEW-1",
1222
+ roadmapId: "RM-001",
1223
+ title: "Edited Title 1",
1224
+ orderIndex: 0,
1225
+ createdAt: "2026-01-01T00:00:00.000Z",
1226
+ updatedAt: "2026-01-01T00:00:00.000Z",
1227
+ },
1228
+ {
1229
+ id: "RMS-NEW-2",
1230
+ roadmapId: "RM-001",
1231
+ title: "Title 2",
1232
+ orderIndex: 1,
1233
+ createdAt: "2026-01-01T00:00:00.000Z",
1234
+ updatedAt: "2026-01-01T00:00:00.000Z",
1235
+ },
1236
+ ];
1237
+
1238
+ (api.createRoadmapMilestone as ReturnType<typeof vi.fn>)
1239
+ .mockResolvedValueOnce(mockMilestones[0])
1240
+ .mockResolvedValueOnce(mockMilestones[1]);
1241
+
1242
+ const { result } = renderHook(() => useRoadmaps());
1243
+
1244
+ await waitFor(() => {
1245
+ expect(result.current.loading).toBe(false);
1246
+ });
1247
+
1248
+ result.current.selectRoadmap("RM-001");
1249
+ await waitFor(() => {
1250
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1251
+ });
1252
+
1253
+ // Set suggestions
1254
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1255
+ suggestions: [
1256
+ { title: "Original Title 1" },
1257
+ { title: "Original Title 2" },
1258
+ ],
1259
+ });
1260
+
1261
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1262
+
1263
+ await waitFor(() => {
1264
+ expect(result.current.milestoneSuggestions).toHaveLength(2);
1265
+ });
1266
+
1267
+ // Get draft IDs
1268
+ const suggestions = result.current.milestoneSuggestions;
1269
+ const draftId1 = suggestions[0].id;
1270
+ const draftId2 = suggestions[1].id;
1271
+
1272
+ // Edit the first draft
1273
+ result.current.updateMilestoneSuggestionDraft(draftId1, {
1274
+ title: "Edited Title 1",
1275
+ });
1276
+
1277
+ // Accept all
1278
+ await result.current.acceptAllMilestoneSuggestions();
1279
+
1280
+ // Verify sequential calls with edited value for first suggestion
1281
+ expect(api.createRoadmapMilestone).toHaveBeenCalledTimes(2);
1282
+ expect(api.createRoadmapMilestone).toHaveBeenNthCalledWith(
1283
+ 1,
1284
+ "RM-001",
1285
+ { title: "Edited Title 1", description: undefined },
1286
+ undefined
1287
+ );
1288
+ expect(api.createRoadmapMilestone).toHaveBeenNthCalledWith(
1289
+ 2,
1290
+ "RM-001",
1291
+ { title: "Original Title 2", description: undefined },
1292
+ undefined
1293
+ );
1294
+ });
1295
+
1296
+ it("clearing drafts removes only draft state (not already persisted milestones)", async () => {
1297
+ const { result } = renderHook(() => useRoadmaps());
1298
+
1299
+ await waitFor(() => {
1300
+ expect(result.current.loading).toBe(false);
1301
+ });
1302
+
1303
+ result.current.selectRoadmap("RM-001");
1304
+ await waitFor(() => {
1305
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1306
+ });
1307
+
1308
+ // Set suggestions
1309
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1310
+ suggestions: [{ title: "Suggestion 1" }, { title: "Suggestion 2" }],
1311
+ });
1312
+
1313
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1314
+
1315
+ await waitFor(() => {
1316
+ expect(result.current.milestoneSuggestions).toHaveLength(2);
1317
+ expect(result.current.milestones).toHaveLength(2); // Existing milestones from mock
1318
+ });
1319
+
1320
+ // Clear suggestions
1321
+ result.current.clearMilestoneSuggestions();
1322
+
1323
+ await waitFor(() => {
1324
+ expect(result.current.milestoneSuggestions).toHaveLength(0);
1325
+ // Existing milestones should still be there
1326
+ expect(result.current.milestones).toHaveLength(2);
1327
+ });
1328
+ });
1329
+
1330
+ it("stale async suggestion responses are ignored after project change", async () => {
1331
+ const { result, rerender } = renderHook(
1332
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1333
+ { initialProps: { projectId: "proj-1" } }
1334
+ );
1335
+
1336
+ await waitFor(() => {
1337
+ expect(result.current.loading).toBe(false);
1338
+ });
1339
+
1340
+ result.current.selectRoadmap("RM-001");
1341
+ await waitFor(() => {
1342
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1343
+ });
1344
+
1345
+ // Set up a slow-responding mock
1346
+ let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void;
1347
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockImplementation(() => {
1348
+ return new Promise((resolve) => {
1349
+ resolveGenerate = resolve;
1350
+ });
1351
+ });
1352
+
1353
+ // Start generating
1354
+ const generatePromise = result.current.generateMilestoneSuggestions("Build something", 5);
1355
+
1356
+ // Change project before the promise resolves
1357
+ rerender({ projectId: "proj-2" });
1358
+
1359
+ // Resolve the promise - should be ignored
1360
+ resolveGenerate!({ suggestions: [{ title: "Stale Milestone" }] });
1361
+ await generatePromise;
1362
+
1363
+ // Suggestions should NOT be set for the old project
1364
+ expect(result.current.milestoneSuggestions).toHaveLength(0);
1365
+ });
1366
+
1367
+ it("prevents acceptance of empty title", async () => {
1368
+ const { result } = renderHook(() => useRoadmaps());
1369
+
1370
+ await waitFor(() => {
1371
+ expect(result.current.loading).toBe(false);
1372
+ });
1373
+
1374
+ result.current.selectRoadmap("RM-001");
1375
+ await waitFor(() => {
1376
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1377
+ });
1378
+
1379
+ // Generate suggestions
1380
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1381
+ suggestions: [{ title: "Valid Title" }],
1382
+ });
1383
+
1384
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1385
+
1386
+ await waitFor(() => {
1387
+ expect(result.current.milestoneSuggestions).toHaveLength(1);
1388
+ });
1389
+
1390
+ const draftId = result.current.milestoneSuggestions[0].id;
1391
+
1392
+ // Edit to make title empty
1393
+ result.current.updateMilestoneSuggestionDraft(draftId, {
1394
+ title: "",
1395
+ });
1396
+
1397
+ // Try to accept - should fail
1398
+ const onError = vi.fn();
1399
+ await expect(
1400
+ result.current.acceptMilestoneSuggestion(draftId, { onError })
1401
+ ).rejects.toThrow("Title cannot be empty");
1402
+ expect(onError).toHaveBeenCalled();
1403
+ });
1404
+
1405
+ it("prevents acceptance of whitespace-only title", async () => {
1406
+ const { result } = renderHook(() => useRoadmaps());
1407
+
1408
+ await waitFor(() => {
1409
+ expect(result.current.loading).toBe(false);
1410
+ });
1411
+
1412
+ result.current.selectRoadmap("RM-001");
1413
+ await waitFor(() => {
1414
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1415
+ });
1416
+
1417
+ // Generate suggestions
1418
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1419
+ suggestions: [{ title: "Valid Title" }],
1420
+ });
1421
+
1422
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1423
+
1424
+ await waitFor(() => {
1425
+ expect(result.current.milestoneSuggestions).toHaveLength(1);
1426
+ });
1427
+
1428
+ const draftId = result.current.milestoneSuggestions[0].id;
1429
+
1430
+ // Edit to make title whitespace-only
1431
+ result.current.updateMilestoneSuggestionDraft(draftId, {
1432
+ title: " ",
1433
+ });
1434
+
1435
+ // Try to accept - should fail
1436
+ const onError = vi.fn();
1437
+ await expect(
1438
+ result.current.acceptMilestoneSuggestion(draftId, { onError })
1439
+ ).rejects.toThrow("Title cannot be empty");
1440
+ expect(onError).toHaveBeenCalled();
1441
+ });
1442
+
1443
+ it("accepts a single milestone suggestion by draftId", async () => {
1444
+ const mockMilestone = {
1445
+ id: "RMS-NEW",
1446
+ roadmapId: "RM-001",
1447
+ title: "New Milestone",
1448
+ orderIndex: 0,
1449
+ createdAt: "2026-01-01T00:00:00.000Z",
1450
+ updatedAt: "2026-01-01T00:00:00.000Z",
1451
+ };
1452
+
1453
+ (api.createRoadmapMilestone as ReturnType<typeof vi.fn>).mockResolvedValue(mockMilestone);
1454
+
1455
+ const { result } = renderHook(() => useRoadmaps());
1456
+
1457
+ await waitFor(() => {
1458
+ expect(result.current.loading).toBe(false);
1459
+ });
1460
+
1461
+ result.current.selectRoadmap("RM-001");
1462
+ await waitFor(() => {
1463
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1464
+ });
1465
+
1466
+ // Generate suggestions
1467
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1468
+ suggestions: [{ title: "New Milestone", description: "Description" }],
1469
+ });
1470
+
1471
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1472
+
1473
+ await waitFor(() => {
1474
+ expect(result.current.milestoneSuggestions).toHaveLength(1);
1475
+ });
1476
+
1477
+ const draftId = result.current.milestoneSuggestions[0].id;
1478
+
1479
+ // Accept the suggestion
1480
+ await result.current.acceptMilestoneSuggestion(draftId);
1481
+
1482
+ expect(api.createRoadmapMilestone).toHaveBeenCalledWith(
1483
+ "RM-001",
1484
+ { title: "New Milestone", description: "Description" },
1485
+ undefined
1486
+ );
1487
+ });
1488
+
1489
+ it("clears milestone suggestions when project changes", async () => {
1490
+ const { result, rerender } = renderHook(
1491
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1492
+ { initialProps: { projectId: "proj-1" } }
1493
+ );
1494
+
1495
+ await waitFor(() => {
1496
+ expect(result.current.loading).toBe(false);
1497
+ });
1498
+
1499
+ result.current.selectRoadmap("RM-001");
1500
+ await waitFor(() => {
1501
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1502
+ });
1503
+
1504
+ // Set suggestions
1505
+ (api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
1506
+ suggestions: [{ title: "Milestone" }],
1507
+ });
1508
+
1509
+ await result.current.generateMilestoneSuggestions("Build something", 5);
1510
+
1511
+ await waitFor(() => {
1512
+ expect(result.current.milestoneSuggestions).toHaveLength(1);
1513
+ });
1514
+
1515
+ // Change project
1516
+ rerender({ projectId: "proj-2" });
1517
+
1518
+ // Suggestions should be cleared
1519
+ expect(result.current.milestoneSuggestions).toHaveLength(0);
1520
+ });
1521
+ });
1522
+
1523
+ describe("Handoff / Export", () => {
1524
+ const mockHandoffPayload = {
1525
+ mission: {
1526
+ sourceRoadmapId: "RM-001",
1527
+ title: "Q2 Roadmap",
1528
+ description: "Q2 product roadmap",
1529
+ milestones: [
1530
+ {
1531
+ sourceMilestoneId: "RMS-001",
1532
+ title: "Milestone 1",
1533
+ description: "First milestone",
1534
+ orderIndex: 0,
1535
+ features: [
1536
+ { sourceFeatureId: "RF-001", title: "Feature 1", description: "First feature", orderIndex: 0 },
1537
+ ],
1538
+ },
1539
+ ],
1540
+ },
1541
+ features: [
1542
+ {
1543
+ source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "Q2 Roadmap", milestoneTitle: "Milestone 1", milestoneOrderIndex: 0, featureOrderIndex: 0 },
1544
+ title: "Feature 1",
1545
+ description: "First feature",
1546
+ },
1547
+ ],
1548
+ };
1549
+
1550
+ beforeEach(() => {
1551
+ // Reset fetchHandoff mock for each test
1552
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockReset();
1553
+ });
1554
+
1555
+ it("fetches handoff payload for a roadmap", async () => {
1556
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
1557
+
1558
+ const { result } = renderHook(() => useRoadmaps());
1559
+
1560
+ await waitFor(() => {
1561
+ expect(result.current.loading).toBe(false);
1562
+ });
1563
+
1564
+ // Clear any stale handoff state from previous tests
1565
+ result.current.clearHandoff();
1566
+
1567
+ result.current.selectRoadmap("RM-001");
1568
+ await waitFor(() => {
1569
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1570
+ });
1571
+
1572
+ await result.current.fetchHandoff("RM-001");
1573
+
1574
+ await waitFor(() => {
1575
+ expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
1576
+ expect(result.current.isFetchingHandoff).toBe(false);
1577
+ expect(result.current.handoffError).toBeNull();
1578
+ });
1579
+ });
1580
+
1581
+ it("handles fetchHandoff error", async () => {
1582
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
1583
+ new Error("Failed to fetch handoff")
1584
+ );
1585
+
1586
+ const { result } = renderHook(() => useRoadmaps());
1587
+
1588
+ await waitFor(() => {
1589
+ expect(result.current.loading).toBe(false);
1590
+ });
1591
+
1592
+ // Clear any stale handoff state
1593
+ result.current.clearHandoff();
1594
+
1595
+ await result.current.fetchHandoff("RM-001");
1596
+
1597
+ await waitFor(() => {
1598
+ expect(result.current.handoffError).toBeDefined();
1599
+ expect(result.current.handoffPayload).toBeNull();
1600
+ expect(result.current.isFetchingHandoff).toBe(false);
1601
+ });
1602
+ });
1603
+
1604
+ it("clears handoff state with clearHandoff", async () => {
1605
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
1606
+
1607
+ const { result, rerender } = renderHook(() => useRoadmaps());
1608
+
1609
+ await waitFor(() => {
1610
+ expect(result.current.loading).toBe(false);
1611
+ });
1612
+
1613
+ // Clear any stale handoff state
1614
+ result.current.clearHandoff();
1615
+
1616
+ await result.current.fetchHandoff("RM-001");
1617
+
1618
+ await waitFor(() => {
1619
+ expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
1620
+ });
1621
+
1622
+ // Call clearHandoff and rerender to get fresh state
1623
+ result.current.clearHandoff();
1624
+ rerender();
1625
+
1626
+ expect(result.current.handoffPayload).toBeNull();
1627
+ expect(result.current.handoffError).toBeNull();
1628
+ expect(result.current.isFetchingHandoff).toBe(false);
1629
+ });
1630
+
1631
+ it("clears handoff payload when project changes", async () => {
1632
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
1633
+
1634
+ const { result, rerender } = renderHook(
1635
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1636
+ { initialProps: { projectId: "proj-1" } }
1637
+ );
1638
+
1639
+ await waitFor(() => {
1640
+ expect(result.current.loading).toBe(false);
1641
+ });
1642
+
1643
+ // Clear any stale handoff state
1644
+ result.current.clearHandoff();
1645
+
1646
+ await result.current.fetchHandoff("RM-001");
1647
+
1648
+ await waitFor(() => {
1649
+ expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
1650
+ });
1651
+
1652
+ // Change project
1653
+ rerender({ projectId: "proj-2" });
1654
+
1655
+ // Handoff should be cleared
1656
+ expect(result.current.handoffPayload).toBeNull();
1657
+ expect(result.current.handoffError).toBeNull();
1658
+ });
1659
+
1660
+ it("sends correct projectId when fetching handoff", async () => {
1661
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
1662
+
1663
+ const { result } = renderHook(() => useRoadmaps({ projectId: "proj-test" }));
1664
+
1665
+ await waitFor(() => {
1666
+ expect(result.current.loading).toBe(false);
1667
+ });
1668
+
1669
+ // Clear any stale handoff state
1670
+ result.current.clearHandoff();
1671
+
1672
+ await result.current.fetchHandoff("RM-001");
1673
+
1674
+ await waitFor(() => {
1675
+ expect(api.fetchRoadmapHandoff).toHaveBeenCalledWith("RM-001", "proj-test");
1676
+ });
1677
+ });
1678
+
1679
+ it("does not set stale handoff response after project change", async () => {
1680
+ const { result, rerender } = renderHook(
1681
+ ({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
1682
+ { initialProps: { projectId: "proj-1" } }
1683
+ );
1684
+
1685
+ await waitFor(() => {
1686
+ expect(result.current.loading).toBe(false);
1687
+ });
1688
+
1689
+ // Clear any stale handoff state
1690
+ result.current.clearHandoff();
1691
+
1692
+ // Start fetch but don't resolve yet
1693
+ let resolveHandoff: ((value: typeof mockHandoffPayload) => void) | null = null;
1694
+ (api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
1695
+ return new Promise((resolve) => {
1696
+ resolveHandoff = resolve;
1697
+ });
1698
+ });
1699
+
1700
+ const fetchPromise = result.current.fetchHandoff("RM-001");
1701
+
1702
+ // Change project before promise resolves
1703
+ rerender({ projectId: "proj-2" });
1704
+
1705
+ // Resolve the promise
1706
+ expect(resolveHandoff).not.toBeNull();
1707
+ resolveHandoff?.(mockHandoffPayload);
1708
+ await fetchPromise;
1709
+
1710
+ // Handoff should NOT be set because we're in a different project now
1711
+ expect(result.current.handoffPayload).toBeNull();
1712
+ });
1713
+ });
1714
+
1715
+ describe("No-op suppression", () => {
1716
+ it("skips API call when reordering features to same order", async () => {
1717
+ const { result } = renderHook(() => useRoadmaps());
1718
+
1719
+ await waitFor(() => {
1720
+ expect(result.current.loading).toBe(false);
1721
+ });
1722
+
1723
+ result.current.selectRoadmap("RM-001");
1724
+ await waitFor(() => {
1725
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1726
+ });
1727
+
1728
+ // Try to reorder with same order as current
1729
+ const currentFeatureIds = result.current.featuresByMilestoneId["RMS-001"]?.map((f) => f.id) || [];
1730
+
1731
+ await result.current.reorderFeatures("RMS-001", currentFeatureIds);
1732
+
1733
+ // API should NOT have been called
1734
+ expect(api.reorderRoadmapFeatures).not.toHaveBeenCalled();
1735
+ });
1736
+
1737
+ it("skips API call when moving feature to same position in same milestone", async () => {
1738
+ const { result } = renderHook(() => useRoadmaps());
1739
+
1740
+ await waitFor(() => {
1741
+ expect(result.current.loading).toBe(false);
1742
+ });
1743
+
1744
+ result.current.selectRoadmap("RM-001");
1745
+ await waitFor(() => {
1746
+ expect(result.current.selectedRoadmapId).toBe("RM-001");
1747
+ });
1748
+
1749
+ // The feature RF-001 is already at index 0, try to move it to index 0
1750
+ await result.current.moveFeature("RF-001", "RMS-001", 0);
1751
+
1752
+ // API should NOT have been called
1753
+ expect(api.moveRoadmapFeature).not.toHaveBeenCalled();
1754
+ });
1755
+ });
1756
+ });