diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b418a3c1970..dec106c9345 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -120,10 +120,10 @@ jobs: with: java-version: 1.9 - - name: Robolectric tests - FAQ, Help, Mydownloads, Parser, ProfileProgress, RecyclerView, Story, Utility tests + - name: Robolectric tests - FAQ, Help, Mydownloads, Parser, ProfileProgress, RecyclerView, State, Story, Utility tests # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: | - sudo ./gradlew :app:testDebugUnitTest --tests org.oppia.app.faq* --tests org.oppia.app.help* --tests org.oppia.app.mydownloads* --tests org.oppia.app.parser* --tests org.oppia.app.profileprogress* --tests org.oppia.app.recyclerview* --tests org.oppia.app.splash* --tests org.oppia.app.story* --tests org.oppia.app.utility* --tests org.oppia.app.topic.questionplayer* + sudo ./gradlew :app:testDebugUnitTest --tests org.oppia.app.faq* --tests org.oppia.app.help* --tests org.oppia.app.mydownloads* --tests org.oppia.app.parser* --tests org.oppia.app.player.state* --tests org.oppia.app.profileprogress* --tests org.oppia.app.recyclerview* --tests org.oppia.app.splash* --tests org.oppia.app.story* --tests org.oppia.app.utility* --tests org.oppia.app.topic.questionplayer* - name: Upload App Test Reports uses: actions/upload-artifact@v2 if: ${{ always() }} # IMPORTANT: Upload reports regardless of status diff --git a/WORKSPACE b/WORKSPACE index 402d5152e45..7951285d72d 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -142,6 +142,7 @@ maven_install( "com.chaos.view:pinview:1.4.3", "com.crashlytics.sdk.android:crashlytics:2.9.8", "com.github.bumptech.glide:glide:4.11.0", + "com.github.bumptech.glide:mocks:4.11.0", "com.google.android.material:material:1.2.0-alpha02", "com.google.firebase:firebase-analytics:17.4.4", "com.google.firebase:firebase-crashlytics:17.1.1", diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 0cad45121c9..567bdf9f3ab 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -637,6 +637,7 @@ TEST_DEPS = [ artifact("androidx.test.espresso:espresso-intents:3.1.0"), artifact("androidx.test.ext:junit"), artifact("androidx.test:runner:1.2.0"), + artifact("com.github.bumptech.glide:mocks:4.11.0"), artifact("com.google.truth:truth"), artifact("org.jetbrains.kotlin:kotlin-test-junit"), artifact("org.jetbrains.kotlin:kotlin-reflect"), diff --git a/app/build.gradle b/app/build.gradle index 12afe69f1ec..01e18f2512b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ dependencies { 'androidx.multidex:multidex:2.0.1', 'androidx.recyclerview:recyclerview:1.0.0', 'com.chaos.view:pinview:1.4.3', - 'com.github.bumptech.glide:glide:4.9.0', + 'com.github.bumptech.glide:glide:4.11.0', 'com.google.android.material:material:1.2.0-alpha02', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.4.2', @@ -106,6 +106,7 @@ dependencies { 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', 'com.google.truth:truth:0.43', + 'com.github.bumptech.glide:mocks:4.11.0', 'org.robolectric:annotations:4.3', 'org.robolectric:robolectric:4.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index 7df4ac47f6f..7e0c6b5ad06 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -2,7 +2,10 @@ package org.oppia.app.player.state import android.app.Application import android.content.Context +import android.os.Build import android.view.View +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario.launch @@ -19,8 +22,7 @@ import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.intent.Intents import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasChildCount @@ -35,8 +37,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.firebase.FirebaseApp import dagger.BindsInstance import dagger.Component -import dagger.Module -import dagger.Provides import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString @@ -46,23 +46,56 @@ import org.hamcrest.Matcher import org.hamcrest.TypeSafeMatcher import org.junit.After import org.junit.Before +import org.junit.Ignore +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.oppia.app.R +import org.oppia.app.activity.ActivityComponent +import org.oppia.app.application.ActivityComponentFactory +import org.oppia.app.application.ApplicationComponent +import org.oppia.app.application.ApplicationModule +import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTENT +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.DRAG_DROP_SORT_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FEEDBACK +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NEXT_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NUMERIC_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.RATIO_EXPRESSION_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.RETURN_TO_TOPIC_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMITTED_ANSWER import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMIT_ANSWER_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.TEXT_INPUT_INTERACTION import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.ChildViewCoordinatesProvider import org.oppia.app.utility.CustomGeneralLocation import org.oppia.app.utility.DragViewAction import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.app.utility.RecyclerViewCoordinatesProvider import org.oppia.app.utility.clickPoint +import org.oppia.data.backends.gae.NetworkModule +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.domain.onboarding.ExpirationMetaDataRetrieverModule import org.oppia.domain.oppialogger.LogStorageModule +import org.oppia.domain.question.QuestionModule +import org.oppia.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.domain.topic.TEST_EXPLORATION_ID_0 import org.oppia.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.domain.topic.TEST_EXPLORATION_ID_4 @@ -70,14 +103,21 @@ import org.oppia.domain.topic.TEST_EXPLORATION_ID_5 import org.oppia.domain.topic.TEST_EXPLORATION_ID_6 import org.oppia.domain.topic.TEST_STORY_ID_0 import org.oppia.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.testing.OppiaTestRule +import org.oppia.testing.RunOn +import org.oppia.testing.TestAccessibilityModule +import org.oppia.testing.TestCoroutineDispatchers import org.oppia.testing.TestDispatcherModule import org.oppia.testing.TestLogReportingModule +import org.oppia.testing.TestPlatform import org.oppia.testing.profile.ProfileTestHelper -import org.oppia.util.caching.CacheAssetsLocally -import org.oppia.util.logging.EnableConsoleLog -import org.oppia.util.logging.EnableFileLog -import org.oppia.util.logging.GlobalLogLevel -import org.oppia.util.logging.LogLevel +import org.oppia.util.caching.testing.CachingTestModule +import org.oppia.util.gcsresource.GcsResourceModule +import org.oppia.util.logging.LoggerModule +import org.oppia.util.parser.GlideImageLoaderModule +import org.oppia.util.parser.HtmlParserEntityTypeModule +import org.oppia.util.parser.ImageParsingModule +import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.util.concurrent.TimeoutException import javax.inject.Inject @@ -85,49 +125,67 @@ import javax.inject.Singleton /** Tests for [StateFragment]. */ @RunWith(AndroidJUnit4::class) +@Config(application = StateFragmentTest.TestApplication::class, qualifiers = "port-xxhdpi") @LooperMode(LooperMode.Mode.PAUSED) class StateFragmentTest { + @get:Rule + val oppiaTestRule = OppiaTestRule() + @Inject lateinit var profileTestHelper: ProfileTestHelper @Inject lateinit var context: Context + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + private val internalProfileId: Int = 1 @Before fun setUp() { Intents.init() setUpTestApplicationComponent() + testCoroutineDispatchers.registerIdlingResource() profileTestHelper.initializeProfiles() FirebaseApp.initializeApp(context) } @After fun tearDown() { + testCoroutineDispatchers.unregisterIdlingResource() Intents.release() } // TODO(#388): Add more test-cases - // 1. Actually going through each of the exploration states with typing text/clicking the correct answers for each of the interactions. - // 2. Verifying the button visibility state based on whether text is missing, then present/missing for text input or numeric input. + // 1. Actually going through each of the exploration states with typing text/clicking the correct + // answers for each of the interactions. + // 2. Verifying the button visibility state based on whether text is missing, then + // present/missing for text input or numeric input. // 3. Testing providing the wrong answer and showing feedback and the same question again. - // 4. Configuration change with typed text (e.g. for numeric or text input) retains that temporary text and you can continue with the exploration after rotating. - // 5. Configuration change after submitting the wrong answer to show that the old answer & re-ask of the question stay the same. - // 6. Backward/forward navigation along with configuration changes to verify that you stay on the navigated state. + // 4. Configuration change with typed text (e.g. for numeric or text input) retains that + // temporary + // text and you can continue with the exploration after rotating. + // 5. Configuration change after submitting the wrong answer to show that the old answer & re-ask + // of the question stay the same. + // 6. Backward/forward navigation along with configuration changes to verify that you stay on the + // navigated state. // 7. Verifying that old answers were present when navigation backward/forward. // 8. Testing providing the wrong answer and showing hints. // 9. Testing all possible invalid/error input cases for each interaction. - // 10. Testing interactions with custom Oppia tags (including images) render correctly (when manually inspected) and are correctly functional. - // 11. Update the tests to work properly on Robolectric (requires idling resource + replacing the dispatchers to leverage a coordinated test dispatcher library). - // 12. Add tests for hints & solutions. - // 13. Add tests for audio states, including: audio playing & having an error, or no-network connectivity scenarios. See the PR introducing this comment & #1340 / #1341 for context. - // TODO(#56): Add support for testing that previous/next button states are properly retained on config changes. + // 10. Testing interactions with custom Oppia tags (including images) render correctly (when + // manually inspected) and are correctly functional. + // 11. Add tests for hints & solutions. + // 13. Add tests for audio states, including: audio playing & having an error, or no-network + // connectivity scenarios. See the PR introducing this comment & #1340 / #1341 for context. + // TODO(#56): Add support for testing that previous/next button states are properly retained on + // config changes. @Test fun testStateFragment_loadExp_explorationLoads() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + // Due to the exploration activity loading, the play button should no longer be visible. onView(withId(R.id.play_test_exploration_button)).check(matches(not(isDisplayed()))) } @@ -137,7 +195,9 @@ class StateFragmentTest { fun testStateFragment_loadExp_explorationLoads_changeConfiguration_buttonIsNotVisible() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + // Due to the exploration activity loading, the play button should no longer be visible. onView(withId(R.id.play_test_exploration_button)).check(matches(not(isDisplayed()))) } @@ -147,6 +207,9 @@ class StateFragmentTest { fun testStateFragment_loadExp_explorationHasContinueButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + + scrollToViewType(CONTINUE_INTERACTION) + onView(withId(R.id.continue_button)).check(matches(isDisplayed())) } } @@ -155,7 +218,10 @@ class StateFragmentTest { fun testStateFragment_loadExp_changeConfiguration_explorationHasContinueButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + + scrollToViewType(CONTINUE_INTERACTION) onView(withId(R.id.continue_button)).check(matches(isDisplayed())) } } @@ -164,7 +230,10 @@ class StateFragmentTest { fun testStateFragment_loadExp_secondState_hasSubmitButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + + clickContinueInteractionButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check( matches(withText(R.string.state_submit_button)) ) @@ -176,9 +245,11 @@ class StateFragmentTest { fun testStateFragment_loadExp_changeConfiguration_secondState_hasSubmitButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) + rotateToLandscape() + + clickContinueInteractionButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check( matches(withText(R.string.state_submit_button)) ) @@ -186,17 +257,28 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_secondState_submitAnswer_submitChangesToContinueButton() { + fun testStateFragment_loadExp_secondState_submitAnswer_submitButtonIsClickable() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) + clickContinueInteractionButton() + + typeFractionText("1/2") + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) - onView(withId(R.id.submit_answer_button)).perform(click()) + } + } + + @Test + fun testStateFragment_loadExp_secondState_submitAnswer_clickSubmit_continueButtonIsVisible() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + clickContinueInteractionButton() + typeFractionText("1/2") + + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check( matches(withText(R.string.state_continue_button)) ) @@ -204,18 +286,30 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_submitAnswer_submitChangesToContinueButton() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_landscape_secondState_submitAnswer_submitButtonIsClickable() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() + + typeFractionText("1/2") + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) + } + } + + @Test + fun testStateFragment_loadExp_land_secondState_submitAnswer_clickSubmit_continueIsVisible() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + rotateToLandscape() + clickContinueInteractionButton() + typeFractionText("1/2") + + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check( matches(withText(R.string.state_continue_button)) ) @@ -226,87 +320,97 @@ class StateFragmentTest { fun testStateFragment_loadExp_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() // Attempt to submit an invalid answer. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeFractionText("1/") + clickSubmitAnswerButton() // The submission button should now be disabled and there should be an error. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) onView(withId(R.id.fraction_input_error)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() // Attempt to submit an invalid answer. - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeFractionText("1/") + clickSubmitAnswerButton() // The submission button should now be disabled and there should be an error. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) onView(withId(R.id.fraction_input_error)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_secondState_invalidAnswer_updated_reenabledSubmitButton() { + fun testStateFragment_loadExp_secondState_invalidAnswer_submitAnswerIsNotEnabled() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + clickContinueInteractionButton() + + typeFractionText("1/") + clickSubmitAnswerButton() + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) + } + } + + @Test + fun testStateFragment_loadExp_secondState_invalidAnswer_updated_submitAnswerIsEnabled() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + clickContinueInteractionButton() + typeFractionText("1/") + clickSubmitAnswerButton() + // Add another '2' to change the pending input text. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("2"), - closeSoftKeyboard() - ) + typeFractionText("2") // The submit button should be re-enabled since the text view changed. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_invalidAnswer_updated_reenabledSubmitButton() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_secondState_invalidAnswer_submitAnswerIsNotEnabled() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() + + typeFractionText("1/") + clickSubmitAnswerButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) + } + } + + @Test + fun testStateFragment_loadExp_land_secondState_invalidAnswer_updated_submitAnswerIsEnabled() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + rotateToLandscape() + clickContinueInteractionButton() + typeFractionText("1/") + clickSubmitAnswerButton() // Add another '2' to change the pending input text. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("2"), - closeSoftKeyboard() - ) + typeFractionText("2") // The submit button should be re-enabled since the text view changed. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @@ -322,17 +426,14 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_worksCorrectly() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) + mergeDragAndDropItems(position = 0) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -344,18 +445,15 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_invalidAnswer_correctItemCount() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView(withId(R.id.submit_answer_button)).perform(click()) + mergeDragAndDropItems(position = 0) + clickSubmitAnswerButton() + + scrollToViewType(SUBMITTED_ANSWER) onView(withId(R.id.submitted_answer_recycler_view)).check(matches(hasChildCount(3))) onView( atPositionOnView( @@ -368,30 +466,18 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_dragItem_worksCorrectly() { + // Note to self: current setup allows the user to drag the view without issues (now that + // event interception isn't a problem), however the view is going partly offscreen which + // is triggering an infinite animation loop in ItemTouchHelper). launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( - DragViewAction( - RecyclerViewCoordinatesProvider( - 0, - ChildViewCoordinatesProvider( - R.id.drag_drop_item_container, - GeneralLocation.CENTER - ) - ), - RecyclerViewCoordinatesProvider(2, CustomGeneralLocation.UNDER_RIGHT), - Press.FINGER - ) - ) + mergeDragAndDropItems(position = 0) + dragAndDropItem(fromPosition = 0, toPosition = 2) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -403,24 +489,15 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_unlinkFirstItem_worksCorrectly() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_unlink_items - ) - ).perform(click()) + mergeDragAndDropItems(position = 0) + unlinkDragAndDropItems(position = 0) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -432,23 +509,30 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadImageRegion_clickRegion6_region6Clicked() { + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. + fun testStateFragment_loadImageRegion_clickRegion6_submitButtonClickable() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) - onView(withId(R.id.submit_answer_button)).perform(click()) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. + fun testStateFragment_loadImageRegion_clickRegion6_clickSubmit_receivesCorrectFeedback() { + launchForExploration(TEST_EXPLORATION_ID_5).use { + startPlayingExploration() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Saturn")) @@ -458,80 +542,57 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_submitButtonDisabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun loadImageRegion_defaultRegionClick_defaultRegionClicked_submitButtonDisabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.1f, 0.5f) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.1f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_submitButtonEnabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_correctFeedback() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToFeedback() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Saturn")) @@ -541,23 +602,16 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_correctAnswer() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToAnswer() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(SUBMITTED_ANSWER) onView(withId(R.id.submitted_answer_text_view)).check( matches( withText("Clicks on Saturn") @@ -567,49 +621,32 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_continueButtonIsDisplayed() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToContinue() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check(matches(isDisplayed())) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun loadImageRegion_clickRegion6_clickedRegion5_region5Clicked_correctFeedback() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.2f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToFeedback() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickImageRegion(pointX = 0.2f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Jupiter")) @@ -619,10 +656,12 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_firstState_previousAndNextButtonIsNotDisplayed() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfiguration_firstState_prevAndNextButtonIsNotDisplayed() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) } @@ -633,21 +672,20 @@ class StateFragmentTest { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueButton_previousButtonIsDisplayed() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfig_submitAnswer_clickContinue_prevButtonIsDisplayed() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) } } @@ -656,44 +694,45 @@ class StateFragmentTest { fun testStateFragment_loadExp_submitAnswer_clickContinueThenPrevious_onlyNextButtonIsShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() // Since we navigated back to the first state, only the next navigation button is visible. + scrollToViewType(NEXT_NAVIGATION_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueThenPrevious_onlyNextButtonIsShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfig_submit_clickContinueThenPrev_onlyNextButtonShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() // Since we navigated back to the first state, only the next navigation button is visible. + scrollToViewType(NEXT_NAVIGATION_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_submitAnswer_clickContinueThenPreviousThenNext_prevAndSubmitShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_submitAnswer_clickContinueThenPrevThenNext_prevAndSubmitShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) - onView(withId(R.id.next_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() + clickNextNavigationButton() - // Navigating back to the second state should show the previous & submit buttons, but not the next button. + // Navigating back to the second state should show the previous & submit buttons, but not the + // next button. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) onView(withId(R.id.submit_answer_button)).check(matches(isDisplayed())) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) @@ -701,16 +740,18 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueThenPreviousThenNext_prevAndSubmitShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_submit_clickContinueThenPrevThenNext_prevAndSubmitShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) - onView(withId(R.id.next_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() + clickNextNavigationButton() - // Navigating back to the second state should show the previous & submit buttons, but not the next button. + // Navigating back to the second state should show the previous & submit buttons, but not the + // next button. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) onView(withId(R.id.submit_answer_button)).check(matches(isDisplayed())) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) @@ -718,6 +759,7 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadExp_continueToEndExploration_hasReturnToTopicButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() @@ -725,6 +767,7 @@ class StateFragmentTest { playThroughPrototypeExploration() // Ninth state: end exploration. + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) onView(withId(R.id.return_to_topic_button)).check( matches(withText(R.string.state_end_exploration_button)) ) @@ -732,13 +775,17 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_continueToEndExploration_hasReturnToTopicButton() { // ktlint-disable max-line-length + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + @Ignore("Currently failing due to a regression") // TODO(#1769): Re-enable. + fun testStateFragment_loadExp_changeConfiguration_continueToEnd_hasReturnToTopicButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + rotateToLandscape() playThroughPrototypeExploration() // Ninth state: end exploration. + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) onView(withId(R.id.return_to_topic_button)).check( matches(withText(R.string.state_end_exploration_button)) ) @@ -746,12 +793,13 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadExp_continueToEndExploration_clickReturnToTopic_destroysActivity() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() playThroughPrototypeExploration() - onView(withId(R.id.return_to_topic_button)).perform(click()) + clickReturnToTopicButton() // Due to the exploration activity finishing, the play button should be visible again. onView(withId(R.id.play_test_exploration_button)).check(matches(isDisplayed())) @@ -759,12 +807,15 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_continueToEndExploration_clickReturnToTopic_destroysActivity() { // ktlint-disable max-line-length + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + @Ignore("Currently failing due to a regression") // TODO(#1769): Re-enable. + fun testStateFragment_loadExp_changeConfig_continueToEnd_clickReturnToTopic_destroysActivity() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + rotateToLandscape() playThroughPrototypeExploration() - onView(withId(R.id.return_to_topic_button)).perform(click()) + clickReturnToTopicButton() // Due to the exploration activity finishing, the play button should be visible again. onView(withId(R.id.play_test_exploration_button)).check(matches(isDisplayed())) @@ -776,6 +827,8 @@ class StateFragmentTest { launchForExploration(TEST_EXPLORATION_ID_0).use { startPlayingExploration() + scrollToViewType(CONTENT) + val htmlResult = "Hi, welcome to Oppia! is a tool that helps you create interactive learning " + "activities that can be continually improved over time.\n\nIncidentally, do you " + @@ -789,10 +842,12 @@ class StateFragmentTest { } @Test - fun testContentCard_forDemoExploration_changeConfiguration_withCustomOppiaTags_displaysParsedHtml() { // ktlint-disable max-line-length + fun testContentCard_forDemoExploration_changeConfig_withCustomOppiaTags_displaysParsedHtml() { launchForExploration(TEST_EXPLORATION_ID_0).use { startPlayingExploration() + scrollToViewType(CONTENT) + val htmlResult = "Hi, welcome to Oppia! is a tool that helps you create interactive learning activities " + "that can be continually improved over time.\n\nIncidentally, do you know where " + @@ -809,11 +864,10 @@ class StateFragmentTest { fun testStateFragment_inputRatio_submit_correctAnswerDisplayed() { launchForExploration(TEST_EXPLORATION_ID_6).use { startPlayingExploration() - onView(withId(R.id.ratio_input_interaction_view)).perform( - typeText("4:5"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeRatioExpression("4:5") + + clickSubmitAnswerButton() + onView(withId(R.id.submitted_answer_text_view)) .check(matches(ViewMatchers.withContentDescription("4 to 5"))) } @@ -831,167 +885,229 @@ class StateFragmentTest { private fun startPlayingExploration() { onView(withId(R.id.play_test_exploration_button)).perform(click()) - waitForExplorationToBeLoaded() - } - - private fun waitForExplorationToBeLoaded() { - // TODO(#89): We should instead rely on IdlingResource to wait for the exploration to be fully loaded. Using - // standard activity transitions seems to work better than a fragment transaction for Espresso, but this isn't - // compatible with Robolectric since only one activity can be loaded at a time in Robolectric. - waitForTheView(withId(R.id.content_text_view)) + testCoroutineDispatchers.runCurrent() } private fun playThroughPrototypeExploration() { // First state: Continue interaction. - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() // Second state: Fraction input. Correct answer: 1/2. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + typeFractionText("1/2") + clickSubmitAnswerButton() + clickContinueNavigationButton() // Third state: Multiple choice. Correct answer: Eagle. - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 2, - targetViewId = R.id.multiple_choice_radio_button - ) - ).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + selectMultipleChoiceOption(optionPosition = 2) + clickContinueNavigationButton() // Fourth state: Item selection (radio buttons). Correct answer: Green. - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 0, - targetViewId = R.id.multiple_choice_radio_button - ) - ).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + selectMultipleChoiceOption(optionPosition = 0) + clickContinueNavigationButton() // Fourth state: Item selection (checkboxes). Correct answer: {Red, Green, Blue}. + selectItemSelectionCheckbox(optionPosition = 0) + selectItemSelectionCheckbox(optionPosition = 2) + selectItemSelectionCheckbox(optionPosition = 3) + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Fifth state: Numeric input. Correct answer: 121. + typeNumericInput("121") + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Sixth state: Ratio input. Correct answer: 4:5. + typeRatioExpression("4:5") + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Seventh state: Text input. Correct answer: finnish. + typeTextInput("finnish") + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Eighth state: Drag Drop Sort. Correct answer: Move 1st item to 4th position. + dragAndDropItem(fromPosition = 0, toPosition = 3) + clickSubmitAnswerButton() onView( atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, + recyclerViewId = R.id.submitted_answer_recycler_view, position = 0, - targetViewId = R.id.item_selection_checkbox - ) - ).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 2, - targetViewId = R.id.item_selection_checkbox + targetViewId = R.id.submitted_answer_content_text_view ) - ).perform(click()) + ).check(matches(withText("3/5"))) + clickContinueNavigationButton() + + // Ninth state: Drag Drop Sort with grouping. Correct answer: Merge First Two and after merging + // move 2nd item to 3rd position. + mergeDragAndDropItems(position = 1) + unlinkDragAndDropItems(position = 1) + mergeDragAndDropItems(position = 0) + dragAndDropItem(fromPosition = 1, toPosition = 2) + clickSubmitAnswerButton() onView( atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 3, - targetViewId = R.id.item_selection_checkbox + recyclerViewId = R.id.submitted_answer_recycler_view, + position = 0, + targetViewId = R.id.submitted_answer_content_text_view ) - ).perform(click()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + ).check(matches(withText("0.6"))) + clickContinueNavigationButton() + } - // Fifth state: Numeric input. Correct answer: 121. - onView(withId(R.id.numeric_input_interaction_view)).perform( - typeText("121"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + private fun rotateToLandscape() { + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + } - // Sixth state: Ratio input. Correct answer: 4:5. - onView(withId(R.id.ratio_input_interaction_view)).perform( - typeText("4:5"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + private fun clickContinueInteractionButton() { + scrollToViewType(CONTINUE_INTERACTION) + onView(withId(R.id.continue_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } - // Seventh state: Text input. Correct answer: finnish. - onView(withId(R.id.text_input_interaction_view)).perform( - typeText("finnish"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + private fun typeFractionText(text: String) { + scrollToViewType(FRACTION_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.fraction_input_interaction_view) + } - // Eighth state: Drag Drop Sort. Correct answer: Move 1st item to 4th position. + @Suppress("SameParameterValue") + private fun typeNumericInput(text: String) { + scrollToViewType(NUMERIC_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.numeric_input_interaction_view) + } + + @Suppress("SameParameterValue") + private fun typeTextInput(text: String) { + scrollToViewType(TEXT_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.text_input_interaction_view) + } + + @Suppress("SameParameterValue") + private fun typeRatioExpression(text: String) { + scrollToViewType(RATIO_EXPRESSION_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.ratio_input_interaction_view) + } + + private fun selectMultipleChoiceOption(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.multiple_choice_radio_button) + } + + private fun selectItemSelectionCheckbox(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.item_selection_checkbox) + } + + private fun dragAndDropItem(fromPosition: Int, toPosition: Int) { + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( DragViewAction( RecyclerViewCoordinatesProvider( - 0, + fromPosition, ChildViewCoordinatesProvider( R.id.drag_drop_item_container, GeneralLocation.CENTER ) ), - RecyclerViewCoordinatesProvider(3, CustomGeneralLocation.UNDER_RIGHT), + RecyclerViewCoordinatesProvider(toPosition, CustomGeneralLocation.UNDER_RIGHT), Press.FINGER ) ) + testCoroutineDispatchers.runCurrent() + } + + private fun mergeDragAndDropItems(position: Int) { + clickDragAndDropOption(position, targetViewId = R.id.drag_drop_content_group_item) + } + + private fun unlinkDragAndDropItems(position: Int) { + clickDragAndDropOption(position, targetViewId = R.id.drag_drop_content_unlink_items) + } + + @Suppress("SameParameterValue") + private fun clickImageRegion(pointX: Float, pointY: Float) { + onView(withId(R.id.image_click_interaction_image_view)).perform( + clickPoint(pointX, pointY) + ) + testCoroutineDispatchers.runCurrent() + } + + private fun clickSubmitAnswerButton() { + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.submitted_answer_recycler_view, - position = 0, - targetViewId = R.id.submitted_answer_content_text_view - ) - ).check(matches(withText("3/5"))) + testCoroutineDispatchers.runCurrent() + } + + private fun clickContinueNavigationButton() { + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } - // Ninth state: Drag Drop Sort with grouping. Correct answer: Merge First Two and after merging move 2nd item to 3rd position . - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 1, - targetViewId = R.id.drag_drop_content_group_item + private fun clickReturnToTopicButton() { + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) + onView(withId(R.id.return_to_topic_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickPreviousNavigationButton() { + onView(withId(R.id.previous_state_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickNextNavigationButton() { + scrollToViewType(NEXT_NAVIGATION_BUTTON) + onView(withId(R.id.next_state_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun waitForImageViewInteractionToFullyLoad() { + // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 + waitForTheView( + allOf( + withId(R.id.image_click_interaction_image_view), + WithNonZeroDimensionsMatcher() ) - ).perform(click()) + ) + } + + private fun typeTextIntoInteraction(text: String, interactionViewId: Int) { + onView(withId(interactionViewId)).perform( + appendText(text), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + } + + private fun clickSelection(optionPosition: Int, targetViewId: Int) { + scrollToViewType(SELECTION_INTERACTION) onView( atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 1, - targetViewId = R.id.drag_drop_content_unlink_items + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetViewId ) ).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickDragAndDropOption(position: Int, targetViewId: Int) { + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item + position = position, + targetViewId = targetViewId ) ).perform(click()) - onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( - DragViewAction( - RecyclerViewCoordinatesProvider( - 1, - ChildViewCoordinatesProvider( - R.id.drag_drop_item_container, - GeneralLocation.CENTER - ) - ), - RecyclerViewCoordinatesProvider(2, CustomGeneralLocation.UNDER_RIGHT), - Press.FINGER - ) + testCoroutineDispatchers.runCurrent() + } + + private fun scrollToViewType(viewType: StateItemViewModel.ViewType) { + onView(withId(R.id.state_recycler_view)).perform( + scrollToHolder(StateViewHolderTypeMatcher(viewType)) ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.submitted_answer_recycler_view, - position = 0, - targetViewId = R.id.submitted_answer_content_text_view - ) - ).check(matches(withText("0.6"))) - onView(withId(R.id.continue_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() } private fun waitForTheView(viewMatcher: Matcher): ViewInteraction { @@ -999,10 +1115,7 @@ class StateFragmentTest { } private fun setUpTestApplicationComponent() { - DaggerStateFragmentTest_TestApplicationComponent.builder() - .setApplication(ApplicationProvider.getApplicationContext()) - .build() - .inject(this) + ApplicationProvider.getApplicationContext().inject(this) } // TODO(#59): Remove these waits once we can ensure that the production executors are not depended on in tests. @@ -1015,6 +1128,7 @@ class StateFragmentTest { * Perform action of waiting for a specific matcher to finish. Adapted from: * https://stackoverflow.com/a/22563297/3689782. */ + @Suppress("SameParameterValue") private fun waitForMatch(viewMatcher: Matcher, millis: Long): ViewAction { return object : ViewAction { override fun getDescription(): String { @@ -1048,80 +1162,6 @@ class StateFragmentTest { } } - @Module - class TestModule { - @Provides - @Singleton - fun provideContext(application: Application): Context { - return application - } - - // TODO(#59): Either isolate these to their own shared test module, or use the real logging - // module in tests to avoid needing to specify these settings for tests. - @EnableConsoleLog - @Provides - fun provideEnableConsoleLog(): Boolean = true - - @EnableFileLog - @Provides - fun provideEnableFileLog(): Boolean = false - - @GlobalLogLevel - @Provides - fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE - - @CacheAssetsLocally - @Provides - fun provideCacheAssetsLocally(): Boolean = true - } - - @Singleton - @Component( - modules = [ - TestModule::class, TestLogReportingModule::class, LogStorageModule::class, - TestDispatcherModule::class - ] - ) - interface TestApplicationComponent { - @Component.Builder - interface Builder { - @BindsInstance - fun setApplication(application: Application): Builder - - fun build(): TestApplicationComponent - } - - fun inject(stateFragmentTest: StateFragmentTest) - } - - private fun scrollToViewType(viewType: StateItemViewModel.ViewType): ViewAction { - return RecyclerViewActions.scrollToHolder(StateViewHolderTypeMatcher(viewType)) - } - - private fun scrollToSubmit() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(SUBMIT_ANSWER_BUTTON) - ) - } - - private fun scrollToFeedback() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(FEEDBACK) - ) - } - - private fun scrollToAnswer() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(SUBMITTED_ANSWER) - ) - } - - private fun scrollToContinue() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(CONTINUE_NAVIGATION_BUTTON) - ) - } - /** * [BaseMatcher] that matches against the first occurrence of the specified view holder type in * StateFragment's RecyclerView. @@ -1151,4 +1191,71 @@ class StateFragmentTest { description.appendText("with non-zero width and height") } } + + /** + * Appends the specified text to a view. This is needed because Robolectric doesn't seem to + * properly input digits for text views using 'android:digits'. See + * https://github.com/robolectric/robolectric/issues/5110 for specifics. + */ + private fun appendText(text: String): ViewAction { + val typeTextViewAction = typeText(text) + return object : ViewAction { + override fun getDescription(): String = typeTextViewAction.description + + override fun getConstraints(): Matcher = typeTextViewAction.constraints + + override fun perform(uiController: UiController?, view: View?) { + // Appending text only works on Robolectric, whereas Espresso needs to use typeText(). + if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { + (view as? EditText)?.append(text) + testCoroutineDispatchers.runCurrent() + } else { + typeTextViewAction.perform(uiController, view) + } + } + } + } + + @Singleton + @Component( + modules = [ + TestDispatcherModule::class, ApplicationModule::class, NetworkModule::class, + LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, + PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(stateFragmentTest: StateFragmentTest) + } + + class TestApplication : Application(), ActivityComponentFactory { + private val component: TestApplicationComponent by lazy { + DaggerStateFragmentTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(stateFragmentTest: StateFragmentTest) { + component.inject(stateFragmentTest) + } + + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() + } + } } diff --git a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt index 09ecd900428..3118221a916 100644 --- a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt @@ -27,13 +27,18 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.load.engine.executor.MockGlideExecutor import com.google.common.truth.Truth.assertThat import dagger.Component +import kotlinx.coroutines.CoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.hamcrest.Description import org.hamcrest.Matcher +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -76,6 +81,7 @@ import org.oppia.domain.topic.FRACTIONS_EXPLORATION_ID_1 import org.oppia.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.domain.topic.TEST_STORY_ID_0 import org.oppia.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.testing.CoroutineExecutorService import org.oppia.testing.TestAccessibilityModule import org.oppia.testing.TestCoroutineDispatchers import org.oppia.testing.TestDispatcherModule @@ -87,6 +93,7 @@ import org.oppia.util.logging.LoggerModule import org.oppia.util.parser.GlideImageLoaderModule import org.oppia.util.parser.HtmlParserEntityTypeModule import org.oppia.util.parser.ImageParsingModule +import org.oppia.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowMediaPlayer @@ -108,15 +115,12 @@ class StateFragmentLocalTest { createAudioUrl(explorationId = "MjZzEVOG47_1", audioFileName = "content-en-ouqm7j21vt8.mp3") private val audioDataSource1 = DataSource.toDataSource(AUDIO_URL_1, /* headers= */ null) + @Inject lateinit var profileTestHelper: ProfileTestHelper + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject @field:ApplicationContext lateinit var context: Context @Inject - lateinit var profileTestHelper: ProfileTestHelper - - @Inject - lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - - @Inject - @field:ApplicationContext - lateinit var context: Context + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher private val internalProfileId: Int = 1 private val solutionIndex: Int = 4 @@ -124,10 +128,31 @@ class StateFragmentLocalTest { @Before fun setUp() { setUpTestApplicationComponent() + + // Initialize Glide such that all of its executors use the same shared dispatcher pool as the + // rest of Oppia so that thread execution can be synchronized via Oppia's test coroutine + // dispatchers. + val executorService = MockGlideExecutor.newTestExecutor( + CoroutineExecutorService(backgroundCoroutineDispatcher) + ) + Glide.init( + context, + GlideBuilder().setDiskCacheExecutor(executorService) + .setAnimationExecutor(executorService) + .setSourceExecutor(executorService) + ) + profileTestHelper.initializeProfiles() ShadowMediaPlayer.addException(audioDataSource1, IOException("Test does not have networking")) } + @After + fun tearDown() { + // Ensure lingering tasks are completed (otherwise Glide can enter a permanently broken state + // during initialization for the next test). + testCoroutineDispatchers.advanceUntilIdle() + } + @Test fun testStateFragment_loadExploration_explorationLoads() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { @@ -145,11 +170,11 @@ class StateFragmentLocalTest { onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SELECTION_INTERACTION)) onView(withSubstring("the pieces must be the same size.")).perform(click()) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(CONTINUE_NAVIGATION_BUTTON)) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withId(R.id.continue_navigation_button)).perform(click()) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withSubstring("of the above circle is red?")).check(matches(isDisplayed())) } diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt new file mode 100644 index 00000000000..04e02c76fa5 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt @@ -0,0 +1,28 @@ +package org.oppia.testing + +import org.oppia.testing.TestPlatform.ESPRESSO +import org.oppia.testing.TestPlatform.ROBOLECTRIC + +/** Specifies a test platform to target in conjunction with [RunOn]. */ +enum class TestPlatform { + /** Corresponds to local tests run in the Java VM via Robolectric. */ + ROBOLECTRIC, + + /** Corresponds to instrumented tests that can run on a real device or emulator via Espresso. */ + ESPRESSO +} + +/** + * Test class or method annotation for specifying all of platforms which either the tests of the + * class or the specific method may run on. By default, tests are assumed to be able to run on both + * Espresso & Robolectric. + * + * The target platforms are specified as varargs of [TestPlatform]s. + * + * Note that this annotation only works if the test also has an [OppiaTestRule] hooked up. + * + * Note that when defined on both a class and a method, the list of platforms defined on the method + * is used and any defined at the class level are ignored. + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class RunOn(vararg val testPlatforms: TestPlatform = [ROBOLECTRIC, ESPRESSO]) diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt b/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt new file mode 100644 index 00000000000..358ec27bec5 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt @@ -0,0 +1,56 @@ +package org.oppia.testing + +import android.os.Build +import org.junit.AssumptionViolatedException +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** JUnit rule to enable [RunOn] test targeting. */ +class OppiaTestRule : TestRule { + override fun apply(base: Statement?, description: Description?): Statement { + return object : Statement() { + override fun evaluate() { + val targetPlatforms = description.getTargetPlatforms() + val currentPlatform = getCurrentPlatform() + if (currentPlatform in targetPlatforms) { + // Only run this test if it's targeting the current platform. + base?.evaluate() + } else { + // See https://github.com/junit-team/junit4/issues/116 for context. + throw AssumptionViolatedException( + "Test targeting ${targetPlatforms.toPluralDescription()} ignored on $currentPlatform" + ) + } + } + } + } + + private fun getCurrentPlatform(): TestPlatform { + return if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { + TestPlatform.ROBOLECTRIC + } else { + TestPlatform.ESPRESSO + } + } + + private companion object { + private fun Array.toPluralDescription(): String { + return if (size > 1) "platforms ${this.joinToString()}" else "platform ${this.first()}" + } + + private fun Description?.getTargetPlatforms(): Array { + val methodTargetPlatforms = this?.getTargetTestPlatforms() + val classTargetPlatforms = this?.testClass?.getTargetTestPlatforms() + return methodTargetPlatforms ?: classTargetPlatforms ?: TestPlatform.values() + } + + private fun Description.getTargetTestPlatforms(): Array? { + return getAnnotation(RunOn::class.java)?.testPlatforms + } + + private fun Class.getTargetTestPlatforms(): Array? { + return getAnnotation(RunOn::class.java)?.testPlatforms + } + } +} diff --git a/utility/build.gradle b/utility/build.gradle index 90929c30bae..9317b4d87bc 100644 --- a/utility/build.gradle +++ b/utility/build.gradle @@ -48,7 +48,7 @@ dependencies { 'androidx.appcompat:appcompat:1.0.2', 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03', 'com.caverock:androidsvg-aar:1.4', - 'com.github.bumptech.glide:glide:4.9.0', + 'com.github.bumptech.glide:glide:4.11.0', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.4.2', 'com.google.firebase:firebase-core:17.4.2', @@ -68,7 +68,7 @@ dependencies { project(":testing"), ) kapt( - 'com.github.bumptech.glide:compiler:4.9.0', + 'com.github.bumptech.glide:compiler:4.11.0', 'com.google.dagger:dagger-compiler:2.24' ) kaptTest(