1package daemon
2
3import (
4 "context"
5 "os"
6 "testing"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/layout"
12)
13
14func TestMovePaneMutationSameSession(t *testing.T) {
15 p1, release1 := newBlockingPTY(t)
16 p2, release2 := newBlockingPTY(t)
17 defer release1()
18 defer release2()
19 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
20 sourceTab, destinationTab := sess.tabs[0], sess.tabs[1]
21 sourceTab.stableID = "source-tab"
22 destinationTab.stableID = "destination-tab"
23 moved := sourceTab.focusedPane()
24 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
25 moved.ctx, moved.cancel = movedCtx, movedCancel
26
27 err := d.movePane(movePaneRequest{
28 Source: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
29 SourceTabID: domain.TabStableID(sourceTab.stableID),
30 SourcePaneID: domain.PaneStableID(moved.stableID),
31 Destination: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
32 DestinationTabID: domain.TabStableID(destinationTab.stableID),
33 })
34 require.NoError(t, err)
35 require.Same(t, moved, destinationTab.panes[moved.id])
36 require.Equal(t, layout.PaneID("pane-2"), moved.id)
37 require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus)
38 require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1"))
39 require.Len(t, sess.tabs, 1)
40 require.Same(t, destinationTab, sess.tabs[0])
41 require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process")
42}
43
44func TestMovePaneMutationCrossSession(t *testing.T) {
45 p1, release1 := newBlockingPTY(t)
46 p2, release2 := newBlockingPTY(t)
47 defer release1()
48 defer release2()
49 d, source, _, _ := newManualSessionWithPTYs(t, p1)
50 source.mu.Lock()
51 clearAttachmentsForTestLocked(source)
52 source.mu.Unlock()
53 sourceTab := source.tabs[0]
54 sourceTab.stableID = "source-tab"
55 moved := sourceTab.focusedPane()
56 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
57 moved.ctx, moved.cancel = movedCtx, movedCancel
58
59 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
60 destinationTab := destination.tabs[0]
61 destinationTab.stableID = "destination-tab"
62 publishTiledPaneOwners(destination, destinationTab)
63 d.mu.Lock()
64 d.sessions[destination.id] = destination
65 d.mu.Unlock()
66
67 err := d.movePane(movePaneRequest{
68 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
69 SourceTabID: domain.TabStableID(sourceTab.stableID),
70 SourcePaneID: domain.PaneStableID(moved.stableID),
71 Destination: moveSessionLocator{ID: destination.id},
72 DestinationTabID: domain.TabStableID(destinationTab.stableID),
73 })
74 require.NoError(t, err)
75 require.Nil(t, source.tabs)
76 require.Same(t, moved, destinationTab.panes[moved.id])
77 require.Equal(t, destinationTab, destination.tabs[0])
78 require.Same(t, destination, moved.ownerSnapshot().session)
79 require.Same(t, destinationTab, moved.ownerSnapshot().tab)
80 require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
81}
82
83func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
84 movedPTY := newQuietPTY()
85 d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
86 clip, err := os.CreateTemp(t.TempDir(), "move-clip-")
87 require.NoError(t, err)
88 require.NoError(t, clip.Close())
89 source.mu.Lock()
90 clearAttachmentsForTestLocked(source)
91 source.clipFiles = []string{clip.Name()}
92 source.mu.Unlock()
93 active.setSession(nil)
94 moved := source.tabs[0].focusedPane()
95 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
96 moved.ctx, moved.cancel = movedCtx, movedCancel
97 movedTab := source.tabs[0]
98 movedTab.stableID = "source-tab"
99
100 destinationPTY := newQuietPTY()
101 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
102 publishTiledPaneOwners(destination, destination.tabs[0])
103 d.mu.Lock()
104 d.sessions[destination.id] = destination
105 d.mu.Unlock()
106
107 err = d.movePane(movePaneRequest{
108 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
109 SourceTabID: domain.TabStableID(movedTab.stableID),
110 SourcePaneID: domain.PaneStableID(moved.stableID),
111 Destination: moveSessionLocator{ID: destination.id},
112 DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
113 })
114 require.NoError(t, err)
115 select {
116 case <-movedPTY.done:
117 t.Fatal("retiring the source closed the transferred PTY")
118 default:
119 }
120 select {
121 case <-moved.ctx.Done():
122 t.Fatal("retiring the source cancelled the transferred pane process")
123 default:
124 }
125 select {
126 case <-source.ctx.Done():
127 default:
128 t.Fatal("empty source lifecycle was not cancelled")
129 }
130 _, err = os.Stat(clip.Name())
131 require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed")
132}
133
134func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
135 movedPTY := newQuietPTY()
136 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
137 source.mu.Lock()
138 clearAttachmentsForTestLocked(source)
139 source.mu.Unlock()
140 moved := source.tabs[0].focusedPane()
141 movedTab := source.tabs[0]
142 movedTab.stableID = "source-tab"
143
144 transport := &closeTrackingTransport{}
145 parked := &attachedClient{tr: transport, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, resumeCapable: true}
146 parked.initOverlays()
147 require.True(t, d.parkAttachment(source, parked))
148 destinationPTY := newQuietPTY()
149 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
150 publishTiledPaneOwners(destination, destination.tabs[0])
151 d.mu.Lock()
152 d.sessions[destination.id] = destination
153 d.mu.Unlock()
154
155 require.NoError(t, d.movePane(movePaneRequest{
156 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
157 SourceTabID: domain.TabStableID(movedTab.stableID),
158 SourcePaneID: domain.PaneStableID(moved.stableID),
159 Destination: moveSessionLocator{ID: destination.id},
160 DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
161 }))
162 d.mu.Lock()
163 require.Empty(t, d.resume.parked, "retired source parked clients must leave the daemon registry")
164 d.mu.Unlock()
165 require.True(t, transport.Closed(), "retired source parked transport was not closed")
166}
167
168func TestPrepareMovePaneCandidateIsPure(t *testing.T) {
169 t.Parallel()
170
171 newPaneRef := func(id layout.PaneID, stableID string) *pane {
172 return &pane{id: id, stableID: stableID}
173 }
174 horizontal := func(ids ...layout.PaneID) *layout.Node {
175 children := make([]*layout.Node, 0, len(ids))
176 for _, id := range ids {
177 children = append(children, layout.NewLeaf(id))
178 }
179 return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children}
180 }
181 stack := func(ids ...layout.PaneID) *layout.Node {
182 children := make([]*layout.Node, 0, len(ids))
183 for _, id := range ids {
184 children = append(children, layout.NewLeaf(id))
185 }
186 return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]}
187 }
188
189 tests := []struct {
190 name string
191 sourceTree *layout.Tree
192 sourceFloating floatingState
193 destinationTree *layout.Tree
194 destinationSize domain.Size
195 destinationNext int
196 wantErr error
197 wantRemoveTab bool
198 wantSourceFocus layout.PaneID
199 wantDestination layout.PaneID
200 wantDestinationNext int
201 wantDestinationIDs []layout.PaneID
202 }{
203 {
204 name: "split source closes on clone and destination inserts right of focus",
205 sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
206 destinationTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"},
207 destinationSize: domain.Size{Cols: 100, Rows: 4},
208 destinationNext: 2,
209 wantSourceFocus: "pane-1",
210 wantDestination: "pane-3",
211 wantDestinationNext: 4,
212 wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3", "pane-2"},
213 },
214 {
215 name: "stack source remains valid after close",
216 sourceTree: &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"},
217 destinationTree: layout.NewTree("pane-1"),
218 destinationSize: domain.Size{Cols: 41, Rows: 4},
219 destinationNext: 2,
220 wantSourceFocus: "pane-1",
221 wantDestination: "pane-3",
222 wantDestinationNext: 4,
223 wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
224 },
225 {
226 name: "final tiled pane requests source tab removal",
227 sourceTree: layout.NewTree("pane-2"),
228 destinationTree: layout.NewTree("pane-1"),
229 destinationSize: domain.Size{Cols: 41, Rows: 2},
230 destinationNext: 2,
231 wantRemoveTab: true,
232 wantDestination: "pane-3",
233 wantDestinationNext: 4,
234 wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
235 },
236 {
237 name: "final tiled pane rejects warming floating sibling",
238 sourceTree: layout.NewTree("pane-2"),
239 sourceFloating: floatingWarming,
240 destinationTree: layout.NewTree("pane-1"),
241 destinationSize: domain.Size{Cols: 41, Rows: 2},
242 destinationNext: 2,
243 wantErr: errMoveFloatingWarming,
244 },
245 {
246 name: "final tiled pane rejects hidden floating sibling",
247 sourceTree: layout.NewTree("pane-2"),
248 sourceFloating: floatingHidden,
249 destinationTree: layout.NewTree("pane-1"),
250 destinationSize: domain.Size{Cols: 41, Rows: 2},
251 destinationNext: 2,
252 wantErr: errMovePaneFloatingSibling,
253 },
254 {
255 name: "final tiled pane rejects visible floating sibling",
256 sourceTree: layout.NewTree("pane-2"),
257 sourceFloating: floatingVisible,
258 destinationTree: layout.NewTree("pane-1"),
259 destinationSize: domain.Size{Cols: 41, Rows: 2},
260 destinationNext: 2,
261 wantErr: errMovePaneFloatingSibling,
262 },
263 {
264 name: "destination too small rejects candidate",
265 sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
266 destinationTree: layout.NewTree("pane-1"),
267 destinationSize: domain.Size{Cols: 40, Rows: 2},
268 destinationNext: 2,
269 wantErr: layout.ErrTooSmall,
270 },
271 }
272
273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {
275 moved := newPaneRef("pane-2", "stable-moved")
276 sourcePanes := map[layout.PaneID]*pane{"pane-2": moved}
277 for _, id := range layout.LeafIDs(tt.sourceTree.Root) {
278 if id != moved.id {
279 sourcePanes[id] = newPaneRef(id, "source-"+string(id))
280 }
281 }
282 destinationPanes := make(map[layout.PaneID]*pane)
283 for _, id := range layout.LeafIDs(tt.destinationTree.Root) {
284 destinationPanes[id] = newPaneRef(id, "destination-"+string(id))
285 }
286 source := &tab{
287 tree: tt.sourceTree,
288 panes: sourcePanes,
289 nextPaneID: 17,
290 size: domain.Size{Cols: 80, Rows: 4},
291 floating: floatingSlot{state: tt.sourceFloating, generation: 9},
292 }
293 destination := &tab{
294 tree: tt.destinationTree,
295 panes: destinationPanes,
296 nextPaneID: tt.destinationNext,
297 size: tt.destinationSize,
298 }
299 sourceBefore := source.tree.Clone()
300 destinationBefore := destination.tree.Clone()
301 sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID
302 movedIDBefore, movedStableIDBefore := moved.id, moved.stableID
303
304 candidate, err := prepareMovePaneCandidate(source, destination, moved)
305
306 require.ErrorIs(t, err, tt.wantErr)
307 require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree")
308 require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree")
309 require.Equal(t, sourceNextBefore, source.nextPaneID)
310 require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter")
311 require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID")
312 require.Equal(t, movedStableIDBefore, moved.stableID)
313 require.Same(t, moved, source.panes[movedIDBefore])
314 for id, p := range destinationPanes {
315 require.Same(t, p, destination.panes[id])
316 }
317 if tt.wantErr != nil {
318 require.Nil(t, candidate)
319 return
320 }
321
322 require.Same(t, moved, candidate.pane)
323 require.Equal(t, movedIDBefore, candidate.sourceID)
324 require.Equal(t, tt.wantDestination, candidate.destinationID)
325 require.NotEqual(t, candidate.sourceID, candidate.destinationID)
326 require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab)
327 require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID")
328 require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root))
329 require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus)
330 require.NotEmpty(t, candidate.destinationPlacements)
331 if tt.wantRemoveTab {
332 require.Nil(t, candidate.sourceTree)
333 require.Nil(t, candidate.sourcePlacements)
334 } else {
335 require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus)
336 require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus))
337 require.NotEmpty(t, candidate.sourcePlacements)
338 }
339 require.Equal(t, "stable-moved", candidate.pane.stableID)
340 })
341 }
342}