1package daemon
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/layout"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 scopy "github.com/bnema/vev/internal/usecase/copy"
14)
15
16func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
17 pty, release := newBlockingPTY(t)
18 defer release()
19 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20 d.enterCopyMode(sess, ac)
21 require.True(t, ac.overlays.copyActive())
22 ac.overlays.copyMu.Lock()
23 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
24 ac.overlays.copyMu.Unlock()
25
26 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
27
28 require.False(t, ac.overlays.copyActive())
29 ac.overlays.copyMu.Lock()
30 require.False(t, ac.overlays.copyPointer.valid)
31 require.False(t, ac.overlays.copyClick.valid)
32 ac.overlays.copyMu.Unlock()
33}
34
35func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
36 d, sess, ac, _, releases := newManualTabSession(t, 2)
37 defer func() {
38 for _, release := range releases {
39 release()
40 }
41 }()
42 d.enterCopyMode(sess, ac)
43 require.True(t, ac.overlays.copyActive())
44 ac.overlays.copyMu.Lock()
45 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
46 ac.overlays.copyMu.Unlock()
47
48 require.True(t, selectTestAttachmentTab(sess, 1))
49 d.activateTab(sess, testAttachmentTab(sess))
50
51 require.False(t, ac.overlays.copyActive())
52 ac.overlays.copyMu.Lock()
53 require.False(t, ac.overlays.copyPointer.valid)
54 require.False(t, ac.overlays.copyClick.valid)
55 ac.overlays.copyMu.Unlock()
56}
57
58func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
59 for _, tt := range []struct {
60 name string
61 leaseBacked bool
62 }{
63 {name: "direct", leaseBacked: false},
64 {name: "lease-backed", leaseBacked: true},
65 } {
66 t.Run(tt.name, func(t *testing.T) {
67 d, sess, ac, _, releases := newManualTabSession(t, 2)
68 defer releaseAll(releases)
69 rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
70 rc.attach(ac)
71 sess.installRenderCoordinator(rc)
72 defer func() { rc.beginSessionTeardown().finish() }()
73 path := make(chan bool, 1)
74 rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked }
75 var lease *attachmentLease
76 if tt.leaseBacked {
77 lease = rc.attachmentLease(ac)
78 require.NotNil(t, lease)
79 }
80
81 d.enterCopyMode(sess, ac)
82 require.True(t, ac.overlays.copyActive())
83 d.activateTabAfterResizeForLease(sess, testAttachmentTab(sess), true, ac, lease)
84
85 require.False(t, ac.overlays.copyActive())
86 require.Equal(t, tt.leaseBacked, <-path)
87 })
88 }
89}
90
91func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
92 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
93 tb := testAttachmentTab(sess)
94 closingPTY := portsmocks.NewMockPTY(t)
95 closingPTY.EXPECT().Close().Return(nil).Once()
96 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"}
97 closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
98 tb.panes["pane-2"] = closing
99 ac := &attachedClient{output: newOutputStateStream()}
100 ac.initOverlays()
101 sess.registerAttachment(ac)
102 ac.setSession(sess)
103 closing.mu.Lock()
104 document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105 closing.mu.Unlock()
106 ac.overlays.copyMu.Lock()
107 ac.overlays.copyPane = closing
108 ac.overlays.copyDocument = document
109 ac.overlays.copyMode = scopy.NewMode(document)
110 seedCopyInteractionLocked(ac.overlays, closing, document)
111 ac.overlays.copyMu.Unlock()
112
113 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
114 require.False(t, ac.overlays.copyActive())
115 require.Nil(t, copyTargetPane(ac.overlays))
116 ac.overlays.copyMu.Lock()
117 require.False(t, ac.overlays.copyPointer.valid)
118 require.False(t, ac.overlays.copyClick.valid)
119 ac.overlays.copyMu.Unlock()
120}
121
122func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
123 d, sess, ac, _, releases := newManualTabSession(t, 2)
124 defer func() {
125 for _, release := range releases {
126 release()
127 }
128 }()
129 tb := testAttachmentTab(sess)
130 p := tb.focusedPane()
131 d.enterCopyMode(sess, ac)
132 require.True(t, ac.overlays.copyActive())
133
134 require.NoError(t, d.closePane(sess, tb, p.id, nil, false))
135 require.False(t, ac.overlays.copyActive())
136 require.Nil(t, copyTargetPane(ac.overlays))
137}
138
139func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
140 d, sess, ac, _, releases := newManualTabSession(t, 2)
141 defer func() {
142 for _, release := range releases {
143 release()
144 }
145 }()
146 tb := testAttachmentTab(sess)
147 p := tb.focusedPane()
148 p.mu.Lock()
149 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150 p.mu.Unlock()
151
152 // Pause after candidate publication, then make the captured target stale.
153 // A render snapshot while paused must not expose the candidate document.
154 reached := make(chan struct{})
155 resume := make(chan struct{})
156 done := make(chan struct{})
157 d.beforeCopyModeRevalidate = func() {
158 close(reached)
159 <-resume
160 }
161 resumed := false
162 defer func() {
163 if !resumed {
164 close(resume)
165 }
166 select {
167 case <-done:
168 d.beforeCopyModeRevalidate = nil
169 case <-time.After(time.Second):
170 t.Error("copy mode publication did not finish after revalidation gate was released")
171 }
172 }()
173
174 result := make(chan bool, 1)
175 go func() {
176 defer close(done)
177 result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
178 }()
179 select {
180 case <-reached:
181 case <-time.After(time.Second):
182 t.Fatal("copy mode publication did not reach revalidation gate")
183 }
184
185 renderSnapshot := ac.overlays.SnapshotForRender()
186 candidateWasRenderable := renderSnapshot.copyActive
187 renderSnapshot.Unlock()
188 require.True(t, selectTestAttachmentTab(sess, 1))
189 close(resume)
190 resumed = true
191
192 select {
193 case published := <-result:
194 require.False(t, published)
195 case <-time.After(time.Second):
196 t.Fatal("copy mode publication did not finish after revalidation gate was released")
197 }
198 require.False(t, candidateWasRenderable)
199 require.False(t, ac.overlays.copyActive())
200 require.Nil(t, copyTargetPane(ac.overlays))
201}
202
203func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) {
204 for _, tc := range []struct {
205 name string
206 invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane)
207 }{
208 {
209 name: "release during publication",
210 invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) {
211 d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))
212 },
213 },
214 {
215 name: "pane close during publication",
216 invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) {
217 require.NoError(t, d.closePane(sess, tb, p.id, ac, false))
218 },
219 },
220 } {
221 t.Run(tc.name, func(t *testing.T) {
222 d, sess, ac, _, releases := newManualTabSession(t, 2)
223 defer func() {
224 for _, release := range releases {
225 release()
226 }
227 }()
228 tb := testAttachmentTab(sess)
229 p := tb.focusedPane()
230
231 // A real press is required: the motion publication must inherit this
232 // pointer epoch, rather than publishing a fresh interaction.
233 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
234 ac.overlays.copyMu.Lock()
235 require.True(t, ac.overlays.copyPointer.valid)
236 ac.overlays.copyMu.Unlock()
237
238 reached := make(chan struct{})
239 resume := make(chan struct{})
240 d.beforeCopyModeRevalidate = func() {
241 close(reached)
242 <-resume
243 }
244 resumed := false
245 defer func() {
246 if !resumed {
247 close(resume)
248 }
249 d.beforeCopyModeRevalidate = nil
250 }()
251 done := make(chan struct{})
252 go func() {
253 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
254 close(done)
255 }()
256 select {
257 case <-reached:
258 case <-time.After(time.Second):
259 t.Fatal("copy mode publication did not reach revalidation gate")
260 }
261
262 tc.invalidate(t, d, sess, ac, tb, p)
263 close(resume)
264 resumed = true
265 select {
266 case <-done:
267 case <-time.After(time.Second):
268 t.Fatal("copy mode publication did not finish after revalidation gate was released")
269 }
270
271 ac.overlays.copyMu.Lock()
272 require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
273 require.Nil(t, ac.overlays.copyCandidate)
274 require.False(t, ac.overlays.copyPointer.valid)
275 require.False(t, ac.overlays.copyClick.valid)
276 ac.overlays.copyMu.Unlock()
277 })
278 }
279}
280
281func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) {
282 d, sess, ac, _, releases := newManualTabSession(t, 2)
283 defer func() {
284 for _, release := range releases {
285 release()
286 }
287 }()
288 tb := testAttachmentTab(sess)
289 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
290 installTestFloating(tb, floating, true)
291 tb.mu.Lock()
292 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
293 tb.mu.Unlock()
294 require.True(t, visible)
295 press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
296 motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3)
297
298 d.handleInput(sess, ac, press)
299 ac.overlays.copyMu.Lock()
300 require.True(t, ac.overlays.copyPointer.valid)
301 ac.overlays.copyMu.Unlock()
302
303 reached := make(chan struct{})
304 resume := make(chan struct{})
305 d.beforeCopyModeRevalidate = func() {
306 close(reached)
307 <-resume
308 }
309 resumed := false
310 defer func() {
311 if !resumed {
312 close(resume)
313 }
314 d.beforeCopyModeRevalidate = nil
315 }()
316 done := make(chan struct{})
317 go func() {
318 d.handleInput(sess, ac, motion)
319 close(done)
320 }()
321 select {
322 case <-reached:
323 case <-time.After(time.Second):
324 t.Fatal("copy mode publication did not reach revalidation gate")
325 }
326 d.teardownFloating(tb, ac)
327 close(resume)
328 resumed = true
329 select {
330 case <-done:
331 case <-time.After(time.Second):
332 t.Fatal("copy mode publication did not finish after revalidation gate was released")
333 }
334
335 ac.overlays.copyMu.Lock()
336 require.Nil(t, ac.overlays.copyMode)
337 require.Nil(t, ac.overlays.copyCandidate)
338 require.False(t, ac.overlays.copyPointer.valid)
339 require.False(t, ac.overlays.copyClick.valid)
340 ac.overlays.copyMu.Unlock()
341}
342
343func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
344 for _, tc := range []struct {
345 name string
346 invalidate func(*session, *tab, *pane)
347 }{
348 {
349 name: "removed pane",
350 invalidate: func(_ *session, tb *tab, p *pane) {
351 tb.mu.Lock()
352 delete(tb.panes, p.id)
353 tb.mu.Unlock()
354 },
355 },
356 {
357 name: "inactive tab",
358 invalidate: func(sess *session, _ *tab, _ *pane) {
359 require.True(t, selectTestAttachmentTab(sess, 1))
360 },
361 },
362 } {
363 t.Run(tc.name, func(t *testing.T) {
364 d, sess, ac, _, releases := newManualTabSession(t, 2)
365 defer func() {
366 for _, release := range releases {
367 release()
368 }
369 }()
370 tb := testAttachmentTab(sess)
371 p := tb.focusedPane()
372 p.mu.Lock()
373 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374 p.mu.Unlock()
375 tc.invalidate(sess, tb, p)
376
377 require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil, nil))
378 require.False(t, ac.overlays.copyActive())
379 require.Nil(t, copyTargetPane(ac.overlays))
380 })
381 }
382}