blob: f6b7b74d4bfc2d3238bebecd318f6fa23580db2b [file] [log] [blame]
Joshua Tsujicb9312f2020-02-13 03:22:56 -05001/*
2 * Copyright (C) 2020 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 */
16package com.android.systemui.util.magnetictarget
17
18import android.testing.AndroidTestingRunner
19import android.testing.TestableLooper
20import android.view.MotionEvent
21import android.view.View
22import androidx.dynamicanimation.animation.FloatPropertyCompat
23import androidx.test.filters.SmallTest
24import com.android.systemui.SysuiTestCase
25import com.android.systemui.util.animation.PhysicsAnimatorTestUtils
26import org.junit.Assert.assertEquals
27import org.junit.Assert.assertFalse
28import org.junit.Assert.assertTrue
29import org.junit.Before
30import org.junit.Test
31import org.junit.runner.RunWith
32import org.mockito.ArgumentMatchers
33import org.mockito.ArgumentMatchers.anyFloat
34import org.mockito.Mockito
35import org.mockito.Mockito.`when`
36import org.mockito.Mockito.doAnswer
37import org.mockito.Mockito.mock
38import org.mockito.Mockito.never
39import org.mockito.Mockito.times
40import org.mockito.Mockito.verify
41import org.mockito.Mockito.verifyNoMoreInteractions
42
43@TestableLooper.RunWithLooper
44@RunWith(AndroidTestingRunner::class)
45@SmallTest
46class MagnetizedObjectTest : SysuiTestCase() {
47 /** Incrementing value for fake MotionEvent timestamps. */
48 private var time = 0L
49
50 /** Value to add to each new MotionEvent's timestamp. */
51 private var timeStep = 100
52
53 private val underlyingObject = this
54
55 private lateinit var targetView: View
56
57 private val targetSize = 200
58 private val targetCenterX = 500
59 private val targetCenterY = 900
60 private val magneticFieldRadius = 200
61
62 private var objectX = 0f
63 private var objectY = 0f
64 private val objectSize = 50f
65
66 private lateinit var magneticTarget: MagnetizedObject.MagneticTarget
67 private lateinit var magnetizedObject: MagnetizedObject<*>
68 private lateinit var magnetListener: MagnetizedObject.MagnetListener
69
70 private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
71 override fun setValue(target: MagnetizedObjectTest?, value: Float) {
72 objectX = value
73 }
74 override fun getValue(target: MagnetizedObjectTest?): Float {
75 return objectX
76 }
77 }
78
79 private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
80 override fun setValue(target: MagnetizedObjectTest?, value: Float) {
81 objectY = value
82 }
83
84 override fun getValue(target: MagnetizedObjectTest?): Float {
85 return objectY
86 }
87 }
88
89 @Before
90 fun setup() {
91 PhysicsAnimatorTestUtils.prepareForTest()
92
93 // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached
94 // to a real window (it'll always return x = 0, y = 0).
95 targetView = mock(View::class.java)
96 `when`(targetView.context).thenReturn(context)
97
98 // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's
99 // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900).
100 `when`(targetView.width).thenReturn(targetSize) // width = 200
101 `when`(targetView.height).thenReturn(targetSize) // height = 200
102 doAnswer { invocation ->
103 (invocation.arguments[0] as IntArray).also { location ->
104 // Return the top left of the target.
105 location[0] = targetCenterX - targetSize / 2 // x = 400
106 location[1] = targetCenterY - targetSize / 2 // y = 800
107 }
108 }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any())
Joshua Tsujif39539d2020-04-03 18:53:06 -0400109 doAnswer { invocation ->
110 (invocation.arguments[0] as Runnable).run()
111 true
112 }.`when`(targetView).post(ArgumentMatchers.any())
Joshua Tsujicb9312f2020-02-13 03:22:56 -0500113 `when`(targetView.context).thenReturn(context)
114
115 magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius)
116
117 magnetListener = mock(MagnetizedObject.MagnetListener::class.java)
118 magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>(
119 context, underlyingObject, xProperty, yProperty) {
120 override fun getWidth(underlyingObject: MagnetizedObjectTest): Float {
121 return objectSize
122 }
123
124 override fun getHeight(underlyingObject: MagnetizedObjectTest): Float {
125 return objectSize
126 }
127
128 override fun getLocationOnScreen(
129 underlyingObject: MagnetizedObjectTest,
130 loc: IntArray
131 ) {
132 loc[0] = objectX.toInt()
133 loc[1] = objectY.toInt() }
134 }
135
136 magnetizedObject.magnetListener = magnetListener
137 magnetizedObject.addTarget(magneticTarget)
138
139 timeStep = 100
140 }
141
142 @Test
143 fun testMotionEventConsumption() {
144 // Start at (0, 0). No magnetic field here.
145 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
146 x = 0, y = 0, action = MotionEvent.ACTION_DOWN)))
147
148 // Move to (400, 400), which is solidly outside the magnetic field.
149 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
150 x = 200, y = 200)))
151
152 // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were
153 // square. It's not, because they're not.
154 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
155 x = targetCenterX - magneticFieldRadius + 5,
156 y = targetCenterY - magneticFieldRadius + 5)))
157
158 // Move to (400, 800). That's solidly in the radius so the magnetic target should begin
159 // consuming events.
160 assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
161 x = targetCenterX - 100,
162 y = targetCenterY - 100)))
163
164 // Release at (400, 800). Since we're in the magnetic target, it should return true and
165 // consume the ACTION_UP.
166 assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
167 x = 400, y = 800, action = MotionEvent.ACTION_UP)))
168
169 // ACTION_DOWN outside the field.
170 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
171 x = 200, y = 200, action = MotionEvent.ACTION_DOWN)))
172
173 // Move to the center. We absolutely should consume events there.
174 assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
175 x = targetCenterX,
176 y = targetCenterY)))
177
178 // Drag out to (0, 0) and we should be returning false again.
179 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
180 x = 0, y = 0)))
181
182 // The ACTION_UP event shouldn't be consumed either since it's outside the field.
183 assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
184 x = 0, y = 0, action = MotionEvent.ACTION_UP)))
185 }
186
187 @Test
188 fun testMotionEventConsumption_downInMagneticField() {
189 // We should consume DOWN events if they occur in the field.
190 assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
191 x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN)))
192 }
193
194 @Test
195 fun testMoveIntoAroundAndOutOfMagneticField() {
196 // Move around but don't touch the magnetic field.
197 dispatchMotionEvents(
198 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
199 getMotionEvent(x = 100, y = 100),
200 getMotionEvent(x = 200, y = 200))
201
202 // You can't become unstuck if you were never stuck in the first place.
203 verify(magnetListener, never()).onStuckToTarget(magneticTarget)
204 verify(magnetListener, never()).onUnstuckFromTarget(
205 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
206 eq(false))
207
208 // Move into and then around inside the magnetic field.
209 dispatchMotionEvents(
210 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
211 getMotionEvent(x = targetCenterX, y = targetCenterY),
212 getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100))
213
214 // We should only have received one call to onStuckToTarget and none to unstuck.
215 verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
216 verify(magnetListener, never()).onUnstuckFromTarget(
217 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
218 eq(false))
219
220 // Move out of the field and then release.
221 dispatchMotionEvents(
222 getMotionEvent(x = 100, y = 100),
223 getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP))
224
225 // We should have received one unstuck call and no more stuck calls. We also should never
226 // have received an onReleasedInTarget call.
227 verify(magnetListener, times(1)).onUnstuckFromTarget(
228 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
229 eq(false))
230 verifyNoMoreInteractions(magnetListener)
231 }
232
233 @Test
234 fun testMoveIntoOutOfAndBackIntoMagneticField() {
235 // Move into the field
236 dispatchMotionEvents(
237 getMotionEvent(
238 x = targetCenterX - magneticFieldRadius,
239 y = targetCenterY - magneticFieldRadius,
240 action = MotionEvent.ACTION_DOWN),
241 getMotionEvent(
242 x = targetCenterX, y = targetCenterY))
243
244 verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
245 verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
246
247 // Move back out.
248 dispatchMotionEvents(
249 getMotionEvent(
250 x = targetCenterX - magneticFieldRadius,
251 y = targetCenterY - magneticFieldRadius))
252
253 verify(magnetListener, times(1)).onUnstuckFromTarget(
254 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
255 eq(false))
256 verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
257
258 // Move in again and release in the magnetic field.
259 dispatchMotionEvents(
260 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
261 getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50),
262 getMotionEvent(x = targetCenterX, y = targetCenterY),
263 getMotionEvent(
264 x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP))
265
266 verify(magnetListener, times(2)).onStuckToTarget(magneticTarget)
267 verify(magnetListener).onReleasedInTarget(magneticTarget)
268 verifyNoMoreInteractions(magnetListener)
269 }
270
271 @Test
272 fun testFlingTowardsTarget_towardsTarget() {
273 timeStep = 10
274
275 // Forcefully fling the object towards the target (but never touch the magnetic field).
276 dispatchMotionEvents(
277 getMotionEvent(
278 x = targetCenterX,
279 y = 0,
280 action = MotionEvent.ACTION_DOWN),
281 getMotionEvent(
282 x = targetCenterX,
283 y = targetCenterY / 2),
284 getMotionEvent(
285 x = targetCenterX,
286 y = targetCenterY - magneticFieldRadius * 2,
287 action = MotionEvent.ACTION_UP))
288
289 // Nevertheless it should have ended up stuck to the target.
290 verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
291 }
292
293 @Test
294 fun testFlingTowardsTarget_towardsButTooSlow() {
295 // Very, very slowly fling the object towards the target (but never touch the magnetic
296 // field). This value is only used to create MotionEvent timestamps, it will not block the
297 // test for 10 seconds.
298 timeStep = 10000
299 dispatchMotionEvents(
300 getMotionEvent(
301 x = targetCenterX,
302 y = 0,
303 action = MotionEvent.ACTION_DOWN),
304 getMotionEvent(
305 x = targetCenterX,
306 y = targetCenterY / 2),
307 getMotionEvent(
308 x = targetCenterX,
309 y = targetCenterY - magneticFieldRadius * 2,
310 action = MotionEvent.ACTION_UP))
311
312 // No sticking should have occurred.
313 verifyNoMoreInteractions(magnetListener)
314 }
315
316 @Test
317 fun testFlingTowardsTarget_missTarget() {
318 timeStep = 10
319 // Forcefully fling the object down, but not towards the target.
320 dispatchMotionEvents(
321 getMotionEvent(
322 x = 0,
323 y = 0,
324 action = MotionEvent.ACTION_DOWN),
325 getMotionEvent(
326 x = 0,
327 y = targetCenterY / 2),
328 getMotionEvent(
329 x = 0,
330 y = targetCenterY - magneticFieldRadius * 2,
331 action = MotionEvent.ACTION_UP))
332
333 verifyNoMoreInteractions(magnetListener)
334 }
335
336 @Test
337 fun testMagnetAnimation() {
338 // Make sure the object starts at (0, 0).
339 assertEquals(0f, objectX)
340 assertEquals(0f, objectY)
341
342 // Trigger the magnet animation, and block the test until it ends.
343 PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
344 magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
345 x = targetCenterX,
346 y = targetCenterY,
347 action = MotionEvent.ACTION_DOWN))
348
349 // The object's (top-left) position should now position it centered over the target.
350 assertEquals(targetCenterX - objectSize / 2, objectX)
351 assertEquals(targetCenterY - objectSize / 2, objectY)
352 }
353
354 @Test
355 fun testMultipleTargets() {
356 val secondMagneticTarget = getSecondMagneticTarget()
357
358 // Drag into the second target.
359 dispatchMotionEvents(
360 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
361 getMotionEvent(x = 100, y = 900))
362
363 // Verify that we received an onStuck for the second target, and no others.
364 verify(magnetListener).onStuckToTarget(secondMagneticTarget)
365 verifyNoMoreInteractions(magnetListener)
366
367 // Drag into the original target.
368 dispatchMotionEvents(
369 getMotionEvent(x = 0, y = 0),
370 getMotionEvent(x = 500, y = 900))
371
372 // We should have unstuck from the second one and stuck into the original one.
373 verify(magnetListener).onUnstuckFromTarget(
374 eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false))
375 verify(magnetListener).onStuckToTarget(magneticTarget)
376 verifyNoMoreInteractions(magnetListener)
377 }
378
379 @Test
380 fun testMultipleTargets_flingIntoSecond() {
381 val secondMagneticTarget = getSecondMagneticTarget()
382
383 timeStep = 10
384
385 // Fling towards the second target.
386 dispatchMotionEvents(
387 getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN),
388 getMotionEvent(x = 100, y = 350),
389 getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP))
390
391 // Verify that we received an onStuck for the second target.
392 verify(magnetListener).onStuckToTarget(secondMagneticTarget)
393
394 // Fling towards the first target.
395 dispatchMotionEvents(
396 getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN),
397 getMotionEvent(x = 400, y = 350),
398 getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP))
399
400 // Verify that we received onStuck for the original target.
401 verify(magnetListener).onStuckToTarget(magneticTarget)
402 }
403
404 private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget {
405 // The first target view is at bounds (400, 800, 600, 1000) and it has a center of
406 // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900).
407 val secondTargetView = mock(View::class.java)
408 var secondTargetCenterX = 100
409 var secondTargetCenterY = 900
410
411 `when`(secondTargetView.context).thenReturn(context)
412 `when`(secondTargetView.width).thenReturn(targetSize) // width = 200
413 `when`(secondTargetView.height).thenReturn(targetSize) // height = 200
414 doAnswer { invocation ->
Joshua Tsujif39539d2020-04-03 18:53:06 -0400415 (invocation.arguments[0] as Runnable).run()
416 true
417 }.`when`(secondTargetView).post(ArgumentMatchers.any())
418 doAnswer { invocation ->
Joshua Tsujicb9312f2020-02-13 03:22:56 -0500419 (invocation.arguments[0] as IntArray).also { location ->
420 // Return the top left of the target.
421 location[0] = secondTargetCenterX - targetSize / 2 // x = 0
422 location[1] = secondTargetCenterY - targetSize / 2 // y = 800
423 }
424 }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any())
425
426 return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius)
427 }
428
429 /**
430 * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default).
431 * The event's time fields will be incremented by 10ms each time this is called, so tha
432 * VelocityTracker works.
433 */
434 private fun getMotionEvent(
435 x: Int,
436 y: Int,
437 action: Int = MotionEvent.ACTION_MOVE
438 ): MotionEvent {
439 return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0)
440 .also { time += timeStep }
441 }
442
443 /** Dispatch all of the provided events to the target view. */
444 private fun dispatchMotionEvents(vararg events: MotionEvent) {
445 events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) }
446 }
447
448 /** Prevents Kotlin from being mad that eq() is nullable. */
449 private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
450}