@runfusion/fusion 0.24.0 → 0.26.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.
- package/README.md +6 -0
- package/dist/bin.js +18646 -15669
- package/dist/client/assets/AgentDetailView-Cv-vgOj3.js +18 -0
- package/dist/client/assets/{AgentsView-BkB9FiMT.js → AgentsView-D6Zi5zfP.js} +4 -4
- package/dist/client/assets/ChatView-CAHjY9uO.js +1 -0
- package/dist/client/assets/{DevServerView-BkvtjZBa.js → DevServerView--_WBvIDQ.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-BK-KbnhP.js → DirectoryPicker-xedtR-Rd.js} +1 -1
- package/dist/client/assets/{DocumentsView-BEg1CQAk.js → DocumentsView-Bg2oaZks.js} +1 -1
- package/dist/client/assets/{EvalsView-Berf9bQm.js → EvalsView-B3uOCXfr.js} +1 -1
- package/dist/client/assets/{ExperimentalAgentOnboardingModal-jcInE50G.js → ExperimentalAgentOnboardingModal-Bx6yXVS5.js} +1 -1
- package/dist/client/assets/{InsightsView-BX5bSF1J.js → InsightsView-Q1zvtF4F.js} +1 -1
- package/dist/client/assets/MemoryView-xcN_eouf.js +2 -0
- package/dist/client/assets/MemoryView-zaXewZzi.css +1 -0
- package/dist/client/assets/{NodesView-DLUOBLf6.js → NodesView-RxXg58_Q.js} +1 -1
- package/dist/client/assets/{PiExtensionsManager-COlJf0Kx.js → PiExtensionsManager-Cc8aAZXg.js} +2 -2
- package/dist/client/assets/PluginManager-BEkyBajl.js +1 -0
- package/dist/client/assets/{ResearchView-BzCcDAS4.css → ResearchView-BEI4ZSGs.css} +1 -1
- package/dist/client/assets/ResearchView-CERNf7sJ.js +1 -0
- package/dist/client/assets/{SettingsModal-yRqM4DV8.js → SettingsModal-B1r0yASu.js} +1 -1
- package/dist/client/assets/SettingsModal-BLsac7CJ.js +31 -0
- package/dist/client/assets/SettingsModal-Cis-4Lot.css +1 -0
- package/dist/client/assets/{SetupWizardModal-uUZk3TKT.js → SetupWizardModal-D1q548_L.js} +1 -1
- package/dist/client/assets/{SkillsView-CP8JX0P_.js → SkillsView-ClLM6u6p.js} +1 -1
- package/dist/client/assets/StashRecoveryView-B_8WIQEo.css +1 -0
- package/dist/client/assets/StashRecoveryView-ze0pEZ5U.js +1 -0
- package/dist/client/assets/{TodoView-DCRIkDZ-.js → TodoView-CTmIfy2M.js} +1 -1
- package/dist/client/assets/{dashboard-view-lR7YYmSC.js → dashboard-view-4xAN3yO5.js} +2 -2
- package/dist/client/assets/{folder-open-DHjELt8-.js → folder-open-BZuKESeq.js} +1 -1
- package/dist/client/assets/index-Bdw6llW6.js +692 -0
- package/dist/client/assets/index-CZGlyJuS.css +1 -0
- package/dist/client/assets/{star-DYesq1AV.js → star-D75YKEq-.js} +1 -1
- package/dist/client/assets/{upload-DTWF3Db5.js → upload-BYYTgWFj.js} +1 -1
- package/dist/client/assets/{users--syrel4l.js → users-RS90Aii3.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/package.json +1 -1
- package/dist/extension.js +5640 -3618
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-cli-printing-press/manifest.json +6 -0
- package/dist/plugins/fusion-plugin-cli-printing-press/package.json +26 -0
- package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts +20 -0
- package/dist/plugins/fusion-plugin-cli-printing-press/src/index.ts +14 -0
- package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +9 -11
- package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/bundled.js +30 -0
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +3 -28
- package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +899 -895
- package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +68 -71
- package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +47 -50
- package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-paperclip-runtime/bundled.js +155 -109
- package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/src/index.ts +49 -3
- package/dist/plugins/fusion-plugin-reports/src/report-schema.ts +38 -0
- package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-schema.test.ts +66 -0
- package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-store.test.ts +177 -0
- package/dist/plugins/fusion-plugin-reports/src/store/report-store.ts +341 -0
- package/dist/plugins/fusion-plugin-reports/src/store/report-types.ts +77 -0
- package/dist/plugins/fusion-plugin-roadmap/{src/dashboard/RoadmapsView.css → bundled.css} +13 -219
- package/dist/plugins/fusion-plugin-roadmap/bundled.js +29535 -0
- package/dist/plugins/fusion-plugin-roadmap/package.json +4 -41
- package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
- package/package.json +2 -3
- package/dist/client/assets/AgentDetailView-gy_5SUj2.js +0 -18
- package/dist/client/assets/ChatView-B_-B8fqu.js +0 -1
- package/dist/client/assets/MemoryView-CKElJY_3.js +0 -2
- package/dist/client/assets/MemoryView-DiajLXby.css +0 -1
- package/dist/client/assets/PluginManager-CfW55BF4.js +0 -1
- package/dist/client/assets/ResearchView-B256Lr8I.js +0 -1
- package/dist/client/assets/SettingsModal-BeA_nQtW.js +0 -31
- package/dist/client/assets/SettingsModal-DzsLquBu.css +0 -1
- package/dist/client/assets/index-CQyVRLOb.js +0 -692
- package/dist/client/assets/index-CxA2Nn0_.css +0 -1
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +0 -58
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +0 -301
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +0 -27
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +0 -157
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +0 -126
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +0 -35
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +0 -36
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +0 -112
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +0 -115
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +0 -128
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +0 -82
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +0 -307
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +0 -60
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +0 -75
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +0 -62
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +0 -78
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +0 -95
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +0 -74
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +0 -58
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +0 -121
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +0 -70
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +0 -89
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +0 -86
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +0 -167
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +0 -66
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +0 -81
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +0 -35
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +0 -19
- package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +0 -70
- package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +0 -8
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +0 -53
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +0 -60
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +0 -45
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +0 -114
- package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +0 -24
- package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +0 -91
- package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +0 -15
- package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +0 -21
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +0 -17
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +0 -292
- package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +0 -65
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +0 -101
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +0 -92
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +0 -48
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +0 -31
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +0 -2559
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +0 -1144
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +0 -1756
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +0 -70
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +0 -7
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +0 -8
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +0 -1188
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +0 -20
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +0 -6
- package/dist/plugins/fusion-plugin-roadmap/src/index.ts +0 -74
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +0 -41
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +0 -15
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +0 -15
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +0 -283
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +0 -21
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +0 -310
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +0 -5
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +0 -361
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +0 -408
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +0 -68
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +0 -300
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +0 -381
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +0 -3
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +0 -445
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +0 -334
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +0 -1318
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +0 -163
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +0 -37
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +0 -188
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +0 -311
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +0 -299
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +0 -765
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +0 -1
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +0 -1001
|
@@ -1,1756 +0,0 @@
|
|
|
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
|
-
});
|