1package vt
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "testing"
8
9 renderer "github.com/bnema/vev-vt/core"
10)
11
12var (
13 benchmarkHistoryViewSink HistoryView
14 benchmarkHistorySnapshotSink HistorySnapshotView
15 benchmarkDamageCaptureSink DamageCapture
16 benchmarkHistoryBlobSink []byte
17 benchmarkHistoryBlobsSink [][]byte
18)
19
20// TestHistoryViewPartialTailAllocationBounded guards the capture property used
21// by persistence: retained sealed chunks are shared, while only the small
22// mutable tail is copied into a view.
23func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
24 const (
25 width = 120
26 chunkRows = 64
27 tailRows = 7
28 )
29 sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows)
30 sealed.SealAndView()
31 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
32 partialBefore := partial.View()
33
34 // Copying a partial tail costs one allocation per row plus a fixed set of
35 // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
36 // the row slice, bounds, and logical-style metadata parallel to it.
37 const tailHeaders = 6
38
39 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
40 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
41 if partialAllocs > sealedAllocs+tailRows+tailHeaders {
42 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
43 }
44 if partial.View().Chunk(0) != partialBefore.Chunk(0) {
45 t.Fatal("unchanged sealed chunks must retain identity across partial-tail views")
46 }
47}
48
49func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) {
50 history := benchmarkHistory(b, 10_000, 120, 256)
51 sealed := history.SealAndView()
52 if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil {
53 b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount())
54 }
55
56 b.ReportAllocs()
57 b.ResetTimer()
58 for b.Loop() {
59 benchmarkHistoryViewSink = history.View()
60 }
61 b.StopTimer()
62 if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) {
63 b.Fatal("unchanged sealed history chunks were not reused")
64 }
65 b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view")
66}
67
68func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
69 history := benchmarkHistory(b, 10_000, 120, 256)
70 before := history.View()
71 if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil {
72 b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount())
73 }
74
75 b.ReportAllocs()
76 b.ResetTimer()
77 for b.Loop() {
78 benchmarkHistoryViewSink = history.View()
79 }
80 b.StopTimer()
81 if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) {
82 b.Fatal("partial-tail capture did not reuse its sealed chunks")
83 }
84 b.ReportMetric(float64(10_000%256), "tailrows/view")
85}
86
87func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) {
88 history := benchmarkHistory(b, 10_000, 120, 256)
89 if got, want := len(history.tail), 16; got != want {
90 b.Fatalf("partial tail rows = %d, want %d", got, want)
91 }
92 sealed := history.View().Chunk(0)
93
94 b.ReportAllocs()
95 b.ResetTimer()
96 for b.Loop() {
97 benchmarkHistorySnapshotSink = history.SnapshotView()
98 }
99 b.StopTimer()
100 if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 {
101 b.Fatal("snapshot did not preserve chunk identity and tail rows")
102 }
103 b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view")
104}
105
106func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
107 history := benchmarkHistory(b, 256, 120, 256)
108 chunk := history.SealAndView().Chunk(0)
109 if chunk == nil {
110 b.Fatal("invalid history chunk fixture")
111 }
112
113 b.ReportAllocs()
114 b.ResetTimer()
115 for b.Loop() {
116 var err error
117 benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk)
118 if err != nil {
119 b.Fatal(err)
120 }
121 }
122 b.StopTimer()
123 if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil {
124 b.Fatalf("encoded history chunk is invalid: %v", err)
125 }
126 b.SetBytes(int64(len(benchmarkHistoryBlobSink)))
127}
128
129func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) {
130 history := benchmarkHistory(b, 256, 120, 256)
131 blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0))
132 if err != nil {
133 b.Fatal(err)
134 }
135 if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 {
136 b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err)
137 }
138
139 b.ReportAllocs()
140 b.SetBytes(int64(len(blob)))
141 b.ResetTimer()
142 for b.Loop() {
143 var err error
144 benchmarkHistoryViewSink, err = UnmarshalHistory(blob)
145 if err != nil {
146 b.Fatal(err)
147 }
148 }
149 b.StopTimer()
150 if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 {
151 b.Fatal("decoded history chunk lost rows")
152 }
153}
154
155func BenchmarkMarshalSealedHistory10KRows(b *testing.B) {
156 history := benchmarkHistory(b, 10_000, 120, 256)
157 view := history.SealAndView()
158 if view.Len() != 10_000 || view.ChunkCount() != 40 {
159 b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount())
160 }
161
162 b.ReportAllocs()
163 b.ResetTimer()
164 for b.Loop() {
165 var err error
166 benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view)
167 if err != nil {
168 b.Fatal(err)
169 }
170 }
171 b.StopTimer()
172 if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 {
173 b.Fatal("sealed-history encoder produced an incomplete capture")
174 }
175 b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op")
176}
177
178func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
179 t.Helper()
180 history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows})
181 for row := range rows {
182 cells := make([]renderer.Cell, width)
183 for col := range cells {
184 cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
185 }
186 requireHistoryAppend(t, history, cells)
187 }
188 return history
189}
190
191// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
192// viewport. The 10K-history variant must track the control rather than history
193// length; history is deliberately populated without changing the viewport.
194func BenchmarkScreenResizeReflowViewport(b *testing.B) {
195 b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
196 b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
197}
198
199func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
200 b.Helper()
201 s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
202 if historyRows > 0 {
203 for range historyRows {
204 requireHistoryAppend(b, s.history, make([]renderer.Cell, 120))
205 }
206 // History normally receives rows evicted from this screen, whose IDs are
207 // allocated after every existing live or historical row. This fixture
208 // prepopulates history directly, so advance and refresh the live IDs to
209 // preserve that invariant before exercising resize eviction.
210 s.nextRowID = s.history.nextRowID - 1
211 for y := range s.buffer.rowIDs {
212 s.buffer.rowIDs[y] = s.nextRowIDValue()
213 }
214 }
215 s.Write(bytes.Repeat([]byte("x"), 120*39))
216 s.ClearDamage()
217 sizes := [2][2]int{{80, 40}, {120, 40}}
218
219 b.ReportAllocs()
220 b.ResetTimer()
221 for i := 0; b.Loop(); i++ {
222 s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
223 s.ClearDamage()
224 }
225 b.StopTimer()
226 if got := s.History(); historyRows > 0 && got.Len() < historyRows {
227 b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
228 }
229}
230
231func BenchmarkScreenPrintableASCII(b *testing.B) {
232 s := NewScreen(120, 40)
233 chunk := append(bytes.Repeat([]byte("x"), 119), '\r')
234 b.SetBytes(int64(len(chunk)))
235 b.ReportAllocs()
236 for b.Loop() {
237 s.Write(chunk)
238 s.ClearDamage()
239 }
240}
241
242func BenchmarkScreenKittyAPC(b *testing.B) {
243 payload := base64.RawStdEncoding.EncodeToString(make([]byte, 256*256*4))
244 apc := []byte("\x1b_Ga=t,i=1,f=32,s=256,v=256,q=2;" + payload + "\x1b\\")
245 b.SetBytes(int64(len(apc)))
246 for _, fragmented := range []bool{false, true} {
247 b.Run(map[bool]string{false: "single-write", true: "byte-by-byte"}[fragmented], func(b *testing.B) {
248 s := NewScreen(120, 40)
249 b.ReportAllocs()
250 for b.Loop() {
251 if fragmented {
252 for i := range apc {
253 s.Write(apc[i : i+1 : i+1])
254 }
255 } else {
256 s.Write(apc)
257 }
258 s.ClearDamage()
259 }
260 })
261 }
262}
263
264// scrollbackFloodChunk is 1000 colored 160-column lines ending in CRLF, the
265// shape of a build log or `yes`-style flood scrolling into history.
266func scrollbackFloodChunk() []byte {
267 var chunk bytes.Buffer
268 line := bytes.Repeat([]byte("x"), 140)
269 for i := range 1000 {
270 fmt.Fprintf(&chunk, "\x1b[3%dmflood %06d %s\x1b[0m\r\n", i%8, i, line)
271 }
272 return chunk.Bytes()
273}
274
275// BenchmarkScreenScrollbackFlood measures steady-state output once history is
276// full, so every new line evicts the oldest retained row.
277func BenchmarkScreenScrollbackFlood(b *testing.B) {
278 chunk := scrollbackFloodChunk()
279 for _, rows := range []int{0, 10_000, 50_000} {
280 b.Run(fmt.Sprintf("history-%d", rows), func(b *testing.B) {
281 s := NewScreen(160, 45)
282 if rows > 0 {
283 s = NewScreenWithHistory(160, 45, HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30})
284 }
285 for range rows/1000 + 1 {
286 s.Write(chunk)
287 }
288 s.ClearDamage()
289 b.SetBytes(int64(len(chunk)))
290 b.ReportAllocs()
291 for b.Loop() {
292 s.Write(chunk)
293 s.ClearDamage()
294 }
295 })
296 }
297}
298
299func BenchmarkScreenMixedUTF8(b *testing.B) {
300 s := NewScreen(120, 40)
301 chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r')
302 b.SetBytes(int64(len(chunk)))
303 b.ReportAllocs()
304 for b.Loop() {
305 s.Write(chunk)
306 s.ClearDamage()
307 }
308}
309
310func BenchmarkScreenCSIHeavy(b *testing.B) {
311 s := NewScreen(120, 40)
312 chunk := []byte("\x1b[1;1H\x1b[2K\x1b[31;48;2;1;2;3mstatus\x1b[0m\x1b[10C\x1b[?25l\x1b[?25h\r")
313 b.SetBytes(int64(len(chunk)))
314 b.ReportAllocs()
315 for b.Loop() {
316 s.Write(chunk)
317 s.ClearDamage()
318 }
319}
320
321func BenchmarkScreenCaptureDamage(b *testing.B) {
322 s := NewScreen(120, 40)
323 chunk := []byte("x")
324 s.ClearDamage()
325 b.ReportAllocs()
326 for b.Loop() {
327 s.Write(chunk)
328 benchmarkDamageCaptureSink = s.CaptureDamage()
329 if len(benchmarkDamageCaptureSink.Damage) == 0 {
330 b.Fatal("expected captured damage")
331 }
332 if !s.AcknowledgeDamage(benchmarkDamageCaptureSink.Generation) {
333 b.Fatal("expected current damage acknowledgement")
334 }
335 }
336}
337
338func BenchmarkScreenShellRedrawBurst(b *testing.B) {
339 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
340 s := NewScreen(120, 40)
341 s.ClearDamage()
342 b.ReportAllocs()
343 for b.Loop() {
344 for range 200 {
345 s.Write(chunk)
346 s.ClearDamage()
347 }
348 }
349}
350
351func BenchmarkScreenFullscreenScrollRegion(b *testing.B) {
352 chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n")
353 s := NewScreen(120, 40)
354 s.ClearDamage()
355 b.ReportAllocs()
356 for b.Loop() {
357 for range 500 {
358 s.Write(chunk)
359 s.ClearDamage()
360 }
361 }
362}