1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/keys"
11 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/picker"
13)
14
15func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
16 tests := []struct {
17 name string
18 dir layout.Direction
19 cfg domain.NavConfig
20 position int
21 count int
22 want overflowStep
23 }{
24 {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2},
25 {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2},
26 {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}},
27 {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}},
28 {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
29 {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2},
30 {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}},
31 {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
32 {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
33 {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
34 {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2},
35 {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2},
36 }
37
38 for _, tt := range tests {
39 t.Run(tt.name, func(t *testing.T) {
40 require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count))
41 })
42 }
43}
44
45func TestPrepareTabOverflowRejectsMissingExpectedSource(t *testing.T) {
46 d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
47 target := sess.tabs[1]
48 target.mu.Lock()
49 target.size = domain.Size{Cols: 41, Rows: 10}
50 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
51 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
52 target.mu.Unlock()
53
54 missing := &tab{stableID: "missing-source"}
55 _, ok := d.prepareTabOverflowForAttachment(sess, nil, missing, layout.Right, domain.Rect{Width: 20, Height: 10}, 1)
56 require.False(t, ok)
57}
58
59func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
60 tests := []struct {
61 name string
62 start int
63 action keys.Action
64 wantActive int
65 wantFocus layout.PaneID
66 }{
67 {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
68 {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
69 }
70
71 for _, tt := range tests {
72 t.Run(tt.name, func(t *testing.T) {
73 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
74 sess.mu.Lock()
75 selectTestAttachmentTabLocked(sess, tt.start)
76 target := sess.tabs[tt.wantActive]
77 source := sess.tabs[tt.start]
78 sess.mu.Unlock()
79 target.mu.Lock()
80 target.size = domain.Size{Cols: 41, Rows: 10}
81 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
82 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
83 target.mu.Unlock()
84 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
85
86 daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
87
88 require.Equal(t, tt.wantActive, testAttachmentTabIndex(sess))
89 target.mu.Lock()
90 require.Equal(t, tt.wantFocus, target.tree.Focus)
91 target.mu.Unlock()
92 require.NotSame(t, source, testAttachmentTab(sess))
93 })
94 }
95}
96
97func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
98 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
99 alpha.mu.Lock()
100 alpha.name = "alpha"
101 alpha.mu.Unlock()
102
103 newSession := func(id, name string, active int, focus layout.PaneID) *session {
104 tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
105 target := tabs[active]
106 target.mu.Lock()
107 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
109 target.mu.Unlock()
110 return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name, incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: tabs}
111 }
112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
113 echo := newSession("live-echo", "echo", 1, "pane-2")
114
115 // Deliberately register the live sessions out of alphabetical order. The
116 // stopped name sorts between alpha and charlie but must never be resumed.
117 d.mu.Lock()
118 delete(d.sessions, alpha.id)
119 d.sessions[echo.id] = echo
120 d.sessions[alpha.id] = alpha
121 d.sessions[charlie.id] = charlie
122 d.inactive["bravo"] = inactiveSession{name: "bravo", cwd: "/tmp/bravo"}
123 d.mu.Unlock()
124 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
125 handler := daemonKeyHandler{d: d, ac: ac}
126
127 moves := []struct {
128 name string
129 action keys.Action
130 want *session
131 }{
132 {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
133 {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
134 {name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
135 {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
136 {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
137 {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
138 }
139 for _, move := range moves {
140 t.Run(move.name, func(t *testing.T) {
141 handler.Action(move.action, nil)
142 require.Same(t, move.want, ac.currentSession())
143 })
144 }
145
146 for _, target := range []*session{charlie, echo} {
147 require.Equal(t, 0, testAttachmentTabIndex(target), "new attachment starts at the deterministic first tab")
148 target.tabs[1].mu.Lock()
149 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
150 target.tabs[1].mu.Unlock()
151 }
152 d.mu.Lock()
153 _, stopped := d.inactive["bravo"]
154 var bravoLive bool
155 for _, candidate := range d.sessions {
156 if candidate == nil {
157 continue
158 }
159 candidate.mu.Lock()
160 bravoLive = bravoLive || candidate.name == "bravo"
161 candidate.mu.Unlock()
162 }
163 d.mu.Unlock()
164 alpha.mu.Lock()
165 attached := alpha.snapshotAttachmentsLocked()
166 alpha.mu.Unlock()
167 require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
168 require.False(t, bravoLive, "the stopped session is absent from the live registry")
169 require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning")
170}
171
172func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
173 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
174 alpha.mu.Lock()
175 alpha.name = "alpha"
176 alpha.mu.Unlock()
177 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
178 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
179 d.mu.Lock()
180 d.sessions[charlie.id] = charlie
181 d.mu.Unlock()
182 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
183
184 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown, nil)
185
186 require.Same(t, alpha, ac.currentSession())
187 alpha.mu.Lock()
188 require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
189 alpha.mu.Unlock()
190 charlie.mu.Lock()
191 require.Empty(t, charlie.snapshotAttachmentsLocked())
192 charlie.mu.Unlock()
193}
194
195func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
197 alpha.mu.Lock()
198 alpha.name = "alpha"
199 alpha.mu.Unlock()
200 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
201 d.mu.Lock()
202 d.sessions[charlie.id] = charlie
203 d.mu.Unlock()
204
205 target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
206 require.True(t, ok)
207 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
208
209 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
210 require.Same(t, alpha, ac.currentSession())
211 alpha.mu.Lock()
212 require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
213 alpha.mu.Unlock()
214 charlie.mu.Lock()
215 require.Empty(t, charlie.snapshotAttachmentsLocked())
216 charlie.mu.Unlock()
217}
218
219func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
220 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
221 alpha.mu.Lock()
222 alpha.name = "alpha"
223 expectedSource := alpha.tabs[0]
224 selectTestAttachmentTabLocked(alpha, 1)
225 alpha.mu.Unlock()
226 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
227 d.mu.Lock()
228 d.sessions[charlie.id] = charlie
229 d.mu.Unlock()
230
231 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
232 require.Same(t, alpha, ac.currentSession())
233 alpha.mu.Lock()
234 require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
235 alpha.mu.Unlock()
236 charlie.mu.Lock()
237 require.Empty(t, charlie.snapshotAttachmentsLocked())
238 charlie.mu.Unlock()
239}
240
241func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
242 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
243 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
244 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
245 d.mu.Lock()
246 d.sessions[charlie.id] = charlie
247 d.mu.Unlock()
248
249 require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
250 require.Same(t, charlie, ac.currentSession())
251}
252
253func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
254 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
255 alpha.mu.Lock()
256 alpha.name = "alpha"
257 alpha.mu.Unlock()
258 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
259 d.mu.Lock()
260 d.sessions[charlie.id] = charlie
261 d.mu.Unlock()
262
263 target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
264 require.True(t, ok)
265 replacement := &attachedClient{}
266 alpha.mu.Lock()
267 alpha.registerAttachmentLocked(replacement)
268 alpha.mu.Unlock()
269
270 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
271 require.Same(t, alpha, ac.currentSession())
272 alpha.mu.Lock()
273 require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement)
274 alpha.mu.Unlock()
275 charlie.mu.Lock()
276 require.Empty(t, charlie.snapshotAttachmentsLocked())
277 charlie.mu.Unlock()
278}
279
280func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
281 d, alpha, ac, sends := newManualSessionWithPTYs(t, nil)
282 // This race test generates many full views and semantic route updates.
283 // Keep its test client reading rather than filling the bounded transport.
284 stop, drained := make(chan struct{}), make(chan struct{})
285 go func() {
286 defer close(drained)
287 for {
288 select {
289 case <-sends:
290 case <-stop:
291 return
292 }
293 }
294 }()
295 defer func() { close(stop); <-drained }()
296 alpha.mu.Lock()
297 alpha.name = "alpha"
298 alpha.mu.Unlock()
299 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
300 echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
301 d.mu.Lock()
302 d.sessions[echo.id] = echo
303 d.sessions[charlie.id] = charlie
304 d.mu.Unlock()
305 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
306
307 start := make(chan struct{})
308 renameErrors := make(chan error, 40)
309 var wg sync.WaitGroup
310 wg.Go(func() {
311 <-start
312 for i := range 40 {
313 name := "charlie"
314 if i%2 == 0 {
315 name = "delta"
316 }
317 if err := d.renameSession(charlie, name); err != nil {
318 renameErrors <- err
319 }
320 }
321 })
322
323 close(start)
324 handler := daemonKeyHandler{d: d, ac: ac}
325 for range 20 {
326 handler.Action(keys.ActionFocusPaneDown, nil)
327 handler.Action(keys.ActionFocusPaneUp, nil)
328 }
329 wg.Wait()
330 close(renameErrors)
331 for err := range renameErrors {
332 require.NoError(t, err)
333 }
334
335 current := ac.currentSession()
336 owners := make([]*session, 0, 1)
337 d.mu.Lock()
338 for _, candidate := range d.sessions {
339 if candidate == nil {
340 continue
341 }
342 candidate.mu.Lock()
343 if attachmentRegisteredLocked(candidate, ac) {
344 owners = append(owners, candidate)
345 }
346 candidate.mu.Unlock()
347 }
348 d.mu.Unlock()
349 require.Len(t, owners, 1)
350 require.Same(t, current, owners[0])
351}
352
353func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
354 tests := []struct {
355 name string
356 setup func(d *Daemon, sess *session)
357 dir keys.Action
358 }{
359 {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
360 {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
361 {name: "failed target entry", setup: func(d *Daemon, sess *session) {
362 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
363 sess.tabs[1].mu.Lock()
364 sess.tabs[1].tree = nil
365 sess.tabs[1].mu.Unlock()
366 }, dir: keys.ActionFocusPaneRight},
367 {name: "visible floating pane", setup: func(d *Daemon, sess *session) {
368 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
369 installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
370 }, dir: keys.ActionFocusPaneRight},
371 }
372
373 for _, tt := range tests {
374 t.Run(tt.name, func(t *testing.T) {
375 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
376 tt.setup(d, sess)
377
378 daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
379
380 require.Equal(t, 0, testAttachmentTabIndex(sess))
381 })
382 }
383}