v0.3.0 da0887c

popup_test.go RawHistoryFolder

1package popup
2
3import (
4	"os"
5	"path/filepath"
6	"slices"
7	"strconv"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/neferclient"
13
14	"github.com/bnema/neferbar/internal/module"
15)
16
17func TestCellRectRoundsOutward(t *testing.T) {
18	// Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
19	got := CellRect(2, 1, 13, 1.6, 20)
20	if want := (neferclient.Rect{X: 16, Y: 0, Width: 9, Height: 20}); got != want { // [16.25, 24.375) -> [16, 25)
21		t.Fatalf("CellRect = %+v, want %+v", got, want)
22	}
23	got = CellRect(0, 4, 10, 1, 24)
24	if want := (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 24}); got != want {
25		t.Fatalf("CellRect = %+v, want %+v", got, want)
26	}
27}
28
29func TestAnchorIsClampedToTheShownCells(t *testing.T) {
30	// The module shows cells 10..13 (4 cells) of the row; 10 px cells at scale 1.
31	r, ok := Anchor(10, 4, 1, 2, 10, 1, 20)
32	if !ok || r != (neferclient.Rect{X: 110, Width: 20, Height: 20}) {
33		t.Fatalf("Anchor = %+v %v", r, ok)
34	}
35	// A width that runs past the shown cells is cut at the module's edge.
36	r, ok = Anchor(10, 4, 3, 5, 10, 1, 20)
37	if !ok || r.X != 130 || r.Width != 10 {
38		t.Fatalf("clamped Anchor = %+v %v", r, ok)
39	}
40	for _, c := range [][2]int{{-1, 1}, {4, 1}, {0, 0}, {0, -2}} {
41		if _, ok = Anchor(10, 4, c[0], c[1], 10, 1, 20); ok {
42			t.Errorf("Anchor(col %d, width %d) must fail", c[0], c[1])
43		}
44	}
45	if _, ok = Anchor(0, 4, 0, 1, 0, 1, 20); ok {
46		t.Error("zero cell width must fail")
47	}
48	if _, ok = Anchor(0, 4, 0, 1, 10, 0, 20); ok {
49		t.Error("zero scale must fail")
50	}
51}
52
53func TestEdgeFollowsTheBarPosition(t *testing.T) {
54	if Edge(false) != neferclient.EdgeBottom || Edge(true) != neferclient.EdgeTop {
55		t.Fatal("a top bar opens below, a bottom bar above")
56	}
57	p := Placement(neferclient.Rect{Width: 1, Height: 1}, true, 100, 50)
58	if p.Edge != neferclient.EdgeTop || p.Gravity != neferclient.EdgeTop || p.Width != 100 || p.Height != 50 {
59		t.Fatalf("placement = %+v", p)
60	}
61	want := neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY
62	if p.Adjust != want {
63		t.Fatalf("adjust = %v, want %v", p.Adjust, want)
64	}
65}
66
67func TestClamp(t *testing.T) {
68	for _, c := range []struct {
69		w, h       float64
70		cw, ch     int32
71		wantScroll bool
72	}{
73		{100.2, 50.1, 101, 51, false},
74		{0, 0, 1, 1, false},
75		{200, 600, 200, 600, false},
76		{200, 600.5, 200, MaxHeight, true},
77		{200, 5000, 200, MaxHeight, true},
78	} {
79		cw, ch, scroll := Clamp(c.w, c.h)
80		if cw != c.cw || ch != c.ch || scroll != c.wantScroll {
81			t.Errorf("Clamp(%v,%v) = %d,%d,%v; want %d,%d,%v", c.w, c.h, cw, ch, scroll, c.cw, c.ch, c.wantScroll)
82		}
83	}
84}
85
86func TestCSS(t *testing.T) {
87	got := CSS(Style{Font: `My "Nerd" Font`, Size: 14.5, FG: [3]uint8{0xcc, 0xcc, 0xcc}, BG: [3]uint8{0x10, 0x20, 0x30}, Accent: [3]uint8{0x35, 0x84, 0xe4}})
88	want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
89.title { font-weight: bold; }
90.body { opacity: 0.85; }
91.table { column-gap: 1.5em; }
92.key { opacity: 0.7; }
93button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
94checkbox.item { padding-left: 2em; }
95button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
96button.item:disabled, checkbox.item:disabled { opacity: 0.5; }
97button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: #cccccc; }
98separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
99`
100	if got != want {
101		t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
102	}
103	// Non-ASCII names stay as they are: %q would have turned them into escapes.
104	if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) {
105		t.Fatalf("font-family changed:\n%s", got)
106	}
107	// Runes that do not print are dropped: controls, bidi overrides, DEL, BOM.
108	if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" {
109		t.Fatalf("cssString = %q", got)
110	}
111	// A font name cannot break out of the declaration.
112	evil := CSS(Style{Font: `x"; } body { `, Size: 10})
113	if strings.Count(evil, "{") != strings.Count(want, "{") {
114		t.Fatalf("a font name injected rules:\n%s", evil)
115	}
116}
117
118func TestWriteCSSPermissions(t *testing.T) {
119	dir := t.TempDir()
120	t.Setenv("XDG_RUNTIME_DIR", dir)
121	path, err := WriteCSS("app { color: red; }\n")
122	if err != nil {
123		t.Fatal(err)
124	}
125	if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") {
126		t.Fatalf("path = %s", path)
127	}
128	for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
129		st, err := os.Stat(p)
130		if err != nil || st.Mode().Perm() != want {
131			t.Errorf("%s: mode %v err %v, want %v", p, st.Mode().Perm(), err, want)
132		}
133	}
134	// A rewrite replaces the file and leaves no temporary behind.
135	if _, err = WriteCSS("app { color: blue; }\n"); err != nil {
136		t.Fatal(err)
137	}
138	if b, _ := os.ReadFile(path); string(b) != "app { color: blue; }\n" {
139		t.Fatalf("content = %q", b)
140	}
141	if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
142		t.Fatalf("directory holds %d entries, want 1", len(ents))
143	}
144	// Another bar's file is left alone; ours is removed.
145	other := filepath.Join(filepath.Dir(path), "popup-1.css")
146	if err = os.WriteFile(other, []byte("x"), 0o600); err != nil {
147		t.Fatal(err)
148	}
149	RemoveCSS()
150	if _, err = os.Stat(path); !os.IsNotExist(err) {
151		t.Fatalf("RemoveCSS left %s (%v)", path, err)
152	}
153	if _, err = os.Stat(other); err != nil {
154		t.Fatalf("RemoveCSS touched another bar's file: %v", err)
155	}
156	RemoveCSS() // twice is fine
157	t.Setenv("XDG_RUNTIME_DIR", "")
158	if _, err = WriteCSS("x"); err == nil {
159		t.Fatal("no runtime dir must fail")
160	}
161}
162
163func TestTooltipLines(t *testing.T) {
164	// A blank line in the body is kept; blank lines around it are not.
165	m := newTooltip("Title", "\na\n\nb\n", nil)
166	if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "", "b"}) {
167		t.Fatalf("tooltip = %+v", m)
168	}
169	if m = newTooltip("", "", nil); len(m.lines) != 0 || m.cols != 0 {
170		t.Fatalf("empty tooltip = %+v", m)
171	}
172}
173
174func TestTooltipTable(t *testing.T) {
175	m := newTooltip("", "", [][]string{{"Signal", "62%"}, {"Band", "5 GHz", "ch 100"}, {"x"}})
176	if m.cols != 3 || len(m.rows) != 3 {
177		t.Fatalf("table = %+v", m)
178	}
179}
180
181func TestSameTooltipComparesRows(t *testing.T) {
182	a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183	for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} {
184		if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) {
185			t.Fatalf("%q is a different table", rows)
186		}
187	}
188	if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
189		t.Fatal("same table, same tooltip")
190	}
191}
192
193func sampleMenu() *menuModel {
194	return newMenu([]module.MenuItem{
195		{ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
196		{ID: 4, Label: "Settings", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
197			{ID: 5, Label: "Advanced", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
198				{ID: 8, Label: "Deep", Kind: module.KindNormal, Enabled: true},
199			}},
200			{ID: 6, Label: "Quiet", Kind: module.KindCheck, Enabled: true},
201		}},
202	}, 9)
203}
204
205func TestMenuTransitions(t *testing.T) {
206	m := sampleMenu()
207	if m.chosen != -1 || m.inSubmenu() || len(m.level()) != 2 {
208		t.Fatalf("start = %+v", m)
209	}
210	if m.pop() {
211		t.Fatal("pop at the top level must do nothing")
212	}
213	if m.push(0) {
214		t.Fatal("a leaf is not a submenu")
215	}
216	if m.push(7) || m.push(-1) {
217		t.Fatal("out of range push must fail")
218	}
219	if m.changed {
220		t.Fatal("failed moves must not flag a change")
221	}
222	if !m.push(1) || !m.inSubmenu() || !m.changed || m.level()[0].ID != 5 {
223		t.Fatalf("after push = %+v", m)
224	}
225	m.changed = false
226	if !m.push(0) || m.level()[0].ID != 8 || len(m.path) != 2 {
227		t.Fatalf("after second push = %+v", m)
228	}
229	if !m.pop() {
230		t.Fatalf("first pop = %+v", m)
231	}
232	if !m.pop() || m.inSubmenu() || len(m.level()) != 2 || !m.changed {
233		t.Fatalf("after pops = %+v", m)
234	}
235	m.choose(6)
236	if m.chosen != 6 {
237		t.Fatalf("chosen = %d", m.chosen)
238	}
239}
240
241func TestMenuLevelKeysDiffer(t *testing.T) {
242	m := sampleMenu()
243	top := m.levelKey()
244	m.push(1)
245	if m.levelKey() == top {
246		t.Fatal("levels must not share control keys")
247	}
248}
249
250func TestMapKey(t *testing.T) {
251	for _, c := range []struct {
252		sym     uint32
253		sub     bool
254		want    keyAction
255		comment string
256	}{
257		{keysymDown, false, keyNext, "Down walks forward"},
258		{keysymUp, true, keyPrevious, "Up walks back"},
259		{keysymEscape, false, keyClose, "Escape closes"},
260		{keysymEscape, true, keyClose, "Escape closes from a submenu too"},
261		{keysymLeft, true, keyBack, "Left leaves a submenu"},
262		{keysymBackSpace, true, keyBack, "BackSpace leaves a submenu"},
263		{keysymLeft, false, keyForward, "Left does nothing at the top"},
264		{keysymBackSpace, false, keyForward, "BackSpace does nothing at the top"},
265		{keysymTab, false, keyForward, "Tab goes to nefergui"},
266		{0x20, false, keyForward, "Space goes to nefergui"},
267		{0xff0d, true, keyForward, "Return goes to nefergui"},
268	} {
269		if got := mapKey(c.sym, c.sub); got != c.want {
270			t.Errorf("%s: mapKey(%#x, %v) = %d, want %d", c.comment, c.sym, c.sub, got, c.want)
271		}
272	}
273}
274
275func TestTooltipDoesNotWaitForTheWarmUpButMenusDo(t *testing.T) {
276	h := &Host{}
277	if !h.waitWarm(KindTooltip) || !h.waitWarm(KindMenu) {
278		t.Fatal("without a warm-up nothing waits")
279	}
280	h.warm = make(chan struct{})
281	if h.waitWarm(KindTooltip) {
282		t.Fatal("a tooltip must be skipped while the warm-up runs")
283	}
284	if h.WaitTimeout(10 * time.Millisecond) {
285		t.Fatal("WaitTimeout reported a running warm-up as done")
286	}
287	got := make(chan bool)
288	go func() { got <- h.waitWarm(KindMenu) }()
289	select {
290	case <-got:
291		t.Fatal("a menu must wait for the warm-up")
292	case <-time.After(30 * time.Millisecond):
293	}
294	close(h.warm)
295	if !<-got {
296		t.Fatal("a menu goes on once the warm-up ended")
297	}
298	if !h.waitWarm(KindTooltip) || !h.WaitTimeout(time.Second) {
299		t.Fatal("after the warm-up a tooltip goes on")
300	}
301}
302
303func TestShowsTooltip(t *testing.T) {
304	m := &module.Module{}
305	other := &module.Module{}
306	c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
307	h := &Host{kind: KindTooltip, owner: m, tipCtl: c}
308	if !h.showsTooltip(m, c) {
309		t.Fatal("the same tooltip of the same owner is shown")
310	}
311	for name, d := range map[string]module.Control{
312		"title": {Col: 1, Width: 2, Title: "T2", Body: "B"}, "body": {Col: 1, Width: 2, Title: "T", Body: "B2"},
313		"col": {Col: 2, Width: 2, Title: "T", Body: "B"}, "width": {Col: 1, Width: 3, Title: "T", Body: "B"},
314	} {
315		if h.showsTooltip(m, d) {
316			t.Errorf("a tooltip with another %s counts as shown", name)
317		}
318	}
319	if h.showsTooltip(other, c) {
320		t.Error("another owner's tooltip counts as shown")
321	}
322	h.kind = KindMenu
323	if h.showsTooltip(m, c) {
324		t.Error("a menu counts as a tooltip")
325	}
326	if (*Host)(nil).ShowsTooltip(m, c) || (&Host{}).ShowsTooltip(m, c) {
327		t.Error("no popup open must not match")
328	}
329}
330
331func TestFitToScrollsAMenuThatCannotBeResized(t *testing.T) {
332	m := newMenu(nil, 1)
333	m.fitTo(200, 300) // the new level fits: no scrolling
334	if m.scroll {
335		t.Fatal("a level that fits must not scroll")
336	}
337	m.fitTo(400, 300)
338	if !m.scroll || m.scrollH != 300-2*Inset {
339		t.Fatalf("scroll %v height %d", m.scroll, m.scrollH)
340	}
341	m.fitTo(400, 1)
342	if m.scrollH < 1 {
343		t.Fatalf("scroll height %d", m.scrollH)
344	}
345}

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