1package core
2
3import (
4 "fmt"
5 "reflect"
6 "testing"
7)
8
9// cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding
10// a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom
11// (and this count) are updated.
12const cellPageFieldCount = 13
13
14func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
15 t.Helper()
16 p, err := NewCellPayload(grapheme, link)
17 if err != nil {
18 t.Fatal(err)
19 }
20 return p
21}
22
23func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
24 t.Helper()
25 f := NewFrame(width, height)
26 styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
27 for y := range height {
28 for x := range width {
29 c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
30 if (x+y)%5 == 0 {
31 c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
32 }
33 f.Set(x, y, c)
34 }
35 }
36 if height > 2 {
37 f.ScrollUp(0, height-1, 1)
38 }
39 return f
40}
41
42func requireFramesEqual(t *testing.T, got, want Frame) {
43 t.Helper()
44 if got.Width != want.Width || got.Height != want.Height {
45 t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
46 }
47 for y := range want.Height {
48 if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
49 t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
50 }
51 }
52 if err := got.CheckInvariants(); err != nil {
53 t.Fatal(err)
54 }
55}
56
57func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
58 src := copyTestFrame(t, 9, 5, 'a')
59 var dst Frame
60 dst.CopyFrom(src)
61 requireFramesEqual(t, dst, src)
62 if dst.page == src.page {
63 t.Fatal("copy aliases source page")
64 }
65
66 // Mutating the source must not affect the copy.
67 before := dst.Clone()
68 src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
69 requireFramesEqual(t, dst, before)
70
71 // Copy over a different, larger-state destination: the page pointer and
72 // backing arrays are reused and stale styles/payloads do not leak.
73 other := copyTestFrame(t, 9, 5, 'k')
74 other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
75 page := dst.page
76 cells := &dst.page.cells[0]
77 dst.CopyFrom(other)
78 if dst.page != page || &dst.page.cells[0] != cells {
79 t.Fatal("CopyFrom did not reuse destination storage")
80 }
81 requireFramesEqual(t, dst, other)
82 if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
83 t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
84 }
85
86 // The copy stays a fully functional, independent frame.
87 dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
88 dst.ScrollDown(0, 4, 2)
89 if err := dst.CheckInvariants(); err != nil {
90 t.Fatal(err)
91 }
92 requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
93}
94
95func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
96
97func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
98 dst := copyTestFrame(t, 4, 3, 'a')
99 small := copyTestFrame(t, 2, 2, 'x')
100 dst.CopyFrom(small)
101 requireFramesEqual(t, dst, small)
102 large := copyTestFrame(t, 12, 7, 'm')
103 dst.CopyFrom(large)
104 requireFramesEqual(t, dst, large)
105
106 before := dst.Clone()
107 dst.CopyFrom(Frame{})
108 dst.CopyFrom(Frame{Width: 3, Height: 3})
109 requireFramesEqual(t, dst, before)
110 dst.CopyFrom(dst)
111 requireFramesEqual(t, dst, before)
112 var nilFrame *Frame
113 nilFrame.CopyFrom(large)
114}
115
116func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
117 src := copyTestFrame(t, 40, 10, 'a')
118 dst := src.Clone()
119 dst.CopyFrom(src)
120 if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
121 t.Fatalf("CopyFrom allocated %v times", n)
122 }
123}
124
125func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
126 dst := copyTestFrame(t, 240, 80, 'a')
127 small := copyTestFrame(t, 20, 5, 'b')
128 dst.CopyFrom(small)
129 if got := cap(dst.page.cells); got > 4*len(small.page.cells) {
130 t.Fatalf("CopyFrom kept %d cells for a %d-cell source", got, len(small.page.cells))
131 }
132 for y := range small.Height {
133 if !RowsEqualAt(dst, y, small, y) {
134 t.Fatalf("row %d differs after shrinking copy", y)
135 }
136 }
137}
138
139func TestCopyFromClearsStalePayloadSlots(t *testing.T) {
140 src := copyTestFrame(t, 8, 2, 'a')
141 dst := NewFrame(8, 2)
142 for x := range 8 {
143 dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))})
144 }
145 old := len(dst.page.payloads)
146 dst.CopyFrom(src)
147 if len(dst.page.payloads) >= old {
148 t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old)
149 }
150 for i, slot := range dst.page.payloads[len(dst.page.payloads):old] {
151 if slot != (payloadSlot{}) {
152 t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value)
153 }
154 }
155}
156
157func TestRowsEqualAt(t *testing.T) {
158 a := copyTestFrame(t, 10, 4, 'a')
159 b := a.Clone()
160 for y := range a.Height {
161 if !RowsEqualAt(a, y, b, y) {
162 t.Fatalf("clone row %d differs", y)
163 }
164 }
165
166 // Equal content with different physical rotation and different ID tables.
167 c := NewFrame(a.Width, a.Height)
168 // Intern an unrelated style first so IDs differ from a's.
169 c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
170 for y := range a.Height {
171 for x := range a.Width {
172 c.Set(x, y, a.Cell(x, y))
173 }
174 }
175 for y := range a.Height {
176 if !RowsEqualAt(a, y, c, y) {
177 t.Fatalf("semantically equal row %d reported different", y)
178 }
179 if got, want := RowsEqualAt(a, y, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
180 t.Fatalf("row %d: RowsEqualAt=%v DeepEqual=%v", y, got, want)
181 }
182 }
183
184 // Each kind of difference is detected on the right row only.
185 diffs := []Cell{
186 {Rune: 'Q', Style: a.Cell(3, 1).Style},
187 {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
188 {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
189 {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
190 }
191 for i, d := range diffs {
192 m := a.Clone()
193 m.Set(3, 1, d)
194 for y := range a.Height {
195 want := y != 1
196 if got := RowsEqualAt(a, y, m, y); got != want {
197 t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
198 }
199 }
200 }
201
202 // Payload present on one side only, and differing payloads.
203 m := a.Clone()
204 px := -1
205 for x := range a.Width {
206 if a.Cell(x, 0).Payload != (CellPayload{}) {
207 px = x
208 break
209 }
210 }
211 if px < 0 {
212 t.Fatal("test frame row 0 has no payload")
213 }
214 m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
215 if RowsEqualAt(a, 0, m, 0) {
216 t.Fatal("payload removal not detected")
217 }
218
219 // Inactive RGB fields and other equivalent spellings compare equal.
220 e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
221 s1 := Style{Bold: true, Foreground: 2, Background: -1}
222 s2 := s1
223 s2.ForegroundRGB = RGB{R: 9}
224 e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
225 e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
226 if !RowsEqualAt(e1, 0, e2, 0) {
227 t.Fatal("equivalent styles reported different")
228 }
229
230 // A row compares equal at a different index after scrolling.
231 shifted := a.Clone()
232 shifted.ScrollUp(0, a.Height-1, 1)
233 for y := range a.Height - 1 {
234 if !RowsEqualAt(a, y+1, shifted, y) {
235 t.Fatalf("row %d of a != row %d of scrolled copy", y+1, y)
236 }
237 }
238
239 // Mismatched shape or range is unequal and never panics.
240 if RowsEqualAt(a, 0, NewFrame(a.Width+1, a.Height), 0) || RowsEqualAt(a, -1, b, -1) || RowsEqualAt(a, a.Height, b, a.Height) || RowsEqualAt(Frame{}, 0, Frame{}, 0) {
241 t.Fatal("invalid comparison reported equal")
242 }
243}
244
245func TestRowsEqualAtDoesNotAllocate(t *testing.T) {
246 a := copyTestFrame(t, 80, 4, 'a')
247 b := a.Clone()
248 if n := testing.AllocsPerRun(20, func() { RowsEqualAt(a, 2, b, 2) }); n != 0 {
249 t.Fatalf("RowsEqualAt allocated %v times", n)
250 }
251}
252
253func TestCopyFromHandlesEveryPageField(t *testing.T) {
254 typ := reflect.TypeOf(cellPage{})
255 if typ.NumField() != cellPageFieldCount {
256 t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount)
257 }
258
259 // Build a source that populates every field, and a destination whose every
260 // field holds different, stale state.
261 src := copyTestFrame(t, 9, 5, 'a')
262 src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}})
263 dst := copyTestFrame(t, 9, 5, 'k')
264 dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}})
265 dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot
266 dst.CopyFrom(src)
267
268 sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page)
269 populated := 0
270 for i := range typ.NumField() {
271 name := typ.Field(i).Name
272 a, b := sp.Field(i), dp.Field(i)
273 if !a.IsZero() {
274 populated++
275 }
276 if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 {
277 continue // nil and empty are equivalent
278 }
279 // Printing works on unexported fields and treats nil and empty alike;
280 // fmt sorts map keys, so the rendering is deterministic.
281 if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want {
282 t.Errorf("cellPage.%s = %s, want %s", name, got, want)
283 }
284 }
285 if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty
286 t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField())
287 }
288}
289
290func TestCopyFromDropsStaleStyleCache(t *testing.T) {
291 styleA := Style{Bold: true, Foreground: 1, Background: -1}
292 styleB := Style{Italic: true, Foreground: 2, Background: -1}
293
294 // dst: style A lives in slot k and is cached by the last Set.
295 dst := NewFrame(4, 2)
296 dst.Set(0, 0, Cell{Rune: 'a', Style: styleA})
297 k := dst.page.styleCacheID
298 if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA {
299 t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k)
300 }
301
302 // src: a different style occupies the same slot k, and no cache.
303 src := NewFrame(4, 2)
304 src.Set(0, 0, Cell{Rune: 'b', Style: styleB})
305 src.page.styleCacheOK = false
306 if src.page.styleIndex[styleB] != k {
307 t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k)
308 }
309
310 dst.CopyFrom(src)
311 if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache {
312 t.Fatal("CopyFrom left a stale style cache")
313 }
314 cell := Cell{Rune: 'c', Style: styleA}
315 dst.Set(1, 1, cell)
316 if got := dst.Cell(1, 1); !got.Equal(cell) {
317 t.Fatalf("cell = %+v, want %+v", got, cell)
318 }
319 if got := dst.Cell(0, 0); got.Style != styleB {
320 t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB)
321 }
322 if err := dst.CheckInvariants(); err != nil {
323 t.Fatal(err)
324 }
325}
326
327func TestRowsEqualAtRejectsCorruptPayloadIDs(t *testing.T) {
328 a := copyTestFrame(t, 4, 1, 'a')
329 b := a.Clone()
330 b.page.cells[b.page.rows[0]].payloadID = uint32(len(b.page.payloads) + 5)
331 if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(b, 0, a, 0) {
332 t.Fatal("corrupt payload ID reported equal")
333 }
334}