1package core
2
3import (
4 "math"
5 "slices"
6 "testing"
7 "time"
8)
9
10func TestSwipeTrackerVelocity(t *testing.T) {
11 var tr swipeTracker
12 for i := range 11 {
13 tr.push(10, time.Duration(i)*10*time.Millisecond)
14 }
15 // 150 ms of history: 16 pushes would fit, 11 span 100 ms and 110 units.
16 if got := tr.velocity(); math.Abs(got-1100) > 1e-9 {
17 t.Fatalf("velocity %v, want 1100", got)
18 }
19 if tr.pos != 110 {
20 t.Fatalf("pos %v", tr.pos)
21 }
22 // A flick lands further than where the fingers left it.
23 if end := tr.projectedEnd(); end <= tr.pos {
24 t.Fatalf("projected %v not past %v", end, tr.pos)
25 }
26}
27
28func TestSwipeTrackerForgetsOldMovement(t *testing.T) {
29 var tr swipeTracker
30 tr.push(500, 0)
31 tr.push(1, 10*time.Millisecond)
32 // The fingers rested before lifting: the old flick no longer counts.
33 tr.push(0, 400*time.Millisecond)
34 if v := tr.velocity(); v != 0 {
35 t.Fatalf("velocity %v after a pause", v)
36 }
37 if tr.projectedEnd() != tr.pos {
38 t.Fatal("a still swipe lands where it is")
39 }
40}
41
42func TestSwipeTrackerIgnoresEarlierEvents(t *testing.T) {
43 var tr swipeTracker
44 tr.push(5, 20*time.Millisecond)
45 tr.push(5, 10*time.Millisecond)
46 if tr.pos != 5 || len(tr.history) != 1 {
47 t.Fatalf("pos %v history %d", tr.pos, len(tr.history))
48 }
49}
50
51func TestSpringSettles(t *testing.T) {
52 s := viewSpring(-300, 0)
53 if v := s.valueAt(0); math.Abs(v+300) > 1e-9 {
54 t.Fatalf("start %v", v)
55 }
56 d := s.duration()
57 if d <= 0 || d > 2*time.Second {
58 t.Fatalf("duration %v", d)
59 }
60 if v := s.valueAt(d); math.Abs(v) > 0.5 {
61 t.Fatalf("end %v", v)
62 }
63 // Critically damped: it never passes its target.
64 for at := time.Duration(0); at < d; at += time.Millisecond {
65 if v := s.valueAt(at); v > 1e-9 {
66 t.Fatalf("overshoot %v at %v", v, at)
67 }
68 }
69}
70
71func TestSpringKeepsVelocity(t *testing.T) {
72 // A spring launched toward its target moves faster at first.
73 still, flung := viewSpring(-300, 0), viewSpring(-300, 3000)
74 at := 10 * time.Millisecond
75 if still.valueAt(at) >= flung.valueAt(at) {
76 t.Fatal("initial velocity ignored")
77 }
78}
79
80func TestSpringOverdamped(t *testing.T) {
81 s := spring{From: 0, To: 1, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001}
82 d := s.duration()
83 if v := s.valueAt(d); math.IsNaN(v) || math.Abs(v-1) > 0.01 {
84 t.Fatalf("value %v at %v", v, d)
85 }
86 if (spring{From: 0, To: 0, DampingRatio: 1.15, Stiffness: 850, Epsilon: 0.0001}).duration() != 0 {
87 t.Fatal("a spring at rest takes time")
88 }
89}
90
91func TestMotionEndsOnTarget(t *testing.T) {
92 start := time.Unix(10, 0)
93 m := newMotion(workspaceSpring(0.4, 0), start, 1)
94 if v, done := m.at(start); done || v != 0.4 {
95 t.Fatalf("start %v %t", v, done)
96 }
97 if v, done := m.at(start.Add(10 * time.Second)); !done || v != 0 {
98 t.Fatalf("end %v %t", v, done)
99 }
100}
101
102func TestRubberBand(t *testing.T) {
103 r := workspaceBand
104 if r.clamp(0, 2, 1.5) != 1.5 {
105 t.Fatal("inside moves freely")
106 }
107 past := r.clamp(0, 2, 3)
108 if past <= 2 || past >= 2+r.limit {
109 t.Fatalf("past the end %v", past)
110 }
111 if before := r.clamp(0, 2, -10); before >= 0 || before <= -r.limit {
112 t.Fatalf("before the start %v", before)
113 }
114 if r.clampDerivative(0, 2, 1) != 1 || r.clampDerivative(0, 2, 3) >= 1 {
115 t.Fatal("derivative")
116 }
117}
118
119func TestSnap(t *testing.T) {
120 // Four columns of 400 on an 800 wide view, no gaps: views align on
121 // column edges between 0 and 800.
122 w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, MaxColumns: 2}
123 for i := range 4 {
124 w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
125 }
126 if got := w.snapPoints(); !slices.Equal(got, []float64{0, 400, 800}) {
127 t.Fatalf("snap points %v", got)
128 }
129 for _, tc := range []struct {
130 view int
131 forward bool
132 focus int
133 }{
134 {view: 0, forward: true, focus: 0},
135 {view: 400, forward: true, focus: 2},
136 {view: 400, forward: false, focus: 1},
137 {view: 800, forward: true, focus: 3},
138 } {
139 if focus := w.snapFocus(tc.view, tc.forward); focus != tc.focus {
140 t.Errorf("snapFocus(%d, %t) = %d; want %d", tc.view, tc.forward, focus, tc.focus)
141 }
142 }
143}
144
145// Column edges closer than snapSpacing merge: a step always shows. The
146// last column's end stays, dropping a point close before it.
147func TestSnapPointsMergeClose(t *testing.T) {
148 for _, tc := range []struct {
149 widths []int
150 want []float64
151 }{
152 // Views aligning a column: 0, 600, 640 (merged into 600), 1000.
153 {[]int{600, 400, 640, 360}, []float64{0, 600, 1000}},
154 // 0, 360, 600, 960 and the end 1000: 960 goes, 1000 stays.
155 {[]int{600, 360, 400, 640}, []float64{0, 360, 600, 1000}},
156 // Columns narrower than the view: one point.
157 {[]int{300, 300}, []float64{0}},
158 } {
159 // 100 cells of 10 px, no gaps: a width in px is px/10 cells.
160 w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}, MaxColumns: 100}
161 for _, px := range tc.widths {
162 w.Columns = append(w.Columns, Column{Cells: px / 10, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
163 }
164 if got := w.snapPoints(); !slices.Equal(got, tc.want) {
165 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
166 }
167 }
168}
169
170// swipe pushes n updates of delta, 8 ms apart, from time 0.
171func swipe(s *snapSwipe, n int, delta float64) time.Duration {
172 at := time.Duration(0)
173 for range n {
174 at += 8 * time.Millisecond
175 s.push(delta, at)
176 }
177 return at
178}
179
180// slowSwipe pushes dist over two seconds and rests before the lift.
181func slowSwipe(s *snapSwipe, dist float64) time.Duration {
182 at := time.Duration(0)
183 for range 40 {
184 at += 50 * time.Millisecond
185 s.push(dist/40, at)
186 }
187 return at + 200*time.Millisecond
188}
189
190func workspaceSnap(start float64) snapSwipe {
191 return newSnapSwipe(start, math.Round(start), 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
192}
193
194// One swipe reaches the next workspace at most, however fast or far: a
195// slow one settles on the closest, back unless past halfway; a cancelled
196// one returns.
197func TestSnapSwipeOneStep(t *testing.T) {
198 for _, tc := range []struct {
199 name string
200 push func(*snapSwipe) time.Duration
201 cancelled bool
202 want float64
203 }{
204 {"quick flick down", func(s *snapSwipe) time.Duration { return swipe(s, 6, 30) }, false, 3},
205 {"quick flick up", func(s *snapSwipe) time.Duration { return swipe(s, 6, -30) }, false, 1},
206 {"hard long flick", func(s *snapSwipe) time.Duration { return swipe(s, 30, 60) }, false, 3},
207 {"short quick flick", func(s *snapSwipe) time.Duration { return swipe(s, 3, 20) }, false, 3},
208 {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, 100) }, false, 2},
209 {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, 200) }, false, 3},
210 {"slow far up", func(s *snapSwipe) time.Duration { return slowSwipe(s, -900) }, false, 1},
211 {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 10, 30) }, true, 2},
212 // Most of the way down, then a hard flick back: one step up.
213 {"forward then hard back", func(s *snapSwipe) time.Duration {
214 at := slowSwipe(s, 250)
215 for range 4 {
216 at += 8 * time.Millisecond
217 s.push(-30, at)
218 }
219 return at
220 }, false, 1},
221 } {
222 s := workspaceSnap(2)
223 at := tc.push(&s)
224 if p := s.pos(); p < 1-workspaceBand.limit || p > 3+workspaceBand.limit {
225 t.Errorf("%s: view %v went past the neighbors", tc.name, p)
226 }
227 if got, _ := s.end(tc.cancelled, at); got != tc.want {
228 t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
229 }
230 }
231}
232
233// A swipe caught during a landing steps from the landing: one more.
234func TestSnapSwipeDuringLanding(t *testing.T) {
235 s := newSnapSwipe(2.6, 3, 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
236 at := swipe(&s, 6, 30)
237 if p := s.pos(); p > 4+workspaceBand.limit {
238 t.Fatalf("view %v went past 4", p)
239 }
240 if got, _ := s.end(false, at); got != 4 {
241 t.Fatalf("landed on %v, want 4", got)
242 }
243}
244
245// From a view between points (a merged column edge) a slow swipe never
246// settles behind where it started; a quick one reaches the points around.
247func TestSnapSwipeRestBetweenPoints(t *testing.T) {
248 for _, tc := range []struct {
249 name string
250 push func(*snapSwipe) time.Duration
251 want float64
252 }{
253 {"slow", func(s *snapSwipe) time.Duration { return slowSwipe(s, 5) }, 640},
254 {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 4, -40) }, 600},
255 {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 4, 40) }, 1000},
256 } {
257 s := newSnapSwipe(640, 640, 1, []float64{0, 600, 1000}, workspaceBand.scaled(1000))
258 at := tc.push(&s)
259 if got, _ := s.end(false, at); got != tc.want {
260 t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
261 }
262 }
263}
264
265// A swipe caught while sliding to a far workspace lands on that one or
266// its neighbors, never back where the slide shows.
267func TestSnapSwipeCaughtFarFromRest(t *testing.T) {
268 for _, delta := range []float64{-60, 60} {
269 s := newSnapSwipe(0.5, 4, 1/workspaceSwipeMovement, indexPoints(6), workspaceBand)
270 at := swipe(&s, 20, delta)
271 if got, _ := s.end(false, at); got < 3 || got > 5 {
272 t.Errorf("delta %v: landed on %v, want 3 to 5", delta, got)
273 }
274 }
275}
276
277// The view follows the fingers in their direction, without jumps, from a
278// start on a point, between points, or caught past the edge (a spring back
279// from the rubber band); near a point it sticks, a full step shows whole.
280func TestSnapSwipeDetents(t *testing.T) {
281 for _, start := range []float64{2, 2.4, -0.03, 4.03} {
282 s := workspaceSnap(start)
283 if p := s.pos(); math.Abs(p-start) > 1e-9 {
284 t.Fatalf("start %v shows %v", start, p)
285 }
286 for _, dir := range []float64{1, -1} {
287 s := workspaceSnap(start)
288 prev := s.pos()
289 for i := range 400 {
290 s.push(dir, time.Duration(i)*time.Millisecond)
291 p := s.pos()
292 if (p-prev)*dir < 0 || math.Abs(p-prev) > 0.01 {
293 t.Fatalf("start %v dir %v: view %v then %v at %d", start, dir, prev, p, i)
294 }
295 prev = p
296 }
297 }
298 }
299 s := workspaceSnap(2)
300 s.push(300, 0)
301 if p := s.pos(); math.Abs(p-3) > 1e-9 {
302 t.Fatalf("a full step shows %v", p)
303 }
304 s = workspaceSnap(2)
305 s.push(30, 0)
306 if p := s.pos(); p >= 2.1 || p <= 2 {
307 t.Fatalf("near a point the view moved to %v for 0.1", p)
308 }
309}
310
311// One workspace: nowhere to go, the view resists and stays.
312func TestSnapSwipeSinglePoint(t *testing.T) {
313 s := newSnapSwipe(0, 0, 1/workspaceSwipeMovement, indexPoints(1), workspaceBand)
314 at := swipe(&s, 10, 40)
315 if p := s.pos(); p <= 0 || p > workspaceBand.limit {
316 t.Fatalf("view %v", p)
317 }
318 target, velocity := s.end(false, at)
319 if target != 0 || math.IsNaN(velocity) {
320 t.Fatalf("landed on %v at %v", target, velocity)
321 }
322}
323
324func TestStepSwipe(t *testing.T) {
325 for _, tc := range []struct {
326 name string
327 push func(*snapSwipe) time.Duration
328 cancelled bool
329 want int
330 }{
331 {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 3, -40) }, false, -1},
332 {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, false, 1},
333 {"hard", func(s *snapSwipe) time.Duration { return swipe(s, 30, 80) }, false, 1},
334 {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, -100) }, false, 0},
335 {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, -200) }, false, -1},
336 {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, true, 0},
337 } {
338 s := newStepSwipe()
339 at := tc.push(&s)
340 target, v := s.end(tc.cancelled, at)
341 if got := int(math.Round(target)); got != tc.want {
342 t.Errorf("%s: step %d, want %d", tc.name, got, tc.want)
343 }
344 if math.IsNaN(v) {
345 t.Errorf("%s: velocity NaN", tc.name)
346 }
347 }
348}
349
350// With no column fully shown (a column wider than the space between the
351// gaps), the focus goes to the column at the left edge.
352func TestSnapFocusWideColumn(t *testing.T) {
353 w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, Gaps: 10}
354 for i := range 3 {
355 w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
356 }
357 // Full width with gaps: 780 wide, between 10 and 790. The view
358 // columnX(1)+1 shifts it 1 px left, so it is not fully shown.
359 w.Columns[1].FullWidth = true
360 if !w.slidable() {
361 t.Fatal("the workspace does not scroll")
362 }
363 view := w.columnX(1) - w.Usable.X - w.gap() + 1
364 for _, forward := range []bool{true, false} {
365 if focus := w.snapFocus(view, forward); focus != 1 {
366 t.Fatalf("forward=%t: focus %d, want the wide column 1", forward, focus)
367 }
368 }
369}
370
371func TestSpringVelocityAt(t *testing.T) {
372 springs := map[string]spring{
373 "critical": {From: -300, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001},
374 "under": {From: -300, DampingRatio: 0.5, Stiffness: 800, Epsilon: 0.0001},
375 "over": {From: -300, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001},
376 }
377 const h = time.Microsecond
378 for name, s := range springs {
379 for _, v0 := range []float64{0, 3000} {
380 s := s
381 s.Velocity = v0
382 if got := s.velocityAt(0); math.Abs(got-v0) > 1e-9*max(1, math.Abs(v0)) {
383 t.Errorf("%s v0=%v: velocityAt(0) = %v", name, v0, got)
384 }
385 for _, at := range []time.Duration{5, 50, 200} {
386 at *= time.Millisecond
387 want := (s.valueAt(at+h) - s.valueAt(at-h)) / (2 * h.Seconds())
388 got := s.velocityAt(at)
389 if math.Abs(got-want) > 1e-3*max(1, math.Abs(want)) {
390 t.Errorf("%s v0=%v at %v: velocityAt = %v, derivative %v", name, v0, at, got, want)
391 }
392 }
393 }
394 }
395}
396
397func TestMotionSlowdownScalesTime(t *testing.T) {
398 start := time.Unix(10, 0)
399 s := viewSpring(-300, 0)
400 one, two := newMotion(s, start, 1), newMotion(s, start, 2)
401 if two.end != 2*one.end {
402 t.Fatalf("end %v, want %v", two.end, 2*one.end)
403 }
404 for _, d := range []time.Duration{5 * time.Millisecond, 40 * time.Millisecond, 150 * time.Millisecond} {
405 a, _ := one.at(start.Add(d))
406 b, _ := two.at(start.Add(2 * d))
407 if math.Abs(a-b) > 1e-9 {
408 t.Errorf("at %v: %v vs %v", d, a, b)
409 }
410 }
411 if m := newMotion(s, start, 0); m.slow != 1 || m.end != one.end {
412 t.Fatalf("slowdown 0 is not 1: %+v", m)
413 }
414}
415
416func TestMotionVelocity(t *testing.T) {
417 start := time.Unix(10, 0)
418 for _, slow := range []float64{1, 2.5} {
419 m := newMotion(viewSpring(-300, 3000), start, slow)
420 if v := m.velocity(); v != 3000 {
421 t.Fatalf("slow %v: initial velocity %v", slow, v)
422 }
423 d := 30 * time.Millisecond
424 m.at(start.Add(d))
425 tau := time.Duration(float64(d) / slow)
426 if want := m.spring.velocityAt(tau) / slow; m.velocity() != want {
427 t.Fatalf("slow %v: velocity %v, want %v", slow, m.velocity(), want)
428 }
429 if _, done := m.at(start.Add(time.Hour)); !done || m.velocity() != 0 {
430 t.Fatalf("slow %v: settled velocity %v", slow, m.velocity())
431 }
432 }
433}
434
435func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
436 start := time.Unix(10, 0)
437 w := &Workspace{View: 100, view: slide{off: -300}}
438 w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2)
439 w.view.motion.at(start.Add(20 * time.Millisecond))
440 v := w.view.motion.velocity()
441 if v <= 0 {
442 t.Fatalf("velocity %v", v)
443 }
444 w.View = 40
445 w.retarget(100)
446 if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 {
447 t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion)
448 }
449 if got := w.view.motion.velocity(); got != v {
450 t.Fatalf("velocity %v after retarget, want %v", got, v)
451 }
452 if got := w.view.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 {
453 t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
454 }
455 var idle Workspace
456 idle.View = 5
457 idle.retarget(0)
458 if idle.view.motion.on || idle.view.off != 0 {
459 t.Fatal("retarget without a slide must not start one")
460 }
461}
462
463// A pixel spring settles once its rounded position can no longer differ
464// from the target: well before its epsilon tail, and from then on the
465// spring itself (run to its epsilon) never leaves half a pixel of the
466// target. A level spring (fade, veil) runs to its epsilon.
467func TestMotionPixelSnapCutsTail(t *testing.T) {
468 start := time.Unix(10, 0)
469 for _, tc := range []struct {
470 name string
471 from, v0 float64
472 }{{"from rest", 100, 0}, {"flung past", 100, -3000}, {"flung back", 100, 3000}, {"short", 3, 0}, {"stopped near", 0.4, 60}} {
473 m := newMotion(viewSpring(tc.from, tc.v0), start, 1)
474 var settledAt time.Duration
475 for d := time.Duration(0); d <= m.end; d += time.Millisecond {
476 if _, done := m.at(start.Add(d)); done {
477 settledAt = d
478 break
479 }
480 }
481 if settledAt == 0 || settledAt >= m.end {
482 t.Fatalf("%s: settled at %v of %v", tc.name, settledAt, m.end)
483 }
484 if v, done := m.at(start.Add(settledAt)); !done || v != 0 {
485 t.Fatalf("%s: settled value %v %v", tc.name, v, done)
486 }
487 for d := settledAt; d <= m.end+time.Second; d += 100 * time.Microsecond {
488 if x := m.spring.valueAt(d); math.Abs(x) >= pixelSnap {
489 t.Fatalf("%s: %v past the snap at %v (settled at %v)", tc.name, x, d, settledAt)
490 }
491 }
492 t.Logf("%s: settled at %v, epsilon end %v", tc.name, settledAt, m.end)
493 }
494 level := newMotion(levelSpring(1, 0), start, 1)
495 if v, done := level.at(start.Add(level.end - time.Millisecond)); done || v == 0 {
496 t.Fatalf("a level spring snapped: %v %v", v, done)
497 }
498 if _, done := level.at(start.Add(level.end)); !done {
499 t.Fatal("a level spring never ends")
500 }
501}
502
503// A workspace slide snaps in output pixels: at a 600 px output half a pixel
504// is 1/1200 of a workspace.
505func TestSwitchSpringSnapsAtOutputPixels(t *testing.T) {
506 m := newMonitorWithIDs("", "", new(uint64))
507 m.template.Output = Rect{W: 800, H: 600}
508 s := m.switchSpring(0.5, 0)
509 if s.Snap != pixelSnap/600 {
510 t.Fatalf("snap %v", s.Snap)
511 }
512 if s.settled(0.5/600, 0) || !s.settled(0.2/600, 0) {
513 t.Fatal("snap threshold")
514 }
515}