1package bar
2
3import (
4 "log/slog"
5 "slices"
6 "testing"
7
8 "github.com/bnema/neferclient"
9
10 "github.com/bnema/neferbar/internal/layout"
11 "github.com/bnema/neferbar/internal/module"
12 "github.com/bnema/neferbar/internal/racecheck"
13)
14
15// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16// at 4-7, both in the left zone.
17type inputRig struct {
18 lay *layout.Layout
19 a, b *module.Module
20}
21
22func newInputRig(t testing.TB) *inputRig {
23 t.Helper()
24 wake := make(chan struct{}, 1)
25 log := slog.New(slog.DiscardHandler)
26 a := module.New("a", module.Left, "true", wake, log)
27 b := module.New("b", module.Left, "true", wake, log)
28 a.Interactive = true
29 lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30 module.PublishForTest(a, []byte("AAAA"))
31 module.PublishForTest(b, []byte("BBBB"))
32 lay.Update()
33 lay.Compose()
34 return &inputRig{lay: lay, a: a, b: b}
35}
36
37// drain returns the lines queued on m and empties its queue.
38func drain(m *module.Module) []string {
39 return module.DrainForTest(m)
40}
41
42func TestHoverAndLeave(t *testing.T) {
43 r := newInputRig(t)
44 var p pointerState
45 p.motion(r.lay, 2)
46 p.motion(r.lay, 2) // same column: nothing
47 p.motion(r.lay, 3)
48 p.motion(r.lay, 5) // over the non-interactive module: leaves a
49 p.motion(r.lay, 6) // still nothing
50 p.motion(r.lay, 1)
51 p.leave()
52 p.leave()
53 want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54 if got := drain(r.a); !slices.Equal(got, want) {
55 t.Fatalf("lines = %q, want %q", got, want)
56 }
57 if got := drain(r.b); len(got) != 0 {
58 t.Fatalf("a non-interactive module got %q", got)
59 }
60}
61
62func TestClickTokensAndButtons(t *testing.T) {
63 r := newInputRig(t)
64 var p pointerState
65 p.button(r.lay, 1, btnLeft, 7)
66 p.button(r.lay, 2, btnMiddle, 8)
67 p.button(r.lay, 3, btnRight, 9)
68 p.button(r.lay, 3, 0x113, 10) // another button: ignored
69 p.button(r.lay, 5, btnLeft, 11)
70 p.button(r.lay, 20, btnLeft, 12) // empty bar space
71 want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72 if got := drain(r.a); !slices.Equal(got, want) {
73 t.Fatalf("lines = %q, want %q", got, want)
74 }
75 if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76 t.Fatalf("press = %+v", p.press)
77 }
78}
79
80func TestScrollLines(t *testing.T) {
81 r := newInputRig(t)
82 var p pointerState
83 p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84 p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85 p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86 p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87 p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88 p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89 want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90 if got := drain(r.a); !slices.Equal(got, want) {
91 t.Fatalf("lines = %q, want %q", got, want)
92 }
93}
94
95func TestWheelAccumulator(t *testing.T) {
96 var a wheelAcc
97 for _, c := range []struct {
98 axis uint32
99 v120 int32
100 d float64
101 want int32
102 }{
103 {neferclient.AxisVertical, 120, 0, 1},
104 {neferclient.AxisVertical, 240, 0, 2},
105 {neferclient.AxisVertical, -120, 0, -1},
106 {neferclient.AxisVertical, 60, 0, 0},
107 {neferclient.AxisVertical, 60, 0, 1}, // two half notches
108 {neferclient.AxisHorizontal, 120, 0, 1},
109 {neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110 } {
111 if got := a.add(c.axis, c.v120, c.d); got != c.want {
112 t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113 }
114 }
115 a.reset()
116 for _, c := range []struct {
117 d float64
118 want int32
119 }{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120 if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121 t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122 }
123 }
124 if a.add(2, 120, 0) != 0 {
125 t.Error("an unknown axis must be ignored")
126 }
127}
128
129func TestInputRect(t *testing.T) {
130 // Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131 // 16.25..40.625, rounded outward.
132 got := inputRect(2, 3, 13, 1.6, 24)
133 want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134 if got != want {
135 t.Fatalf("rect = %+v, want %+v", got, want)
136 }
137 if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138 t.Fatalf("exact rect = %+v", got)
139 }
140 if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141 t.Fatalf("scale 2 rect = %+v", got)
142 }
143}
144
145func TestInputRegionUpdates(t *testing.T) {
146 r := newInputRig(t)
147 var in inputRegion
148 key := inputKey{cellW: 10, scale: 1, height: 20}
149 rects, changed := in.update(r.lay, key)
150 want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151 if !changed || !slices.Equal(rects, want) {
152 t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153 }
154 if _, changed = in.update(r.lay, key); changed {
155 t.Fatal("same spans must not change the region")
156 }
157 if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158 t.Fatal("a scale change must change the region")
159 }
160 in.reset()
161 if _, changed = in.update(r.lay, key); !changed {
162 t.Fatal("a new surface needs the region again")
163 }
164 // No interactive module left: back to an empty, non-nil region.
165 r.a.Interactive = false
166 rects, changed = in.update(r.lay, key)
167 if !changed || rects == nil || len(rects) != 0 {
168 t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169 }
170 if _, changed = in.update(r.lay, key); changed {
171 t.Fatal("staying empty must not change the region")
172 }
173}
174
175func TestPointerAllocs(t *testing.T) {
176 if racecheck.Enabled {
177 t.Skip("the race detector allocates")
178 }
179 r := newInputRig(t)
180 var p pointerState
181 cols := [...]int{1, 2, 3, 5, 9}
182 i := 0
183 if got := testing.AllocsPerRun(200, func() {
184 p.motion(r.lay, cols[i%len(cols)])
185 i++
186 }); got > 0 {
187 t.Errorf("motion allocates %.1f objects; want 0", got)
188 }
189 if got := testing.AllocsPerRun(200, func() {
190 p.button(r.lay, 1, btnLeft, 1)
191 p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192 p.leave()
193 }); got > 0 {
194 t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195 }
196 var in inputRegion
197 key := inputKey{cellW: 10, scale: 1, height: 20}
198 in.update(r.lay, key)
199 if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200 t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201 }
202}
203
204func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205 r := newInputRig(t)
206 b := &Bar{lay: r.lay}
207 b.ptr.motion(r.lay, 2)
208 b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209 b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210 drain(r.a)
211
212 b.resetInput()
213
214 if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215 t.Fatalf("lines = %q, want one leave", got)
216 }
217 if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218 t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219 }
220 if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221 t.Fatal("the new surface needs its input region again")
222 }
223}
224
225// A failed SetInputRegion resets the tracking, so the next draw tries again.
226func TestFailedInputRegionIsRetried(t *testing.T) {
227 r := newInputRig(t)
228 var in inputRegion
229 key := inputKey{cellW: 10, scale: 1, height: 20}
230 if _, changed := in.update(r.lay, key); !changed {
231 t.Fatal("first update must change")
232 }
233 in.reset() // what syncInputRegion does when the request fails
234 if _, changed := in.update(r.lay, key); !changed {
235 t.Fatal("after a failure the same region must be sent again")
236 }
237}
238
239func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240 r := newInputRig(t)
241 b := &Bar{lay: r.lay, sid: 1}
242 axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243 b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244 }
245 axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246 axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247 axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248 if got := drain(r.a); len(got) != 0 {
249 t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250 }
251 axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252 if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253 t.Fatalf("lines = %q, want one step", got)
254 }
255 // Stopping the other axis leaves this one alone.
256 axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257 axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258 axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259 if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260 t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261 }
262 var a wheelAcc
263 a.stop(7) // unknown axis: no panic
264}
265
266func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267 r := newInputRig(t)
268 var p pointerState
269 p.motion(r.lay, 1)
270 if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271 t.Fatalf("lines = %q", got)
272 }
273 // The script restarts: the new process knows nothing of the hover.
274 module.SetGenForTest(r.a, 7)
275 p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276 if got := drain(r.a); len(got) != 0 {
277 t.Fatalf("lines = %q, want no leave for the new process", got)
278 }
279 // The same move onto the module again sends it a hover.
280 p.motion(r.lay, 1)
281 module.SetGenForTest(r.a, 8)
282 p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283 if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284 t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285 }
286
287 // leave after a restart sends nothing to the new process.
288 p.motion(r.lay, 2)
289 drain(r.a)
290 module.SetGenForTest(r.a, 9)
291 p.leave()
292 if got := drain(r.a); len(got) != 0 {
293 t.Fatalf("lines = %q, want none", got)
294 }
295 if p.hover != nil {
296 t.Fatal("hover not cleared")
297 }
298
299 // Without a restart nothing changes.
300 p.motion(r.lay, 2)
301 p.motion(r.lay, 6)
302 if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303 t.Fatalf("lines = %q", got)
304 }
305 if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306 t.Errorf("motion allocates %.1f", got)
307 }
308}
309
310func TestCellColumnAtFractionalScale(t *testing.T) {
311 // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312 for _, c := range []struct {
313 x float64
314 want int
315 }{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316 if got := cellColumn(c.x, 1.6, 13); got != c.want {
317 t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318 }
319 }
320}
321
322func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323 r := newInputRig(t) // a: columns 0-3, b: 4-7
324 b := &Bar{lay: r.lay, sid: 5}
325 // Another surface (a popup): ignored before the bar's size is even read.
326 b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327 if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328 t.Fatalf("a foreign surface moved the hover: %q", got)
329 }
330 // Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331 b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332 // Logical x 33 is column 4: the non-interactive module, so a leave.
333 b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334 b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335 b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336 b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337 want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338 if got := drain(r.a); !slices.Equal(got, want) {
339 t.Fatalf("lines = %q, want %q", got, want)
340 }
341}