v0.1.0 7deb448

history_codec_test.go RawHistoryFolder

1package vt
2
3import (
4	"bytes"
5	"encoding/binary"
6	"fmt"
7	"math"
8	"testing"
9
10	"github.com/bnema/vev/pkg/renderer"
11)
12
13func TestHistoryCodecV2Bytes(t *testing.T) {
14	h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15	if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
16		t.Fatalf("append: %v", err)
17	}
18	got, err := MarshalHistory(h.SealAndView())
19	if err != nil {
20		t.Fatalf("marshal: %v", err)
21	}
22
23	want := []byte{
24		'V', 'T', 'H', '1', 2, // magic, version
25		0, 0, 0, 1, // chunk count
26		0, 0, 0, 1, // row count
27		0, 0, 0, 1, // cell count
28		0, 0, 0, 'a', // Rune
29		0,    // flags
30		0, 0, // Attrs
31		0, 0, 0, 0, 0, 0, 0, 0, // Foreground
32		0, 0, 0, 0, 0, 0, 0, 0, // Background
33		0, 0, 0, // ForegroundRGB
34		0, 0, 0, // BackgroundRGB
35		0,                      // UnderlineStyle
36		0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
37		0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
38		0, 0, 0, 1, 1, // bound: End=1, Soft=true
39	}
40	if !bytes.Equal(got, want) {
41		t.Fatalf("MarshalHistory() = % x, want % x", got, want)
42	}
43}
44
45func TestHistoryCodecRoundTripsBounds(t *testing.T) {
46	h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
47	bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
48	for _, b := range bounds {
49		if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
50			t.Fatalf("append: %v", err)
51		}
52	}
53	blob, err := MarshalHistory(h.SealAndView())
54	if err != nil {
55		t.Fatalf("marshal: %v", err)
56	}
57	view, err := UnmarshalHistory(blob)
58	if err != nil {
59		t.Fatalf("unmarshal: %v", err)
60	}
61	for i, want := range bounds {
62		if got := view.Bound(i); got != want {
63			t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
64		}
65	}
66}
67
68func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69	// Canonical v2 fixture from TestHistoryCodecV2Bytes.
70	v2 := []byte{
71		'V', 'T', 'H', '1', 2,
72		0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,
73		0, 0, 0, 'a',
74		0, 0, 0,
75		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76		0, 0, 0, 0, 0, 0,
77		0,
78		0, 0, 0, 0, 0, 0, 0, 0,
79		0, 0, 0,
80		0, 0, 0, 1, 1,
81	}
82
83	// This is a genuine v1-layout payload: version 1 and no trailing bound
84	// bytes. It is not a v2 payload with only its version byte changed.
85	v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...)
86	v1[4] = 1
87	if _, err := UnmarshalHistory(v1); err == nil {
88		t.Fatal("accepted a genuine v1 history payload")
89	}
90
91	for i := 0; i < len(v2); i++ {
92		if _, err := UnmarshalHistory(v2[:i]); err == nil {
93			t.Fatalf("accepted truncated prefix of length %d", i)
94		}
95	}
96	if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97		t.Fatal("accepted trailing garbage")
98	}
99}
100
101func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
102	indexed := renderer.Style{
103		Bold:              true,
104		Attrs:             renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
105		Foreground:        123,
106		Background:        45,
107		UnderlineStyle:    renderer.UnderlineDashed,
108		HasUnderlineColor: true,
109		UnderlineColor:    201,
110	}
111	rgb := renderer.Style{
112		Italic:               true,
113		Inverse:              true,
114		Attrs:                renderer.AttrBlink,
115		HasForegroundRGB:     true,
116		ForegroundRGB:        renderer.RGB{R: 1, G: 2, B: 3},
117		HasBackgroundRGB:     true,
118		BackgroundRGB:        renderer.RGB{R: 4, G: 5, B: 6},
119		UnderlineStyle:       renderer.UnderlineCurly,
120		HasUnderlineColorRGB: true,
121		UnderlineColorRGB:    renderer.RGB{R: 7, G: 8, B: 9},
122	}
123	tests := []struct {
124		name string
125		rows [][]renderer.Cell
126	}{
127		{
128			name: "indexed RGB blank and wide continuation cells are exact",
129			rows: [][]renderer.Cell{
130				{
131					{Rune: 'I', Style: indexed},
132					{Rune: 'R', Style: rgb},
133					{Rune: ' ', Style: renderer.DefaultStyle()},
134					{Rune: '界', Style: rgb},
135					{Continuation: true, Style: rgb},
136				},
137				{
138					{Rune: ' ', Style: renderer.DefaultStyle()},
139					{Rune: 'X', Style: indexed},
140				},
141			},
142		},
143	}
144
145	for _, tt := range tests {
146		t.Run(tt.name, func(t *testing.T) {
147			history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
148			for _, row := range tt.rows {
149				requireHistoryAppend(t, history, row)
150			}
151
152			encoded, err := MarshalHistory(history.View())
153			if err != nil {
154				t.Fatalf("marshal history: %v", err)
155			}
156			decoded, err := UnmarshalHistory(encoded)
157			if err != nil {
158				t.Fatalf("unmarshal history: %v", err)
159			}
160			assertHistoryRowsEqual(t, decoded, tt.rows)
161		})
162	}
163}
164
165func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
166	history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
167	requireHistoryAppend(t, history, historyRow("AAAA"))
168	requireHistoryAppend(t, history, historyRow("BBBB"))
169	encoded, err := MarshalHistory(history.View())
170	if err != nil {
171		t.Fatalf("marshal history: %v", err)
172	}
173
174	tests := []struct {
175		name string
176		data []byte
177	}{
178		{name: "empty", data: nil},
179		{name: "truncated prefix", data: encoded[:len(encoded)-1]},
180		{name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
181	}
182
183	for _, tt := range tests {
184		t.Run(tt.name, func(t *testing.T) {
185			if _, err := UnmarshalHistory(tt.data); err == nil {
186				t.Fatal("unmarshal accepted malformed history payload")
187			}
188		})
189	}
190}
191
192func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
193	const (
194		rows  = 10_000
195		width = 120
196	)
197
198	encoded, err := MarshalHistory(historyViewWithDimensions(rows, width))
199	if err != nil {
200		t.Fatalf("marshal representative history: %v", err)
201	}
202	decoded, err := UnmarshalHistory(encoded)
203	if err != nil {
204		t.Fatalf("unmarshal representative history: %v", err)
205	}
206	if got := decoded.Len(); got != rows {
207		t.Fatalf("decoded rows = %d, want %d", got, rows)
208	}
209	for _, rowIndex := range []int{0, rows - 1} {
210		if got := len(decoded.Row(rowIndex)); got != width {
211			t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width)
212		}
213	}
214}
215
216func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
217	const supportedRows = 12_000
218	tests := []struct {
219		name string
220		view HistoryView
221		data []byte
222	}{
223		{
224			name: "too many rows",
225			view: historyViewWithDimensions(supportedRows+1, 0),
226			data: historyPayloadWithDimensions(supportedRows+1, 0),
227		},
228	}
229
230	for _, tt := range tests {
231		t.Run(tt.name, func(t *testing.T) {
232			if _, err := MarshalHistory(tt.view); err == nil {
233				t.Fatal("marshal accepted dimensions beyond the supported limits")
234			}
235			if _, err := UnmarshalHistory(tt.data); err == nil {
236				t.Fatal("unmarshal accepted dimensions beyond the supported limits")
237			}
238		})
239	}
240}
241
242func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) {
243	data := aggregateCellLimitDeclaration()
244	if len(data) >= 1024 {
245		t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data))
246	}
247	if _, err := PreflightHistoryBlob(data); err == nil {
248		t.Fatal("preflight accepted aggregate cell declaration")
249	}
250	if _, err := UnmarshalHistory(data); err == nil {
251		t.Fatal("unmarshal accepted aggregate cell declaration")
252	}
253}
254
255func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
256	for _, width := range []int{161, 293, math.MaxUint16} {
257		t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
258			view := historyViewWithDimensions(1, width)
259			encoded, err := MarshalHistory(view)
260			if err != nil {
261				t.Fatalf("marshal width %d: %v", width, err)
262			}
263			decoded, err := UnmarshalHistory(encoded)
264			if err != nil {
265				t.Fatalf("unmarshal width %d: %v", width, err)
266			}
267			if got := len(decoded.Row(0)); got != width {
268				t.Fatalf("decoded width = %d, want %d", got, width)
269			}
270			if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
271				t.Fatalf("preflight stats = %+v, err = %v", stats, err)
272			}
273		})
274	}
275}
276
277func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
278	tests := []struct {
279		name       string
280		chunkCount int
281		rowCount   int
282	}{
283		{name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1},
284		{name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows},
285	}
286
287	for _, tt := range tests {
288		t.Run(tt.name, func(t *testing.T) {
289			if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
290				t.Fatal("unmarshal accepted resource-exhausting history declarations")
291			}
292		})
293	}
294}
295
296func historyViewWithDimensions(rowCount, width int) HistoryView {
297	totalRows := rowCount
298	row := make([]renderer.Cell, width)
299	for i := range row {
300		row[i].Rune = 'x'
301	}
302	chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
303	for rowCount > 0 {
304		chunkRows := min(rowCount, maxHistoryChunkRows)
305		rows := make([][]renderer.Cell, chunkRows)
306		for i := range rows {
307			rows[i] = row
308		}
309		chunks = append(chunks, &HistoryChunk{rows: rows})
310		rowCount -= chunkRows
311	}
312	return HistoryView{chunks: chunks, rows: totalRows}
313}
314
315func historyPayloadWithDimensions(rowCount, width int) []byte {
316	data := make([]byte, 9)
317	copy(data, historyMagic)
318	data[4] = historyVersion
319	chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
320	binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
321	for rowCount > 0 {
322		chunkRows := min(rowCount, maxHistoryChunkRows)
323		data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
324		for range chunkRows {
325			data = binary.BigEndian.AppendUint32(data, uint32(width))
326			//nolint:makezero // The header prefix is retained while zeroed cell records are appended.
327			data = append(data, make([]byte, width*historyCellBytes)...)
328		}
329		rowCount -= chunkRows
330	}
331	return data
332}
333
334// aggregateCellLimitDeclaration declares one row, which is within the row
335// budget, but its cells exceed the aggregate budget. It deliberately omits
336// cell payload bytes: the aggregate limit must reject it before allocation.
337func aggregateCellLimitDeclaration() []byte {
338	data := make([]byte, 9, 21)
339	copy(data, historyMagic)
340	data[4] = historyVersion
341	binary.BigEndian.PutUint32(data[5:], 1)
342	data = binary.BigEndian.AppendUint32(data, 1)
343	return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
344}
345
346func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
347	data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
348	copy(data, historyMagic)
349	data[4] = historyVersion
350	binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
351	for range chunkCount {
352		data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
353		for range rowCount {
354			data = binary.BigEndian.AppendUint32(data, 0)
355		}
356	}
357	return data
358}
359
360func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
361	valid, err := MarshalHistory(historyViewWithDimensions(1, 1))
362	if err != nil {
363		t.Fatalf("marshal valid history: %v", err)
364	}
365	invalidRune := append([]byte(nil), valid...)
366	binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
367	invalidUnderlineStyle := append([]byte(nil), valid...)
368	invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1)
369
370	tests := []struct {
371		name  string
372		data  []byte
373		valid bool
374	}{
375		{name: "valid", data: valid, valid: true},
376		{name: "truncated cell", data: valid[:len(valid)-1]},
377		{name: "trailing byte", data: append(append([]byte(nil), valid...), 0)},
378		{name: "invalid rune", data: invalidRune},
379		{name: "invalid underline style", data: invalidUnderlineStyle},
380		{name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381		{name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
382		{name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},
383	}
384	for _, tt := range tests {
385		t.Run(tt.name, func(t *testing.T) {
386			_, preflightErr := PreflightHistoryBlob(tt.data)
387			_, unmarshalErr := UnmarshalHistory(tt.data)
388			if (preflightErr == nil) != tt.valid || (unmarshalErr == nil) != tt.valid {
389				t.Fatalf("preflight error = %v, unmarshal error = %v, want valid = %t", preflightErr, unmarshalErr, tt.valid)
390			}
391		})
392	}
393}
394
395func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
396	valid, err := MarshalHistory(historyViewWithDimensions(1, 1))
397	if err != nil {
398		f.Fatalf("marshal valid history: %v", err)
399	}
400	invalidRune := append([]byte(nil), valid...)
401	binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
402	f.Add(valid)
403	f.Add(invalidRune)
404	f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))
405	f.Add([]byte("not a history payload"))
406
407	f.Fuzz(func(t *testing.T, data []byte) {
408		_, preflightErr := PreflightHistoryBlob(data)
409		_, unmarshalErr := UnmarshalHistory(data)
410		if (preflightErr == nil) != (unmarshalErr == nil) {
411			t.Fatalf("preflight error = %v, unmarshal error = %v", preflightErr, unmarshalErr)
412		}
413	})
414}
415
416func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
417	t.Helper()
418	if got := view.Len(); got != len(want) {
419		t.Fatalf("row count = %d, want %d", got, len(want))
420	}
421	for y, wantRow := range want {
422		gotRow := view.Row(y)
423		if len(gotRow) != len(wantRow) {
424			t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow))
425		}
426		for x, wantCell := range wantRow {
427			if !gotRow[x].Equal(wantCell) {
428				t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell)
429			}
430		}
431	}
432}

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