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}