v0.5.1 8e4df5c

history_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close