test(daemon): restore render pipeline cache coverage
- commit
- 66775dabba8ecda3d404496e78777c80df763fa2
- author
- brice · · 2026-07-14 20:13 UTC
- parent
- 30f99e6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +178 -3
M internal/usecase/daemon/floating_render_test.go +24 -0
| @@ -332,6 +332,30 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||
| 332 | 332 | } | |
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | + | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | |
| 336 | + | base := renderer.NewFrame(80, 24) | |
| 337 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| 338 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | |
| 339 | + | input := floatingComposeInput{ | |
| 340 | + | baseFrame: base, | |
| 341 | + | floating: capturedFloatingRenderState{ | |
| 342 | + | visible: true, | |
| 343 | + | pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1}, | |
| 344 | + | geometry: geometry, title: "float", generation: 1, titleGeneration: 1, | |
| 345 | + | }, | |
| 346 | + | content: content, | |
| 347 | + | cache: composeCacheInput{ | |
| 348 | + | valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1, | |
| 349 | + | }, | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) }) | |
| 353 | + | // The production entry point deliberately clones Frame.Cells and its row | |
| 354 | + | // offsets to keep the cached base immutable; those are its two unavoidable | |
| 355 | + | // allocations. A higher count would reintroduce avoidable cache churn. | |
| 356 | + | require.LessOrEqual(t, allocs, float64(2)) | |
| 357 | + | } | |
| 358 | + | ||
| 335 | 359 | func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) { | |
| 336 | 360 | base := renderer.NewFrame(80, 24) | |
| 337 | 361 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| @@ -332,6 +332,30 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||||
| 332 | } | 332 | } | ||
| 333 | } | 333 | } | ||
| 334 | 334 | ||||
| 335 | + | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | |||
| 336 | + | base := renderer.NewFrame(80, 24) | |||
| 337 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |||
| 338 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | |||
| 339 | + | input := floatingComposeInput{ | |||
| 340 | + | baseFrame: base, | |||
| 341 | + | floating: capturedFloatingRenderState{ | |||
| 342 | + | visible: true, | |||
| 343 | + | pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1}, | |||
| 344 | + | geometry: geometry, title: "float", generation: 1, titleGeneration: 1, | |||
| 345 | + | }, | |||
| 346 | + | content: content, | |||
| 347 | + | cache: composeCacheInput{ | |||
| 348 | + | valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1, | |||
| 349 | + | }, | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) }) | |||
| 353 | + | // The production entry point deliberately clones Frame.Cells and its row | |||
| 354 | + | // offsets to keep the cached base immutable; those are its two unavoidable | |||
| 355 | + | // allocations. A higher count would reintroduce avoidable cache churn. | |||
| 356 | + | require.LessOrEqual(t, allocs, float64(2)) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 335 | func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) { | 359 | func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) { | ||
| 336 | base := renderer.NewFrame(80, 24) | 360 | base := renderer.NewFrame(80, 24) | ||
| 337 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | 361 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | ||
M internal/usecase/daemon/render_damage_receipt_test.go +31 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/picker" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| @@ -45,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | |||
| 45 | 46 | ||
| 46 | 47 | // These are deliberately end-to-end pipeline tests: the pending damage comes | |
| 47 | 48 | // from a real pane VT Write, not a hand-built FullRedraw. | |
| 49 | + | func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) { | |
| 50 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 51 | + | tb := sess.tabs[0] | |
| 52 | + | visible := tb.focusedPane() | |
| 53 | + | collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 54 | + | tb.panes[collapsed.id] = collapsed | |
| 55 | + | tb.tree = &layout.Tree{ | |
| 56 | + | Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id}, | |
| 57 | + | Focus: visible.id, | |
| 58 | + | } | |
| 59 | + | visible.screen.ClearDamage() | |
| 60 | + | collapsed.screen.ClearDamage() | |
| 61 | + | collapsed.screen.Write([]byte("hidden damage")) | |
| 62 | + | ||
| 63 | + | ac.sendMu.Lock() | |
| 64 | + | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}}) | |
| 65 | + | require.True(t, ok) | |
| 66 | + | collapsedReceipt := false | |
| 67 | + | for _, receipt := range state.receipts { | |
| 68 | + | collapsedReceipt = collapsedReceipt || receipt.pane == collapsed | |
| 69 | + | } | |
| 70 | + | require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane") | |
| 71 | + | require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage") | |
| 72 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 73 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |
| 74 | + | ||
| 75 | + | <-sends | |
| 76 | + | require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt") | |
| 77 | + | } | |
| 78 | + | ||
| 48 | 79 | func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) { | |
| 49 | 80 | for _, tt := range []struct { | |
| 50 | 81 | name string | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | "github.com/bnema/vev/internal/usecase/picker" | 9 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 9 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| @@ -45,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | |||||
| 45 | 46 | ||||
| 46 | // These are deliberately end-to-end pipeline tests: the pending damage comes | 47 | // These are deliberately end-to-end pipeline tests: the pending damage comes | ||
| 47 | // from a real pane VT Write, not a hand-built FullRedraw. | 48 | // from a real pane VT Write, not a hand-built FullRedraw. | ||
| 49 | + | func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) { | |||
| 50 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 51 | + | tb := sess.tabs[0] | |||
| 52 | + | visible := tb.focusedPane() | |||
| 53 | + | collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23}) | |||
| 54 | + | tb.panes[collapsed.id] = collapsed | |||
| 55 | + | tb.tree = &layout.Tree{ | |||
| 56 | + | Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id}, | |||
| 57 | + | Focus: visible.id, | |||
| 58 | + | } | |||
| 59 | + | visible.screen.ClearDamage() | |||
| 60 | + | collapsed.screen.ClearDamage() | |||
| 61 | + | collapsed.screen.Write([]byte("hidden damage")) | |||
| 62 | + | ||||
| 63 | + | ac.sendMu.Lock() | |||
| 64 | + | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}}) | |||
| 65 | + | require.True(t, ok) | |||
| 66 | + | collapsedReceipt := false | |||
| 67 | + | for _, receipt := range state.receipts { | |||
| 68 | + | collapsedReceipt = collapsedReceipt || receipt.pane == collapsed | |||
| 69 | + | } | |||
| 70 | + | require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane") | |||
| 71 | + | require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage") | |||
| 72 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |||
| 73 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |||
| 74 | + | ||||
| 75 | + | <-sends | |||
| 76 | + | require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 48 | func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) { | 79 | func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) { | ||
| 49 | for _, tt := range []struct { | 80 | for _, tt := range []struct { | ||
| 50 | name string | 81 | name string | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +123 -3
| @@ -3,11 +3,13 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | + | "maps" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 14 | "github.com/stretchr/testify/require" | |
| 13 | 15 | ) | |
| @@ -86,6 +88,126 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |||
| 86 | 88 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage) | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 91 | + | func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) { | |
| 92 | + | 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}} | |
| 93 | + | initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |
| 94 | + | committed := composeFrame(initial, composeCacheInput{}) | |
| 95 | + | require.Equal(t, '│', committed.frame.At(20, 1).Rune) | |
| 96 | + | focusedStyle := committed.frame.At(0, 1).Style | |
| 97 | + | dimmedStyle := committed.frame.At(21, 1).Style | |
| 98 | + | ||
| 99 | + | undamaged := initial | |
| 100 | + | undamaged.reset = false | |
| 101 | + | undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()}) | |
| 102 | + | undamaged.panes[0].damage = nil | |
| 103 | + | undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 104 | + | undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}} | |
| 105 | + | out := composeFrame(undamaged, committed.cache, composeCacheInput{}) | |
| 106 | + | require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit") | |
| 107 | + | require.Equal(t, 'x', out.frame.At(21, 1).Rune) | |
| 108 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1}) | |
| 109 | + | ||
| 110 | + | focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme) | |
| 111 | + | focusChanged.reset = false | |
| 112 | + | focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil | |
| 113 | + | out = composeFrame(focusChanged, out.cache, committed.cache) | |
| 114 | + | require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed") | |
| 115 | + | require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed") | |
| 116 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 117 | + | ||
| 118 | + | vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme) | |
| 119 | + | vertical.reset = false | |
| 120 | + | vertical.panes[0].damage, vertical.panes[1].damage = nil, nil | |
| 121 | + | out = composeFrame(vertical, out.cache, committed.cache) | |
| 122 | + | require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider") | |
| 123 | + | require.Equal(t, '─', out.frame.At(20, 3).Rune) | |
| 124 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) { | |
| 128 | + | initial := cachedStackTitleState("one", 1, true) | |
| 129 | + | committed := composeFrame(initial, composeCacheInput{}) | |
| 130 | + | ||
| 131 | + | renamed := initial | |
| 132 | + | renamed.reset = false | |
| 133 | + | renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2 | |
| 134 | + | out := composeFrame(renamed, committed.cache, composeCacheInput{}) | |
| 135 | + | require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7]) | |
| 136 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |
| 137 | + | ||
| 138 | + | unchanged := composeFrame(renamed, out.cache, committed.cache) | |
| 139 | + | require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |
| 140 | + | ||
| 141 | + | bars := cacheBarState("one", true) | |
| 142 | + | committed = composeFrame(bars, composeCacheInput{}) | |
| 143 | + | bars.reset = false | |
| 144 | + | bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}} | |
| 145 | + | out = composeFrame(bars, committed.cache, composeCacheInput{}) | |
| 146 | + | for _, damage := range out.damage { | |
| 147 | + | require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached") | |
| 148 | + | require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached") | |
| 149 | + | } | |
| 150 | + | bars.panes[0].damage = nil | |
| 151 | + | bars.bars.bottomRight = "two" | |
| 152 | + | out = composeFrame(bars, out.cache, committed.cache) | |
| 153 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1}) | |
| 154 | + | require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1}) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |
| 158 | + | state := cachedStackTitleState("title", 1, true) | |
| 159 | + | committed, scratch := composeCacheInput{}, composeCacheInput{} | |
| 160 | + | compose := func() { | |
| 161 | + | out := composeFrame(state, committed, scratch) | |
| 162 | + | scratch, committed = committed, out.cache | |
| 163 | + | state.reset = false | |
| 164 | + | } | |
| 165 | + | compose() | |
| 166 | + | compose() | |
| 167 | + | compose() | |
| 168 | + | ||
| 169 | + | allocs := testing.AllocsPerRun(100, compose) | |
| 170 | + | require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState { | |
| 174 | + | left, right := layout.PaneID("left"), layout.PaneID("right") | |
| 175 | + | placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}} | |
| 176 | + | leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R') | |
| 177 | + | if direction == layout.Vertical { | |
| 178 | + | placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}} | |
| 179 | + | leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R') | |
| 180 | + | } | |
| 181 | + | return capturedRenderState{ | |
| 182 | + | reset: true, | |
| 183 | + | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true}, | |
| 184 | + | 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()}}}, | |
| 185 | + | theme: theme, | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState { | |
| 190 | + | first, second := layout.PaneID("first"), layout.PaneID("second") | |
| 191 | + | placements := []layout.Placement{{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}, {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}} | |
| 192 | + | return capturedRenderState{ | |
| 193 | + | reset: reset, | |
| 194 | + | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | |
| 195 | + | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, title: "second", titleGeneration: 1, placement: placements[1], focused: true}}, | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func cacheBarState(bottom string, reset bool) capturedRenderState { | |
| 200 | + | pane := cachePaneFrame(6, 1, 'P') | |
| 201 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}} | |
| 202 | + | 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}} | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func cachePaneFrame(width, height int, r rune) renderer.Frame { | |
| 206 | + | frame := renderer.NewFrame(width, height) | |
| 207 | + | frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 208 | + | return frame | |
| 209 | + | } | |
| 210 | + | ||
| 89 | 211 | func cacheState(title string, generation uint64) capturedRenderState { | |
| 90 | 212 | pane := renderer.NewFrame(6, 1) | |
| 91 | 213 | for x, r := range title[:min(len(title), 6)] { | |
| @@ -106,9 +228,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||
| 106 | 228 | out := in | |
| 107 | 229 | out.frame = in.frame.Clone() | |
| 108 | 230 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | |
| 109 | - | for id, generation := range in.titleGenerations { | |
| 110 | - | out.titleGenerations[id] = generation | |
| 111 | - | } | |
| 231 | + | maps.Copy(out.titleGenerations, in.titleGenerations) | |
| 112 | 232 | out.damage = append([]renderer.Damage(nil), in.damage...) | |
| 113 | 233 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | |
| 114 | 234 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | |
| @@ -3,11 +3,13 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | + | "maps" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 11 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 15 | ) | ||
| @@ -86,6 +88,126 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |||||
| 86 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage) | 88 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage) | ||
| 87 | } | 89 | } | ||
| 88 | 90 | ||||
| 91 | + | func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) { | |||
| 92 | + | 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}} | |||
| 93 | + | initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |||
| 94 | + | committed := composeFrame(initial, composeCacheInput{}) | |||
| 95 | + | require.Equal(t, '│', committed.frame.At(20, 1).Rune) | |||
| 96 | + | focusedStyle := committed.frame.At(0, 1).Style | |||
| 97 | + | dimmedStyle := committed.frame.At(21, 1).Style | |||
| 98 | + | ||||
| 99 | + | undamaged := initial | |||
| 100 | + | undamaged.reset = false | |||
| 101 | + | undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()}) | |||
| 102 | + | undamaged.panes[0].damage = nil | |||
| 103 | + | undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 104 | + | undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}} | |||
| 105 | + | out := composeFrame(undamaged, committed.cache, composeCacheInput{}) | |||
| 106 | + | require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit") | |||
| 107 | + | require.Equal(t, 'x', out.frame.At(21, 1).Rune) | |||
| 108 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1}) | |||
| 109 | + | ||||
| 110 | + | focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme) | |||
| 111 | + | focusChanged.reset = false | |||
| 112 | + | focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil | |||
| 113 | + | out = composeFrame(focusChanged, out.cache, committed.cache) | |||
| 114 | + | require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed") | |||
| 115 | + | require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed") | |||
| 116 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |||
| 117 | + | ||||
| 118 | + | vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme) | |||
| 119 | + | vertical.reset = false | |||
| 120 | + | vertical.panes[0].damage, vertical.panes[1].damage = nil, nil | |||
| 121 | + | out = composeFrame(vertical, out.cache, committed.cache) | |||
| 122 | + | require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider") | |||
| 123 | + | require.Equal(t, '─', out.frame.At(20, 3).Rune) | |||
| 124 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) { | |||
| 128 | + | initial := cachedStackTitleState("one", 1, true) | |||
| 129 | + | committed := composeFrame(initial, composeCacheInput{}) | |||
| 130 | + | ||||
| 131 | + | renamed := initial | |||
| 132 | + | renamed.reset = false | |||
| 133 | + | renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2 | |||
| 134 | + | out := composeFrame(renamed, committed.cache, composeCacheInput{}) | |||
| 135 | + | require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7]) | |||
| 136 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |||
| 137 | + | ||||
| 138 | + | unchanged := composeFrame(renamed, out.cache, committed.cache) | |||
| 139 | + | require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |||
| 140 | + | ||||
| 141 | + | bars := cacheBarState("one", true) | |||
| 142 | + | committed = composeFrame(bars, composeCacheInput{}) | |||
| 143 | + | bars.reset = false | |||
| 144 | + | bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}} | |||
| 145 | + | out = composeFrame(bars, committed.cache, composeCacheInput{}) | |||
| 146 | + | for _, damage := range out.damage { | |||
| 147 | + | require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached") | |||
| 148 | + | require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached") | |||
| 149 | + | } | |||
| 150 | + | bars.panes[0].damage = nil | |||
| 151 | + | bars.bars.bottomRight = "two" | |||
| 152 | + | out = composeFrame(bars, out.cache, committed.cache) | |||
| 153 | + | require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1}) | |||
| 154 | + | require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1}) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||
| 158 | + | state := cachedStackTitleState("title", 1, true) | |||
| 159 | + | committed, scratch := composeCacheInput{}, composeCacheInput{} | |||
| 160 | + | compose := func() { | |||
| 161 | + | out := composeFrame(state, committed, scratch) | |||
| 162 | + | scratch, committed = committed, out.cache | |||
| 163 | + | state.reset = false | |||
| 164 | + | } | |||
| 165 | + | compose() | |||
| 166 | + | compose() | |||
| 167 | + | compose() | |||
| 168 | + | ||||
| 169 | + | allocs := testing.AllocsPerRun(100, compose) | |||
| 170 | + | require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState { | |||
| 174 | + | left, right := layout.PaneID("left"), layout.PaneID("right") | |||
| 175 | + | placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}} | |||
| 176 | + | leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R') | |||
| 177 | + | if direction == layout.Vertical { | |||
| 178 | + | placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}} | |||
| 179 | + | leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R') | |||
| 180 | + | } | |||
| 181 | + | return capturedRenderState{ | |||
| 182 | + | reset: true, | |||
| 183 | + | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true}, | |||
| 184 | + | 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()}}}, | |||
| 185 | + | theme: theme, | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState { | |||
| 190 | + | first, second := layout.PaneID("first"), layout.PaneID("second") | |||
| 191 | + | placements := []layout.Placement{{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}, {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}} | |||
| 192 | + | return capturedRenderState{ | |||
| 193 | + | reset: reset, | |||
| 194 | + | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | |||
| 195 | + | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, title: "second", titleGeneration: 1, placement: placements[1], focused: true}}, | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func cacheBarState(bottom string, reset bool) capturedRenderState { | |||
| 200 | + | pane := cachePaneFrame(6, 1, 'P') | |||
| 201 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}} | |||
| 202 | + | 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}} | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func cachePaneFrame(width, height int, r rune) renderer.Frame { | |||
| 206 | + | frame := renderer.NewFrame(width, height) | |||
| 207 | + | frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 208 | + | return frame | |||
| 209 | + | } | |||
| 210 | + | ||||
| 89 | func cacheState(title string, generation uint64) capturedRenderState { | 211 | func cacheState(title string, generation uint64) capturedRenderState { | ||
| 90 | pane := renderer.NewFrame(6, 1) | 212 | pane := renderer.NewFrame(6, 1) | ||
| 91 | for x, r := range title[:min(len(title), 6)] { | 213 | for x, r := range title[:min(len(title), 6)] { | ||
| @@ -106,9 +228,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||||
| 106 | out := in | 228 | out := in | ||
| 107 | out.frame = in.frame.Clone() | 229 | out.frame = in.frame.Clone() | ||
| 108 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | 230 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | ||
| 109 | - | for id, generation := range in.titleGenerations { | 231 | + | maps.Copy(out.titleGenerations, in.titleGenerations) |
| 110 | - | out.titleGenerations[id] = generation | |||
| 111 | - | } | |||
| 112 | out.damage = append([]renderer.Damage(nil), in.damage...) | 232 | out.damage = append([]renderer.Damage(nil), in.damage...) | ||
| 113 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | 233 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | ||
| 114 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | 234 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | ||