v0.2.0 80c61e3

render_damage_receipt_test.go RawHistoryFolder

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{} }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close