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}