v0.3.0 da0887c

layout_test.go RawHistoryFolder

1package layout
2
3import (
4	"fmt"
5	"log/slog"
6	"testing"
7
8	"github.com/bnema/neferbar/internal/glyph"
9	"github.com/bnema/neferbar/internal/module"
10	"github.com/bnema/neferbar/internal/racecheck"
11)
12
13var testPalette = [16][3]uint8{{1, 1, 1}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, {0, 0, 255}}
14
15func newTestLayout(cols int) (*Layout, []*module.Module) {
16	wake := make(chan struct{}, 1)
17	log := slog.New(slog.DiscardHandler)
18	mods := []*module.Module{
19		module.New("l", module.Left, "true", wake, log),
20		module.New("c", module.Center, "true", wake, log),
21		module.New("r", module.Right, "true", wake, log),
22	}
23	return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, testPalette, mods), mods
24}
25
26func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
27
28func rowText(l *Layout) string {
29	row := l.Compose()
30	rs := make([]rune, len(row))
31	for i, c := range row {
32		rs[i] = c.Rune
33	}
34	return string(rs)
35}
36
37func TestZones(t *testing.T) {
38	l, m := newTestLayout(20)
39	publish(m[0], "LEFT")
40	publish(m[1], "mid")
41	publish(m[2], "RIGHT")
42	if !l.Update() {
43		t.Fatal("Update reported no change")
44	}
45	got := rowText(l)
46	want := "LEFT    mid  RIGHT"
47	if len(want) < 20 {
48		want += "   "[:20-len(want)]
49	}
50	if got[:4] != "LEFT" || got[15:] != "RIGHT" {
51		t.Fatalf("row = %q", got)
52	}
53	if i := indexOf(got, "mid"); i < 4 || i > 15 {
54		t.Fatalf("center misplaced in %q", got)
55	}
56}
57
58func indexOf(s, sub string) int {
59	for i := 0; i+len(sub) <= len(s); i++ {
60		if s[i:i+len(sub)] == sub {
61			return i
62		}
63	}
64	return -1
65}
66
67func TestOverlapCutsCenterFirst(t *testing.T) {
68	l, m := newTestLayout(12)
69	publish(m[0], "AAAAA")
70	publish(m[1], "CCCCCCCC")
71	publish(m[2], "ZZZZZ")
72	l.Update()
73	got := rowText(l)
74	if got[:5] != "AAAAA" || got[7:] != "ZZZZZ" {
75		t.Fatalf("sides must survive: %q", got)
76	}
77}
78
79func TestColorsAndClip(t *testing.T) {
80	l, m := newTestLayout(6)
81	publish(m[0], "\x1b[31mR\x1b[0mx\x1b[1;38;2;1;2;3mverylongtext")
82	l.Update()
83	row := l.Compose()
84	if row[0].Rune != 'R' || row[0].FG == l.fg {
85		t.Fatalf("red cell: %+v", row[0])
86	}
87	if row[1].FG != l.fg {
88		t.Fatalf("reset should restore default fg: %+v", row[1])
89	}
90	if row[2].Style&glyph.Bold == 0 || row[2].FG != [3]uint8{1, 2, 3} {
91		t.Fatalf("bold truecolor cell: %+v", row[2])
92	}
93}
94
95func TestNewFrameReplacesOld(t *testing.T) {
96	l, m := newTestLayout(10)
97	publish(m[0], "long text")
98	l.Update()
99	publish(m[0], "ab")
100	l.Update()
101	if got := rowText(l); got[:2] != "ab" || got[2] != ' ' {
102		t.Fatalf("old text lingers: %q", got)
103	}
104}
105
106// TestUpdateComposeAllocs: parsing a frame and composing the row must not
107// allocate once the buffers are warm.
108func TestUpdateComposeAllocs(t *testing.T) {
109	if racecheck.Enabled {
110		t.Skip("the race detector allocates")
111	}
112	l, m := newTestLayout(100)
113	frames := make([]string, 8)
114	for i := range frames {
115		frames[i] = fmt.Sprintf("\x1b[48;2;%d;10;10m \x1b[0m clock %02d \x1b[1;32mok\x1b[0m \uf017", i*30, i)
116	}
117	n := 0
118	step := func() {
119		publish(m[2], frames[n%len(frames)])
120		n++
121		l.Update()
122		l.Compose()
123	}
124	for i := 0; i < 20; i++ {
125		step()
126	}
127	if got := testing.AllocsPerRun(500, step); got > 0 {
128		t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
129	}
130}
131
132func TestTextStyles(t *testing.T) {
133	l, m := newTestLayout(10)
134	publish(m[0], "\x1b[3mi\x1b[0m\x1b[4mu\x1b[0m\x1b[9ms\x1b[0m\x1b[1;3;4;9mA\x1b[0mn")
135	l.Update()
136	row := l.Compose()
137	want := []glyph.Style{glyph.Italic, glyph.Underline, glyph.Strike, glyph.Bold | glyph.Italic | glyph.Underline | glyph.Strike, 0}
138	for i, w := range want {
139		if row[i].Style != w {
140			t.Errorf("cell %d (%q): style %04b, want %04b", i, row[i].Rune, row[i].Style, w)
141		}
142	}
143}
144
145func TestAnsiColorsComeFromThePalette(t *testing.T) {
146	l, m := newTestLayout(6)
147	publish(m[0], "\x1b[31mr\x1b[44mb\x1b[0m\x1b[38;5;2mg")
148	l.Update()
149	row := l.Compose()
150	if row[0].FG != testPalette[1] {
151		t.Errorf("\\e[31m = %v, want palette[1] %v", row[0].FG, testPalette[1])
152	}
153	if row[1].BG != testPalette[4] {
154		t.Errorf("\\e[44m background = %v, want palette[4] %v", row[1].BG, testPalette[4])
155	}
156	if row[2].FG != testPalette[2] {
157		t.Errorf("\\e[38;5;2m = %v, want palette[2] %v", row[2].FG, testPalette[2])
158	}
159	pal := testPalette
160	pal[1] = [3]uint8{9, 9, 9}
161	l.SetColors(l.fg, l.bg, pal)
162	if got := l.Compose()[0].FG; got != pal[1] {
163		t.Errorf("after SetColors, \\e[31m = %v, want %v", got, pal[1])
164	}
165}
166
167// A frame that stops inside an escape sequence must not blank the frames after it.
168func TestBrokenEscapeDoesNotSwallowNextFrames(t *testing.T) {
169	for name, broken := range map[string]string{
170		"osc":  "\x1b]0;title",
171		"csi":  "\x1b[38;2;1",
172		"dcs":  "\x1bP1;2",
173		"apc":  "\x1b_Gf=24",
174		"esc":  "\x1b",
175		"utf8": "ab\xe2\x82",
176	} {
177		l, m := newTestLayout(10)
178		publish(m[0], broken)
179		l.Update()
180		publish(m[0], "hello")
181		l.Update()
182		if got := rowText(l); got[:5] != "hello" {
183			t.Errorf("%s: row after the next frame = %q, want hello", name, got)
184		}
185	}
186}
187
188// The center zone sits on the middle of the bar, not on the middle of the
189// space left between a wide left zone and a narrow right one.
190func TestCenterIsOnTheMiddleOfTheBar(t *testing.T) {
191	l, m := newTestLayout(40)
192	publish(m[0], "LLLLLLLLLL") // 10 cells on the left
193	publish(m[1], "cccc")       // 4 in the middle
194	publish(m[2], "RRRR")       // 4 on the right
195	l.Update()
196	got := rowText(l)
197	if i := indexOf(got, "cccc"); i != 18 {
198		t.Fatalf("center starts at cell %d, want 18 (the bar is 40 cells wide): %q", i, got)
199	}
200}
201
202// When the middle of the bar is taken, the center moves just enough to fit.
203func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
204	l, m := newTestLayout(40)
205	publish(m[0], "LLLLLLLLLLLLLLLLLLLL") // 20 cells: reaches past the ideal start of 18
206	publish(m[1], "cccc")
207	publish(m[2], "RRRR")
208	l.Update()
209	got := rowText(l)
210	if i := indexOf(got, "cccc"); i != 20 {
211		t.Fatalf("center starts at cell %d, want 20 (right after the left zone): %q", i, got)
212	}
213	// And the other way round: a wide right zone pushes it left.
214	l, m = newTestLayout(40)
215	publish(m[0], "LL")
216	publish(m[1], "cccc")
217	publish(m[2], "RRRRRRRRRRRRRRRRRRRR") // 20 cells: the center must end by cell 20
218	l.Update()
219	got = rowText(l)
220	if i := indexOf(got, "cccc"); i != 16 {
221		t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222	}
223}
224
225func spanOf(spans []Span, m *module.Module) (Span, bool) {
226	for _, s := range spans {
227		if s.M == m {
228			return s, true
229		}
230	}
231	return Span{}, false
232}
233
234func TestSpansPerZone(t *testing.T) {
235	l, m := newTestLayout(20)
236	publish(m[0], "LEFT")
237	publish(m[1], "mid")
238	publish(m[2], "RIGHT")
239	l.Update()
240	row := rowText(l)
241	spans := l.Spans(nil)
242	if len(spans) != 3 {
243		t.Fatalf("spans = %+v, want 3", spans)
244	}
245	for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246		s, ok := spanOf(spans, m[i])
247		if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248			t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249		}
250	}
251	// Spans come in module order.
252	if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253		t.Errorf("spans out of order: %+v", spans)
254	}
255}
256
257func TestSpansTwoModulesInOneZone(t *testing.T) {
258	wake := make(chan struct{}, 1)
259	log := slog.New(slog.DiscardHandler)
260	a := module.New("a", module.Left, "true", wake, log)
261	b := module.New("b", module.Left, "true", wake, log)
262	l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263	publish(a, "AA")
264	publish(b, "BBB")
265	l.Update()
266	l.Compose()
267	sa, _ := spanOf(l.Spans(nil), a)
268	sb, _ := spanOf(l.Spans(nil), b)
269	if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270		t.Fatalf("spans = %+v %+v", sa, sb)
271	}
272	for col, want := range map[int]struct {
273		m   *module.Module
274		off int
275		ok  bool
276	}{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277		gm, off, ok := l.At(col)
278		if gm != want.m || off != want.off || ok != want.ok {
279			t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280		}
281	}
282}
283
284func TestSpansAreClipped(t *testing.T) {
285	// The right zone wins over the left one: left is cut to the room left.
286	l, m := newTestLayout(8)
287	publish(m[0], "AAAAA")
288	publish(m[2], "ZZZZZ")
289	l.Update()
290	l.Compose()
291	spans := l.Spans(nil)
292	if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293		t.Errorf("left span = %+v, want {0,3}", s)
294	}
295	if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296		t.Errorf("right span = %+v, want {3,5}", s)
297	}
298	if _, _, ok := l.At(3); !ok {
299		t.Error("column 3 belongs to the right module")
300	}
301	if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302		t.Errorf("At(3) = %v, %d", gm, off)
303	}
304
305	// The center is cut first, and may vanish entirely.
306	l, m = newTestLayout(12)
307	publish(m[0], "AAAAA")
308	publish(m[1], "CCCCCCCC")
309	publish(m[2], "ZZZZZ")
310	l.Update()
311	l.Compose()
312	if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313		t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314	}
315	l, m = newTestLayout(10)
316	publish(m[0], "AAAAA")
317	publish(m[1], "CC")
318	publish(m[2], "ZZZZZ")
319	l.Update()
320	l.Compose()
321	if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322		t.Error("a fully clipped module must have no span")
323	}
324	if _, _, ok := l.At(5); !ok {
325		// column 5 is the first of the right zone
326		t.Error("At(5) must find the right module")
327	}
328}
329
330func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331	l, m := newTestLayout(10)
332	publish(m[0], "")
333	publish(m[2], "R")
334	l.Update()
335	l.Compose()
336	if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337		t.Error("an empty module has no span")
338	}
339	if len(l.Spans(nil)) != 1 {
340		t.Errorf("spans = %+v", l.Spans(nil))
341	}
342	// A module that empties out loses its span at the next Compose.
343	publish(m[2], "")
344	l.Update()
345	l.Compose()
346	if got := l.Spans(nil); len(got) != 0 {
347		t.Errorf("spans after emptying = %+v", got)
348	}
349	if _, _, ok := l.At(9); ok {
350		t.Error("At must not find an emptied module")
351	}
352}
353
354func TestComposeAndSpansAllocs(t *testing.T) {
355	if racecheck.Enabled {
356		t.Skip("the race detector allocates")
357	}
358	l, m := newTestLayout(40)
359	publish(m[0], "left")
360	publish(m[1], "center")
361	publish(m[2], "right")
362	l.Update()
363	if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364		t.Errorf("Compose allocates %.1f objects; want 0", got)
365	}
366	buf := make([]Span, 0, 8)
367	if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368		t.Errorf("Spans allocates %.1f objects; want 0", got)
369	}
370	if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371		t.Errorf("At allocates %.1f objects; want 0", got)
372	}
373}

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 5

Tags 4

↑↓ moveenter openesc close