v0.7.1 ff50ee4

frame_copy_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close