v0.1.0 af61ae3

history_codec_test.go RawHistoryFolder

1package vt
2
3import (
4	"bytes"
5	"encoding/binary"
6	"fmt"
7	"math"
8	"testing"
9
10	renderer "github.com/bnema/vev-vt/core"
11)
12
13func TestHistoryCodecV3Bytes(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', 3, // magic, version
25		0, 0, 0, 1, // chunk count
26		0, 0, 0, 0, 0, 0, 0, 2, // next row ID
27		0, 0, 0, 1, // row count
28		0, 0, 0, 0, 0, 0, 0, 1, // row ID
29		0, 0, 0, 1, // cell count
30		0, 0, 0, 'a', // Rune
31		0,    // flags
32		0, 0, // Attrs
33		0, 0, 0, 0, 0, 0, 0, 0, // Foreground
34		0, 0, 0, 0, 0, 0, 0, 0, // Background
35		0, 0, 0, // ForegroundRGB
36		0, 0, 0, // BackgroundRGB
37		0,                      // UnderlineStyle
38		0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
39		0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
40		0, 0, 0, 1, 1, // bound: End=1, Soft=true
41	}
42	if !bytes.Equal(got, want) {
43		t.Fatalf("MarshalHistory() = % x, want % x", got, want)
44	}
45}
46
47func TestHistoryCodecRoundTripsBounds(t *testing.T) {
48	h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
49	bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
50	for _, b := range bounds {
51		if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
52			t.Fatalf("append: %v", err)
53		}
54	}
55	blob, err := MarshalHistory(h.SealAndView())
56	if err != nil {
57		t.Fatalf("marshal: %v", err)
58	}
59	view, err := UnmarshalHistory(blob)
60	if err != nil {
61		t.Fatalf("unmarshal: %v", err)
62	}
63	for i, want := range bounds {
64		if got := view.Bound(i); got != want {
65			t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
66		}
67	}
68}
69
70func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
71	// The previous layouts are deliberately incompatible with stable IDs.
72	legacy := []byte{
73		'V', 'T', 'H', '1', 2,
74		0, 0, 0, 1, 0, 0, 0, 1,
75		0, 0, 0, 1, 0, 0, 0, 'a',
76		0, 0, 0,
77		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
78		0, 0, 0, 0, 0, 0,
79		0,
80		0, 0, 0, 0, 0, 0, 0, 0,
81		0, 0, 0,
82		0, 0, 0, 1, 1,
83	}
84	for _, version := range []byte{1, 2} {
85		data := append([]byte(nil), legacy...)
86		data[4] = version
87		if _, err := UnmarshalHistory(data); err == nil {
88			t.Fatalf("accepted legacy history version %d", version)
89		}
90	}
91}
92
93func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
94	indexed := renderer.Style{
95		Bold:              true,
96		Attrs:             renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
97		Foreground:        123,
98		Background:        45,
99		UnderlineStyle:    renderer.UnderlineDashed,
100		HasUnderlineColor: true,
101		UnderlineColor:    201,
102	}
103	rgb := renderer.Style{
104		Italic:               true,
105		Inverse:              true,
106		Attrs:                renderer.AttrBlink,
107		HasForegroundRGB:     true,
108		ForegroundRGB:        renderer.RGB{R: 1, G: 2, B: 3},
109		HasBackgroundRGB:     true,
110		BackgroundRGB:        renderer.RGB{R: 4, G: 5, B: 6},
111		UnderlineStyle:       renderer.UnderlineCurly,
112		HasUnderlineColorRGB: true,
113		UnderlineColorRGB:    renderer.RGB{R: 7, G: 8, B: 9},
114	}
115	tests := []struct {
116		name string
117		rows [][]renderer.Cell
118	}{
119		{
120			name: "indexed RGB blank and wide continuation cells are exact",
121			rows: [][]renderer.Cell{
122				{
123					{Rune: 'I', Style: indexed},
124					{Rune: 'R', Style: rgb},
125					{Rune: ' ', Style: renderer.DefaultStyle()},
126					{Rune: '界', Style: rgb},
127					{Continuation: true, Style: rgb},
128				},
129				{
130					{Rune: ' ', Style: renderer.DefaultStyle()},
131					{Rune: 'X', Style: indexed},
132				},
133			},
134		},
135	}
136
137	for _, tt := range tests {
138		t.Run(tt.name, func(t *testing.T) {
139			history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
140			for _, row := range tt.rows {
141				requireHistoryAppend(t, history, row)
142			}
143
144			encoded, err := MarshalHistory(history.View())
145			if err != nil {
146				t.Fatalf("marshal history: %v", err)
147			}
148			decoded, err := UnmarshalHistory(encoded)
149			if err != nil {
150				t.Fatalf("unmarshal history: %v", err)
151			}
152			assertHistoryRowsEqual(t, decoded, tt.rows)
153		})
154	}
155}
156
157func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
158	history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
159	requireHistoryAppend(t, history, historyRow("AAAA"))
160	requireHistoryAppend(t, history, historyRow("BBBB"))
161	encoded, err := MarshalHistory(history.View())
162	if err != nil {
163		t.Fatalf("marshal history: %v", err)
164	}
165
166	for i := range len(encoded) {
167		if _, err := UnmarshalHistory(encoded[:i]); err == nil {
168			t.Fatalf("accepted truncated version 3 prefix of length %d", i)
169		}
170	}
171	if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
172		t.Fatal("accepted trailing garbage after version 3 payload")
173	}
174}
175
176func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
177	const (
178		rows  = 10_000
179		width = 120
180	)
181
182	encoded, err := MarshalHistory(historyViewWithDimensions(rows, width))
183	if err != nil {
184		t.Fatalf("marshal representative history: %v", err)
185	}
186	decoded, err := UnmarshalHistory(encoded)
187	if err != nil {
188		t.Fatalf("unmarshal representative history: %v", err)
189	}
190	if got := decoded.Len(); got != rows {
191		t.Fatalf("decoded rows = %d, want %d", got, rows)
192	}
193	for _, rowIndex := range []int{0, rows - 1} {
194		if got := len(decoded.Row(rowIndex)); got != width {
195			t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width)
196		}
197	}
198}
199
200func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
201	const supportedRows = 12_000
202	tests := []struct {
203		name string
204		view HistoryView
205		data []byte
206	}{
207		{
208			name: "too many rows",
209			view: historyViewWithDimensions(supportedRows+1, 0),
210			data: historyPayloadWithDimensions(supportedRows+1, 0),
211		},
212	}
213
214	for _, tt := range tests {
215		t.Run(tt.name, func(t *testing.T) {
216			if _, err := MarshalHistory(tt.view); err == nil {
217				t.Fatal("marshal accepted dimensions beyond the supported limits")
218			}
219			if _, err := UnmarshalHistory(tt.data); err == nil {
220				t.Fatal("unmarshal accepted dimensions beyond the supported limits")
221			}
222		})
223	}
224}
225
226func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) {
227	data := aggregateCellLimitDeclaration()
228	if len(data) >= 1024 {
229		t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data))
230	}
231	if _, err := PreflightHistoryBlob(data); err == nil {
232		t.Fatal("preflight accepted aggregate cell declaration")
233	}
234	if _, err := UnmarshalHistory(data); err == nil {
235		t.Fatal("unmarshal accepted aggregate cell declaration")
236	}
237}
238
239func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
240	for _, width := range []int{161, 293, math.MaxUint16} {
241		t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
242			view := historyViewWithDimensions(1, width)
243			encoded, err := MarshalHistory(view)
244			if err != nil {
245				t.Fatalf("marshal width %d: %v", width, err)
246			}
247			decoded, err := UnmarshalHistory(encoded)
248			if err != nil {
249				t.Fatalf("unmarshal width %d: %v", width, err)
250			}
251			if got := len(decoded.Row(0)); got != width {
252				t.Fatalf("decoded width = %d, want %d", got, width)
253			}
254			if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
255				t.Fatalf("preflight stats = %+v, err = %v", stats, err)
256			}
257		})
258	}
259}
260
261func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
262	tests := []struct {
263		name       string
264		chunkCount int
265		rowCount   int
266	}{
267		{name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1},
268		{name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows},
269	}
270
271	for _, tt := range tests {
272		t.Run(tt.name, func(t *testing.T) {
273			if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
274				t.Fatal("unmarshal accepted resource-exhausting history declarations")
275			}
276		})
277	}
278}
279
280func historyViewWithDimensions(rowCount, width int) HistoryView {
281	totalRows := rowCount
282	row := make([]renderer.Cell, width)
283	for i := range row {
284		row[i].Rune = 'x'
285	}
286	chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
287	rowID := RowID(1)
288	for rowCount > 0 {
289		chunkRows := min(rowCount, maxHistoryChunkRows)
290		rows := make([][]renderer.Cell, chunkRows)
291		rowIDs := make([]RowID, chunkRows)
292		for i := range rows {
293			rows[i] = row
294			rowIDs[i] = rowID
295			rowID++
296		}
297		chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
298		rowCount -= chunkRows
299	}
300	return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
301}
302
303func historyPayloadWithDimensions(rowCount, width int) []byte {
304	data := make([]byte, 17)
305	copy(data, historyMagic)
306	data[4] = historyVersion
307	chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
308	binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309	binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310	rowID := uint64(1)
311	for rowCount > 0 {
312		chunkRows := min(rowCount, maxHistoryChunkRows)
313		data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
314		for range chunkRows {
315			data = binary.BigEndian.AppendUint64(data, rowID)
316			rowID++
317			data = binary.BigEndian.AppendUint32(data, uint32(width))
318			//nolint:makezero // The header prefix is retained while zeroed cell records are appended.
319			data = append(data, make([]byte, width*historyCellBytes)...)
320			data = binary.BigEndian.AppendUint32(data, uint32(width))
321			data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
322		}
323		rowCount -= chunkRows
324	}
325	return data
326}
327
328// aggregateCellLimitDeclaration declares one row, which is within the row
329// budget, but its cells exceed the aggregate budget. It deliberately omits
330// cell payload bytes: the aggregate limit must reject it before allocation.
331func aggregateCellLimitDeclaration() []byte {
332	data := make([]byte, 17, 29)
333	copy(data, historyMagic)
334	data[4] = historyVersion
335	binary.BigEndian.PutUint32(data[5:], 1)
336	binary.BigEndian.PutUint64(data[9:], 2)
337	data = binary.BigEndian.AppendUint32(data, 1)
338	data = binary.BigEndian.AppendUint64(data, 1)
339	return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
340}
341
342func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
343	data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))
344	copy(data, historyMagic)
345	data[4] = historyVersion
346	binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347	binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
348	id := uint64(1)
349	for range chunkCount {
350		data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
351		for range rowCount {
352			data = binary.BigEndian.AppendUint64(data, id)
353			id++
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[33:37], ^uint32(0))
367	invalidUnderlineStyle := append([]byte(nil), valid...)
368	invalidUnderlineStyle[33+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, 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[33:37], ^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 2

Tags 13

↑↓ moveenter openesc close