1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/ui"
11)
12
13// TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a
14// client whose terminal window lost focus shows the bell of its own tab but
15// never clears it, while a focused client, or one whose terminal never
16// reports focus, clears it on its visible paint as before.
17func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) {
18 tests := []struct {
19 name string
20 focus domain.TerminalFocus
21 wantAcked bool
22 }{
23 {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
24 {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
25 {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
26 }
27 for _, tt := range tests {
28 t.Run(tt.name, func(t *testing.T) {
29 d, sess, ac, sends, releases := newManualTabSession(t, 1)
30 defer releases[0]()
31 selectTestAttachmentTab(sess, 0)
32 ac.setTerminalFocus(tt.focus)
33 d.noteAttention(sess, sess.tabs[0])
34
35 d.setAttentionFrame(1)
36 d.paint(sess, ac, true, nil)
37 require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell")
38
39 sess.mu.Lock()
40 defer sess.mu.Unlock()
41 require.Equal(t, !tt.wantAcked, sess.tabs[0].attention)
42 })
43 }
44}
45
46// TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a
47// hidden client left pending clears once that client's window is focused and
48// paints again.
49func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) {
50 d, sess, ac, sends, releases := newManualTabSession(t, 1)
51 defer releases[0]()
52 selectTestAttachmentTab(sess, 0)
53 ac.setTerminalFocus(domain.TerminalFocusUnfocused)
54 d.noteAttention(sess, sess.tabs[0])
55 d.setAttentionFrame(1)
56 d.paint(sess, ac, true, nil)
57 mustOutputData(t, sends)
58 require.True(t, sess.anyAttention(), "hidden client keeps the bell")
59
60 require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused))
61 require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change")
62 d.paint(sess, ac, true, nil)
63 mustOutputData(t, sends)
64 require.False(t, sess.anyAttention(), "focused client clears the bell")
65}
66
67// TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in
68// Hello is the attachment's focus from its first paint, so a hidden window
69// that opens an attachment never clears the bell of the tab it lands on.
70func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) {
71 tests := []struct {
72 name string
73 focus domain.TerminalFocus
74 wantAcked bool
75 }{
76 {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
77 {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
78 {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
79 }
80 for _, tt := range tests {
81 t.Run(tt.name, func(t *testing.T) {
82 d, sess, first, _, releases := newManualTabSession(t, 1)
83 defer releases[0]()
84 first.setTerminalFocus(domain.TerminalFocusUnfocused)
85 d.noteAttention(sess, sess.tabs[0])
86
87 tr, sends := newCapturingTransport(t)
88 peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus})
89 require.NoError(t, err)
90 require.Equal(t, tt.focus, peer.terminalFocus())
91 require.True(t, sess.switchAttachmentTab(peer, 0))
92 d.setAttentionFrame(1)
93 d.paint(sess, peer, true, nil)
94 mustOutputData(t, sends)
95 require.Equal(t, !tt.wantAcked, sess.anyAttention())
96 })
97 }
98}
99
100// TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report:
101// only a gain of focus repaints, and the bell a focused window finds on its
102// tab is drawn for one pulse before a later paint acknowledges it.
103func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
104 d, sess, ac, sends, releases := newManualTabSession(t, 1)
105 defer releases[0]()
106 selectTestAttachmentTab(sess, 0)
107 effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}}
108
109 d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused})
110 require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus())
111 require.Empty(t, sends, "losing focus does not repaint")
112 d.noteAttention(sess, sess.tabs[0])
113
114 d.setAttentionFrame(0)
115 d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
116 require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus())
117 mustOutputData(t, sends)
118 require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame")
119
120 d.setAttentionFrame(1)
121 d.paint(sess, ac, false, nil)
122 require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell")
123 require.False(t, sess.anyAttention(), "the drawn bell is acknowledged")
124
125 // The acknowledgement repaints every client; drain those frames.
126 for len(sends) > 0 {
127 <-sends
128 }
129 d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
130 require.Empty(t, sends, "an unchanged focus does not repaint")
131}
132
133// TestFocusGainClaimsSharedGeometry pins that a client whose terminal window
134// gains focus becomes the shared geometry claimant, while losing focus leaves
135// the current claimant in place and a repeated report never claims again.
136func TestFocusGainClaimsSharedGeometry(t *testing.T) {
137 tests := []struct {
138 name string
139 prior domain.TerminalFocus
140 focus domain.TerminalFocus
141 wantClaimed bool
142 }{
143 {name: "unknown to focused claims", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusFocused, wantClaimed: true},
144 {name: "unfocused to focused claims", prior: domain.TerminalFocusUnfocused, focus: domain.TerminalFocusFocused, wantClaimed: true},
145 {name: "repeated focused report does not claim", prior: domain.TerminalFocusFocused, focus: domain.TerminalFocusFocused, wantClaimed: false},
146 {name: "focus loss keeps claimant", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusUnfocused, wantClaimed: false},
147 }
148 for _, tt := range tests {
149 t.Run(tt.name, func(t *testing.T) {
150 d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
151 first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
152 first.setTerminalFocus(tt.prior)
153 second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
154 require.True(t, sess.geometry.reconcile(d, sess, second))
155 requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
156
157 token := sess.captureAttachmentCapability(first, first.transport())
158 first.installTestAttachmentCapability(token)
159 effect, admitted := first.beginAttachmentEffect(token)
160 require.True(t, admitted)
161 d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: tt.focus})
162 effect.End()
163
164 if tt.wantClaimed {
165 requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
166 return
167 }
168 requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
169 })
170 }
171}
172
173// TestUnfocusedClaimDoesNotTakeGeometry pins the claim ranking: a claim from
174// an attachment that reported losing focus (a resumed or tiled background
175// window) never displaces one that may be seen, but still wins once it is the
176// only candidate. Without focus reports the newest claim wins as before.
177func TestUnfocusedClaimDoesNotTakeGeometry(t *testing.T) {
178 tests := []struct {
179 name string
180 firstFocus domain.TerminalFocus
181 lateFocus domain.TerminalFocus
182 wantLate bool
183 }{
184 {name: "unfocused late claim loses to focused", firstFocus: domain.TerminalFocusFocused, lateFocus: domain.TerminalFocusUnfocused, wantLate: false},
185 {name: "unfocused late claim loses to unknown", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnfocused, wantLate: false},
186 {name: "unknown focus keeps newest-claim rule", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnknown, wantLate: true},
187 {name: "focused late claim wins", firstFocus: domain.TerminalFocusUnfocused, lateFocus: domain.TerminalFocusFocused, wantLate: true},
188 }
189 for _, tt := range tests {
190 t.Run(tt.name, func(t *testing.T) {
191 d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
192 first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
193 first.setTerminalFocus(tt.firstFocus)
194 require.True(t, sess.geometry.reconcile(d, sess, first))
195 requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
196
197 late := &attachedClient{output: newOutputStateStream()}
198 late.tr, _ = newCapturingTransport(t)
199 late.output.attachment = late
200 late.initOverlays()
201 late.setGeometry(domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
202 late.setTerminalFocus(tt.lateFocus)
203 late.setSession(sess)
204 require.True(t, sess.registerAttachment(late)) // attach and resume claim here
205 sess.geometry.reconcile(d, sess, late) // as a client Resize does
206
207 if !tt.wantLate {
208 requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
209 // Once the seen claimant leaves, the unfocused claim is the only one.
210 d.clientGone(sess, first, first.transport(), true)
211 sess.geometry.reconcile(d, sess, nil)
212 }
213 requireMultiplexGeometry(t, sess, late, domain.Size{Cols: 120, Rows: 38})
214 })
215 }
216}