1package daemon
2
3import (
4 "context"
5 "io"
6 "maps"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/domain/layout"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/keys"
18)
19
20func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21 tests := []struct {
22 name string
23 mutate func(*layout.Tree, domain.Rect) (bool, error)
24 wantChanged bool
25 wantGeneration uint64
26 wantSnapshotGen uint64
27 wantResizes bool
28 }{
29 {
30 name: "changed candidate publishes once",
31 mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32 return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33 },
34 wantChanged: true,
35 wantGeneration: 1,
36 wantSnapshotGen: 1,
37 wantResizes: true,
38 },
39 {
40 name: "unchanged candidate is discarded",
41 mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42 candidate.Focus = "pane-2"
43 return false, nil
44 },
45 },
46 }
47
48 for _, tt := range tests {
49 t.Run(tt.name, func(t *testing.T) {
50 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51 before := h.tab.tree.Clone()
52
53 changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54
55 require.NoError(t, err)
56 require.Equal(t, tt.wantChanged, changed)
57 h.tab.mu.Lock()
58 require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59 if tt.wantChanged {
60 require.NotEqual(t, before, h.tab.tree)
61 } else {
62 require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63 }
64 h.tab.mu.Unlock()
65 h.session.snapshotMu.Lock()
66 require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67 h.session.snapshotMu.Unlock()
68 if tt.wantResizes {
69 require.NotZero(t, h.totalResizes())
70 } else {
71 require.Zero(t, h.totalResizes())
72 }
73 })
74 }
75}
76
77func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78 tests := []struct {
79 name string
80 stale func(*paneRearrangeHarness) daemonActionTarget
81 }{
82 {
83 name: "session",
84 stale: func(h *paneRearrangeHarness) daemonActionTarget {
85 target := h.target("pane-1")
86 h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}}
87 return target
88 },
89 },
90 {
91 name: "tab",
92 stale: func(h *paneRearrangeHarness) daemonActionTarget {
93 target := h.target("pane-1")
94 h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95 return target
96 },
97 },
98 {
99 name: "pane identity",
100 stale: func(h *paneRearrangeHarness) daemonActionTarget {
101 target := h.target("pane-1")
102 h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103 return target
104 },
105 },
106 }
107
108 for _, tt := range tests {
109 t.Run(tt.name, func(t *testing.T) {
110 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111 before := h.tab.tree.Clone()
112 called := false
113
114 changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115 called = true
116 return true, nil
117 })
118
119 require.False(t, changed)
120 require.ErrorIs(t, err, layout.ErrNotFound)
121 require.False(t, called, "stale identity must be rejected before invoking the mutation")
122 require.Equal(t, before, h.tab.tree)
123 require.Zero(t, h.tab.layoutGeneration)
124 require.Zero(t, h.totalResizes())
125 })
126 }
127}
128
129func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131 locksAvailable := make(chan bool, 8)
132 for _, tracked := range h.ptys {
133 tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134 }
135
136 done := make(chan error, 1)
137 go func() {
138 done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139 kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140 })
141 }()
142
143 select {
144 case err := <-done:
145 require.NoError(t, err)
146 case <-time.After(2 * time.Second):
147 t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148 }
149 require.NotZero(t, h.totalResizes())
150 close(locksAvailable)
151 for available := range locksAvailable {
152 require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153 }
154}
155
156func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158 h.tab.mu.Lock()
159 h.tab.tree.Focus = "pane-3"
160 before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161 maps.Copy(before, h.tab.panes)
162 h.tab.mu.Unlock()
163
164 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
165 kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
166 })
167
168 require.NoError(t, err)
169 h.tab.mu.Lock()
170 require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
171 require.Len(t, h.tab.panes, len(before))
172 for id, p := range before {
173 require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
174 }
175 h.tab.mu.Unlock()
176 h.factory.AssertNotCalled(t, "Open")
177 for _, tracked := range h.ptys {
178 require.Zero(t, tracked.closeCount())
179 }
180}
181
182func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
183 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
184 ac := &attachedClient{}
185 ac.setSession(h.session)
186 h.session.mu.Lock()
187 h.session.registerAttachmentLocked(ac)
188 h.session.mu.Unlock()
189 invalidations := make(chan renderInvalidation, 2)
190 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
191 rc.attach(ac)
192 h.session.installRenderCoordinator(rc)
193 before := h.snapshot()
194 request := daemonActionRequest{
195 kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
196 }
197
198 err := (daemonActions{d: h.daemon}).Run(request)
199 require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel")
200
201 require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
202 require.Zero(t, h.totalResizes())
203 h.factory.AssertNotCalled(t, "Open")
204 for _, tracked := range h.ptys {
205 require.Zero(t, tracked.closeCount())
206 }
207 require.Empty(t, h.daemon.notices.history())
208 requireNoInvalidation(t, invalidations)
209}
210
211func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
212 tests := []struct {
213 name string
214 size domain.Size
215 tree *layout.Tree
216 pane layout.PaneID
217 dir layout.Direction
218 cause error
219 wantMsg string
220 }{
221 {
222 name: "unsupported layout",
223 size: domain.Size{Cols: 80, Rows: 22},
224 tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
225 layout.NewLeaf("pane-1"),
226 {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
227 }}, Focus: "pane-1"},
228 pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
229 wantMsg: "pane rearrangement requires a column layout",
230 },
231 {
232 name: "too small",
233 size: domain.Size{Cols: 40, Rows: 5},
234 tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
235 layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
236 }}, Focus: "pane-1"},
237 pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
238 wantMsg: "not enough space to rearrange pane",
239 },
240 }
241
242 for _, tt := range tests {
243 t.Run(tt.name, func(t *testing.T) {
244 h := newPaneRearrangeHarness(t, tt.size, tt.tree)
245 before := h.snapshot()
246
247 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
248 kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
249 })
250
251 require.ErrorIs(t, err, tt.cause)
252 var userErr *domain.UserError
253 require.ErrorAs(t, err, &userErr)
254 require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
255 require.Equal(t, domain.NoticeWarn, userErr.Severity)
256 require.Equal(t, tt.wantMsg, userErr.Msg)
257 require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
258 require.Zero(t, h.totalResizes())
259 h.factory.AssertNotCalled(t, "Open")
260 for _, tracked := range h.ptys {
261 require.Zero(t, tracked.closeCount())
262 }
263 })
264 }
265}
266
267func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
268 tests := []struct {
269 name string
270 invoke func(*paneRearrangeHarness, *attachedClient) error
271 wantCleanupInvalidation bool
272 }{
273 {
274 name: "palette cleans up its closed overlay",
275 invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
276 return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft()
277 },
278 wantCleanupInvalidation: true,
279 },
280 {
281 name: "control remains silent",
282 invoke: func(h *paneRearrangeHarness, _ *attachedClient) error {
283 return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft()
284 },
285 },
286 {
287 name: "key remains silent",
288 invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
289 daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft, nil)
290 return nil
291 },
292 },
293 }
294
295 for _, tt := range tests {
296 t.Run(tt.name, func(t *testing.T) {
297 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
298 ac := &attachedClient{}
299 ac.setSession(h.session)
300 h.session.mu.Lock()
301 h.session.registerAttachmentLocked(ac)
302 h.session.mu.Unlock()
303 invalidations := make(chan renderInvalidation, 2)
304 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
305 rc.attach(ac)
306 h.session.installRenderCoordinator(rc)
307 before := h.snapshot()
308
309 require.NoError(t, tt.invoke(h, ac))
310
311 require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state")
312 require.Empty(t, h.daemon.notices.history())
313 if tt.wantCleanupInvalidation {
314 invalidation := awaitInvalidation(t, invalidations)
315 require.Equal(t, "palette.go", invalidation.producer)
316 require.True(t, invalidation.reset)
317 }
318 requireNoInvalidation(t, invalidations)
319 })
320 }
321}
322
323type paneRearrangeHarness struct {
324 daemon *Daemon
325 session *session
326 tab *tab
327 panes map[layout.PaneID]*pane
328 ptys map[layout.PaneID]*paneRearrangePTY
329 factory *portsmocks.MockPTYFactory
330}
331
332type paneRearrangeSnapshot struct {
333 tree *layout.Tree
334 layoutGeneration uint64
335 snapshotGeneration uint64
336 snapshotDirty bool
337 panes map[layout.PaneID]*pane
338 rects map[layout.PaneID]domain.Rect
339 screens map[layout.PaneID]domain.Size
340}
341
342func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
343 t.Helper()
344 factory := portsmocks.NewMockPTYFactory(t)
345 d := newTestDaemon(t, factory, stubClock{})
346 ids := layout.LeafIDs(tree.Root)
347 require.NotEmpty(t, ids)
348 ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
349 panes := make(map[layout.PaneID]*pane, len(ids))
350 for _, id := range ids {
351 ptys[id] = &paneRearrangePTY{}
352 }
353 tb := newTab(ptys[ids[0]], size)
354 first := tb.panes["pane-1"]
355 delete(tb.panes, "pane-1")
356 first.id = ids[0]
357 panes[ids[0]] = first
358 tb.panes[ids[0]] = first
359 for _, id := range ids[1:] {
360 p := newPane(id, ptys[id], size)
361 panes[id] = p
362 tb.panes[id] = p
363 }
364 tb.tree = tree.Clone()
365 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
366 require.True(t, ok, "test fixture layout must solve")
367 for _, placement := range placements {
368 p := tb.panes[placement.ID]
369 p.rect = placement.Content
370 p.screen.Resize(placement.Content.Width, placement.Content.Height)
371 }
372 tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
373 for _, p := range tb.panes {
374 p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
375 }
376 sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
377 sess.snapEligible.Store(true)
378 d.sessions[sess.id] = sess
379 return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
380}
381
382func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
383 t.Helper()
384 ctx, cancel := context.WithCancel(parent)
385 t.Cleanup(cancel)
386 return ctx, cancel
387}
388
389func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
390 return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
391}
392
393func (h *paneRearrangeHarness) totalResizes() int {
394 total := 0
395 for _, tracked := range h.ptys {
396 total += tracked.resizeCount()
397 }
398 return total
399}
400
401func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
402 h.tab.mu.Lock()
403 snapshot := paneRearrangeSnapshot{
404 tree: h.tab.tree.Clone(),
405 layoutGeneration: h.tab.layoutGeneration,
406 panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
407 rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
408 screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
409 }
410 for id, p := range h.tab.panes {
411 snapshot.panes[id] = p
412 p.mu.Lock()
413 snapshot.rects[id] = p.rect
414 snapshot.screens[id] = domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}
415 p.mu.Unlock()
416 }
417 h.tab.mu.Unlock()
418 h.session.snapshotMu.Lock()
419 snapshot.snapshotGeneration = h.session.snapshotGeneration
420 snapshot.snapshotDirty = h.session.snapDirty.Load()
421 h.session.snapshotMu.Unlock()
422 return snapshot
423}
424
425func (h *paneRearrangeHarness) stateLocksAvailable() bool {
426 done := make(chan struct{})
427 go func() {
428 h.daemon.mu.Lock()
429 h.session.mu.Lock()
430 h.tab.mu.Lock()
431 for _, p := range h.panes {
432 p.mu.Lock()
433 }
434 for _, p := range h.panes {
435 p.mu.Unlock()
436 }
437 h.tab.mu.Unlock()
438 h.session.mu.Unlock()
439 h.daemon.mu.Unlock()
440 close(done)
441 }()
442 select {
443 case <-done:
444 return true
445 case <-time.After(250 * time.Millisecond):
446 return false
447 }
448}
449
450func threeColumnTree() *layout.Tree {
451 return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
452 layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
453 }}, Focus: "pane-1"}
454}
455
456type paneRearrangePTY struct {
457 mu sync.Mutex
458 resizes []domain.Size
459 closes int
460 onResize func()
461}
462
463func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
464func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
465func (p *paneRearrangePTY) Resize(geometry domain.Geometry) error {
466 p.mu.Lock()
467 p.resizes = append(p.resizes, geometry.Size)
468 hook := p.onResize
469 p.mu.Unlock()
470 if hook != nil {
471 hook()
472 }
473 return nil
474}
475func (p *paneRearrangePTY) Close() error {
476 p.mu.Lock()
477 p.closes++
478 p.mu.Unlock()
479 return nil
480}
481func (*paneRearrangePTY) Pid() int { return 0 }
482func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
483
484func (p *paneRearrangePTY) resizeCount() int {
485 p.mu.Lock()
486 defer p.mu.Unlock()
487 return len(p.resizes)
488}
489
490func (p *paneRearrangePTY) closeCount() int {
491 p.mu.Lock()
492 defer p.mu.Unlock()
493 return p.closes
494}
495
496var _ ports.PTY = (*paneRearrangePTY)(nil)