1package vt
2
3import (
4 "bytes"
5 "errors"
6 "sync"
7 "testing"
8
9 renderer "github.com/bnema/vev-vt/core"
10 "github.com/stretchr/testify/require"
11)
12
13func compressionFixture(t testing.TB) *History {
14 t.Helper()
15 h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16})
16 row := historyRow(string(bytes.Repeat([]byte("a"), 80)))
17 payload, err := renderer.NewCellPayload("e\u0301", "https://example.com")
18 require.NoError(t, err)
19 row[0].Payload = payload
20 for range 48 {
21 require.NoError(t, h.Append(row, LineBound{End: 80, Soft: true}))
22 }
23 return h
24}
25
26func compressFixture(t testing.TB, h *History) {
27 t.Helper()
28 n, err := h.CompressIdle(100)
29 require.NoError(t, err)
30 require.Zero(t, n, "first observation must not compress a recently read page")
31 n, err = h.CompressIdle(100)
32 require.NoError(t, err)
33 require.Equal(t, 2, n)
34}
35
36func TestColdHistoryPreservesIdentityAccountingAndContents(t *testing.T) {
37 h := compressionFixture(t)
38 view := h.View()
39 chunk := view.Chunk(0)
40 logical := h.LogicalBytes()
41 encoded, err := MarshalHistory(view)
42 require.NoError(t, err)
43 compressFixture(t, h)
44 for _, sealed := range h.chunks {
45 require.Equal(t, len(sealed.page.compressed), cap(sealed.page.compressed), "compressed backing must not retain buffer slack")
46 }
47 stats := h.CompressionStats()
48 require.Equal(t, 2, stats.ColdPages)
49 require.Positive(t, stats.CompressedBytes)
50 require.Less(t, stats.CompressedBytes+stats.ResidentLogicalBytes, logical)
51 require.Equal(t, logical, h.LogicalBytes())
52 require.Same(t, chunk, h.View().Chunk(0))
53 // Persistence and streaming search do not fill random-access caches.
54 again, err := MarshalHistory(view)
55 require.NoError(t, err)
56 require.Equal(t, encoded, again)
57 rows := 0
58 require.NoError(t, view.Range(func(row []renderer.Cell) bool {
59 require.Equal(t, 'a', row[0].Rune)
60 require.Equal(t, "e\u0301", row[0].Payload.Grapheme())
61 rows++
62 return true
63 }))
64 require.Equal(t, 48, rows)
65 require.Equal(t, 2, h.CompressionStats().ColdPages)
66 require.Equal(t, 'a', view.Cell(0, 0).Rune)
67 require.Equal(t, 1, h.CompressionStats().ColdPages)
68 require.Equal(t, RowID(1), view.RowID(0))
69 require.Equal(t, LineBound{End: 80, Soft: true}, view.Bound(0))
70 require.NoError(t, chunk.CheckInvariants())
71 _, err = h.CompressIdle(100)
72 require.NoError(t, err)
73 n, err := h.CompressIdle(100)
74 require.NoError(t, err)
75 require.Equal(t, 1, n, "an unread restored cache is released without recompression")
76}
77
78func TestColdHistoryIncrementalBudgetAndEviction(t *testing.T) {
79 h := compressionFixture(t)
80 before := h.View()
81 for range 4 {
82 n, err := h.CompressIdle(1)
83 require.NoError(t, err)
84 require.LessOrEqual(t, n, 1)
85 }
86 require.Equal(t, 2, h.CompressionStats().ColdPages)
87 for range 20 {
88 require.NoError(t, h.Append(historyRow("new"), LineBound{End: 3}))
89 }
90 require.Equal(t, 64, h.Len())
91 require.Equal(t, RowID(5), h.View().RowID(0))
92 require.Equal(t, RowID(1), before.RowID(0))
93 require.Equal(t, 'a', before.Cell(0, 0).Rune)
94 require.NoError(t, h.View().Chunk(0).CheckInvariants())
95}
96
97func TestColdHistoryRejectsCorruptedBacking(t *testing.T) {
98 h := compressionFixture(t)
99 compressFixture(t, h)
100 chunk := h.View().Chunk(0)
101 chunk.page.compressed[len(chunk.page.compressed)-1] ^= 0xff
102 require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt)
103 _, err := MarshalHistory(h.View())
104 require.ErrorIs(t, err, ErrHistoryCorrupt)
105 func() {
106 defer func() {
107 value := recover()
108 err, ok := value.(error)
109 require.True(t, ok)
110 require.True(t, errors.Is(err, ErrHistoryCorrupt))
111 }()
112 _ = h.View().Cell(0, 0)
113 t.Fatal("corrupt read silently succeeded")
114 }()
115}
116
117func TestColdHistoryRejectsBackingLengthCorruption(t *testing.T) {
118 for _, tt := range []struct {
119 name string
120 mutate func(*sealedPage)
121 }{
122 {"trailing data", func(p *sealedPage) { p.compressed = append(p.compressed, 0) }},
123 {"truncated data", func(p *sealedPage) { p.compressed = p.compressed[:len(p.compressed)-1] }},
124 {"short decoded length", func(p *sealedPage) { p.encodedSize-- }},
125 {"oversized decoded length", func(p *sealedPage) { p.encodedSize = 257 << 20 }},
126 } {
127 t.Run(tt.name, func(t *testing.T) {
128 h := compressionFixture(t)
129 compressFixture(t, h)
130 chunk := h.View().Chunk(0)
131 tt.mutate(chunk.page)
132 require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt)
133 })
134 }
135}
136
137func TestColdHistoryBorrowedReadConcurrentWithMaintenance(t *testing.T) {
138 h := compressionFixture(t)
139 view := h.View()
140 var wg sync.WaitGroup
141 wg.Add(1)
142 go func() {
143 defer wg.Done()
144 for range 100 {
145 if view.Cell(0, 0).Rune != 'a' {
146 t.Error("borrowed contents changed")
147 return
148 }
149 }
150 }()
151 for range 100 {
152 _, err := h.CompressIdle(2)
153 require.NoError(t, err)
154 }
155 wg.Wait()
156}
157
158func BenchmarkColdHistoryRestore(b *testing.B) {
159 h := compressionFixture(b)
160 compressFixture(b, h)
161 chunk := h.View().Chunk(0)
162 b.ReportAllocs()
163 for b.Loop() {
164 if err := chunk.Restore(); err != nil {
165 b.Fatal(err)
166 }
167 if _, err := h.CompressIdle(100); err != nil {
168 b.Fatal(err)
169 }
170 if _, err := h.CompressIdle(100); err != nil {
171 b.Fatal(err)
172 }
173 }
174}