1package daemon
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/require"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/picker"
12)
13
14func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
15 t.Helper()
16 return setupMovePickerSessionsWithClock(t, stubClock{}, extraDestinationTabs)
17}
18
19func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
20 t.Helper()
21 sourcePTY, releaseSource := newBlockingPTY(t)
22 d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY)
23 source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
24 d.mu.Lock()
25 delete(d.sessions, domain.SessionID("manual"))
26 d.sessions[source.id] = source
27 d.mu.Unlock()
28 sourceTab := source.tabs[0]
29 sourceTab.stableID = "source-tab"
30 sourcePane := sourceTab.focusedPane()
31 sourcePane.stableID = "source-pane"
32
33 releases := []func(){releaseSource}
34 destPTY, releaseDest := newBlockingPTY(t)
35 releases = append(releases, releaseDest)
36 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
37 publishTiledPaneOwners(source, sourceTab)
38 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}}
39 publishTiledPaneOwners(destination, destinationTab)
40 for range extraDestinationTabs {
41 extraPTY, releaseExtra := newBlockingPTY(t)
42 releases = append(releases, releaseExtra)
43 extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23})
44 destination.tabs = append(destination.tabs, extraTab)
45 publishTiledPaneOwners(destination, extraTab)
46 }
47 d.mu.Lock()
48 d.sessions[destination.id] = destination
49 d.mu.Unlock()
50 return d, source, ac, destination, destinationTab, releases
51}
52
53func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
54 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
55 defer releaseAll(releases)
56
57 d.handleInput(source, ac, []byte("\x1b "))
58 require.True(t, ac.overlays.paletteActive())
59 d.handleInput(source, ac, []byte("MFP\r"))
60 require.False(t, ac.overlays.paletteActive())
61 require.True(t, ac.overlays.pickerActive())
62
63 ac.overlays.pickerMu.Lock()
64 intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
65 ac.overlays.pickerMu.Unlock()
66 require.Equal(t, pickerMovePane, intent)
67 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
68 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
69 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
70 require.Same(t, ac, captured.Attachment)
71}
72
73func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
74 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
75 defer releaseAll(releases)
76 sourceTab := source.tabs[0]
77 sourceTab.stableID = "active-tab"
78
79 d.handleInput(source, ac, []byte("\x1b "))
80 d.handleInput(source, ac, []byte("MAT\r"))
81 require.True(t, ac.overlays.pickerActive())
82
83 ac.overlays.pickerMu.Lock()
84 intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
85 ac.overlays.pickerMu.Unlock()
86 require.Equal(t, pickerMoveTab, intent)
87 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
88 require.Equal(t, domain.PaneStableID(""), captured.PaneID)
89 require.Same(t, ac, captured.Attachment)
90}
91
92func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
93 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
94 defer releaseAll(releases)
95 d.mu.Lock()
96 delete(d.sessions, domain.SessionID("destination"))
97 d.mu.Unlock()
98
99 d.handleInput(source, ac, []byte("\x1b "))
100 d.handleInput(source, ac, []byte("MFP\r"))
101
102 require.False(t, ac.overlays.pickerActive())
103 history := d.notices.history()
104 require.NotEmpty(t, history)
105 require.Equal(t, "No destination available.", history[0].Message)
106}
107
108func TestMovePickerEnterCommitsMovePane(t *testing.T) {
109 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
110 defer releaseAll(releases)
111 source.mu.Lock()
112 clearAttachmentsForTestLocked(source)
113 source.mu.Unlock()
114 sourceTab := source.tabs[0]
115 moved := sourceTab.focusedPane()
116
117 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
118 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
119 TabID: "source-tab", PaneID: "source-pane",
120 }))
121 d.handlePickerInput(ac, []byte("\r"))
122
123 require.False(t, ac.overlays.pickerActive())
124 require.Nil(t, source.tabs)
125 require.Same(t, moved, destinationTab.panes[moved.id])
126 require.Same(t, destination, moved.ownerSnapshot().session)
127}
128
129func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
130 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
131 defer releaseAll(releases)
132 source.mu.Lock()
133 clearAttachmentsForTestLocked(source)
134 source.mu.Unlock()
135 sourceTab := source.tabs[0]
136 moved := sourceTab.focusedPane()
137
138 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
139 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
140 TabID: "source-tab", PaneID: "source-pane",
141 }))
142 ac.overlays.pickerMu.Lock()
143 target, ok := ac.overlays.picker.Selected()
144 ac.overlays.pickerMu.Unlock()
145 require.True(t, ok)
146 require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{
147 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
148 TabID: "source-tab", PaneID: "source-pane",
149 }, target))
150
151 require.Nil(t, source.tabs)
152 require.Same(t, moved, destinationTab.panes[moved.id])
153 require.Same(t, destination, moved.ownerSnapshot().session)
154}
155
156func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
157 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
158 defer releaseAll(releases)
159 source.mu.Lock()
160 clearAttachmentsForTestLocked(source)
161 source.mu.Unlock()
162 movedTab := source.tabs[0]
163 movedTab.stableID = "moved-tab"
164
165 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
166 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
167 TabID: "moved-tab",
168 }))
169 d.handlePickerInput(ac, []byte("\r"))
170
171 require.False(t, ac.overlays.pickerActive())
172 require.Nil(t, source.tabs)
173 require.Len(t, destination.tabs, 2)
174 require.Same(t, movedTab, destination.tabs[1])
175}
176
177func TestMovePickerEscapePerformsNoMutation(t *testing.T) {
178 clk := &signalClock{timers: make(chan *signalTimer, 16)}
179 d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0)
180 defer releaseAll(releases)
181 before := len(source.tabs)
182
183 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
184 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
185 TabID: "source-tab", PaneID: "source-pane",
186 }))
187 for len(clk.timers) > 0 {
188 <-clk.timers
189 }
190 d.handlePickerInput(ac, []byte("\x1b"))
191 timer := <-clk.timers
192 timer.ch <- time.Now()
193 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
194 require.Len(t, source.tabs, before)
195}
196
197func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
198 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
199 defer releaseAll(releases)
200
201 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
202 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
203 TabID: "source-tab", PaneID: "source-pane",
204 }))
205 d.mu.Lock()
206 delete(d.sessions, destination.id)
207 d.mu.Unlock()
208
209 d.handlePickerInput(ac, []byte("\r"))
210
211 require.False(t, ac.overlays.pickerActive())
212 history := d.notices.history()
213 require.NotEmpty(t, history)
214 require.Equal(t, "Destination is no longer available.", history[0].Message)
215 require.Len(t, source.tabs, 1)
216}
217
218func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
219 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
220 defer releaseAll(releases)
221 sourceTab := source.tabs[0]
222
223 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
224 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
225 TabID: "source-tab", PaneID: "source-pane",
226 }))
227 sourceTab.mu.Lock()
228 delete(sourceTab.panes, sourceTab.tree.Focus)
229 sourceTab.mu.Unlock()
230
231 d.handlePickerInput(ac, []byte("\r"))
232
233 require.True(t, ac.overlays.pickerActive())
234 history := d.notices.history()
235 require.NotEmpty(t, history)
236 require.Equal(t, "Pane no longer exists.", history[0].Message)
237}
238
239func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
240 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
241 defer releaseAll(releases)
242
243 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
244 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
245 TabID: "source-tab",
246 }))
247 source.mu.Lock()
248 source.tabs = nil
249 source.mu.Unlock()
250
251 d.handlePickerInput(ac, []byte("\r"))
252
253 require.True(t, ac.overlays.pickerActive())
254 history := d.notices.history()
255 require.NotEmpty(t, history)
256 require.Equal(t, "Tab no longer exists.", history[0].Message)
257}
258
259func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
260 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
261 defer releaseAll(releases)
262 require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
263 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
264 TabID: "source-tab", PaneID: "source-pane",
265 }))
266 d.mu.Lock()
267 delete(d.sessions, destination.id)
268 d.mu.Unlock()
269
270 rebuildReached := make(chan struct{})
271 allowClose := make(chan struct{})
272 refreshed := make(chan struct{})
273 ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
274 close(rebuildReached)
275 <-allowClose
276 }
277 go func() {
278 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
279 close(refreshed)
280 }()
281 select {
282 case <-rebuildReached:
283 case <-time.After(time.Second):
284 t.Fatal("timed out waiting for move-picker refresh rebuild")
285 }
286
287 replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
288 d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
289 close(allowClose)
290 select {
291 case <-refreshed:
292 case <-time.After(time.Second):
293 t.Fatal("timed out waiting for move-picker refresh")
294 }
295 ac.overlays.pickerMu.Lock()
296 require.Same(t, replacement, ac.overlays.picker)
297 ac.overlays.pickerMu.Unlock()
298}
299
300func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
301 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
302 defer releaseAll(releases)
303
304 d.handleInput(source, ac, []byte("\x1b "))
305 d.handleInput(source, ac, []byte("MFP\r"))
306 require.True(t, ac.overlays.pickerActive())
307 require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open")
308 source.dispatchMu.Unlock()
309}