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}