| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.systemui.util.magnetictarget |
| |
| import android.testing.AndroidTestingRunner |
| import android.testing.TestableLooper |
| import android.view.MotionEvent |
| import android.view.View |
| import androidx.dynamicanimation.animation.FloatPropertyCompat |
| import androidx.test.filters.SmallTest |
| import com.android.systemui.SysuiTestCase |
| import com.android.systemui.util.animation.PhysicsAnimatorTestUtils |
| import org.junit.Assert.assertEquals |
| import org.junit.Assert.assertFalse |
| import org.junit.Assert.assertTrue |
| import org.junit.Before |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| import org.mockito.ArgumentMatchers |
| import org.mockito.ArgumentMatchers.anyFloat |
| import org.mockito.Mockito |
| import org.mockito.Mockito.`when` |
| import org.mockito.Mockito.doAnswer |
| import org.mockito.Mockito.mock |
| import org.mockito.Mockito.never |
| import org.mockito.Mockito.times |
| import org.mockito.Mockito.verify |
| import org.mockito.Mockito.verifyNoMoreInteractions |
| |
| @TestableLooper.RunWithLooper |
| @RunWith(AndroidTestingRunner::class) |
| @SmallTest |
| class MagnetizedObjectTest : SysuiTestCase() { |
| /** Incrementing value for fake MotionEvent timestamps. */ |
| private var time = 0L |
| |
| /** Value to add to each new MotionEvent's timestamp. */ |
| private var timeStep = 100 |
| |
| private val underlyingObject = this |
| |
| private lateinit var targetView: View |
| |
| private val targetSize = 200 |
| private val targetCenterX = 500 |
| private val targetCenterY = 900 |
| private val magneticFieldRadius = 200 |
| |
| private var objectX = 0f |
| private var objectY = 0f |
| private val objectSize = 50f |
| |
| private lateinit var magneticTarget: MagnetizedObject.MagneticTarget |
| private lateinit var magnetizedObject: MagnetizedObject<*> |
| private lateinit var magnetListener: MagnetizedObject.MagnetListener |
| |
| private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") { |
| override fun setValue(target: MagnetizedObjectTest?, value: Float) { |
| objectX = value |
| } |
| override fun getValue(target: MagnetizedObjectTest?): Float { |
| return objectX |
| } |
| } |
| |
| private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") { |
| override fun setValue(target: MagnetizedObjectTest?, value: Float) { |
| objectY = value |
| } |
| |
| override fun getValue(target: MagnetizedObjectTest?): Float { |
| return objectY |
| } |
| } |
| |
| @Before |
| fun setup() { |
| PhysicsAnimatorTestUtils.prepareForTest() |
| |
| // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached |
| // to a real window (it'll always return x = 0, y = 0). |
| targetView = mock(View::class.java) |
| `when`(targetView.context).thenReturn(context) |
| |
| // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's |
| // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900). |
| `when`(targetView.width).thenReturn(targetSize) // width = 200 |
| `when`(targetView.height).thenReturn(targetSize) // height = 200 |
| doAnswer { invocation -> |
| (invocation.arguments[0] as IntArray).also { location -> |
| // Return the top left of the target. |
| location[0] = targetCenterX - targetSize / 2 // x = 400 |
| location[1] = targetCenterY - targetSize / 2 // y = 800 |
| } |
| }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any()) |
| doAnswer { invocation -> |
| (invocation.arguments[0] as Runnable).run() |
| true |
| }.`when`(targetView).post(ArgumentMatchers.any()) |
| `when`(targetView.context).thenReturn(context) |
| |
| magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius) |
| |
| magnetListener = mock(MagnetizedObject.MagnetListener::class.java) |
| magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>( |
| context, underlyingObject, xProperty, yProperty) { |
| override fun getWidth(underlyingObject: MagnetizedObjectTest): Float { |
| return objectSize |
| } |
| |
| override fun getHeight(underlyingObject: MagnetizedObjectTest): Float { |
| return objectSize |
| } |
| |
| override fun getLocationOnScreen( |
| underlyingObject: MagnetizedObjectTest, |
| loc: IntArray |
| ) { |
| loc[0] = objectX.toInt() |
| loc[1] = objectY.toInt() } |
| } |
| |
| magnetizedObject.magnetListener = magnetListener |
| magnetizedObject.addTarget(magneticTarget) |
| |
| timeStep = 100 |
| } |
| |
| @Test |
| fun testMotionEventConsumption() { |
| // Start at (0, 0). No magnetic field here. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 0, y = 0, action = MotionEvent.ACTION_DOWN))) |
| |
| // Move to (400, 400), which is solidly outside the magnetic field. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 200, y = 200))) |
| |
| // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were |
| // square. It's not, because they're not. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = targetCenterX - magneticFieldRadius + 5, |
| y = targetCenterY - magneticFieldRadius + 5))) |
| |
| // Move to (400, 800). That's solidly in the radius so the magnetic target should begin |
| // consuming events. |
| assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = targetCenterX - 100, |
| y = targetCenterY - 100))) |
| |
| // Release at (400, 800). Since we're in the magnetic target, it should return true and |
| // consume the ACTION_UP. |
| assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 400, y = 800, action = MotionEvent.ACTION_UP))) |
| |
| // ACTION_DOWN outside the field. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 200, y = 200, action = MotionEvent.ACTION_DOWN))) |
| |
| // Move to the center. We absolutely should consume events there. |
| assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY))) |
| |
| // Drag out to (0, 0) and we should be returning false again. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 0, y = 0))) |
| |
| // The ACTION_UP event shouldn't be consumed either since it's outside the field. |
| assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = 0, y = 0, action = MotionEvent.ACTION_UP))) |
| } |
| |
| @Test |
| fun testMotionEventConsumption_downInMagneticField() { |
| // We should consume DOWN events if they occur in the field. |
| assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN))) |
| } |
| |
| @Test |
| fun testMoveIntoAroundAndOutOfMagneticField() { |
| // Move around but don't touch the magnetic field. |
| dispatchMotionEvents( |
| getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN), |
| getMotionEvent(x = 100, y = 100), |
| getMotionEvent(x = 200, y = 200)) |
| |
| // You can't become unstuck if you were never stuck in the first place. |
| verify(magnetListener, never()).onStuckToTarget(magneticTarget) |
| verify(magnetListener, never()).onUnstuckFromTarget( |
| eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), |
| eq(false)) |
| |
| // Move into and then around inside the magnetic field. |
| dispatchMotionEvents( |
| getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100), |
| getMotionEvent(x = targetCenterX, y = targetCenterY), |
| getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100)) |
| |
| // We should only have received one call to onStuckToTarget and none to unstuck. |
| verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) |
| verify(magnetListener, never()).onUnstuckFromTarget( |
| eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), |
| eq(false)) |
| |
| // Move out of the field and then release. |
| dispatchMotionEvents( |
| getMotionEvent(x = 100, y = 100), |
| getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP)) |
| |
| // We should have received one unstuck call and no more stuck calls. We also should never |
| // have received an onReleasedInTarget call. |
| verify(magnetListener, times(1)).onUnstuckFromTarget( |
| eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), |
| eq(false)) |
| verifyNoMoreInteractions(magnetListener) |
| } |
| |
| @Test |
| fun testMoveIntoOutOfAndBackIntoMagneticField() { |
| // Move into the field |
| dispatchMotionEvents( |
| getMotionEvent( |
| x = targetCenterX - magneticFieldRadius, |
| y = targetCenterY - magneticFieldRadius, |
| action = MotionEvent.ACTION_DOWN), |
| getMotionEvent( |
| x = targetCenterX, y = targetCenterY)) |
| |
| verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) |
| verify(magnetListener, never()).onReleasedInTarget(magneticTarget) |
| |
| // Move back out. |
| dispatchMotionEvents( |
| getMotionEvent( |
| x = targetCenterX - magneticFieldRadius, |
| y = targetCenterY - magneticFieldRadius)) |
| |
| verify(magnetListener, times(1)).onUnstuckFromTarget( |
| eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(), |
| eq(false)) |
| verify(magnetListener, never()).onReleasedInTarget(magneticTarget) |
| |
| // Move in again and release in the magnetic field. |
| dispatchMotionEvents( |
| getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100), |
| getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50), |
| getMotionEvent(x = targetCenterX, y = targetCenterY), |
| getMotionEvent( |
| x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP)) |
| |
| verify(magnetListener, times(2)).onStuckToTarget(magneticTarget) |
| verify(magnetListener).onReleasedInTarget(magneticTarget) |
| verifyNoMoreInteractions(magnetListener) |
| } |
| |
| @Test |
| fun testFlingTowardsTarget_towardsTarget() { |
| timeStep = 10 |
| |
| // Forcefully fling the object towards the target (but never touch the magnetic field). |
| dispatchMotionEvents( |
| getMotionEvent( |
| x = targetCenterX, |
| y = 0, |
| action = MotionEvent.ACTION_DOWN), |
| getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY / 2), |
| getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY - magneticFieldRadius * 2, |
| action = MotionEvent.ACTION_UP)) |
| |
| // Nevertheless it should have ended up stuck to the target. |
| verify(magnetListener, times(1)).onStuckToTarget(magneticTarget) |
| } |
| |
| @Test |
| fun testFlingTowardsTarget_towardsButTooSlow() { |
| // Very, very slowly fling the object towards the target (but never touch the magnetic |
| // field). This value is only used to create MotionEvent timestamps, it will not block the |
| // test for 10 seconds. |
| timeStep = 10000 |
| dispatchMotionEvents( |
| getMotionEvent( |
| x = targetCenterX, |
| y = 0, |
| action = MotionEvent.ACTION_DOWN), |
| getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY / 2), |
| getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY - magneticFieldRadius * 2, |
| action = MotionEvent.ACTION_UP)) |
| |
| // No sticking should have occurred. |
| verifyNoMoreInteractions(magnetListener) |
| } |
| |
| @Test |
| fun testFlingTowardsTarget_missTarget() { |
| timeStep = 10 |
| // Forcefully fling the object down, but not towards the target. |
| dispatchMotionEvents( |
| getMotionEvent( |
| x = 0, |
| y = 0, |
| action = MotionEvent.ACTION_DOWN), |
| getMotionEvent( |
| x = 0, |
| y = targetCenterY / 2), |
| getMotionEvent( |
| x = 0, |
| y = targetCenterY - magneticFieldRadius * 2, |
| action = MotionEvent.ACTION_UP)) |
| |
| verifyNoMoreInteractions(magnetListener) |
| } |
| |
| @Test |
| fun testMagnetAnimation() { |
| // Make sure the object starts at (0, 0). |
| assertEquals(0f, objectX) |
| assertEquals(0f, objectY) |
| |
| // Trigger the magnet animation, and block the test until it ends. |
| PhysicsAnimatorTestUtils.setAllAnimationsBlock(true) |
| magnetizedObject.maybeConsumeMotionEvent(getMotionEvent( |
| x = targetCenterX, |
| y = targetCenterY, |
| action = MotionEvent.ACTION_DOWN)) |
| |
| // The object's (top-left) position should now position it centered over the target. |
| assertEquals(targetCenterX - objectSize / 2, objectX) |
| assertEquals(targetCenterY - objectSize / 2, objectY) |
| } |
| |
| @Test |
| fun testMultipleTargets() { |
| val secondMagneticTarget = getSecondMagneticTarget() |
| |
| // Drag into the second target. |
| dispatchMotionEvents( |
| getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN), |
| getMotionEvent(x = 100, y = 900)) |
| |
| // Verify that we received an onStuck for the second target, and no others. |
| verify(magnetListener).onStuckToTarget(secondMagneticTarget) |
| verifyNoMoreInteractions(magnetListener) |
| |
| // Drag into the original target. |
| dispatchMotionEvents( |
| getMotionEvent(x = 0, y = 0), |
| getMotionEvent(x = 500, y = 900)) |
| |
| // We should have unstuck from the second one and stuck into the original one. |
| verify(magnetListener).onUnstuckFromTarget( |
| eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false)) |
| verify(magnetListener).onStuckToTarget(magneticTarget) |
| verifyNoMoreInteractions(magnetListener) |
| } |
| |
| @Test |
| fun testMultipleTargets_flingIntoSecond() { |
| val secondMagneticTarget = getSecondMagneticTarget() |
| |
| timeStep = 10 |
| |
| // Fling towards the second target. |
| dispatchMotionEvents( |
| getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN), |
| getMotionEvent(x = 100, y = 350), |
| getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP)) |
| |
| // Verify that we received an onStuck for the second target. |
| verify(magnetListener).onStuckToTarget(secondMagneticTarget) |
| |
| // Fling towards the first target. |
| dispatchMotionEvents( |
| getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN), |
| getMotionEvent(x = 400, y = 350), |
| getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP)) |
| |
| // Verify that we received onStuck for the original target. |
| verify(magnetListener).onStuckToTarget(magneticTarget) |
| } |
| |
| private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget { |
| // The first target view is at bounds (400, 800, 600, 1000) and it has a center of |
| // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900). |
| val secondTargetView = mock(View::class.java) |
| var secondTargetCenterX = 100 |
| var secondTargetCenterY = 900 |
| |
| `when`(secondTargetView.context).thenReturn(context) |
| `when`(secondTargetView.width).thenReturn(targetSize) // width = 200 |
| `when`(secondTargetView.height).thenReturn(targetSize) // height = 200 |
| doAnswer { invocation -> |
| (invocation.arguments[0] as Runnable).run() |
| true |
| }.`when`(secondTargetView).post(ArgumentMatchers.any()) |
| doAnswer { invocation -> |
| (invocation.arguments[0] as IntArray).also { location -> |
| // Return the top left of the target. |
| location[0] = secondTargetCenterX - targetSize / 2 // x = 0 |
| location[1] = secondTargetCenterY - targetSize / 2 // y = 800 |
| } |
| }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any()) |
| |
| return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius) |
| } |
| |
| /** |
| * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default). |
| * The event's time fields will be incremented by 10ms each time this is called, so tha |
| * VelocityTracker works. |
| */ |
| private fun getMotionEvent( |
| x: Int, |
| y: Int, |
| action: Int = MotionEvent.ACTION_MOVE |
| ): MotionEvent { |
| return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0) |
| .also { time += timeStep } |
| } |
| |
| /** Dispatch all of the provided events to the target view. */ |
| private fun dispatchMotionEvents(vararg events: MotionEvent) { |
| events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) } |
| } |
| |
| /** Prevents Kotlin from being mad that eq() is nullable. */ |
| private fun <T> eq(value: T): T = Mockito.eq(value) ?: value |
| } |