1package daemon
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt"
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/bnema/vev/internal/usecase/picker"
11 "github.com/stretchr/testify/require"
12)
13
14func TestResetCaptureRefreshesPaneAfterPeerAcknowledgesSharedDamage(t *testing.T) {
15 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
16 p := sess.tabs[0].focusedPane()
17 p.mu.Lock()
18 p.screen.ClearDamage()
19 p.screen.Write([]byte("before"))
20 p.mu.Unlock()
21
22 ac.sendMu.Lock()
23 state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: false})
24 require.True(t, ok)
25 before := state.panes[0].frame.Clone()
26 ac.sendMu.Unlock()
27
28 p.mu.Lock()
29 p.screen.Write([]byte("after"))
30 damage := p.screen.CaptureDamage()
31 p.screen.AcknowledgeDamage(damage.Generation)
32 p.mu.Unlock()
33
34 ac.sendMu.Lock()
35 state, ok = captureRenderState(sess, ac, renderCaptureRequest{reset: true})
36 ac.sendMu.Unlock()
37 require.True(t, ok)
38 require.NotEqual(t, before, state.panes[0].frame, "reset capture must not reuse the pane snapshot retained before shared damage was acknowledged")
39}
40
41func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
42 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
43 p := sess.tabs[0].focusedPane()
44 p.mu.Lock()
45 p.screen.ClearDamage()
46 p.screen.Write([]byte("preview-safe"))
47 p.mu.Unlock()
48
49 preview := snapshotPickerPreview(sess.tabs[0])
50 require.NotEmpty(t, preview.Rows)
51 require.Empty(t, ac.renderScratch.receipts, "picker preview must not create primary damage receipts")
52 p.mu.Lock()
53 require.NotEmpty(t, p.screen.Damage(), "picker preview must not acknowledge VT damage")
54 p.mu.Unlock()
55
56 ac.sendMu.Lock()
57 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
58 bars: barState{},
59 overlays: capturedOverlayRenderState{},
60 preview: pickerPreviewEmpty(),
61 floatingCfg: domain.FloatingConfig{},
62 reset: false,
63 lease: nil,
64 })
65 ac.sendMu.Unlock()
66 require.True(t, ok)
67 require.Len(t, state.receipts, 1)
68 require.Same(t, p, state.receipts[0].pane, "the primary transaction owns the pane receipt")
69 p.mu.Lock()
70 require.NotEmpty(t, p.screen.Damage(), "primary capture remains non-destructive until emission commits")
71 p.mu.Unlock()
72}
73
74// These are deliberately end-to-end pipeline tests: the pending damage comes
75// from a real pane VT Write, not a hand-built FullRedraw.
76func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) {
77 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
78 tb := sess.tabs[0]
79 visible := tb.focusedPane()
80 collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
81 tb.panes[collapsed.id] = collapsed
82 tb.tree = &layout.Tree{
83 Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id},
84 Focus: visible.id,
85 }
86 visible.screen.ClearDamage()
87 collapsed.screen.ClearDamage()
88 collapsed.screen.Write([]byte("hidden damage"))
89
90 ac.sendMu.Lock()
91 state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
92 require.True(t, ok)
93 collapsedReceipt := false
94 for _, receipt := range state.receipts {
95 collapsedReceipt = collapsedReceipt || receipt.pane == collapsed
96 }
97 require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
98 require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
99 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100 require.True(t, d.emitFrame(sess, ac, state, composed))
101
102 <-sends
103 require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt")
104}
105
106func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
107 for _, tt := range []struct {
108 name string
109 fail func(*composedRenderFrame, *attachedClient)
110 restore func(*session, *attachedClient, ports.Transport)
111 }{
112 {
113 name: "prepare failure",
114 fail: func(c *composedRenderFrame, _ *attachedClient) {
115 // An invalid frame makes the real output.prepare reject after capture.
116 c.frame = renderer.Frame{Width: 1}
117 },
118 restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
119 },
120 {
121 name: "send failure",
122 fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
123 restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
124 sess.mu.Lock()
125 sess.registerAttachmentLocked(ac)
126 sess.mu.Unlock()
127 ac.setSession(sess)
128 ac.replaceTransport(healthy)
129 },
130 },
131 } {
132 t.Run(tt.name, func(t *testing.T) {
133 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
134 // Prepare failures now schedule recovery through the coordinator;
135 // retain the failed transaction here until this test explicitly retries.
136 d.clock = newNoticeClock()
137 d.attachCoordinator(sess, nil, ac, true)
138 healthy := ac.transport()
139 p := sess.tabs[0].focusedPane()
140
141 // Prime both the renderer shadow and the committed capture cache.
142 d.paint(sess, ac, true, nil)
143 <-sends
144 p.mu.Lock()
145 p.screen.Write([]byte("\x1b[1;1Hchanged"))
146 p.mu.Unlock()
147
148 state, composed := captureComposeForReceiptTest(t, sess, ac)
149 tt.fail(&composed, ac)
150 require.True(t, d.emitFrame(sess, ac, state, composed))
151 p.mu.Lock()
152 require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
153 p.mu.Unlock()
154
155 tt.restore(sess, ac, healthy)
156 state, composed = captureComposeForReceiptTest(t, sess, ac)
157 require.True(t, d.emitFrame(sess, ac, state, composed))
158 frame := <-sends
159 out, err := ports.UnmarshalOutput(frame.Payload)
160 require.NoError(t, err)
161 require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
162 p.mu.Lock()
163 require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
164 require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
165 p.mu.Unlock()
166 })
167 }
168}
169
170func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
171 clock := newNoticeClock()
172 d, sess, ac, _ := newNoticeFixture(t, clock)
173 rc := d.attachCoordinator(sess, nil, ac, true)
174 var producers []string
175 rc.opts.onInvalidate = func(inv renderInvalidation) {
176 // Both the notice repaint and the explicit recovery must re-enter only
177 // after emitFrame releases its attachment locks.
178 require.True(t, ac.sendMu.TryLock(), "invalidation must not run under sendMu")
179 ac.sendMu.Unlock()
180 producers = append(producers, inv.producer)
181 }
182 state, composed := captureComposeForReceiptTest(t, sess, ac)
183 // An invalid frame makes output.prepare fail after capture. emitFrame owns
184 // sendMu on entry and releases it before reporting and rescheduling.
185 composed.frame = renderer.Frame{Width: 1}
186
187 require.True(t, d.emitFrame(sess, ac, state, composed))
188
189 history := d.notices.history()
190 require.Len(t, history, 1)
191 require.Equal(t, domain.NoticeInternal, history[0].Code)
192 require.Equal(t, "display update failed", history[0].Message)
193 toasts, _ := visibleToasts(ac)
194 require.Len(t, toasts, 1)
195 require.Equal(t, "display update failed", toasts[0].Message)
196
197 rc.mu.Lock()
198 pending, reset := rc.pending, rc.pendingReset
199 rc.mu.Unlock()
200 require.True(t, pending, "prepare failure must schedule a recovery render")
201 require.True(t, reset, "recovery must invalidate the full render state")
202 require.Contains(t, producers, "render_pipeline.go:prepare-failed", "prepare failure must explicitly schedule recovery")
203 require.Contains(t, clock.durations(), urgentRenderDeadline, "notice/recovery invalidation must arm the coordinator")
204}
205
206func TestPrepareFailureFallbackOnlySuppressesRecursiveNoticePaint(t *testing.T) {
207 d, sess, ac, sends := newNoticeFixture(t, newNoticeClock())
208
209 // A failure while reportError is repainting its own toast is intentionally
210 // ignored. It must not start another notification/repaint cycle.
211 ac.prepareFailureFallback.Store(true)
212 state, composed := captureComposeForReceiptTest(t, sess, ac)
213 composed.frame = renderer.Frame{Width: 1}
214 require.True(t, d.emitFrame(sess, ac, state, composed))
215 require.Empty(t, d.notices.history())
216 ac.prepareFailureFallback.Store(false)
217
218 // Later outer failures each notify and repaint after sendMu has been
219 // released; neither may be hidden by the prior recursive guard.
220 for want := 1; want <= 2; want++ {
221 state, composed = captureComposeForReceiptTest(t, sess, ac)
222 composed.frame = renderer.Frame{Width: 1}
223 require.True(t, d.emitFrame(sess, ac, state, composed))
224 require.Len(t, d.notices.history(), want)
225 frame := <-sends
226 require.Equal(t, ports.MsgOutput, frame.Type, "outer failure %d must repaint its notice", want)
227 require.False(t, ac.prepareFailureFallback.Load())
228 }
229}
230
231func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
232 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
233 d.paint(sess, ac, true, nil)
234 <-sends
235 p := sess.tabs[0].focusedPane()
236 p.mu.Lock()
237 p.screen.Write([]byte("\x1b[1;1Hfirst"))
238 p.mu.Unlock()
239 state, composed := captureComposeForReceiptTest(t, sess, ac)
240 p.mu.Lock()
241 p.screen.Write([]byte("\x1b[2;1Hsecond"))
242 p.mu.Unlock()
243 require.True(t, d.emitFrame(sess, ac, state, composed))
244 <-sends
245 p.mu.Lock()
246 defer p.mu.Unlock()
247 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
248}
249
250func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
251 t.Helper()
252 ac.sendMu.Lock() // emitFrame releases the transaction lock.
253 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
254 bars: barState{},
255 overlays: capturedOverlayRenderState{},
256 preview: pickerPreviewEmpty(),
257 floatingCfg: domain.FloatingConfig{},
258 reset: false,
259 lease: nil,
260 })
261 require.True(t, ok)
262 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
263}
264
265func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }