1package usecase
2
3import (
4 "fmt"
5 "testing"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/stretchr/testify/require"
9)
10
11func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12 id := newTestIDGen()
13 uc := NewMovePaneToTabUseCase(id)
14
15 tabs := entity.NewTabList()
16
17 paneA := entity.NewPane(entity.PaneID("pA"))
18 tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19 tabs.Add(tabA)
20
21 paneB := entity.NewPane(entity.PaneID("pB"))
22 tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23 tabs.Add(tabB)
24 // Ensure tabB active pane is paneB.
25 tabB.Workspace.ActivePaneID = paneB.ID
26
27 out, err := uc.Execute(MovePaneToTabInput{
28 TabList: tabs,
29 SourceTabID: tabA.ID,
30 SourcePaneID: paneA.ID,
31 TargetTabID: tabB.ID,
32 })
33 require.NoError(t, err)
34 require.True(t, out.SourceTabClosed)
35 require.False(t, out.NewTabCreated)
36 require.Equal(t, tabB.ID, out.TargetTab.ID)
37 require.NotNil(t, out.MovedPaneNode)
38
39 require.Nil(t, tabs.Find(tabA.ID))
40 // Inserted as split in tabB.
41 require.NotNil(t, tabB.Workspace.Root)
42 require.True(t, tabB.Workspace.Root.IsSplit())
43 require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44 require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45 // Active pane becomes moved pane.
46 require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47}
48
49func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50 id := newTestIDGen()
51 uc := NewMovePaneToTabUseCase(id)
52
53 tabs := entity.NewTabList()
54 paneA := entity.NewPane(entity.PaneID("pA"))
55 tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56 tabs.Add(tabA)
57
58 out, err := uc.Execute(MovePaneToTabInput{
59 TabList: tabs,
60 SourceTabID: tabA.ID,
61 SourcePaneID: paneA.ID,
62 TargetTabID: "",
63 })
64 require.NoError(t, err)
65 require.True(t, out.NewTabCreated)
66 require.True(t, out.SourceTabClosed)
67 require.Equal(t, 1, tabs.Count())
68 require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69 require.NotNil(t, out.TargetTab.Workspace.Root)
70 require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71 require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72}
73
74func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75 uc := NewMovePaneToTabUseCase(newTestIDGen())
76 abs := entity.NewTabList()
77 pane := entity.NewPane(entity.PaneID("pA"))
78 tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79 abs.Add(tab)
80
81 _, err := uc.Execute(MovePaneToTabInput{
82 TabList: abs,
83 SourceTabID: tab.ID,
84 SourcePaneID: pane.ID,
85 TargetTabID: tab.ID,
86 })
87 require.Error(t, err)
88}
89
90func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91 uc := NewMovePaneToTabUseCase(newTestIDGen())
92 tabs := entity.NewTabList()
93 pane := entity.NewPane(entity.PaneID("pA"))
94 tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95 tabs.Add(tab)
96
97 _, err := uc.Execute(MovePaneToTabInput{
98 TabList: tabs,
99 SourceTabID: tab.ID,
100 SourcePaneID: entity.PaneID("missing"),
101 TargetTabID: entity.TabID("tB"),
102 })
103 require.Error(t, err)
104}
105
106func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107 id := newTestIDGen()
108 uc := NewMovePaneToTabUseCase(id)
109
110 tabs := entity.NewTabList()
111
112 // Source workspace: split with two panes; move one, leaving one.
113 paneLeft := entity.NewPane(entity.PaneID("pL"))
114 paneRight := entity.NewPane(entity.PaneID("pR"))
115 source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116 // Build a split root manually.
117 source.Workspace.Root = &entity.PaneNode{
118 ID: "root",
119 SplitDir: entity.SplitHorizontal,
120 SplitRatio: 0.5,
121 Children: []*entity.PaneNode{
122 {ID: string(paneLeft.ID), Pane: paneLeft},
123 {ID: string(paneRight.ID), Pane: paneRight},
124 },
125 }
126 source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127 source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128 source.Workspace.ActivePaneID = paneRight.ID
129 tabs.Add(source)
130
131 targetPane := entity.NewPane(entity.PaneID("pT"))
132 target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133 tabs.Add(target)
134
135 out, err := uc.Execute(MovePaneToTabInput{
136 TabList: tabs,
137 SourceTabID: source.ID,
138 SourcePaneID: paneRight.ID,
139 TargetTabID: target.ID,
140 })
141 require.NoError(t, err)
142 require.False(t, out.SourceTabClosed)
143 // Source should now be single leaf.
144 require.Equal(t, 1, source.Workspace.PaneCount())
145 require.NotNil(t, source.Workspace.Root)
146 require.True(t, source.Workspace.Root.IsLeaf())
147 require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148}
149
150func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151 id := newTestIDGen()
152 uc := NewMovePaneToTabUseCase(id)
153
154 tabs := entity.NewTabList()
155
156 paneA := entity.NewPane(entity.PaneID("pA"))
157 source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158 tabs.Add(source)
159
160 // Target workspace: stacked container with two panes; active is second.
161 pane1 := entity.NewPane(entity.PaneID("p1"))
162 pane2 := entity.NewPane(entity.PaneID("p2"))
163 target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164 stack := &entity.PaneNode{
165 ID: "stack",
166 IsStacked: true,
167 ActiveStackIndex: 1,
168 Children: []*entity.PaneNode{
169 {ID: string(pane1.ID), Pane: pane1},
170 {ID: string(pane2.ID), Pane: pane2},
171 },
172 }
173 stack.Children[0].Parent = stack
174 stack.Children[1].Parent = stack
175 target.Workspace.Root = stack
176 target.Workspace.ActivePaneID = pane2.ID
177 tabs.Add(target)
178
179 out, err := uc.Execute(MovePaneToTabInput{
180 TabList: tabs,
181 SourceTabID: source.ID,
182 SourcePaneID: paneA.ID,
183 TargetTabID: target.ID,
184 })
185 require.NoError(t, err)
186 require.NotNil(t, out.TargetTab.Workspace.Root)
187 require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188 // Left child should be the stack container.
189 require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190 // Right child is moved pane.
191 require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192}
193
194// CORRECTNESS-02: Resolve move targets before detaching panes.
195//
196// When a pane move fails after source detach (e.g., nil idGenerator prevents
197// insertion into an existing workspace), the source tab must retain its pane.
198// Previously, detachPaneFromWorkspace was called before resolveTargetTab and
199// insertIntoTargetWorkspace, so a failure in either would leave the pane
200// detached from its original location while returning an error.
201func TestMovePaneToTab_FailedMovePreservesSourcePane(t *testing.T) {
202 // Source tab with a single pane.
203 tabs := entity.NewTabList()
204 paneA := entity.NewPane(entity.PaneID("pA"))
205 tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
206 tabs.Add(tabA)
207
208 // Target tab with an existing pane (non-nil Root), so insertion needs idGenerator.
209 paneB := entity.NewPane(entity.PaneID("pB"))
210 tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
211 tabs.Add(tabB)
212 tabB.Workspace.ActivePaneID = paneB.ID
213
214 // Use nil idGenerator — insertion into existing workspace will fail.
215 uc := NewMovePaneToTabUseCase(nil)
216
217 _, err := uc.Execute(MovePaneToTabInput{
218 TabList: tabs,
219 SourceTabID: tabA.ID,
220 SourcePaneID: paneA.ID,
221 TargetTabID: tabB.ID,
222 })
223 require.Error(t, err)
224 require.Contains(t, err.Error(), "id generator is required")
225
226 // Source must be preserved: tab still exists and pane is still inside it.
227 require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
228 require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
229}
230
231// CORRECTNESS-02: Pre-validate that target workspace has a valid active pane
232// before detaching source. When a target workspace has Root != nil but
233// ActivePane() returns nil (e.g., ActivePaneID points to a non-existent pane),
234// insertIntoTargetWorkspace would fail after detach. Pre-validation catches
235// this early.
236func TestMovePaneToTab_FailsOnTargetWithNoActivePane(t *testing.T) {
237 id := newTestIDGen()
238 uc := NewMovePaneToTabUseCase(id)
239
240 tabs := entity.NewTabList()
241
242 // Source tab.
243 paneA := entity.NewPane(entity.PaneID("pA"))
244 tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
245 tabs.Add(tabA)
246
247 // Target tab with Root != nil but ActivePaneID pointing to a non-existent pane.
248 paneB := entity.NewPane(entity.PaneID("pB"))
249 tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
250 tabB.Workspace.ActivePaneID = "nonexistent"
251 tabs.Add(tabB)
252
253 _, err := uc.Execute(MovePaneToTabInput{
254 TabList: tabs,
255 SourceTabID: tabA.ID,
256 SourcePaneID: paneA.ID,
257 TargetTabID: tabB.ID,
258 })
259 require.Error(t, err)
260 require.Contains(t, err.Error(), "no active pane")
261
262 // Source must be preserved.
263 require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
264 require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
265}
266
267// CORRECTNESS-02: New tab must not be added to TabList when detach fails.
268// When create-new-tab is requested (empty TargetTabID), resolveTargetTab
269// generates IDs and creates the tab but must NOT add it to TabList until
270// after successful detach from source. This test verifies that a detach
271// failure (e.g., pane's parent is not a valid split) does not leave a
272// stale/duplicate tab in the list.
273func TestMovePaneToTab_NewTabNotAddedOnDetachFailure(t *testing.T) {
274 id := newTestIDGen()
275 uc := NewMovePaneToTabUseCase(id)
276
277 tabs := entity.NewTabList()
278
279 paneA := entity.NewPane(entity.PaneID("pA"))
280 tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
281 tabs.Add(tabA)
282
283 // Construct an invalid parent structure so detach fails:
284 // Make paneA's PaneNode a child of a non-split container node.
285 // detachLeafFromWorkspace will reject because parent.IsSplit() is false.
286 invalidParent := &entity.PaneNode{
287 ID: "invalid-parent",
288 Children: []*entity.PaneNode{tabA.Workspace.Root},
289 // SplitDir is deliberately SplitNone (zero value), so IsSplit() returns false.
290 }
291 tabA.Workspace.Root.Parent = invalidParent
292 tabA.Workspace.Root = invalidParent
293
294 initialCount := tabs.Count()
295
296 _, err := uc.Execute(MovePaneToTabInput{
297 TabList: tabs,
298 SourceTabID: tabA.ID,
299 SourcePaneID: paneA.ID,
300 TargetTabID: "", // creates new tab
301 })
302 require.Error(t, err)
303 require.Contains(t, err.Error(), "pane parent is not a split")
304
305 // No new tab should have been added to the list.
306 require.Equal(t, initialCount, tabs.Count(), "no new tab should be added on failed move")
307 // Source tab and pane should still be in their original positions.
308 require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
309 require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
310}
311
312func newTestIDGen() func() string {
313 counter := 0
314 return func() string {
315 counter++
316 return fmt.Sprintf("id%d", counter)
317 }
318}