1package vt
2
3import (
4 "errors"
5 "math"
6 "testing"
7
8 renderer "github.com/bnema/vev-vt/core"
9)
10
11func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
12 t.Helper()
13 if err := history.Append(row, LineBound{End: len(row)}); err != nil {
14 t.Fatalf("append history row: %v", err)
15 }
16}
17
18func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
19 tests := []struct {
20 name string
21 maxRows int
22 chunkRows int
23 rows []string
24 want []string
25 }{
26 {
27 name: "oldest chunks are evicted before newer chunks",
28 maxRows: 4,
29 chunkRows: 2,
30 rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"},
31 want: []string{"cccc", "dddd", "eeee", "ffff"},
32 },
33 {
34 name: "nonmultiple capacity evicts exactly the oldest row",
35 maxRows: 5,
36 chunkRows: 2,
37 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
38 want: []string{"1111", "2222", "3333", "4444", "5555"},
39 },
40 {
41 name: "rows preserve append order across chunk boundaries",
42 maxRows: 4,
43 chunkRows: 2,
44 rows: []string{"0000", "1111", "2222", "3333"},
45 want: []string{"0000", "1111", "2222", "3333"},
46 },
47 }
48
49 for _, tt := range tests {
50 t.Run(tt.name, func(t *testing.T) {
51 history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})
52 for _, text := range tt.rows {
53 requireHistoryAppend(t, history, historyRow(text))
54 }
55
56 view := history.View()
57 if got := view.Len(); got > tt.maxRows {
58 t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows)
59 }
60 if got := historyViewTexts(view); !equalStrings(got, tt.want) {
61 t.Fatalf("view rows = %#v, want %#v", got, tt.want)
62 }
63 if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want {
64 t.Fatalf("chunk count = %d, want %d", got, want)
65 }
66 })
67 }
68}
69
70func TestHistoryTailRotationAndStableViews(t *testing.T) {
71 tests := []struct {
72 name string
73 }{
74 {name: "a full tail seals and a later append cannot change an existing view"},
75 {name: "rows passed to append are copied before the screen recycles them"},
76 }
77
78 for _, tt := range tests {
79 t.Run(tt.name, func(t *testing.T) {
80 history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
81 first := historyRow("AAAA")
82 requireHistoryAppend(t, history, first)
83 requireHistoryAppend(t, history, historyRow("BBBB"))
84 view := history.View()
85 if got, want := view.ChunkCount(), 1; got != want {
86 t.Fatalf("sealed chunk count = %d, want %d", got, want)
87 }
88
89 first[0].Rune = 'X'
90 requireHistoryAppend(t, history, historyRow("CCCC"))
91
92 if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
93 t.Fatalf("stable view rows = %#v, want %#v", got, want)
94 }
95 if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) {
96 t.Fatalf("current view rows = %#v, want %#v", got, want)
97 }
98 })
99 }
100}
101
102func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
103 tests := []struct {
104 name string
105 }{
106 {name: "unchanged sealed chunks are shared by successive views"},
107 }
108
109 for _, tt := range tests {
110 t.Run(tt.name, func(t *testing.T) {
111 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
112 requireHistoryAppend(t, history, historyRow("AAAA"))
113 requireHistoryAppend(t, history, historyRow("BBBB"))
114 before := history.View()
115
116 requireHistoryAppend(t, history, historyRow("CCCC"))
117 after := history.View()
118
119 if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
120 t.Fatal("sealed chunk missing from view")
121 }
122 if before.Chunk(0) != after.Chunk(0) {
123 t.Fatal("unchanged sealed chunk was copied instead of reused")
124 }
125 })
126 }
127}
128
129func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
130 tests := []struct {
131 name string
132 write []byte
133 want []string
134 }{
135 {
136 name: "primary screen evictions enter terminal history",
137 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
138 want: []string{"AAAA"},
139 },
140 {
141 name: "alternate screen scrolling never enters terminal history",
142 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
143 want: []string{"AAAA"},
144 },
145 }
146
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
150 screen.Write(tt.write)
151
152 if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) {
153 t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want)
154 }
155 })
156 }
157}
158
159func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
160 tests := []struct {
161 name string
162 top int
163 bottom int
164 wantRows int
165 wantEvents int
166 }{
167 {
168 name: "interior scroll region does not enter global history",
169 top: 1,
170 bottom: 3,
171 wantRows: 0,
172 wantEvents: 0,
173 },
174 {
175 name: "top-edge scroll enters global history",
176 top: 0,
177 bottom: 3,
178 wantRows: 1,
179 wantEvents: 1,
180 },
181 }
182
183 for _, tt := range tests {
184 t.Run(tt.name, func(t *testing.T) {
185 s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})
186 for y := range s.frame.Height {
187 s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
188 }
189 events := 0
190 s.OnLineEvicted = func([]renderer.Cell) { events++ }
191
192 s.scrollUpRegion(tt.top, tt.bottom, 1)
193
194 if got := s.History().View().Len(); got != tt.wantRows {
195 t.Errorf("history rows = %d, want %d", got, tt.wantRows)
196 }
197 if events != tt.wantEvents {
198 t.Errorf("eviction events = %d, want %d", events, tt.wantEvents)
199 }
200 })
201 }
202}
203
204func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) {
205 const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes
206 history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2})
207 for _, text := range []string{"aa", "bbb", "c", "dd"} {
208 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
209 t.Fatalf("append %q: %v", text, err)
210 }
211 }
212
213 view := history.View()
214 if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
215 t.Fatalf("retained rows = %#v, want %#v", got, want)
216 }
217 if got, want := history.Len(), 2; got != want {
218 t.Fatalf("retained rows = %d, want %d", got, want)
219 }
220 if got, want := history.Cells(), 3; got != want {
221 t.Fatalf("retained cells = %d, want %d", got, want)
222 }
223 if got, want := view.Cells(), 3; got != want {
224 t.Fatalf("view cells = %d, want %d", got, want)
225 }
226}
227
228func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
229 tests := []struct {
230 name string
231 history *History
232 }{
233 {name: "nil history"},
234 {name: "zero-value history", history: &History{}},
235 }
236
237 for _, tt := range tests {
238 t.Run(tt.name, func(t *testing.T) {
239 row := historyRow("row")
240 if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
241 t.Fatalf("append error = %v, want nil", err)
242 }
243 if got := tt.history.Len(); got != 0 {
244 t.Fatalf("retained rows = %d, want 0", got)
245 }
246 })
247 }
248}
249
250func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) {
251 const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes
252 history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2})
253 kept := historyRow("ok")
254 if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
255 t.Fatalf("append retained row: %v", err)
256 }
257 before := history.View()
258
259 wide := historyRow("wide")
260 err := history.Append(wide, LineBound{End: len(wide)})
261 if !errors.Is(err, ErrHistoryRowTooLarge) {
262 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err)
263 }
264 if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
265 t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
266 }
267 if got, want := history.Cells(), 2; got != want {
268 t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
269 }
270}
271
272func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
273 screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxBytes: 83})
274 var events []string
275 screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
276 screen.frame.WriteRow(0, 0, historyRow("AAAA"))
277 screen.frame.WriteRow(1, 0, historyRow("BBBB"))
278 screen.scrollUpRegion(0, 1, 1)
279
280 if got := screen.History().Len(); got != 0 {
281 t.Fatalf("oversized scrollback rows = %d, want 0", got)
282 }
283 if got, want := events, []string{"AAAA"}; !equalStrings(got, want) {
284 t.Fatalf("scroll callbacks = %#v, want %#v", got, want)
285 }
286 if got := lineText(screen, 0); got != "BBBB" {
287 t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
288 }
289}
290
291func TestHistoryAppendRetainsBounds(t *testing.T) {
292 row := func(s string) []renderer.Cell {
293 cells := make([]renderer.Cell, 0, len(s))
294 for _, r := range s {
295 cells = append(cells, renderer.Cell{Rune: r})
296 }
297 return cells
298 }
299
300 tests := []struct {
301 name string
302 chunkRows int
303 bounds []LineBound
304 }{
305 {
306 name: "within the mutable tail",
307 chunkRows: 8,
308 bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
309 },
310 {
311 name: "across a sealed chunk boundary",
312 chunkRows: 2,
313 bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
314 },
315 }
316
317 for _, tc := range tests {
318 t.Run(tc.name, func(t *testing.T) {
319 h := NewHistory(HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: tc.chunkRows})
320 for i, b := range tc.bounds {
321 if err := h.Append(row("abc"[:b.End]), b); err != nil {
322 t.Fatalf("append %d: %v", i, err)
323 }
324 }
325 view := h.SealAndView()
326 for i, want := range tc.bounds {
327 if got := view.Bound(i); got != want {
328 t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
329 }
330 }
331 })
332 }
333}
334
335func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
336 h := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2})
337 for i := range 5 {
338 cells := []renderer.Cell{{Rune: rune('a' + i)}}
339 if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
340 t.Fatalf("append %d: %v", i, err)
341 }
342 }
343 view := h.SealAndView()
344 if view.Len() != 3 {
345 t.Fatalf("Len() = %d, want 3", view.Len())
346 }
347 // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
348 for i, want := range []bool{true, false, true} {
349 if got := view.Bound(i).Soft; got != want {
350 t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.Row(i)[0].Rune)
351 }
352 }
353}
354
355func TestHistorySlabAndTailAllocationBounds(t *testing.T) {
356 if uint64(math.MaxInt) >= uint64(math.MaxUint32) {
357 maxWidth := uint64(math.MaxUint32)
358 width := int(maxWidth)
359 if got := maxHistorySlabRows(width); got != 1 {
360 t.Fatalf("maxHistorySlabRows(MaxUint32) = %d, want 1", got)
361 }
362 if got := maxHistorySlabRows(width / 2); got != 2 {
363 t.Fatalf("maxHistorySlabRows(MaxUint32/2) = %d, want 2", got)
364 }
365 }
366
367 history := NewHistory(HistoryConfig{MaxRows: 256, MaxBytes: 1 << 30, ChunkRows: 256})
368 row := make([]renderer.Cell, 10_000)
369 if err := history.Append(row, LineBound{}); err != nil {
370 t.Fatal(err)
371 }
372 if got, max := cap(history.tailCells), max(len(row), maxTailPreallocCells); got > max {
373 t.Fatalf("wide-row tail capacity = %d, want at most %d", got, max)
374 }
375}
376
377func TestHistorySealsCompactContiguousSlabs(t *testing.T) {
378 const rows, columns = 256, 120
379 history := NewHistory(HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: rows})
380 row := make([]renderer.Cell, columns)
381 for x := range row {
382 row[x] = renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: true, Foreground: 4}}
383 }
384 for range rows {
385 if err := history.Append(row, LineBound{End: columns}); err != nil {
386 t.Fatal(err)
387 }
388 }
389
390 view := history.View()
391 chunk := view.Chunk(0)
392 if view.ChunkCount() != 1 || chunk == nil || chunk.count != rows || chunk.width != columns {
393 t.Fatalf("compact chunk = %#v, chunks %d", chunk, view.ChunkCount())
394 }
395 // The original inline Cell occupied 72 bytes; retain the 4x reduction gate.
396 if got, max := chunk.frameView().LogicalBytes(), uint64(rows*columns*72/4); got >= max {
397 t.Fatalf("compact slab logical bytes = %d, want below %d", got, max)
398 }
399 if got := chunk.frameView().StyleCount(); got != 2 {
400 t.Fatalf("page-local style count = %d, want default plus repeated style", got)
401 }
402 if err := chunk.CheckInvariants(); err != nil {
403 t.Fatalf("compact chunk invariants: %v", err)
404 }
405}
406
407func TestHistorySplitsCompactSlabsWhenRowWidthChanges(t *testing.T) {
408 history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4})
409 for _, text := range []string{"aa", "bb", "ccc", "ddd"} {
410 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
411 t.Fatal(err)
412 }
413 }
414 view := history.View()
415 if got := view.ChunkCount(); got != 2 {
416 t.Fatalf("ChunkCount() = %d, want one slab per consecutive width", got)
417 }
418 for i, want := range []string{"aa", "bb", "ccc", "ddd"} {
419 if got := rowText(view.Row(i)); got != want {
420 t.Fatalf("Row(%d) = %q, want %q", i, got, want)
421 }
422 }
423}
424
425func TestHistoryLargeRowLimitDoesNotInventByteBudget(t *testing.T) {
426 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
427 if got := history.ByteCap(); got != 0 {
428 t.Fatalf("implicit byte capacity = %d, want no byte ceiling", got)
429 }
430 if got := history.Cap(); got != math.MaxInt {
431 t.Fatalf("row ceiling = %d", got)
432 }
433}
434
435func historyRow(text string) []renderer.Cell {
436 row := make([]renderer.Cell, len(text))
437 for i, r := range text {
438 row[i] = renderer.BlankCell()
439 row[i].Rune = r
440 }
441 return row
442}
443
444func historyViewTexts(view HistoryView) []string {
445 rows := make([]string, view.Len())
446 for i := range rows {
447 rows[i] = rowText(view.Row(i))
448 }
449 return rows
450}
451
452func rowText(row []renderer.Cell) string {
453 runes := make([]rune, len(row))
454 for i, cell := range row {
455 runes[i] = cell.Rune
456 }
457 return string(runes)
458}