1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.haptics.slider
18 
19 import androidx.test.ext.junit.runners.AndroidJUnit4
20 import androidx.test.filters.SmallTest
21 import com.android.systemui.SysuiTestCase
22 import com.google.common.truth.Truth.assertThat
23 import kotlinx.coroutines.CoroutineScope
24 import kotlinx.coroutines.ExperimentalCoroutinesApi
25 import kotlinx.coroutines.test.UnconfinedTestDispatcher
26 import kotlinx.coroutines.test.advanceTimeBy
27 import kotlinx.coroutines.test.runTest
28 import org.junit.Before
29 import org.junit.Test
30 import org.junit.runner.RunWith
31 import org.mockito.Mock
32 import org.mockito.Mockito.anyFloat
33 import org.mockito.Mockito.verify
34 import org.mockito.Mockito.verifyNoMoreInteractions
35 import org.mockito.Mockito.verifyZeroInteractions
36 import org.mockito.MockitoAnnotations
37 
38 @SmallTest
39 @OptIn(ExperimentalCoroutinesApi::class)
40 @RunWith(AndroidJUnit4::class)
41 class SliderStateTrackerTest : SysuiTestCase() {
42 
43     @Mock private lateinit var sliderStateListener: SliderStateListener
44     private val sliderEventProducer = FakeSliderEventProducer()
45     private lateinit var mSliderStateTracker: SliderStateTracker
46 
47     @Before
setupnull48     fun setup() {
49         MockitoAnnotations.initMocks(this)
50     }
51 
52     @Test
initializeSliderTracker_startsTrackingnull53     fun initializeSliderTracker_startsTracking() = runTest {
54         // GIVEN Initialized tracker
55         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
56 
57         // THEN the tracker job is active
58         assertThat(mSliderStateTracker.isTracking).isTrue()
59     }
60 
61     @Test
<lambda>null62     fun stopTracking_onAnyState_resetsToIdle() = runTest {
63         enumValues<SliderState>().forEach {
64             // GIVEN Initialized tracker
65             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
66 
67             // GIVEN a state in the state machine
68             mSliderStateTracker.setState(it)
69 
70             // WHEN the tracker stops tracking the state and listening to events
71             mSliderStateTracker.stopTracking()
72 
73             // THEN The state is idle and the tracker is not active
74             assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
75             assertThat(mSliderStateTracker.isTracking).isFalse()
76         }
77     }
78 
79     // Tests on the IDLE state
80     @Test
<lambda>null81     fun initializeSliderTracker_isIdle() = runTest {
82         // GIVEN Initialized tracker
83         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
84 
85         // THEN The state is idle and the listener is not called to play haptics
86         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
87         verifyZeroInteractions(sliderStateListener)
88     }
89 
90     @Test
<lambda>null91     fun startsTrackingTouch_onIdle_entersWaitState() = runTest {
92         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
93 
94         // GIVEN a start of tracking touch event
95         val progress = 0f
96         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
97 
98         // THEN the tracker moves to the wait state and the timer job begins
99         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT)
100         verifyZeroInteractions(sliderStateListener)
101         assertThat(mSliderStateTracker.isWaiting).isTrue()
102     }
103 
104     // Tests on the WAIT state
105 
106     @OptIn(ExperimentalCoroutinesApi::class)
107     @Test
<lambda>null108     fun waitCompletes_onWait_movesToHandleAcquired() = runTest {
109         val config = SeekableSliderTrackerConfig()
110         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
111 
112         // GIVEN a start of tracking touch event that moves the tracker to WAIT
113         val progress = 0f
114         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
115 
116         // WHEN the wait time completes plus a small buffer time
117         advanceTimeBy(config.waitTimeMillis + 10L)
118 
119         // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state
120         assertThat(mSliderStateTracker.currentState)
121             .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
122         assertThat(mSliderStateTracker.isWaiting).isFalse()
123         verify(sliderStateListener).onHandleAcquiredByTouch()
124         verifyNoMoreInteractions(sliderStateListener)
125     }
126 
127     @Test
<lambda>null128     fun impreciseTouch_onWait_movesToHandleAcquired() = runTest {
129         val config = SeekableSliderTrackerConfig()
130         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
131 
132         // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the
133         // slider
134         var progress = 0.5f
135         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
136 
137         // GIVEN a progress event due to an imprecise touch with a progress below threshold
138         progress += (config.jumpThreshold - 0.01f)
139         sliderEventProducer.sendEvent(
140             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
141         )
142 
143         // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state without the timer job
144         // being complete
145         assertThat(mSliderStateTracker.currentState)
146             .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
147         assertThat(mSliderStateTracker.isWaiting).isFalse()
148         verify(sliderStateListener).onHandleAcquiredByTouch()
149         verifyNoMoreInteractions(sliderStateListener)
150     }
151 
152     @Test
<lambda>null153     fun trackJump_onWait_movesToJumpTrackLocationSelected() = runTest {
154         val config = SeekableSliderTrackerConfig()
155         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
156 
157         // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the
158         // slider
159         var progress = 0.5f
160         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
161 
162         // GIVEN a progress event due to a touch on the slider track beyond threshold
163         progress += (config.jumpThreshold + 0.01f)
164         sliderEventProducer.sendEvent(
165             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
166         )
167 
168         // THEN the tracker moves to the jump-track location selected state
169         assertThat(mSliderStateTracker.currentState)
170             .isEqualTo(SliderState.JUMP_TRACK_LOCATION_SELECTED)
171         assertThat(mSliderStateTracker.isWaiting).isFalse()
172         verify(sliderStateListener).onProgressJump(anyFloat())
173         verifyNoMoreInteractions(sliderStateListener)
174     }
175 
176     @Test
<lambda>null177     fun upperBookendSelection_onWait_movesToBookendSelected() = runTest {
178         val config = SeekableSliderTrackerConfig()
179         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
180 
181         // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the
182         // slider
183         var progress = 0.5f
184         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
185 
186         // GIVEN a progress event due to a touch on the slider upper bookend zone.
187         progress = (config.upperBookendThreshold + 0.01f)
188         sliderEventProducer.sendEvent(
189             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
190         )
191 
192         // THEN the tracker moves to the jump-track location selected state
193         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED)
194         assertThat(mSliderStateTracker.isWaiting).isFalse()
195         verifyNoMoreInteractions(sliderStateListener)
196     }
197 
198     @Test
<lambda>null199     fun lowerBookendSelection_onWait_movesToBookendSelected() = runTest {
200         val config = SeekableSliderTrackerConfig()
201         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
202 
203         // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the
204         // slider
205         var progress = 0.5f
206         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
207 
208         // GIVEN a progress event due to a touch on the slider lower bookend zone
209         progress = (config.lowerBookendThreshold - 0.01f)
210         sliderEventProducer.sendEvent(
211             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
212         )
213 
214         // THEN the tracker moves to the JUMP_TRACK_LOCATION_SELECTED state
215         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED)
216         assertThat(mSliderStateTracker.isWaiting).isFalse()
217         verifyNoMoreInteractions(sliderStateListener)
218     }
219 
220     @Test
<lambda>null221     fun stopTracking_onWait_whenWaitingJobIsActive_resetsToIdle() = runTest {
222         val config = SeekableSliderTrackerConfig()
223         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
224 
225         // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the
226         // slider
227         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, 0.5f))
228         assertThat(mSliderStateTracker.isWaiting).isTrue()
229 
230         // GIVEN that the tracker stops tracking the state and listening to events
231         mSliderStateTracker.stopTracking()
232 
233         // THEN the tracker moves to the IDLE state without the timer job being complete
234         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
235         assertThat(mSliderStateTracker.isWaiting).isFalse()
236         assertThat(mSliderStateTracker.isTracking).isFalse()
237         verifyNoMoreInteractions(sliderStateListener)
238     }
239 
240     // Tests on the JUMP_TRACK_LOCATION_SELECTED state
241 
242     @Test
<lambda>null243     fun progressChangeByUser_onJumpTrackLocationSelected_movesToDragHandleDragging() = runTest {
244         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
245 
246         // GIVEN a JUMP_TRACK_LOCATION_SELECTED state
247         mSliderStateTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED)
248 
249         // GIVEN a progress event due to dragging the handle
250         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f))
251 
252         // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state
253         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
254         verify(sliderStateListener).onProgress(anyFloat())
255         verifyNoMoreInteractions(sliderStateListener)
256     }
257 
258     @Test
<lambda>null259     fun touchRelease_onJumpTrackLocationSelected_movesToIdle() = runTest {
260         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
261 
262         // GIVEN a JUMP_TRACK_LOCATION_SELECTED state
263         mSliderStateTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED)
264 
265         // GIVEN that the slider stopped tracking touch
266         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f))
267 
268         // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state
269         verify(sliderStateListener).onHandleReleasedFromTouch()
270         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
271         verifyNoMoreInteractions(sliderStateListener)
272     }
273 
274     @Test
<lambda>null275     fun progressChangeByUser_onJumpBookendSelected_movesToDragHandleDragging() = runTest {
276         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
277 
278         // GIVEN a JUMP_BOOKEND_SELECTED state
279         mSliderStateTracker.setState(SliderState.JUMP_BOOKEND_SELECTED)
280 
281         // GIVEN that the slider stopped tracking touch
282         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f))
283 
284         // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state
285         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
286         verify(sliderStateListener).onProgress(anyFloat())
287         verifyNoMoreInteractions(sliderStateListener)
288     }
289 
290     @Test
<lambda>null291     fun touchRelease_onJumpBookendSelected_movesToIdle() = runTest {
292         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
293 
294         // GIVEN a JUMP_BOOKEND_SELECTED state
295         mSliderStateTracker.setState(SliderState.JUMP_BOOKEND_SELECTED)
296 
297         // GIVEN that the slider stopped tracking touch
298         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f))
299 
300         // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state
301         verify(sliderStateListener).onHandleReleasedFromTouch()
302         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
303         verifyNoMoreInteractions(sliderStateListener)
304     }
305 
306     // Tests on the DRAG_HANDLE_ACQUIRED state
307 
308     @Test
<lambda>null309     fun progressChangeByUser_onHandleAcquired_movesToDragHandleDragging() = runTest {
310         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
311 
312         // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state
313         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
314 
315         // GIVEN a progress change by the user
316         val progress = 0.5f
317         sliderEventProducer.sendEvent(
318             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
319         )
320 
321         // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state
322         verify(sliderStateListener).onProgress(progress)
323         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
324         verifyNoMoreInteractions(sliderStateListener)
325     }
326 
327     @Test
touchRelease_onHandleAcquired_movesToIdlenull328     fun touchRelease_onHandleAcquired_movesToIdle() = runTest {
329         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
330 
331         // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state
332         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
333 
334         // GIVEN that the handle stops tracking touch
335         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f))
336 
337         // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state
338         verify(sliderStateListener).onHandleReleasedFromTouch()
339         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
340         verifyNoMoreInteractions(sliderStateListener)
341     }
342 
343     // Tests on DRAG_HANDLE_DRAGGING
344 
345     @Test
progressChangeByUser_onHandleDragging_progressOutsideOfBookends_doesNotChangeStatenull346     fun progressChangeByUser_onHandleDragging_progressOutsideOfBookends_doesNotChangeState() =
347         runTest {
348             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
349 
350             // GIVEN a DRAG_HANDLE_DRAGGING state
351             mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING)
352 
353             // GIVEN a progress change by the user outside of bookend bounds
354             val progress = 0.5f
355             sliderEventProducer.sendEvent(
356                 SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
357             )
358 
359             // THEN the tracker does not change state and executes the onProgress call
360             assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
361             verify(sliderStateListener).onProgress(progress)
362             verifyNoMoreInteractions(sliderStateListener)
363         }
364 
365     @Test
progressChangeByUser_onHandleDragging_reachesLowerBookend_movesToHandleReachedBookendnull366     fun progressChangeByUser_onHandleDragging_reachesLowerBookend_movesToHandleReachedBookend() =
367         runTest {
368             val config = SeekableSliderTrackerConfig()
369             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
370 
371             // GIVEN a DRAG_HANDLE_DRAGGING state
372             mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING)
373 
374             // GIVEN a progress change by the user reaching the lower bookend
375             val progress = config.lowerBookendThreshold - 0.01f
376             sliderEventProducer.sendEvent(
377                 SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
378             )
379 
380             // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the
381             // corresponding callback
382             assertThat(mSliderStateTracker.currentState)
383                 .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
384             verify(sliderStateListener).onLowerBookend()
385             verifyNoMoreInteractions(sliderStateListener)
386         }
387 
388     @Test
progressChangeByUser_onHandleDragging_reachesUpperBookend_movesToHandleReachedBookendnull389     fun progressChangeByUser_onHandleDragging_reachesUpperBookend_movesToHandleReachedBookend() =
390         runTest {
391             val config = SeekableSliderTrackerConfig()
392             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
393 
394             // GIVEN a DRAG_HANDLE_DRAGGING state
395             mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING)
396 
397             // GIVEN a progress change by the user reaching the upper bookend
398             val progress = config.upperBookendThreshold + 0.01f
399             sliderEventProducer.sendEvent(
400                 SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
401             )
402 
403             // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the
404             // corresponding callback
405             assertThat(mSliderStateTracker.currentState)
406                 .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
407             verify(sliderStateListener).onUpperBookend()
408             verifyNoMoreInteractions(sliderStateListener)
409         }
410 
411     @Test
<lambda>null412     fun touchRelease_onHandleDragging_movesToIdle() = runTest {
413         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
414 
415         // GIVEN a DRAG_HANDLE_DRAGGING state
416         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING)
417 
418         // GIVEN that the slider stops tracking touch
419         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f))
420 
421         // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state
422         verify(sliderStateListener).onHandleReleasedFromTouch()
423         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
424         verifyNoMoreInteractions(sliderStateListener)
425     }
426 
427     // Tests on the DRAG_HANDLE_REACHED_BOOKEND state
428 
429     @Test
progressChangeByUser_outsideOfBookendRange_onLowerBookend_movesToDragHandleDraggingnull430     fun progressChangeByUser_outsideOfBookendRange_onLowerBookend_movesToDragHandleDragging() =
431         runTest {
432             val config = SeekableSliderTrackerConfig()
433             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
434 
435             // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state
436             mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
437 
438             // GIVEN a progress event that falls outside of the lower bookend range
439             val progress = config.lowerBookendThreshold + 0.01f
440             sliderEventProducer.sendEvent(
441                 SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
442             )
443 
444             // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly
445             verify(sliderStateListener).onProgress(progress)
446             assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
447             verifyNoMoreInteractions(sliderStateListener)
448         }
449 
450     @Test
<lambda>null451     fun progressChangeByUser_insideOfBookendRange_onLowerBookend_doesNotChangeState() = runTest {
452         val config = SeekableSliderTrackerConfig()
453         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
454 
455         // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state
456         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
457 
458         // GIVEN a progress event that falls inside of the lower bookend range
459         val progress = config.lowerBookendThreshold - 0.01f
460         sliderEventProducer.sendEvent(
461             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
462         )
463 
464         // THEN the tracker stays in the current state and executes accordingly
465         verify(sliderStateListener).onLowerBookend()
466         assertThat(mSliderStateTracker.currentState)
467             .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
468         verifyNoMoreInteractions(sliderStateListener)
469     }
470 
471     @Test
progressChangeByUser_outsideOfBookendRange_onUpperBookend_movesToDragHandleDraggingnull472     fun progressChangeByUser_outsideOfBookendRange_onUpperBookend_movesToDragHandleDragging() =
473         runTest {
474             val config = SeekableSliderTrackerConfig()
475             initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
476 
477             // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state
478             mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
479 
480             // GIVEN a progress event that falls outside of the upper bookend range
481             val progress = config.upperBookendThreshold - 0.01f
482             sliderEventProducer.sendEvent(
483                 SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
484             )
485 
486             // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly
487             verify(sliderStateListener).onProgress(progress)
488             assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING)
489             verifyNoMoreInteractions(sliderStateListener)
490         }
491 
492     @Test
<lambda>null493     fun progressChangeByUser_insideOfBookendRange_onUpperBookend_doesNotChangeState() = runTest {
494         val config = SeekableSliderTrackerConfig()
495         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
496 
497         // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state
498         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
499 
500         // GIVEN a progress event that falls inside of the upper bookend range
501         val progress = config.upperBookendThreshold + 0.01f
502         sliderEventProducer.sendEvent(
503             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress)
504         )
505 
506         // THEN the tracker stays in the current state and executes accordingly
507         verify(sliderStateListener).onUpperBookend()
508         assertThat(mSliderStateTracker.currentState)
509             .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
510         verifyNoMoreInteractions(sliderStateListener)
511     }
512 
513     @Test
<lambda>null514     fun touchRelease_onHandleReachedBookend_movesToIdle() = runTest {
515         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
516 
517         // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state
518         mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
519 
520         // GIVEN that the handle stops tracking touch
521         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f))
522 
523         // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state
524         verify(sliderStateListener).onHandleReleasedFromTouch()
525         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
526         verifyNoMoreInteractions(sliderStateListener)
527     }
528 
529     @Test
<lambda>null530     fun onStartedTrackingProgram_atTheMiddle_onIdle_movesToArrowHandleMovedOnce() = runTest {
531         // GIVEN an initialized tracker in the IDLE state
532         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
533 
534         // GIVEN a progress due to an external source that lands at the middle of the slider
535         val progress = 0.5f
536         sliderEventProducer.sendEvent(
537             SliderEvent(SliderEventType.STARTED_TRACKING_PROGRAM, progress)
538         )
539 
540         // THEN the state moves to ARROW_HANDLE_MOVED_ONCE and the listener is called to play
541         // haptics
542         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE)
543         verify(sliderStateListener).onSelectAndArrow(progress)
544     }
545 
546     @Test
<lambda>null547     fun onStartedTrackingProgram_atUpperBookend_onIdle_movesToIdle() = runTest {
548         // GIVEN an initialized tracker in the IDLE state
549         val config = SeekableSliderTrackerConfig()
550         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
551 
552         // GIVEN a progress due to an external source that lands at the upper bookend
553         val progress = config.upperBookendThreshold + 0.01f
554         sliderEventProducer.sendEvent(
555             SliderEvent(SliderEventType.STARTED_TRACKING_PROGRAM, progress)
556         )
557 
558         // THEN the tracker executes upper bookend haptics before moving back to IDLE
559         verify(sliderStateListener).onUpperBookend()
560         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
561     }
562 
563     @Test
onStartedTrackingProgram_atLowerBookend_onIdle_movesToIdlenull564     fun onStartedTrackingProgram_atLowerBookend_onIdle_movesToIdle() = runTest {
565         // GIVEN an initialized tracker in the IDLE state
566         val config = SeekableSliderTrackerConfig()
567         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
568 
569         // WHEN a progress is recorded due to an external source that lands at the lower bookend
570         val progress = config.lowerBookendThreshold - 0.01f
571         sliderEventProducer.sendEvent(
572             SliderEvent(SliderEventType.STARTED_TRACKING_PROGRAM, progress)
573         )
574 
575         // THEN the tracker executes lower bookend haptics before moving to IDLE
576         verify(sliderStateListener).onLowerBookend()
577         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
578     }
579 
580     @Test
<lambda>null581     fun onArrowUp_onArrowMovedOnce_movesToIdle() = runTest {
582         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
583         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
584         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)
585 
586         // WHEN the external stimulus is released
587         val progress = 0.5f
588         sliderEventProducer.sendEvent(
589             SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, progress)
590         )
591 
592         // THEN the tracker moves back to IDLE and there are no haptics
593         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
594         verifyZeroInteractions(sliderStateListener)
595     }
596 
597     @Test
<lambda>null598     fun onStartTrackingTouch_onArrowMovedOnce_movesToWait() = runTest {
599         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
600         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
601         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)
602 
603         // WHEN the slider starts tracking touch
604         val progress = 0.5f
605         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
606 
607         // THEN the tracker moves back to WAIT and starts the waiting job. Also, there are no
608         // haptics
609         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT)
610         assertThat(mSliderStateTracker.isWaiting).isTrue()
611         verifyZeroInteractions(sliderStateListener)
612     }
613 
614     @Test
<lambda>null615     fun onProgressChangeByProgram_onArrowMovedOnce_movesToArrowMovesContinuously() = runTest {
616         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
617         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
618         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)
619 
620         // WHEN the slider gets an external progress change
621         val progress = 0.5f
622         sliderEventProducer.sendEvent(
623             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
624         )
625 
626         // THEN the tracker moves to ARROW_HANDLE_MOVES_CONTINUOUSLY and calls the appropriate
627         // haptics
628         assertThat(mSliderStateTracker.currentState)
629             .isEqualTo(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
630         verify(sliderStateListener).onProgress(progress)
631     }
632 
633     @Test
<lambda>null634     fun onArrowUp_onArrowMovesContinuously_movesToIdle() = runTest {
635         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
636         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
637         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
638 
639         // WHEN the external stimulus is released
640         val progress = 0.5f
641         sliderEventProducer.sendEvent(
642             SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, progress)
643         )
644 
645         // THEN the tracker moves to IDLE and no haptics are played
646         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
647         verifyZeroInteractions(sliderStateListener)
648     }
649 
650     @Test
<lambda>null651     fun onStartTrackingTouch_onArrowMovesContinuously_movesToWait() = runTest {
652         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
653         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
654         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
655 
656         // WHEN the slider starts tracking touch
657         val progress = 0.5f
658         sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))
659 
660         // THEN the tracker moves to WAIT and the wait job starts.
661         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT)
662         assertThat(mSliderStateTracker.isWaiting).isTrue()
663         verifyZeroInteractions(sliderStateListener)
664     }
665 
666     @Test
<lambda>null667     fun onProgressChangeByProgram_onArrowMovesContinuously_preservesState() = runTest {
668         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
669         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
670         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
671 
672         // WHEN the slider changes progress programmatically at the middle
673         val progress = 0.5f
674         sliderEventProducer.sendEvent(
675             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
676         )
677 
678         // THEN the tracker stays in the same state and haptics are delivered appropriately
679         assertThat(mSliderStateTracker.currentState)
680             .isEqualTo(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
681         verify(sliderStateListener).onProgress(progress)
682     }
683 
684     @Test
<lambda>null685     fun onProgramProgress_atLowerBookend_onArrowMovesContinuously_movesToIdle() = runTest {
686         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
687         val config = SeekableSliderTrackerConfig()
688         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
689         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
690 
691         // WHEN the slider reaches the lower bookend programmatically
692         val progress = config.lowerBookendThreshold - 0.01f
693         sliderEventProducer.sendEvent(
694             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
695         )
696 
697         // THEN the tracker executes lower bookend haptics before moving to IDLE
698         verify(sliderStateListener).onLowerBookend()
699         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
700     }
701 
702     @Test
<lambda>null703     fun onProgramProgress_atUpperBookend_onArrowMovesContinuously_movesToIdle() = runTest {
704         // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
705         val config = SeekableSliderTrackerConfig()
706         initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config)
707         mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
708 
709         // WHEN the slider reaches the lower bookend programmatically
710         val progress = config.upperBookendThreshold + 0.01f
711         sliderEventProducer.sendEvent(
712             SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
713         )
714 
715         // THEN the tracker executes upper bookend haptics before moving to IDLE
716         verify(sliderStateListener).onUpperBookend()
717         assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE)
718     }
719 
720     @OptIn(ExperimentalCoroutinesApi::class)
initTrackernull721     private fun initTracker(
722         scope: CoroutineScope,
723         config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
724     ) {
725         mSliderStateTracker =
726             SliderStateTracker(sliderStateListener, sliderEventProducer, scope, config)
727         mSliderStateTracker.startTracking()
728     }
729 }
730