v0.3.0 99dc266

render_pipeline_cache_test.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"io"
6	"maps"
7	"testing"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/usecase/layout"
13	themeui "github.com/bnema/vev/internal/usecase/theme"
14	"github.com/bnema/vev/internal/usecase/ui"
15	"github.com/stretchr/testify/require"
16)
17
18type cacheFailTransport struct{}
19
20func (cacheFailTransport) Send(ports.Frame) error     { return errors.New("send failed") }
21func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
22func (cacheFailTransport) Close() error               { return nil }
23
24func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
25	for _, failure := range []struct {
26		name  string
27		apply func(*composedRenderFrame, *attachedClient)
28	}{
29		{
30			name: "prepare",
31			apply: func(composed *composedRenderFrame, _ *attachedClient) {
32				// Frame validation fails in output.prepare after composition.
33				composed.frame = renderer.Frame{Width: 1}
34			},
35		},
36		{
37			name:  "send",
38			apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
39		},
40	} {
41		t.Run(failure.name, func(t *testing.T) {
42			d, sess, ac, sends := newManualSessionWithPTYs(t)
43			healthy := ac.transport()
44			committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
45			ac.pipelineCache = committed.cache
46			before := cloneComposeCache(ac.pipelineCache)
47			pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
48			failure.apply(&pending, ac)
49
50			state := cacheState("next", 2)
51			state.attachment = ac
52			ac.sendMu.Lock()
53			require.True(t, d.emitFrame(sess, ac, &state, pending))
54			require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
55
56			// A send error detaches the failed link. Re-own this test attachment with
57			// its original healthy transport, then retry the pending state.
58			if failure.name == "send" {
59				sess.mu.Lock()
60				sess.registerAttachmentLocked(ac)
61				sess.mu.Unlock()
62				ac.setSession(sess)
63				ac.replaceTransport(healthy)
64			}
65			pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
66			state = cacheState("next", 2)
67			state.attachment = ac
68			ac.sendMu.Lock()
69			require.True(t, d.emitFrame(sess, ac, &state, pending))
70			require.Equal(t, pending.cache, ac.pipelineCache)
71			frame := <-sends
72			output, err := ports.UnmarshalOutput(frame.Payload)
73			require.NoError(t, err)
74			require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
75		})
76	}
77}
78
79func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
80	visible := cacheState("base", 1)
81	committed := composeFrame(visible, composeCacheInput{})
82
83	hidden := visible
84	hidden.floating = capturedFloatingRenderState{}
85	hidden.panes[0].damage = nil
86	result := composeFrame(hidden, committed.cache, committed.cache)
87
88	require.Equal(t, 's', result.frame.Row(1)[2].Rune)
89	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
90}
91
92func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
93	theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
94	initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
95	committed := composeFrame(initial, composeCacheInput{})
96	require.Equal(t, '│', committed.frame.At(20, 1).Rune)
97	focusedStyle := committed.frame.At(0, 1).Style
98	dimmedStyle := committed.frame.At(21, 1).Style
99	require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, dimmedStyle.ForegroundRGB, "inactive panes should use stronger foreground dimming")
100
101	undamaged := initial
102	undamaged.reset = false
103	undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()})
104	undamaged.panes[0].damage = nil
105	undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
106	undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}}
107	out := composeFrame(undamaged, committed.cache, composeCacheInput{})
108	require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit")
109	require.Equal(t, 'x', out.frame.At(21, 1).Rune)
110	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1})
111
112	focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme)
113	focusChanged.reset = false
114	focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil
115	out = composeFrame(focusChanged, out.cache, committed.cache)
116	require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed")
117	require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed")
118	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
119
120	vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme)
121	vertical.reset = false
122	vertical.panes[0].damage, vertical.panes[1].damage = nil, nil
123	out = composeFrame(vertical, out.cache, committed.cache)
124	require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider")
125	require.Equal(t, '─', out.frame.At(20, 3).Rune)
126	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
127}
128
129func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
130	root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
131		{Kind: layout.Leaf, Leaf: "left", Weight: 1},
132		{Kind: layout.Leaf, Leaf: "right", Weight: 1},
133	}}
134	stateFor := func(reset bool, damage []renderer.Damage) capturedRenderState {
135		placements, ok := layout.Solve(root, domain.Rect{Width: 61, Height: 5})
136		require.True(t, ok)
137		panes := make([]capturedPaneRenderState, 0, len(placements))
138		for _, placement := range placements {
139			frame := renderer.NewFrame(placement.Content.Width, placement.Content.Height)
140			for y := 0; y < frame.Height; y++ {
141				for x := 0; x < frame.Width; x++ {
142					frame.Set(x, y, renderer.Cell{Rune: rune(placement.ID[0]), Style: renderer.DefaultStyle()})
143				}
144			}
145			panes = append(panes, capturedPaneRenderState{
146				id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left", damage: damage,
147			})
148		}
149		return capturedRenderState{
150			reset:  reset,
151			layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
152			panes:  panes, styles: resolveStyles(nil),
153		}
154	}
155
156	initial := stateFor(true, []renderer.Damage{renderer.FullRedraw()})
157	committed := composeFrame(initial, composeCacheInput{})
158	root.Children[0].Weight = 2
159	next := stateFor(false, nil)
160	require.NotEqual(t, initial.layout.fingerprint, next.layout.fingerprint)
161	require.Equal(t, 41, next.layout.placements[1].Content.X)
162	out := composeFrame(next, committed.cache, composeCacheInput{})
163	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
164	require.Equal(t, 'l', out.frame.At(31, 1).Rune, "the enlarged pane must replace its old divider/cache footprint")
165	require.Equal(t, 'r', out.frame.At(41, 1).Rune, "weight-derived pane geometry must be reblitted at its new position")
166}
167
168func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
169	theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
170	neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
171	expected := themeui.NewDimmer(theme).Dim(neutralBorder)
172
173	t.Run("divider", func(t *testing.T) {
174		state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
175		state.styles.NeutralBorder = neutralBorder
176
177		out := composeFrame(state, composeCacheInput{})
178		require.Equal(t, expected, out.frame.At(20, 1).Style)
179	})
180
181	t.Run("unfocused title", func(t *testing.T) {
182		state := cachedStackTitleState("collapsed", 1, true)
183		state.theme = theme
184		state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
185		state.styles.NeutralBorder = neutralBorder
186
187		out := composeFrame(state, composeCacheInput{})
188		require.Equal(t, expected, out.frame.At(0, 1).Style)
189	})
190}
191
192func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
193	state := cachedStackTitleState("collapsed", 1, true)
194	state.theme = themeui.Theme{
195		Known:      true,
196		TrueColor:  true,
197		HasFG:      true,
198		HasBG:      true,
199		Foreground: renderer.RGB{R: 220, G: 210, B: 200},
200		Background: renderer.RGB{R: 20, G: 30, B: 40},
201	}
202	state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
203	before := cloneCapturedStackState(state)
204
205	out := composeFrame(state, composeCacheInput{})
206
207	collapsedTitle := out.frame.At(0, 1)
208	require.Equal(t, 'c', collapsedTitle.Rune)
209	require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome")
210	require.Equal(t, 'E', out.frame.At(0, 2).Rune, "expanded pane must draw no title row; its content starts where the title bar used to be")
211	require.Equal(t, before, state, "composition must not mutate the captured source frame")
212}
213
214func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
215	initial := cachedStackTitleState("one", 1, true)
216	committed := composeFrame(initial, composeCacheInput{})
217
218	renamed := initial
219	renamed.reset = false
220	renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
221	out := composeFrame(renamed, committed.cache, composeCacheInput{})
222	require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
223	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
224
225	unchanged := composeFrame(renamed, out.cache, committed.cache)
226	require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
227
228	bars := cacheBarState("one", true)
229	committed = composeFrame(bars, composeCacheInput{})
230	bars.reset = false
231	bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
232	out = composeFrame(bars, committed.cache, composeCacheInput{})
233	for _, damage := range out.damage {
234		require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
235		require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
236	}
237	bars.panes[0].damage = nil
238	bars.bars.bottomRight = "two"
239	out = composeFrame(bars, out.cache, committed.cache)
240	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
241	require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
242}
243
244func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
245	state := cachedStackTitleState("title", 1, true)
246	committed, scratch := composeCacheInput{}, composeCacheInput{}
247	compose := func() {
248		out := composeFrame(state, committed, scratch)
249		scratch, committed = committed, out.cache
250		state.reset = false
251	}
252	compose()
253	compose()
254	compose()
255
256	allocs := testing.AllocsPerRun(100, compose)
257	require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
258}
259
260func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
261	left, right := layout.PaneID("left"), layout.PaneID("right")
262	area := domain.Rect{Width: 41, Height: 5}
263	root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
264	placements, dividers, ok := layout.SolveWithDividers(root, area)
265	if !ok {
266		panic("cached split fixture has invalid geometry")
267	}
268	leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
269	rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
270	return capturedRenderState{
271		reset:  true,
272		layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
273		panes:  []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
274		theme:  theme,
275		styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
276	}
277}
278
279func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
280	first, second := layout.PaneID("first"), layout.PaneID("second")
281	placements := []layout.Placement{
282		{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
283		{ID: second, Content: domain.Rect{Y: 1, Width: 20, Height: 4}, InStack: true},
284	}
285	return capturedRenderState{
286		reset:  reset,
287		layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
288		panes:  []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
289		styles: resolveStyles(nil),
290	}
291}
292
293func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
294	out := in
295	out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
296	out.panes = append([]capturedPaneRenderState(nil), in.panes...)
297	for i := range out.panes {
298		if in.panes[i].frame.Cells != nil {
299			out.panes[i].frame = in.panes[i].frame.Clone()
300		}
301		out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
302		out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
303	}
304	return out
305}
306
307func cacheBarState(bottom string, reset bool) capturedRenderState {
308	pane := cachePaneFrame(6, 1, 'P')
309	placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
310	return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)}
311}
312
313func cachePaneFrame(width, height int, r rune) renderer.Frame {
314	frame := renderer.NewFrame(width, height)
315	frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
316	return frame
317}
318
319func cacheState(title string, generation uint64) capturedRenderState {
320	pane := renderer.NewFrame(6, 1)
321	for x, r := range title[:min(len(title), 6)] {
322		pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
323	}
324	floating := renderer.NewFrame(2, 1)
325	floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
326	return capturedRenderState{
327		reset:    false,
328		layout:   capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
329		panes:    []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
330		floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
331		bars:     barState{topRight: title, bottomRight: title},
332		styles:   resolveStyles(nil),
333	}
334}
335
336func cloneComposeCache(in composeCacheInput) composeCacheInput {
337	out := in
338	out.frame = in.frame.Clone()
339	out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
340	maps.Copy(out.titleGenerations, in.titleGenerations)
341	out.damage = append([]renderer.Damage(nil), in.damage...)
342	out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...)
343	out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
344	out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
345	return out
346}

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