1package vt
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt/core"
7 "github.com/stretchr/testify/require"
8)
9
10var (
11 recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot
12 recoveryTranscriptScreenSink *Screen
13)
14
15func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
16 s := NewScreen(4, 2)
17 s.Write([]byte("keep"))
18
19 snapshot := s.RecoveryTranscriptSnapshot()
20 s.Frame.Set(0, 0, renderer.BlankCell())
21 s.buffer.boundaries[0] = LineBound{}
22
23 view := decodeRecoveryTranscript(t, snapshot)
24 require.Equal(t, []string{"keep"}, historyViewTexts(view))
25 require.Equal(t, LineBound{End: 4}, view.Bound(0))
26}
27
28func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) {
29 s := NewScreen(5, 3)
30 s.Write([]byte("abcdefgh"))
31
32 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
33
34 require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view))
35 require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0))
36 require.Equal(t, LineBound{End: 3}, view.Bound(1))
37}
38
39func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) {
40 const (
41 shortHeight = 8
42 tallHeight = 512
43 )
44 newRetainedScreen := func(height int) *Screen {
45 screen := NewScreen(4, height)
46 for y := range height {
47 screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
48 screen.buffer.boundaries[y] = LineBound{End: 1}
49 }
50 return screen
51 }
52 short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight)
53
54 shortAllocs := testing.AllocsPerRun(20, func() {
55 recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot()
56 })
57 tallAllocs := testing.AllocsPerRun(20, func() {
58 recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot()
59 })
60
61 // Allow a small fixed variance while rejecting the former allocation per row.
62 if tallAllocs > shortAllocs+4 {
63 t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs)
64 }
65}
66
67func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) {
68 s := NewScreen(4, 3)
69 s.Write([]byte("prim"))
70 s.buffer.boundaries[0].Soft = true
71 s.Write([]byte("\x1b[?1049h"))
72 s.Write([]byte("alt"))
73 s.buffer.boundaries[0].Soft = true
74
75 snapshot := s.RecoveryTranscriptSnapshot()
76 s.Frame.Set(0, 0, renderer.BlankCell())
77 s.buffer.boundaries[0] = LineBound{}
78 s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
79 s.alternate.buffer.boundaries[0] = LineBound{}
80
81 view := decodeRecoveryTranscript(t, snapshot)
82
83 require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view))
84 require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard")
85 require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard")
86}
87
88func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) {
89 s := NewScreen(4, 3)
90 s.Write([]byte("main"))
91 s.Write([]byte("\x1b[?1049h"))
92 s.Write([]byte("alt"))
93
94 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
95
96 require.Equal(t, []string{"main", "alt "}, historyViewTexts(view))
97}
98
99func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) {
100 s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2})
101 require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3}))
102 s.Write([]byte("live"))
103
104 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
105
106 require.Equal(t, []string{"live"}, historyViewTexts(view))
107}
108
109func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) {
110 styledBlank := renderer.BlankCell()
111 styledBlank.Style.Bold = true
112
113 tests := []struct {
114 name string
115 candidateCell renderer.Cell
116 candidateBound LineBound
117 wantRows int
118 wantCell renderer.Cell
119 }{
120 {
121 name: "untouched default blank with zero hard bound",
122 candidateCell: renderer.BlankCell(),
123 wantRows: 1,
124 },
125 {
126 name: "written default space",
127 candidateCell: renderer.BlankCell(),
128 candidateBound: LineBound{End: 1},
129 wantRows: 2,
130 wantCell: renderer.BlankCell(),
131 },
132 {
133 name: "styled blank",
134 candidateCell: styledBlank,
135 wantRows: 2,
136 wantCell: styledBlank,
137 },
138 {
139 name: "soft blank",
140 candidateCell: renderer.BlankCell(),
141 candidateBound: LineBound{Soft: true},
142 wantRows: 2,
143 wantCell: renderer.BlankCell(),
144 },
145 }
146
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 s := NewScreen(3, 3)
150 s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
151 s.buffer.boundaries[0] = LineBound{End: 1}
152 s.Frame.Set(0, 1, tt.candidateCell)
153 s.buffer.boundaries[1] = tt.candidateBound
154
155 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
156
157 require.Equal(t, tt.wantRows, view.Len())
158 if tt.wantRows == 2 {
159 require.True(t, view.Row(1)[0].Equal(tt.wantCell))
160 require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened")
161 }
162 })
163 }
164}
165
166func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
167 s := NewScreen(3, 4)
168 s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
169 s.buffer.boundaries[2] = LineBound{End: 1}
170
171 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
172
173 require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view))
174 require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
175}
176
177func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) {
178 s := NewScreen(4, 3)
179 s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}}
180
181 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
182
183 require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
184}
185
186func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
187 s := NewScreen(4, 2)
188
189 got, err := s.RecoveryTranscriptSnapshot().Marshal()
190 require.NoError(t, err)
191 want, err := MarshalHistory(HistoryView{nextRowID: 3})
192 require.NoError(t, err)
193
194 require.Equal(t, want, got)
195 view, err := UnmarshalHistory(got)
196 require.NoError(t, err)
197 require.Zero(t, view.Len())
198 require.Zero(t, view.ChunkCount())
199}
200
201func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
202 s := NewScreen(1, 257)
203 for y := range s.Frame.Height {
204 s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
205 s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
206 }
207
208 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
209
210 require.Equal(t, 257, view.Len())
211 require.Equal(t, 2, view.ChunkCount())
212 require.Len(t, view.Chunk(0).rows, 256)
213 require.Len(t, view.Chunk(1).rows, 1)
214 require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
215 require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
216}
217
218func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
219 s := NewScreen(4, 2)
220 s.Write([]byte("界"))
221 want := append([]renderer.Cell(nil), s.Frame.Row(0)...)
222
223 snapshot := s.RecoveryTranscriptSnapshot()
224 for x := range s.Frame.Width {
225 s.Frame.Set(x, 0, renderer.BlankCell())
226 }
227
228 view := decodeRecoveryTranscript(t, snapshot)
229 require.Equal(t, want, view.Row(0))
230 require.Equal(t, LineBound{End: 2}, view.Bound(0))
231 require.True(t, view.Row(0)[1].Continuation)
232}
233
234func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) {
235 sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"})
236 transcript := recoveryTranscriptBlob(t,
237 []string{"primary", "alternate"},
238 []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
239 )
240
241 screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript)
242 require.NoError(t, err)
243
244 view := screen.History().View()
245 require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
246 require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
247 require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")
248 for y := range screen.Frame.Height {
249 for x := range screen.Frame.Width {
250 require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
251 }
252 }
253 require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
254}
255
256func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) {
257 tests := []struct {
258 name string
259 config HistoryConfig
260 history []string
261 transcript []string
262 want []string
263 }{
264 {
265 name: "row budget",
266 config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},
267 history: []string{"old-1", "old-2", "old-3"},
268 transcript: []string{"new-1", "new-2"},
269 want: []string{"old-3", "new-1", "new-2"},
270 },
271 {
272 name: "cell budget",
273 config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},
274 history: []string{"aa", "b", "ccc"},
275 transcript: []string{"d", "ee"},
276 want: []string{"d", "ee"},
277 },
278 }
279
280 for _, tt := range tests {
281 t.Run(tt.name, func(t *testing.T) {
282 sealed, tail := recoveryHistoryBlobs(t, tt.history)
283 transcript := recoveryTranscriptBlob(t, tt.transcript, nil)
284
285 screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript)
286 require.NoError(t, err)
287 require.Equal(t, tt.want, historyViewTexts(screen.History().View()))
288 })
289 }
290}
291
292func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) {
293 sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
294 transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
295
296 screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript)
297
298 require.ErrorIs(t, err, ErrHistoryRowTooWide)
299 require.Nil(t, screen)
300}
301
302func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) {
303 for _, tt := range []struct {
304 name string
305 alternate bool
306 wantRows int
307 }{
308 {name: "primary", wantRows: 40},
309 {name: "primary-and-active-alternate", alternate: true, wantRows: 80},
310 } {
311 b.Run(tt.name, func(b *testing.B) {
312 screen := NewScreen(120, 40)
313 fillRecoveryTranscriptBenchmarkFrame(screen)
314 if tt.alternate {
315 screen.Write([]byte("\x1b[?1049h"))
316 fillRecoveryTranscriptBenchmarkFrame(screen)
317 }
318
319 b.ReportAllocs()
320 b.ReportMetric(float64(tt.wantRows), "rows/snapshot")
321 b.ResetTimer()
322 for b.Loop() {
323 recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot()
324 }
325 b.StopTimer()
326 if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows {
327 b.Fatalf("captured rows = %d, want %d", got, tt.wantRows)
328 }
329 })
330 }
331}
332
333func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) {
334 for y := range screen.Frame.Height {
335 for x := range screen.Frame.Width {
336 screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
337 }
338 screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width}
339 }
340}
341
342func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
343 const rows = maxHistoryRows
344 source := NewScreen(1, rows)
345 for y := range rows {
346 source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
347 source.buffer.boundaries[y] = LineBound{End: 1}
348 }
349 transcript, err := source.RecoveryTranscriptSnapshot().Marshal()
350 require.NoError(b, err)
351 tail, err := MarshalEmptyHistoryTail()
352 require.NoError(b, err)
353 config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows}
354
355 b.ReportAllocs()
356 b.ReportMetric(float64(rows), "rows/restore")
357 b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore")
358 b.ResetTimer()
359 for b.Loop() {
360 recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript)
361 if err != nil {
362 b.Fatal(err)
363 }
364 }
365}
366
367func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
368 t.Helper()
369 history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2})
370 for _, text := range texts {
371 require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
372 }
373 view := history.SnapshotView()
374 sealed := make([][]byte, view.ChunkCount())
375 for i := range sealed {
376 var err error
377 sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
378 require.NoError(t, err)
379 }
380 tail, err := MarshalHistoryTail(view)
381 require.NoError(t, err)
382 return sealed, tail
383}
384
385func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte {
386 t.Helper()
387 rows := make([][]renderer.Cell, len(texts))
388 cells := 0
389 for i, text := range texts {
390 rows[i] = historyRow(text)
391 cells += len(rows[i])
392 }
393 if bounds == nil {
394 bounds = make([]LineBound, len(rows))
395 for i, row := range rows {
396 bounds[i].End = len(row)
397 }
398 }
399 rowIDs := make([]RowID, len(rows))
400 for i := range rowIDs {
401 rowIDs[i] = RowID(100 + i)
402 }
403 view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
404 if len(rows) > 0 {
405 view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
406 }
407 blob, err := MarshalHistory(view)
408 require.NoError(t, err)
409 return blob
410}
411
412func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
413 t.Helper()
414 blob, err := snapshot.Marshal()
415 require.NoError(t, err)
416 view, err := UnmarshalHistory(blob)
417 require.NoError(t, err)
418 return view
419}