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