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}