1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/stretchr/testify/require"
11)
12
13func TestPaletteAndControlShareResizeAction(t *testing.T) {
14 tests := []struct {
15 name string
16 palette func(paletteExec) error
17 control func(controlExec) error
18 kind daemonActionKind
19 axis layout.Axis
20 delta int
21 }{
22 {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2},
23 {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2},
24 {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1},
25 {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1},
26 {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0},
27 }
28 for _, tt := range tests {
29 t.Run(tt.name, func(t *testing.T) {
30 p := newMockdaemonActionRunner(t)
31 c := newMockdaemonActionRunner(t)
32 paletteRequests := recordActionRuns(p, nil)
33 controlRequests := recordActionRuns(c, nil)
34 require.NoError(t, tt.palette(paletteExec{actions: p}))
35 require.NoError(t, tt.control(controlExec{actions: c}))
36 require.Len(t, *paletteRequests, 1)
37 require.Equal(t, *paletteRequests, *controlRequests)
38 require.Equal(t, tt.kind, (*paletteRequests)[0].kind)
39 require.Equal(t, tt.axis, (*paletteRequests)[0].axis)
40 require.Equal(t, tt.delta, (*paletteRequests)[0].delta)
41 })
42 }
43}
44
45func TestControlEqualizeMapsResizeErrors(t *testing.T) {
46 runner := newMockdaemonActionRunner(t)
47 requests := recordActionRuns(runner, layout.ErrTooSmall)
48 err := (controlExec{actions: runner}).EqualizePanes()
49 require.ErrorIs(t, err, layout.ErrTooSmall)
50 var userErr *domain.UserError
51 require.ErrorAs(t, err, &userErr)
52 require.Len(t, *requests, 1)
53 require.Equal(t, daemonActionEqualizePanes, (*requests)[0].kind)
54}
55
56func TestResizeControlHeadlessAndErrors(t *testing.T) {
57 factory := &controlPTYFactory{}
58 d := newTestDaemon(t, factory, stubClock{})
59 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
60 sess := addControlSession(d, "work", "t_work", "p_work")
61
62 tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
63 require.False(t, tooEarly.Outcome == protocol.CommandSucceeded)
64 require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code)
65 require.Equal(t, "pane is not in a split", tooEarly.Text)
66
67 require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
68 tb := testAttachmentTab(sess)
69 tb.mu.Lock()
70 beforeFocus := tb.tree.Focus
71 beforeGeneration := tb.layoutGeneration
72 var target *pane
73 for id, candidate := range tb.panes {
74 if id != beforeFocus {
75 target = candidate
76 break
77 }
78 }
79 tb.mu.Unlock()
80 require.NotNil(t, target)
81
82 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
83 kind: daemonActionResizePane,
84 target: daemonActionTarget{session: sess, tab: tb, pane: target},
85 axis: layout.Width,
86 delta: resizeStepCols,
87 }))
88 tb.mu.Lock()
89 require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab")
90 require.Equal(t, beforeGeneration+1, tb.layoutGeneration)
91 tb.mu.Unlock()
92
93 equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
94 require.True(t, equalized.Outcome == protocol.CommandSucceeded, equalized.Text)
95 require.Empty(t, equalized.Output)
96}
97
98func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
99 var left, right int
100 pending := []byte(nil)
101 events := overlayEvents{left: func() { left++ }, right: func() { right++ }}
102 routeOverlayBytes([]byte("\x1b["), &pending, events)
103 require.Equal(t, []byte("\x1b["), pending)
104 routeOverlayBytes([]byte("D\x1bOC"), &pending, events)
105 require.Equal(t, 1, left)
106 require.Equal(t, 1, right)
107 require.Empty(t, pending)
108}
109
110// TestResizeModeKeysAndEscapes exercises the modal parser through the real
111// action seam. Every directional spelling must commit exactly one mutation;
112// parser prefixes are deliberately split across reads.
113
114func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
115 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
116 tb := testAttachmentTab(sess)
117 second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
118 tb.mu.Lock()
119 tb.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-2"}
120 tb.panes[second.id] = second
121 tb.mu.Unlock()
122 second.screen.Write([]byte("live resize content"))
123 require.NoError(t, d.enterResizeMode(sess, ac))
124 data := string(mustOutputData(t, sends))
125 require.Contains(t, data, "resize: h/j/k/l or arrows")
126 require.Contains(t, data, "live resize content")
127 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
128}
129
130type resizeLockProbePTY struct {
131 once sync.Once
132 entered chan struct{}
133 release chan struct{}
134 check func()
135}
136
137func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil }
138func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
139func (*resizeLockProbePTY) Close() error { return nil }
140func (*resizeLockProbePTY) Pid() int { return 0 }
141func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
142func (p *resizeLockProbePTY) Resize(domain.Geometry) error {
143 p.once.Do(func() {
144 func() {
145 defer close(p.entered)
146 p.check()
147 }()
148 <-p.release
149 })
150 return nil
151}
152
153func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
154 // Build a live tree directly so the probe is not concurrently owned by a
155 // pane reader; this isolates the lock boundary being asserted.
156 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
157 tb := testAttachmentTab(sess)
158 probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
159 second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
160 tb.mu.Lock()
161 tb.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-2"}
162 tb.panes[second.id] = second
163 tb.mu.Unlock()
164 probe.check = func() {
165 if tb.mu.TryLock() {
166 tb.mu.Unlock()
167 } else {
168 t.Error("PTY callback must not inherit tab.mu")
169 }
170 if ac.overlays.resizeMu.TryLock() {
171 ac.overlays.resizeMu.Unlock()
172 } else {
173 t.Error("PTY callback must not inherit resize overlay mutex")
174 }
175 }
176 require.NoError(t, d.enterResizeMode(sess, ac))
177 done := make(chan struct{})
178 go func() { d.handleResizeInput(ac, []byte("l")); close(done) }()
179 <-probe.entered
180 close(probe.release)
181 <-done
182}