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/domain/layout"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/wire"
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, 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, 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 HasFG: true,
201 HasBG: true,
202 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
203 Background: renderer.RGB{R: 20, G: 30, B: 40},
204 }
205 state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
206 before := cloneCapturedStackState(state)
207
208 out := composeFrame(state, composeCacheInput{})
209
210 collapsedTitle := out.frame.At(0, 1)
211 require.Equal(t, 'c', collapsedTitle.Rune)
212 require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome")
213 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")
214 require.Equal(t, before, state, "composition must not mutate the captured source frame")
215}
216
217func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
218 initial := cachedStackTitleState("one", 1, true)
219 committed := composeFrame(initial, composeCacheInput{})
220
221 renamed := initial
222 renamed.reset = false
223 renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
224 out := composeFrame(renamed, committed.cache, composeCacheInput{})
225 require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
226 require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
227
228 unchanged := composeFrame(renamed, out.cache, committed.cache)
229 require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
230
231 bars := cacheBarState("one", true)
232 committed = composeFrame(bars, composeCacheInput{})
233 bars.reset = false
234 bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
235 out = composeFrame(bars, committed.cache, composeCacheInput{})
236 for _, damage := range out.damage {
237 require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
238 require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
239 }
240 bars.panes[0].damage = nil
241 bars.bars.bottomRight = "two"
242 out = composeFrame(bars, out.cache, committed.cache)
243 require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
244 require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
245}
246
247func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
248 state := cachedStackTitleState("title", 1, true)
249 committed, scratch := composeCacheInput{}, composeCacheInput{}
250 compose := func() {
251 out := composeFrame(state, committed, scratch)
252 scratch, committed = committed, out.cache
253 state.reset = false
254 }
255 compose()
256 compose()
257 compose()
258
259 allocs := testing.AllocsPerRun(100, compose)
260 require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
261}
262
263func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
264 left, right := layout.PaneID("left"), layout.PaneID("right")
265 area := domain.Rect{Width: 41, Height: 5}
266 root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
267 placements, dividers, ok := layout.SolveWithDividers(root, area)
268 if !ok {
269 panic("cached split fixture has invalid geometry")
270 }
271 leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
272 rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
273 return capturedRenderState{
274 reset: true,
275 layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
276 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()}}},
277 theme: theme,
278 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
279 }
280}
281
282func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
283 first, second := layout.PaneID("first"), layout.PaneID("second")
284 placements := []layout.Placement{
285 {ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
286 {ID: second, Content: domain.Rect{Y: 1, Width: 20, Height: 4}, InStack: true},
287 }
288 return capturedRenderState{
289 reset: reset,
290 layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
291 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()}}},
292 styles: resolveStyles(nil),
293 }
294}
295
296func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
297 out := in
298 out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
299 out.panes = append([]capturedPaneRenderState(nil), in.panes...)
300 for i := range out.panes {
301 out.panes[i].frame = in.panes[i].frame.Clone()
302 out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
303 out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
304 }
305 return out
306}
307
308func cacheBarState(bottom string, reset bool) capturedRenderState {
309 pane := cachePaneFrame(6, 1, 'P')
310 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
311 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)}
312}
313
314func cachePaneFrame(width, height int, r rune) renderer.Frame {
315 frame := renderer.NewFrame(width, height)
316 frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
317 return frame
318}
319
320func cacheState(title string, generation uint64) capturedRenderState {
321 pane := renderer.NewFrame(6, 1)
322 for x, r := range title[:min(len(title), 6)] {
323 pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
324 }
325 floating := renderer.NewFrame(2, 1)
326 floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
327 return capturedRenderState{
328 route: publicationTestContext().Route,
329 view: attachmentView{tabID: "tab-1"},
330 focusedPaneID: "pane-1",
331 reset: false,
332 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
333 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()}}},
334 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},
335 bars: barState{topRight: title, bottomRight: title},
336 styles: resolveStyles(nil),
337 }
338}
339
340func cloneComposeCache(in composeCacheInput) composeCacheInput {
341 out := in
342 out.frame = in.frame.Clone()
343 out.copyViewport.frame = in.copyViewport.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}