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