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