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 copy(s.Frame.Row(y), 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 TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208 t.Fatalf("append %q: %v", text, err)
209 }
210 }
211
212 view := history.View()
213 if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
214 t.Fatalf("retained rows = %#v, want %#v", got, want)
215 }
216 if got, want := history.Len(), 2; got != want {
217 t.Fatalf("retained rows = %d, want %d", got, want)
218 }
219 if got, want := history.Cells(), 3; got != want {
220 t.Fatalf("retained cells = %d, want %d", got, want)
221 }
222 if got, want := view.Cells(), 3; got != want {
223 t.Fatalf("view cells = %d, want %d", got, want)
224 }
225}
226
227func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
228 tests := []struct {
229 name string
230 history *History
231 }{
232 {name: "nil history"},
233 {name: "zero-value history", history: &History{}},
234 }
235
236 for _, tt := range tests {
237 t.Run(tt.name, func(t *testing.T) {
238 row := historyRow("row")
239 if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
240 t.Fatalf("append error = %v, want nil", err)
241 }
242 if got := tt.history.Len(); got != 0 {
243 t.Fatalf("retained rows = %d, want 0", got)
244 }
245 })
246 }
247}
248
249func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
250 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
251 kept := historyRow("ok")
252 if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
253 t.Fatalf("append retained row: %v", err)
254 }
255 before := history.View()
256
257 wide := historyRow("wide")
258 err := history.Append(wide, LineBound{End: len(wide)})
259 if !errors.Is(err, ErrHistoryRowTooWide) {
260 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
261 }
262 if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
263 t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
264 }
265 if got, want := history.Cells(), 2; got != want {
266 t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
267 }
268}
269
270func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
271 screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
272 var events []string
273 screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
274 copy(screen.Frame.Row(0), historyRow("AAAA"))
275 copy(screen.Frame.Row(1), historyRow("BBBB"))
276 screen.scrollUpRegion(0, 1, 1)
277
278 if got := screen.History().Len(); got != 0 {
279 t.Fatalf("oversized scrollback rows = %d, want 0", got)
280 }
281 if got, want := events, []string{"AAAA"}; !equalStrings(got, want) {
282 t.Fatalf("scroll callbacks = %#v, want %#v", got, want)
283 }
284 if got := lineText(screen, 0); got != "BBBB" {
285 t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
286 }
287}
288
289func TestHistoryAppendRetainsBounds(t *testing.T) {
290 row := func(s string) []renderer.Cell {
291 cells := make([]renderer.Cell, 0, len(s))
292 for _, r := range s {
293 cells = append(cells, renderer.Cell{Rune: r})
294 }
295 return cells
296 }
297
298 tests := []struct {
299 name string
300 chunkRows int
301 bounds []LineBound
302 }{
303 {
304 name: "within the mutable tail",
305 chunkRows: 8,
306 bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307 },
308 {
309 name: "across a sealed chunk boundary",
310 chunkRows: 2,
311 bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312 },
313 }
314
315 for _, tc := range tests {
316 t.Run(tc.name, func(t *testing.T) {
317 h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318 for i, b := range tc.bounds {
319 if err := h.Append(row("abc"[:b.End]), b); err != nil {
320 t.Fatalf("append %d: %v", i, err)
321 }
322 }
323 view := h.SealAndView()
324 for i, want := range tc.bounds {
325 if got := view.Bound(i); got != want {
326 t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327 }
328 }
329 })
330 }
331}
332
333func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334 h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335 for i := range 5 {
336 cells := []renderer.Cell{{Rune: rune('a' + i)}}
337 if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338 t.Fatalf("append %d: %v", i, err)
339 }
340 }
341 view := h.SealAndView()
342 if view.Len() != 3 {
343 t.Fatalf("Len() = %d, want 3", view.Len())
344 }
345 // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346 for i, want := range []bool{true, false, true} {
347 if got := view.Bound(i).Soft; got != want {
348 t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349 }
350 }
351}
352
353func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
355 if got, want := history.CellCap(), math.MaxInt; got != want {
356 t.Fatalf("default cell cap = %d, want %d", got, want)
357 }
358}
359
360func historyRow(text string) []renderer.Cell {
361 row := make([]renderer.Cell, len(text))
362 for i, r := range text {
363 row[i] = renderer.BlankCell()
364 row[i].Rune = r
365 }
366 return row
367}
368
369func historyViewTexts(view HistoryView) []string {
370 rows := make([]string, view.Len())
371 for i := range rows {
372 rows[i] = rowText(view.Row(i))
373 }
374 return rows
375}
376
377func rowText(row []renderer.Cell) string {
378 runes := make([]rune, len(row))
379 for i, cell := range row {
380 runes[i] = cell.Rune
381 }
382 return string(runes)
383}