test(daemon): pin transient page savings against a no-scratch baseline and cover modal and failed publications
- commit
- df3f959b73009ad9d739ff6f806d17fd4770392f
- author
- brice · · 2026-10-08 07:31 UTC
- committer
- brice · · 2026-10-08 08:12 UTC
- parent
- d5dd60d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- perf/render-copy-allocations
1 file · +38 -12
M internal/usecase/daemon/render_transient_frames_test.go +38 -12
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -24,7 +25,7 @@ func framesShareStorage(a, b renderer.Frame) bool { | |||
| 24 | 25 | return shared | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | - | func transientToastFloatingState(withToast, withFloating bool, seed rune) capturedRenderState { | |
| 28 | + | func transientToastFloatingState(withToast, withFloating, withModal bool, seed rune) capturedRenderState { | |
| 28 | 29 | var notices []domain.Notification | |
| 29 | 30 | if withToast { | |
| 30 | 31 | notices = []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}} | |
| @@ -42,26 +43,46 @@ func transientToastFloatingState(withToast, withFloating bool, seed rune) captur | |||
| 42 | 43 | generation: 1, | |
| 43 | 44 | } | |
| 44 | 45 | } | |
| 46 | + | if withModal { | |
| 47 | + | // A modal without toast or floating is the second writer of the | |
| 48 | + | // overlay transient page. | |
| 49 | + | state.overlays.promptActive = true | |
| 50 | + | state.overlays.prompt = capturedModal{ | |
| 51 | + | active: true, | |
| 52 | + | title: "Prompt", | |
| 53 | + | presentation: ui.Presentation{ | |
| 54 | + | Bounds: domain.Rect{X: 10, Y: 5, Width: 20, Height: 3}, | |
| 55 | + | Inner: domain.Rect{X: 11, Y: 6, Width: 18, Height: 1}, | |
| 56 | + | Borders: ui.BorderAll, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | } | |
| 45 | 60 | return state | |
| 46 | 61 | } | |
| 47 | 62 | ||
| 48 | 63 | // TestComposeFrameTransientScratchNeverAliasesCaches replays the attachment | |
| 49 | - | // commit/spare rotation across toast, floating, and plain frames. Each result | |
| 64 | + | // commit/spare rotation across toast, floating, modal, and plain frames, and | |
| 65 | + | // across failed publications that compose again without committing. Each result | |
| 50 | 66 | // must match an independent composition without scratch, the retained cache | |
| 51 | 67 | // must stay toast-free, and no handed-out frame may share a page with either | |
| 52 | 68 | // cache slot (which would let a later paint corrupt committed state). | |
| 53 | 69 | func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) { | |
| 54 | 70 | type step struct { | |
| 55 | - | toast, floating bool | |
| 71 | + | toast, floating, modal bool | |
| 72 | + | // failed skips commitComposition, as a failed prepare or send does, so | |
| 73 | + | // the next step composes again from the same spare. | |
| 74 | + | failed bool | |
| 56 | 75 | } | |
| 57 | 76 | steps := []step{ | |
| 58 | - | {false, false}, {true, false}, {true, false}, {true, true}, {false, true}, | |
| 59 | - | {true, true}, {true, false}, {false, false}, {true, false}, {false, true}, {true, true}, | |
| 77 | + | {}, {toast: true}, {toast: true}, {toast: true, floating: true}, {floating: true}, | |
| 78 | + | {modal: true}, {modal: true, failed: true}, {modal: true}, {toast: true, modal: true}, | |
| 79 | + | {toast: true, floating: true, failed: true}, {toast: true, floating: true}, {toast: true}, | |
| 80 | + | {}, {toast: true, failed: true}, {toast: true}, {floating: true}, {toast: true, floating: true}, | |
| 60 | 81 | } | |
| 61 | 82 | var render attachmentRenderState | |
| 62 | 83 | for i, st := range steps { | |
| 63 | 84 | seed := rune('A' + i) | |
| 64 | - | state := transientToastFloatingState(st.toast, st.floating, seed) | |
| 85 | + | state := transientToastFloatingState(st.toast, st.floating, st.modal, seed) | |
| 65 | 86 | state.reset = i == 0 | |
| 66 | 87 | // A frame without scratch is the reference: it only ever clones. | |
| 67 | 88 | want := composeFrame(state, render.cache) | |
| @@ -75,17 +96,17 @@ func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) { | |||
| 75 | 96 | // must never touch the committed cache a failed publication falls back to. | |
| 76 | 97 | require.False(t, framesShareStorage(got.frame, render.cache.frame), "step %d: composed frame aliases the committed cache", i) | |
| 77 | 98 | require.False(t, framesShareStorage(got.cache.frame, render.cache.frame), "step %d: new cache base aliases the committed cache", i) | |
| 78 | - | if st.toast || st.floating { | |
| 99 | + | if st.toast || st.floating || st.modal { | |
| 79 | 100 | // A decorated frame lives on a transient page: never the cache base | |
| 80 | 101 | // (toast-free by contract) nor the committed or spare cache pages. | |
| 81 | 102 | require.False(t, framesShareStorage(got.frame, got.cache.frame), "step %d: decorated frame aliases the cache base", i) | |
| 82 | 103 | require.False(t, framesShareStorage(got.frame, render.spare.frame), "step %d: decorated frame aliases the spare cache", i) | |
| 83 | 104 | } | |
| 84 | - | if !st.toast { | |
| 85 | - | require.NotContains(t, frameText(got.cache.frame), "copied") | |
| 86 | - | } | |
| 87 | 105 | require.NotContains(t, frameText(got.cache.frame), "copied", "step %d: cache must stay toast-free", i) | |
| 88 | 106 | ||
| 107 | + | if st.failed { | |
| 108 | + | continue | |
| 109 | + | } | |
| 89 | 110 | beforeCommitted := frameRows(render.cache.frame) | |
| 90 | 111 | render.commitComposition(got.cache) | |
| 91 | 112 | if i > 0 { | |
| @@ -122,9 +143,14 @@ func TestComposeFrameStableToastReusesScratchPages(t *testing.T) { | |||
| 122 | 143 | out := composeFrame(state, cache, scratch) | |
| 123 | 144 | scratch, cache = cache, out.cache | |
| 124 | 145 | }) | |
| 146 | + | // The baseline composes the same frame without scratch, so it clones the | |
| 147 | + | // base page every time. Reuse must save at least one full Clone. | |
| 148 | + | baseline := testing.AllocsPerRun(50, func() { | |
| 149 | + | benchmarkComposeSink.frame = composeFrame(state, cache, composeCacheInput{}).frame | |
| 150 | + | }) | |
| 125 | 151 | cloned := testing.AllocsPerRun(50, func() { benchmarkComposeSink.frame = cache.frame.Clone() }) | |
| 126 | - | require.Less(t, reused, 30.0, "per-frame compose allocations") | |
| 127 | - | t.Logf("compose allocs=%v, one Clone allocs=%v", reused, cloned) | |
| 152 | + | require.Positive(t, cloned) | |
| 153 | + | require.LessOrEqual(t, reused, baseline-cloned, "scratch reuse must save at least one Clone (baseline=%v clone=%v)", baseline, cloned) | |
| 128 | 154 | out := composeFrame(state, cache, scratch) | |
| 129 | 155 | require.False(t, framesShareStorage(out.frame, out.cache.frame)) | |
| 130 | 156 | require.Contains(t, frameText(out.frame), "copied") | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 6 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -24,7 +25,7 @@ func framesShareStorage(a, b renderer.Frame) bool { | |||||
| 24 | return shared | 25 | return shared | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | - | func transientToastFloatingState(withToast, withFloating bool, seed rune) capturedRenderState { | 28 | + | func transientToastFloatingState(withToast, withFloating, withModal bool, seed rune) capturedRenderState { |
| 28 | var notices []domain.Notification | 29 | var notices []domain.Notification | ||
| 29 | if withToast { | 30 | if withToast { | ||
| 30 | notices = []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}} | 31 | notices = []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}} | ||
| @@ -42,26 +43,46 @@ func transientToastFloatingState(withToast, withFloating bool, seed rune) captur | |||||
| 42 | generation: 1, | 43 | generation: 1, | ||
| 43 | } | 44 | } | ||
| 44 | } | 45 | } | ||
| 46 | + | if withModal { | |||
| 47 | + | // A modal without toast or floating is the second writer of the | |||
| 48 | + | // overlay transient page. | |||
| 49 | + | state.overlays.promptActive = true | |||
| 50 | + | state.overlays.prompt = capturedModal{ | |||
| 51 | + | active: true, | |||
| 52 | + | title: "Prompt", | |||
| 53 | + | presentation: ui.Presentation{ | |||
| 54 | + | Bounds: domain.Rect{X: 10, Y: 5, Width: 20, Height: 3}, | |||
| 55 | + | Inner: domain.Rect{X: 11, Y: 6, Width: 18, Height: 1}, | |||
| 56 | + | Borders: ui.BorderAll, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 45 | return state | 60 | return state | ||
| 46 | } | 61 | } | ||
| 47 | 62 | ||||
| 48 | // TestComposeFrameTransientScratchNeverAliasesCaches replays the attachment | 63 | // TestComposeFrameTransientScratchNeverAliasesCaches replays the attachment | ||
| 49 | - | // commit/spare rotation across toast, floating, and plain frames. Each result | 64 | + | // commit/spare rotation across toast, floating, modal, and plain frames, and |
| 65 | + | // across failed publications that compose again without committing. Each result | |||
| 50 | // must match an independent composition without scratch, the retained cache | 66 | // must match an independent composition without scratch, the retained cache | ||
| 51 | // must stay toast-free, and no handed-out frame may share a page with either | 67 | // must stay toast-free, and no handed-out frame may share a page with either | ||
| 52 | // cache slot (which would let a later paint corrupt committed state). | 68 | // cache slot (which would let a later paint corrupt committed state). | ||
| 53 | func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) { | 69 | func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) { | ||
| 54 | type step struct { | 70 | type step struct { | ||
| 55 | - | toast, floating bool | 71 | + | toast, floating, modal bool |
| 72 | + | // failed skips commitComposition, as a failed prepare or send does, so | |||
| 73 | + | // the next step composes again from the same spare. | |||
| 74 | + | failed bool | |||
| 56 | } | 75 | } | ||
| 57 | steps := []step{ | 76 | steps := []step{ | ||
| 58 | - | {false, false}, {true, false}, {true, false}, {true, true}, {false, true}, | 77 | + | {}, {toast: true}, {toast: true}, {toast: true, floating: true}, {floating: true}, |
| 59 | - | {true, true}, {true, false}, {false, false}, {true, false}, {false, true}, {true, true}, | 78 | + | {modal: true}, {modal: true, failed: true}, {modal: true}, {toast: true, modal: true}, |
| 79 | + | {toast: true, floating: true, failed: true}, {toast: true, floating: true}, {toast: true}, | |||
| 80 | + | {}, {toast: true, failed: true}, {toast: true}, {floating: true}, {toast: true, floating: true}, | |||
| 60 | } | 81 | } | ||
| 61 | var render attachmentRenderState | 82 | var render attachmentRenderState | ||
| 62 | for i, st := range steps { | 83 | for i, st := range steps { | ||
| 63 | seed := rune('A' + i) | 84 | seed := rune('A' + i) | ||
| 64 | - | state := transientToastFloatingState(st.toast, st.floating, seed) | 85 | + | state := transientToastFloatingState(st.toast, st.floating, st.modal, seed) |
| 65 | state.reset = i == 0 | 86 | state.reset = i == 0 | ||
| 66 | // A frame without scratch is the reference: it only ever clones. | 87 | // A frame without scratch is the reference: it only ever clones. | ||
| 67 | want := composeFrame(state, render.cache) | 88 | want := composeFrame(state, render.cache) | ||
| @@ -75,17 +96,17 @@ func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) { | |||||
| 75 | // must never touch the committed cache a failed publication falls back to. | 96 | // must never touch the committed cache a failed publication falls back to. | ||
| 76 | require.False(t, framesShareStorage(got.frame, render.cache.frame), "step %d: composed frame aliases the committed cache", i) | 97 | require.False(t, framesShareStorage(got.frame, render.cache.frame), "step %d: composed frame aliases the committed cache", i) | ||
| 77 | require.False(t, framesShareStorage(got.cache.frame, render.cache.frame), "step %d: new cache base aliases the committed cache", i) | 98 | require.False(t, framesShareStorage(got.cache.frame, render.cache.frame), "step %d: new cache base aliases the committed cache", i) | ||
| 78 | - | if st.toast || st.floating { | 99 | + | if st.toast || st.floating || st.modal { |
| 79 | // A decorated frame lives on a transient page: never the cache base | 100 | // A decorated frame lives on a transient page: never the cache base | ||
| 80 | // (toast-free by contract) nor the committed or spare cache pages. | 101 | // (toast-free by contract) nor the committed or spare cache pages. | ||
| 81 | require.False(t, framesShareStorage(got.frame, got.cache.frame), "step %d: decorated frame aliases the cache base", i) | 102 | require.False(t, framesShareStorage(got.frame, got.cache.frame), "step %d: decorated frame aliases the cache base", i) | ||
| 82 | require.False(t, framesShareStorage(got.frame, render.spare.frame), "step %d: decorated frame aliases the spare cache", i) | 103 | require.False(t, framesShareStorage(got.frame, render.spare.frame), "step %d: decorated frame aliases the spare cache", i) | ||
| 83 | } | 104 | } | ||
| 84 | - | if !st.toast { | |||
| 85 | - | require.NotContains(t, frameText(got.cache.frame), "copied") | |||
| 86 | - | } | |||
| 87 | require.NotContains(t, frameText(got.cache.frame), "copied", "step %d: cache must stay toast-free", i) | 105 | require.NotContains(t, frameText(got.cache.frame), "copied", "step %d: cache must stay toast-free", i) | ||
| 88 | 106 | ||||
| 107 | + | if st.failed { | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 89 | beforeCommitted := frameRows(render.cache.frame) | 110 | beforeCommitted := frameRows(render.cache.frame) | ||
| 90 | render.commitComposition(got.cache) | 111 | render.commitComposition(got.cache) | ||
| 91 | if i > 0 { | 112 | if i > 0 { | ||
| @@ -122,9 +143,14 @@ func TestComposeFrameStableToastReusesScratchPages(t *testing.T) { | |||||
| 122 | out := composeFrame(state, cache, scratch) | 143 | out := composeFrame(state, cache, scratch) | ||
| 123 | scratch, cache = cache, out.cache | 144 | scratch, cache = cache, out.cache | ||
| 124 | }) | 145 | }) | ||
| 146 | + | // The baseline composes the same frame without scratch, so it clones the | |||
| 147 | + | // base page every time. Reuse must save at least one full Clone. | |||
| 148 | + | baseline := testing.AllocsPerRun(50, func() { | |||
| 149 | + | benchmarkComposeSink.frame = composeFrame(state, cache, composeCacheInput{}).frame | |||
| 150 | + | }) | |||
| 125 | cloned := testing.AllocsPerRun(50, func() { benchmarkComposeSink.frame = cache.frame.Clone() }) | 151 | cloned := testing.AllocsPerRun(50, func() { benchmarkComposeSink.frame = cache.frame.Clone() }) | ||
| 126 | - | require.Less(t, reused, 30.0, "per-frame compose allocations") | 152 | + | require.Positive(t, cloned) |
| 127 | - | t.Logf("compose allocs=%v, one Clone allocs=%v", reused, cloned) | 153 | + | require.LessOrEqual(t, reused, baseline-cloned, "scratch reuse must save at least one Clone (baseline=%v clone=%v)", baseline, cloned) |
| 128 | out := composeFrame(state, cache, scratch) | 154 | out := composeFrame(state, cache, scratch) | ||
| 129 | require.False(t, framesShareStorage(out.frame, out.cache.frame)) | 155 | require.False(t, framesShareStorage(out.frame, out.cache.frame)) | ||
| 130 | require.Contains(t, frameText(out.frame), "copied") | 156 | require.Contains(t, frameText(out.frame), "copied") | ||