v0.3.0 da0887c

popup_test.go RawHistoryFolder

1package bar
2
3import (
4	"bytes"
5	"errors"
6	"fmt"
7	"log/slog"
8	"slices"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/neferbar/internal/module"
14	"github.com/bnema/neferbar/internal/popup"
15	"github.com/bnema/neferbar/internal/racecheck"
16)
17
18func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
19	r := newInputRig(t)
20	var p pointerState
21	p.button(r.lay, 1, btnRight, 77) // token 1, serial 77, module a
22	now := p.press.at
23
24	if _, ok := p.takeMenuPress(r.a, 2, now); ok {
25		t.Error("a token that is not the latest press was accepted")
26	}
27	if _, ok := p.takeMenuPress(r.a, 0, now); ok {
28		t.Error("token 0 was accepted")
29	}
30	if _, ok := p.takeMenuPress(r.b, 1, now); ok {
31		t.Error("another module answered the press")
32	}
33	if _, ok := p.takeMenuPress(nil, 1, now); ok {
34		t.Error("no module answered the press")
35	}
36	if _, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL+time.Millisecond)); ok {
37		t.Error("a press older than the limit was accepted")
38	}
39	serial, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL))
40	if !ok || serial != 77 {
41		t.Fatalf("takeMenuPress = %d, %v; want 77, true", serial, ok)
42	}
43	if _, ok = p.takeMenuPress(r.a, 1, now); ok {
44		t.Error("one press opened two menus")
45	}
46
47	// A newer press supersedes the old token.
48	p.button(r.lay, 1, btnRight, 78)
49	now = p.press.at
50	if _, ok = p.takeMenuPress(r.a, 1, now); ok {
51		t.Error("the superseded token was accepted")
52	}
53	if serial, ok = p.takeMenuPress(r.a, 2, now); !ok || serial != 78 {
54		t.Errorf("latest press: %d, %v", serial, ok)
55	}
56
57	// A press without a serial cannot grab.
58	p.button(r.lay, 1, btnRight, 0)
59	if _, ok = p.takeMenuPress(r.a, 3, p.press.at); ok {
60		t.Error("a press without a serial was accepted")
61	}
62}
63
64// ctl parses the JSON of a control line, as the bar does.
65func ctl(t testing.TB, js string) module.Control {
66	t.Helper()
67	c, err := module.ParseControl([]byte(js))
68	if err != nil {
69		t.Fatal(err)
70	}
71	return c
72}
73
74// feed prints control lines on module m's stdout, as its script would.
75func feed(m *module.Module, lines ...string) {
76	var sb strings.Builder
77	for _, l := range lines {
78		sb.WriteString("\x1b]777;neferbar;" + l + "\x07\n")
79	}
80	m.ReadFramesForTest(strings.NewReader(sb.String()))
81}
82
83func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
84	r := newInputRig(t)
85	var logs bytes.Buffer
86	b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
87
88	// An invalid line is logged once per module and otherwise ignored.
89	feed(r.a, `{"type":"explode"}`, `{nope`)
90	b.drainControl(r.a)
91	if n := bytes.Count(logs.Bytes(), []byte("invalid control line")); n != 1 {
92		t.Fatalf("logged %d times, want once:\n%s", n, logs.String())
93	}
94
95	tooltip := ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"t"}`)
96	menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)
97	// None of these may reach openPopup: the bar has no surface, so reaching
98	// it would return quietly, hence the checks go through the pointer state
99	// the rules read.
100	b.control(r.b, tooltip) // not interactive
101	b.control(r.a, tooltip) // not hovered
102	b.control(r.a, menu)    // no press at all
103	b.ptr.button(r.lay, 1, btnRight, 9)
104	b.control(r.a, menu) // wrong token
105	if b.ptr.press.used {
106		t.Fatal("a refused menu consumed the press")
107	}
108	b.control(r.a, ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
109	if !b.ptr.press.used {
110		t.Fatal("the right token should have been accepted")
111	}
112}
113
114func TestCoalesceKeepsMenusAndTheLastTooltipOrClose(t *testing.T) {
115	tip := func(title string) module.Control {
116		return ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"`+title+`"}`)
117	}
118	closeC := ctl(t, `{"type":"close"}`)
119	menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`)
120	types := func(cs []module.Control) string {
121		var sb strings.Builder
122		for _, c := range cs {
123			sb.WriteString(c.Type[:1] + c.Title + " ")
124		}
125		return strings.TrimSpace(sb.String())
126	}
127	for name, c := range map[string]struct {
128		in   []module.Control
129		want string
130	}{
131		"none":               {nil, ""},
132		"one":                {[]module.Control{tip("a")}, "ta"},
133		"storm":              {[]module.Control{tip("a"), tip("b"), tip("c")}, "tc"},
134		"tooltip then close": {[]module.Control{tip("a"), closeC}, "c"},
135		"close then tooltip": {[]module.Control{closeC, tip("z")}, "tz"},
136		"menus are kept":     {[]module.Control{tip("a"), menu, tip("b"), menu, closeC}, "m m c"},
137		"only menus":         {[]module.Control{menu, menu}, "m m"},
138	} {
139		if got := types(coalesceControl(slices.Clone(c.in))); got != c.want {
140			t.Errorf("%s: got %q, want %q", name, got, c.want)
141		}
142	}
143}
144
145func TestDrainAppliesOnlyTheLastTooltipOfAWakeUp(t *testing.T) {
146	r := newInputRig(t)
147	b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
148	var seen []string
149	b.ctlBuf = nil
150	for i := range 50 {
151		feed(r.a, fmt.Sprintf(`{"type":"tooltip","col":0,"width":1,"title":"t%d"}`, i))
152	}
153	// Count what survives by running the same steps drainControl does.
154	raw, ok := r.a.TakeControl()
155	for ; ok; raw, ok = r.a.TakeControl() {
156		c, err := module.ParseControl(raw)
157		if err != nil {
158			t.Fatal(err)
159		}
160		b.ctlBuf = append(b.ctlBuf, c)
161	}
162	for _, c := range coalesceControl(b.ctlBuf) {
163		seen = append(seen, c.Title)
164	}
165	// The queue holds 8 lines; of them one remains.
166	if len(seen) != 1 || seen[0] != "t7" {
167		t.Fatalf("applied %q, want just the last queued one", seen)
168	}
169}
170
171func TestTooltipRateLimit(t *testing.T) {
172	r := newInputRig(t)
173	var l tooltipLimiter
174	t0 := time.Now()
175	if !l.allow(r.a, t0) {
176		t.Fatal("the first tooltip must pass")
177	}
178	if l.allow(r.a, t0.Add(tooltipInterval-time.Millisecond)) {
179		t.Fatal("a second tooltip within the interval passed")
180	}
181	if !l.allow(r.b, t0.Add(time.Millisecond)) {
182		t.Fatal("another module shares the limit")
183	}
184	if !l.allow(r.a, t0.Add(tooltipInterval)) {
185		t.Fatal("the limit must lift after the interval")
186	}
187	// A refused tooltip does not push the window.
188	l.allow(r.a, t0.Add(tooltipInterval+10*time.Millisecond))
189	if !l.allow(r.a, t0.Add(2*tooltipInterval)) {
190		t.Fatal("refusals extended the window")
191	}
192	l.forget([]*module.Module{r.b})
193	if _, ok := l.last[r.a]; ok || len(l.last) != 1 {
194		t.Fatalf("forget kept %v", l.last)
195	}
196}
197
198func TestStoppedModulesAreForgotten(t *testing.T) {
199	r := newInputRig(t)
200	b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
201	b.badControl(r.a, errors.New("x"))
202	b.badControl(r.b, errors.New("x"))
203	b.tips.allow(r.a, time.Now())
204	b.forgetModules([]*module.Module{r.b})
205	if len(b.badCtl) != 1 || !b.badCtl[r.b] || len(b.tips.last) != 0 {
206		t.Fatalf("badCtl %v, tips %v", b.badCtl, b.tips.last)
207	}
208}
209
210func TestStepPopupAllocsWithNoPopup(t *testing.T) {
211	if racecheck.Enabled {
212		t.Skip("the race detector allocates")
213	}
214	r := newInputRig(t)
215	b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler), pop: &popup.Host{}, mods: []*module.Module{r.a, r.b}}
216	b.stepPopup() // warm any lazily grown state
217	if got := testing.AllocsPerRun(200, b.stepPopup); got > 0 {
218		t.Errorf("stepPopup with no popup open allocates %.1f objects; want 0", got)
219	}
220}

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