v0.4.2 baf7aea

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				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}

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