detached eb218a3

test(vt): specify immutable history chunks

commit
eb218a3740558146668059a4747b42ce67902a0f
author
brice · · 2026-07-12 14:40 UTC
parent
4c50de8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +273 -0
A pkg/vt/history_codec_test.go +116 -0
@@ -0,0 +1,116 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
10+ indexed := renderer.Style{
11+ Bold: true,
12+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
13+ Foreground: 123,
14+ Background: 45,
15+ UnderlineStyle: renderer.UnderlineDashed,
16+ HasUnderlineColor: true,
17+ UnderlineColor: 201,
18+ }
19+ rgb := renderer.Style{
20+ Italic: true,
21+ Inverse: true,
22+ Attrs: renderer.AttrBlink,
23+ HasForegroundRGB: true,
24+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
25+ HasBackgroundRGB: true,
26+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
27+ UnderlineStyle: renderer.UnderlineCurly,
28+ HasUnderlineColorRGB: true,
29+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
30+ }
31+ tests := []struct {
32+ name string
33+ rows [][]renderer.Cell
34+ }{
35+ {
36+ name: "indexed RGB blank and wide continuation cells are exact",
37+ rows: [][]renderer.Cell{
38+ {
39+ {Rune: 'I', Style: indexed},
40+ {Rune: 'R', Style: rgb},
41+ {Rune: ' ', Style: renderer.DefaultStyle()},
42+ {Rune: '界', Style: rgb},
43+ {Continuation: true, Style: rgb},
44+ },
45+ {
46+ {Rune: ' ', Style: renderer.DefaultStyle()},
47+ {Rune: 'X', Style: indexed},
48+ },
49+ },
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
56+ for _, row := range tt.rows {
57+ history.Append(row)
58+ }
59+
60+ encoded, err := MarshalHistory(history.View())
61+ if err != nil {
62+ t.Fatalf("marshal history: %v", err)
63+ }
64+ decoded, err := UnmarshalHistory(encoded)
65+ if err != nil {
66+ t.Fatalf("unmarshal history: %v", err)
67+ }
68+ assertHistoryRowsEqual(t, decoded, tt.rows)
69+ })
70+ }
71+}
72+
73+func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
74+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
75+ history.Append(historyRow("AAAA"))
76+ history.Append(historyRow("BBBB"))
77+ encoded, err := MarshalHistory(history.View())
78+ if err != nil {
79+ t.Fatalf("marshal history: %v", err)
80+ }
81+
82+ tests := []struct {
83+ name string
84+ data []byte
85+ }{
86+ {name: "empty", data: nil},
87+ {name: "truncated prefix", data: encoded[:len(encoded)-1]},
88+ {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ if _, err := UnmarshalHistory(tt.data); err == nil {
94+ t.Fatal("unmarshal accepted malformed history payload")
95+ }
96+ })
97+ }
98+}
99+
100+func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
101+ t.Helper()
102+ if got := view.Len(); got != len(want) {
103+ t.Fatalf("row count = %d, want %d", got, len(want))
104+ }
105+ for y, wantRow := range want {
106+ gotRow := view.Row(y)
107+ if len(gotRow) != len(wantRow) {
108+ t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow))
109+ }
110+ for x, wantCell := range wantRow {
111+ if !gotRow[x].Equal(wantCell) {
112+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell)
113+ }
114+ }
115+ }
116+}
@@ -0,0 +1,116 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
10+ indexed := renderer.Style{
11+ Bold: true,
12+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
13+ Foreground: 123,
14+ Background: 45,
15+ UnderlineStyle: renderer.UnderlineDashed,
16+ HasUnderlineColor: true,
17+ UnderlineColor: 201,
18+ }
19+ rgb := renderer.Style{
20+ Italic: true,
21+ Inverse: true,
22+ Attrs: renderer.AttrBlink,
23+ HasForegroundRGB: true,
24+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
25+ HasBackgroundRGB: true,
26+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
27+ UnderlineStyle: renderer.UnderlineCurly,
28+ HasUnderlineColorRGB: true,
29+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
30+ }
31+ tests := []struct {
32+ name string
33+ rows [][]renderer.Cell
34+ }{
35+ {
36+ name: "indexed RGB blank and wide continuation cells are exact",
37+ rows: [][]renderer.Cell{
38+ {
39+ {Rune: 'I', Style: indexed},
40+ {Rune: 'R', Style: rgb},
41+ {Rune: ' ', Style: renderer.DefaultStyle()},
42+ {Rune: '界', Style: rgb},
43+ {Continuation: true, Style: rgb},
44+ },
45+ {
46+ {Rune: ' ', Style: renderer.DefaultStyle()},
47+ {Rune: 'X', Style: indexed},
48+ },
49+ },
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
56+ for _, row := range tt.rows {
57+ history.Append(row)
58+ }
59+
60+ encoded, err := MarshalHistory(history.View())
61+ if err != nil {
62+ t.Fatalf("marshal history: %v", err)
63+ }
64+ decoded, err := UnmarshalHistory(encoded)
65+ if err != nil {
66+ t.Fatalf("unmarshal history: %v", err)
67+ }
68+ assertHistoryRowsEqual(t, decoded, tt.rows)
69+ })
70+ }
71+}
72+
73+func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
74+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
75+ history.Append(historyRow("AAAA"))
76+ history.Append(historyRow("BBBB"))
77+ encoded, err := MarshalHistory(history.View())
78+ if err != nil {
79+ t.Fatalf("marshal history: %v", err)
80+ }
81+
82+ tests := []struct {
83+ name string
84+ data []byte
85+ }{
86+ {name: "empty", data: nil},
87+ {name: "truncated prefix", data: encoded[:len(encoded)-1]},
88+ {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ if _, err := UnmarshalHistory(tt.data); err == nil {
94+ t.Fatal("unmarshal accepted malformed history payload")
95+ }
96+ })
97+ }
98+}
99+
100+func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
101+ t.Helper()
102+ if got := view.Len(); got != len(want) {
103+ t.Fatalf("row count = %d, want %d", got, len(want))
104+ }
105+ for y, wantRow := range want {
106+ gotRow := view.Row(y)
107+ if len(gotRow) != len(wantRow) {
108+ t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow))
109+ }
110+ for x, wantCell := range wantRow {
111+ if !gotRow[x].Equal(wantCell) {
112+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell)
113+ }
114+ }
115+ }
116+}
A pkg/vt/history_test.go +157 -0
@@ -0,0 +1,157 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ rows []string
13+ want []string
14+ }{
15+ {
16+ name: "oldest chunks are evicted before newer chunks",
17+ rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"},
18+ want: []string{"cccc", "dddd", "eeee", "ffff"},
19+ },
20+ {
21+ name: "rows preserve append order across chunk boundaries",
22+ rows: []string{"0000", "1111", "2222", "3333"},
23+ want: []string{"0000", "1111", "2222", "3333"},
24+ },
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
30+ for _, text := range tt.rows {
31+ history.Append(historyRow(text))
32+ }
33+
34+ view := history.View()
35+ if got := historyViewTexts(view); !equalStrings(got, tt.want) {
36+ t.Fatalf("view rows = %#v, want %#v", got, tt.want)
37+ }
38+ if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want {
39+ t.Fatalf("chunk count = %d, want %d", got, want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestHistoryTailRotationAndStableViews(t *testing.T) {
46+ tests := []struct {
47+ name string
48+ }{
49+ {name: "a full tail seals and a later append cannot change an existing view"},
50+ {name: "rows passed to append are copied before the screen recycles them"},
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
56+ first := historyRow("AAAA")
57+ history.Append(first)
58+ history.Append(historyRow("BBBB"))
59+ view := history.View()
60+ if got, want := view.ChunkCount(), 1; got != want {
61+ t.Fatalf("sealed chunk count = %d, want %d", got, want)
62+ }
63+
64+ first[0].Rune = 'X'
65+ history.Append(historyRow("CCCC"))
66+
67+ if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
68+ t.Fatalf("stable view rows = %#v, want %#v", got, want)
69+ }
70+ if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) {
71+ t.Fatalf("current view rows = %#v, want %#v", got, want)
72+ }
73+ })
74+ }
75+}
76+
77+func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ }{
81+ {name: "unchanged sealed chunks are shared by successive views"},
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
87+ history.Append(historyRow("AAAA"))
88+ history.Append(historyRow("BBBB"))
89+ before := history.View()
90+
91+ history.Append(historyRow("CCCC"))
92+ after := history.View()
93+
94+ if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
95+ t.Fatal("sealed chunk missing from view")
96+ }
97+ if before.Chunk(0) != after.Chunk(0) {
98+ t.Fatal("unchanged sealed chunk was copied instead of reused")
99+ }
100+ })
101+ }
102+}
103+
104+func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
105+ tests := []struct {
106+ name string
107+ write []byte
108+ want []string
109+ }{
110+ {
111+ name: "primary screen evictions enter terminal history",
112+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
113+ want: []string{"AAAA"},
114+ },
115+ {
116+ name: "alternate screen scrolling never enters terminal history",
117+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
118+ want: []string{"AAAA"},
119+ },
120+ }
121+
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
125+ screen.Write(tt.write)
126+
127+ if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) {
128+ t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want)
129+ }
130+ })
131+ }
132+}
133+
134+func historyRow(text string) []renderer.Cell {
135+ row := make([]renderer.Cell, len(text))
136+ for i, r := range text {
137+ row[i] = renderer.BlankCell()
138+ row[i].Rune = r
139+ }
140+ return row
141+}
142+
143+func historyViewTexts(view HistoryView) []string {
144+ rows := make([]string, view.Len())
145+ for i := range rows {
146+ rows[i] = rowText(view.Row(i))
147+ }
148+ return rows
149+}
150+
151+func rowText(row []renderer.Cell) string {
152+ runes := make([]rune, len(row))
153+ for i, cell := range row {
154+ runes[i] = cell.Rune
155+ }
156+ return string(runes)
157+}
@@ -0,0 +1,157 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ rows []string
13+ want []string
14+ }{
15+ {
16+ name: "oldest chunks are evicted before newer chunks",
17+ rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"},
18+ want: []string{"cccc", "dddd", "eeee", "ffff"},
19+ },
20+ {
21+ name: "rows preserve append order across chunk boundaries",
22+ rows: []string{"0000", "1111", "2222", "3333"},
23+ want: []string{"0000", "1111", "2222", "3333"},
24+ },
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
30+ for _, text := range tt.rows {
31+ history.Append(historyRow(text))
32+ }
33+
34+ view := history.View()
35+ if got := historyViewTexts(view); !equalStrings(got, tt.want) {
36+ t.Fatalf("view rows = %#v, want %#v", got, tt.want)
37+ }
38+ if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want {
39+ t.Fatalf("chunk count = %d, want %d", got, want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestHistoryTailRotationAndStableViews(t *testing.T) {
46+ tests := []struct {
47+ name string
48+ }{
49+ {name: "a full tail seals and a later append cannot change an existing view"},
50+ {name: "rows passed to append are copied before the screen recycles them"},
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
56+ first := historyRow("AAAA")
57+ history.Append(first)
58+ history.Append(historyRow("BBBB"))
59+ view := history.View()
60+ if got, want := view.ChunkCount(), 1; got != want {
61+ t.Fatalf("sealed chunk count = %d, want %d", got, want)
62+ }
63+
64+ first[0].Rune = 'X'
65+ history.Append(historyRow("CCCC"))
66+
67+ if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
68+ t.Fatalf("stable view rows = %#v, want %#v", got, want)
69+ }
70+ if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) {
71+ t.Fatalf("current view rows = %#v, want %#v", got, want)
72+ }
73+ })
74+ }
75+}
76+
77+func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ }{
81+ {name: "unchanged sealed chunks are shared by successive views"},
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
87+ history.Append(historyRow("AAAA"))
88+ history.Append(historyRow("BBBB"))
89+ before := history.View()
90+
91+ history.Append(historyRow("CCCC"))
92+ after := history.View()
93+
94+ if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
95+ t.Fatal("sealed chunk missing from view")
96+ }
97+ if before.Chunk(0) != after.Chunk(0) {
98+ t.Fatal("unchanged sealed chunk was copied instead of reused")
99+ }
100+ })
101+ }
102+}
103+
104+func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
105+ tests := []struct {
106+ name string
107+ write []byte
108+ want []string
109+ }{
110+ {
111+ name: "primary screen evictions enter terminal history",
112+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
113+ want: []string{"AAAA"},
114+ },
115+ {
116+ name: "alternate screen scrolling never enters terminal history",
117+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
118+ want: []string{"AAAA"},
119+ },
120+ }
121+
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
125+ screen.Write(tt.write)
126+
127+ if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) {
128+ t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want)
129+ }
130+ })
131+ }
132+}
133+
134+func historyRow(text string) []renderer.Cell {
135+ row := make([]renderer.Cell, len(text))
136+ for i, r := range text {
137+ row[i] = renderer.BlankCell()
138+ row[i].Rune = r
139+ }
140+ return row
141+}
142+
143+func historyViewTexts(view HistoryView) []string {
144+ rows := make([]string, view.Len())
145+ for i := range rows {
146+ rows[i] = rowText(view.Row(i))
147+ }
148+ return rows
149+}
150+
151+func rowText(row []renderer.Cell) string {
152+ runes := make([]rune, len(row))
153+ for i, cell := range row {
154+ runes[i] = cell.Rune
155+ }
156+ return string(runes)
157+}

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 12

Tags 11

↑↓ moveenter openesc close