diff --git a/build.gradle b/build.gradle index 1980491..a9cbc77 100644 --- a/build.gradle +++ b/build.gradle @@ -14,11 +14,15 @@ buildscript { constraintlayout_version = '2.0.0-beta3' dynamicanimation_version = '1.0.0' navigation_version = '2.1.0' + espresso_version = '3.2.0' + robolectric_version = '4.3.1' + androidxtest_version = '1.2.0' + androidxtest_junit_version = '1.1.1' + mockk_version = '1.9.3' } repositories { google() jcenter() - } dependencies { classpath 'com.android.tools.build:gradle:3.5.2' @@ -34,6 +38,11 @@ allprojects { google() jcenter() } + configurations.all { + resolutionStrategy { + force("org.objenesis:objenesis:2.6") + } + } } task clean(type: Delete) { diff --git a/library/build.gradle b/library/build.gradle index f9dc356..1d10dfe 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -14,6 +14,8 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" archivesBaseName = "$archivesBaseName-$versionName" + + multiDexEnabled true } buildTypes { @@ -22,6 +24,23 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + + testOptions { + unitTests { + includeAndroidResources true + } + } + + sourceSets { + androidTest { + manifest.srcFile 'src/uiTest/AndroidManifest.xml' + java.srcDirs += "src/uiTest/java" + } + test { + manifest.srcFile 'src/uiTest/AndroidManifest.xml' + java.srcDirs += "src/uiTest/java" + } + } } dependencies { @@ -32,9 +51,18 @@ dependencies { implementation "androidx.core:core-ktx:$corektx_version" api "androidx.dynamicanimation:dynamicanimation:$dynamicanimation_version" - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.2.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + implementation "androidx.multidex:multidex:2.0.1" + + testImplementation "androidx.test.ext:truth:$androidxtest_version" + testImplementation "androidx.test.ext:junit:$androidxtest_junit_version" + testImplementation "org.robolectric:robolectric:$robolectric_version" + testImplementation "io.mockk:mockk:$mockk_version" + + androidTestImplementation "androidx.test.ext:truth:$androidxtest_version" + androidTestImplementation "androidx.test.ext:junit:$androidxtest_junit_version" + androidTestImplementation "androidx.test:runner:$androidxtest_version" + androidTestImplementation "org.robolectric:annotations:$robolectric_version" + androidTestImplementation "io.mockk:mockk-android:$mockk_version" } repositories { diff --git a/library/src/androidTest/java/com/github/lcdsmao/springx/ExampleInstrumentedTest.java b/library/src/androidTest/java/com/github/lcdsmao/springx/ExampleInstrumentedTest.java deleted file mode 100644 index 7fea39c..0000000 --- a/library/src/androidTest/java/com/github/lcdsmao/springx/ExampleInstrumentedTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.lcdsmao.springx; - -import android.content.Context; - -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("com.github.lcdsmao.springx.test", appContext.getPackageName()); - } -} diff --git a/library/src/androidTest/java/com/github/lcdsmao/springx/InstrumentationUiTestScope.kt b/library/src/androidTest/java/com/github/lcdsmao/springx/InstrumentationUiTestScope.kt new file mode 100644 index 0000000..be63732 --- /dev/null +++ b/library/src/androidTest/java/com/github/lcdsmao/springx/InstrumentationUiTestScope.kt @@ -0,0 +1,24 @@ +package com.github.lcdsmao.springx + +import androidx.test.platform.app.InstrumentationRegistry + +@Suppress("unused") +class InstrumentationUiTestScope : UiTestScope { + + override fun runOnMainSync(block: () -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + block.invoke() + } + } + + override fun waitForIdleSync() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + + class Runner : UiTestScope.Runner { + + override fun runUiTest(block: () -> Unit) { + block.invoke() + } + } +} diff --git a/library/src/main/res/layout/activity_animation.xml b/library/src/main/res/layout/activity_animation.xml new file mode 100644 index 0000000..5ff64b9 --- /dev/null +++ b/library/src/main/res/layout/activity_animation.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/library/src/test/java/com/github/lcdsmao/springx/ExampleUnitTest.java b/library/src/test/java/com/github/lcdsmao/springx/ExampleUnitTest.java deleted file mode 100644 index 160a883..0000000 --- a/library/src/test/java/com/github/lcdsmao/springx/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.lcdsmao.springx; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} diff --git a/library/src/test/java/com/github/lcdsmao/springx/UnitUiTestScope.kt b/library/src/test/java/com/github/lcdsmao/springx/UnitUiTestScope.kt new file mode 100644 index 0000000..ec85217 --- /dev/null +++ b/library/src/test/java/com/github/lcdsmao/springx/UnitUiTestScope.kt @@ -0,0 +1,46 @@ +package com.github.lcdsmao.springx + +import android.os.Handler +import android.os.Looper +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread + +@Suppress("unused") +class UnitUiTestScope : UiTestScope { + + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun runOnMainSync(block: () -> Unit) { + val latch = CountDownLatch(1) + mainHandler.post { + try { + block.invoke() + } finally { + latch.countDown() + } + } + latch.await() + } + + override fun waitForIdleSync() { + val latch = CountDownLatch(1) + mainHandler.post { + ShadowLooper.idleMainLooper() + latch.countDown() + } + latch.await() + } + + class Runner : UiTestScope.Runner { + + override fun runUiTest(block: () -> Unit) { + val testThread = thread(name = "Unit UI Test Thread") { + block.invoke() + } + while (testThread.isAlive) { + ShadowLooper.idleMainLooper() + } + } + } +} diff --git a/library/src/uiTest/AndroidManifest.xml b/library/src/uiTest/AndroidManifest.xml new file mode 100644 index 0000000..4353500 --- /dev/null +++ b/library/src/uiTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/library/src/uiTest/java/com/github/lcdsmao/springx/AnimationActivity.kt b/library/src/uiTest/java/com/github/lcdsmao/springx/AnimationActivity.kt new file mode 100644 index 0000000..8530ff8 --- /dev/null +++ b/library/src/uiTest/java/com/github/lcdsmao/springx/AnimationActivity.kt @@ -0,0 +1,5 @@ +package com.github.lcdsmao.springx + +import androidx.appcompat.app.AppCompatActivity + +class AnimationActivity : AppCompatActivity(R.layout.activity_animation) diff --git a/library/src/uiTest/java/com/github/lcdsmao/springx/UiTestScope.kt b/library/src/uiTest/java/com/github/lcdsmao/springx/UiTestScope.kt new file mode 100644 index 0000000..238ee15 --- /dev/null +++ b/library/src/uiTest/java/com/github/lcdsmao/springx/UiTestScope.kt @@ -0,0 +1,28 @@ +package com.github.lcdsmao.springx + +interface UiTestScope { + + interface Runner { + fun runUiTest(block: () -> Unit) + } + + fun runOnMainSync(block: () -> Unit) + fun waitForIdleSync() +} + +fun runUiTest(uiTestScope: UiTestScope.() -> Unit) { + val (scope, runner) = getUiTestDelegate() + runner.runUiTest { uiTestScope(scope) } +} + +private fun getUiTestDelegate(): Pair { + val testScopeName = + if (System.getProperty("java.runtime.name")!!.toLowerCase().contains("android")) { + "com.github.lcdsmao.springx.InstrumentationUiTestScope" + } else { + "com.github.lcdsmao.springx.UnitUiTestScope" + } + val runnerName = "$testScopeName\$Runner" + return Class.forName(testScopeName).newInstance() as UiTestScope to + Class.forName(runnerName).newInstance() as UiTestScope.Runner +} diff --git a/library/src/uiTest/java/com/github/lcdsmao/springx/ViewPropertySpringAnimatorTest.kt b/library/src/uiTest/java/com/github/lcdsmao/springx/ViewPropertySpringAnimatorTest.kt new file mode 100644 index 0000000..4684bac --- /dev/null +++ b/library/src/uiTest/java/com/github/lcdsmao/springx/ViewPropertySpringAnimatorTest.kt @@ -0,0 +1,251 @@ +package com.github.lcdsmao.springx + +import android.view.View +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.FloatPropertyCompat +import androidx.dynamicanimation.animation.SpringForce +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifySequence +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.LooperMode + +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class ViewPropertySpringAnimatorTest { + + @get:Rule + val activityRule = ActivityScenarioRule(AnimationActivity::class.java) + + private lateinit var animView: View + + @Before + fun setup() { + activityRule.scenario.onActivity { + animView = it.findViewById(R.id.anim_view) + } + } + + @Test + fun testAnimateCommonProperty() = runUiTest { + val cases = listOf( + DynamicAnimation.X to 100f, + DynamicAnimation.Y to -100f, + DynamicAnimation.Z to 20f, + DynamicAnimation.TRANSLATION_X to -200f, + DynamicAnimation.TRANSLATION_Y to 200f, + DynamicAnimation.TRANSLATION_Z to -20f, + DynamicAnimation.ROTATION to 45f, + DynamicAnimation.ROTATION_X to 30f, + DynamicAnimation.ROTATION_Y to 60f, + DynamicAnimation.SCALE_X to 4f, + DynamicAnimation.SCALE_Y to 8f, + DynamicAnimation.ALPHA to 0.5f + ) + + cases.forEach { (p, v) -> + val listener = mockk>(relaxed = true) + val anim = ViewPropertySpringAnimator(animView).animate(p, v, false) + .setListener(listener) + runOnMainSync { + anim.start() + Truth.assertThat(anim.isRunning).isTrue() + anim.skipToEnd() + } + verify(exactly = 1) { listener.onAnimationStart(anim) } + verify(exactly = 1, timeout = 1000) { listener.onAnimationEnd(anim) } + Truth.assertThat(anim.isRunning).isFalse() + Truth.assertThat(p.getValue(animView)).isEqualTo(v) + } + } + + @Test + fun testAnimateByCommonProperty() = runUiTest { + val cases = listOf( + DynamicAnimation.X to 100f, + DynamicAnimation.Y to -100f, + DynamicAnimation.Z to 20f, + DynamicAnimation.TRANSLATION_X to -200f, + DynamicAnimation.TRANSLATION_Y to 200f, + DynamicAnimation.TRANSLATION_Z to -20f, + DynamicAnimation.ROTATION to 45f, + DynamicAnimation.ROTATION_X to 30f, + DynamicAnimation.ROTATION_Y to 60f, + DynamicAnimation.SCALE_X to 4f, + DynamicAnimation.SCALE_Y to 8f, + DynamicAnimation.ALPHA to -0.5f + ) + + cases.forEach { (p, v) -> + val oldValue = p.getValue(animView) + val listener = mockk>(relaxed = true) + val anim = ViewPropertySpringAnimator(animView).animate(p, v, true) + .setListener(listener) + runOnMainSync { + anim.start() + Truth.assertThat(anim.isRunning).isTrue() + anim.skipToEnd() + } + verify(exactly = 1) { listener.onAnimationStart(anim) } + verify(exactly = 1, timeout = 1000) { listener.onAnimationEnd(anim) } + Truth.assertThat(anim.isRunning).isFalse() + Truth.assertThat(p.getValue(animView)).isEqualTo(oldValue + v) + } + } + + @Test + fun testAnimateCustomProperty() = runUiTest { + val property = object : FloatPropertyCompat("Custom") { + + private var value = 50f + + override fun getValue(view: View): Float { + return value + } + + override fun setValue(view: View, value: Float) { + this.value = value + } + } + val listener = mockk>(relaxed = true) + val anim = ViewPropertySpringAnimator(animView).animateProperty(property, 100f) + .setListener(listener) + runOnMainSync { + anim.start() + Truth.assertThat(anim.isRunning).isTrue() + anim.skipToEnd() + } + verify(exactly = 1) { listener.onAnimationStart(anim) } + verify(exactly = 1, timeout = 1000) { listener.onAnimationEnd(anim) } + Truth.assertThat(anim.isRunning).isFalse() + Truth.assertThat(property.getValue(animView)).isEqualTo(100f) + } + + @Test + fun testAnimateByCustomProperty() = runUiTest { + val property = object : FloatPropertyCompat("Custom") { + + private var value = 50f + + override fun getValue(view: View): Float { + return value + } + + override fun setValue(view: View, value: Float) { + this.value = value + } + } + val listener = mockk>(relaxed = true) + val anim = ViewPropertySpringAnimator(animView).animatePropertyBy(property, 100f) + .setListener(listener) + runOnMainSync { + anim.start() + Truth.assertThat(anim.isRunning).isTrue() + anim.skipToEnd() + } + verify(exactly = 1) { listener.onAnimationStart(anim) } + verify(exactly = 1, timeout = 1000) { listener.onAnimationEnd(anim) } + Truth.assertThat(anim.isRunning).isFalse() + Truth.assertThat(property.getValue(animView)).isEqualTo(50f + 100f) + } + + @Test + fun testAnimatorReuse() = runUiTest { + val anim1 = animView.spring().translationX(-100f) + val listener = mockk>(relaxed = true) + anim1.setListener(listener) + runOnMainSync { anim1.start() } + + val anim2 = animView.spring().translationX(100f) + runOnMainSync { anim2.start() } + Truth.assertThat(anim1).isSameAs(anim2) + + runOnMainSync { anim1.skipToEnd() } + verify(exactly = 1, timeout = 1000) { listener.onAnimationEnd(anim1) } + Truth.assertThat(animView.translationX).isEqualTo(100f) + } + + @Test + @Ignore("Fix the implementation of [ViewPropertySpringAnimator] to let this test pass") + fun testDampingRatio() = runUiTest { + val onEnd = mockk<(Int) -> Unit>(relaxed = true) + val anim = animView.spring() + .defaultDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) + .rotation(100f) { + dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY + onEnd { _, _, _, _ -> onEnd.invoke(2) } + } + .translationY(100f) { + dampingRatio = SpringForce.DAMPING_RATIO_LOW_BOUNCY + onEnd { _, _, _, _ -> onEnd.invoke(1) } + } + .translationX(100f) { + onEnd { _, _, _, _ -> onEnd.invoke(0) } + } + .setListener(onEnd = { onEnd.invoke(3) }) + runOnMainSync { anim.start() } + verify(exactly = 4, timeout = 1000L) { onEnd.invoke(any()) } + verifySequence { + onEnd.invoke(0) + onEnd.invoke(1) + onEnd.invoke(2) + onEnd.invoke(3) + } + } + + @Test + @Ignore("Fix the implementation of [ViewPropertySpringAnimator] to let this test pass") + fun testStiffness() = runUiTest { + val onEnd = mockk<(Int) -> Unit>(relaxed = true) + val anim = animView.spring() + .defaultStiffness(SpringForce.STIFFNESS_HIGH) + .rotation(100f) { + stiffness = SpringForce.STIFFNESS_MEDIUM + onEnd { _, _, _, _ -> onEnd.invoke(2) } + } + .translationY(100f) { + stiffness = SpringForce.STIFFNESS_LOW + onEnd { _, _, _, _ -> onEnd.invoke(1) } + } + .translationX(100f) { + onEnd { _, _, _, _ -> onEnd.invoke(0) } + } + .setListener(onEnd = { onEnd.invoke(3) }) + runOnMainSync { anim.start() } + verify(exactly = 4, timeout = 1000L) { onEnd.invoke(any()) } + verifySequence { + onEnd.invoke(0) + onEnd.invoke(1) + onEnd.invoke(2) + onEnd.invoke(3) + } + } + + private fun ViewPropertySpringAnimator.animate( + property: DynamicAnimation.ViewProperty, + value: Float, + relative: Boolean + ) = apply { + when (property) { + DynamicAnimation.X -> if (relative) xBy(value) else x(value) + DynamicAnimation.Y -> if (relative) yBy(value) else y(value) + DynamicAnimation.Z -> if (relative) zBy(value) else z(value) + DynamicAnimation.TRANSLATION_X -> if (relative) translationXBy(value) else translationX(value) + DynamicAnimation.TRANSLATION_Y -> if (relative) translationYBy(value) else translationY(value) + DynamicAnimation.TRANSLATION_Z -> if (relative) translationZBy(value) else translationZ(value) + DynamicAnimation.ROTATION -> if (relative) rotationBy(value) else rotation(value) + DynamicAnimation.ROTATION_X -> if (relative) rotationXBy(value) else rotationX(value) + DynamicAnimation.ROTATION_Y -> if (relative) rotationYBy(value) else rotationY(value) + DynamicAnimation.SCALE_X -> if (relative) scaleXBy(value) else scaleX(value) + DynamicAnimation.SCALE_Y -> if (relative) scaleYBy(value) else scaleY(value) + DynamicAnimation.ALPHA -> if (relative) alphaBy(value) else alpha(value) + } + } +} diff --git a/sample/src/androidTest/java/com/github/lcdsmao/springsample/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/com/github/lcdsmao/springsample/ExampleInstrumentedTest.kt deleted file mode 100644 index 2998ac0..0000000 --- a/sample/src/androidTest/java/com/github/lcdsmao/springsample/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.lcdsmao.springsample - -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getTargetContext() - assertEquals("com.github.lcdsmao.springsample", appContext.packageName) - } -} diff --git a/sample/src/test/java/com/github/lcdsmao/springsample/ExampleUnitTest.kt b/sample/src/test/java/com/github/lcdsmao/springsample/ExampleUnitTest.kt deleted file mode 100644 index cf796de..0000000 --- a/sample/src/test/java/com/github/lcdsmao/springsample/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.lcdsmao.springsample - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -}