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