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}