From fdff61892d6be29d909d5dea65105c318cffc6e2 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 22:44:21 -0700 Subject: [PATCH] Fix part of #973: Stabilize state fragment tests [Blocked: #1764] (#1630) * Introduce test coroutine dispatchers support in Espresso. This piggybacks off of the solution introduced in #1276 for Robolectric. That PR allows Robolectric tests in app module to share dependencies with production components by creating a test application & telling Robolectric to use it instead of OppiaApplication via a @Config annotation. This PR achieves the same thing by using a custom test runner that reads the same annotation and forces Espresso & instrumentation to use the test application instead of the default OppiaApplication. This setup will be easier once #59 is finished since we can specify the application in per-test manifests that both Robolectric & Espresso will respect. Note that while this lets the same test coroutine dispatchers work in both production & test code for Espresso, some additional work was needed to ensure the coroutines behave correctly. In particular, the coroutines use a fake system clock in Robolectric that requires explicit synchronization points in the tests to allow the clock to move forward & properly coordinate execution between background & main thread operations. However, in Espresso, since everything is using a real clock an idling resource is the preferred way to synchronize execution: it allows the background coroutines to operate in real-time much like they would in production, and then notify Espresso when completed. The test dispatchers API is now setup to support both synchronization mechanisms for both Robolectric & Espresso (the idling resource does nothing on Robolectric and the force synchronization effectively does nothing on Espresso). The first test being demonstrated as now stable is SplashActivityTest (as part of downstream work in #1397. * Revert "Fixes #941: Add radar effect in Hints and solution (#1475)" This reverts commit 41eb10bd0c04596cb18c354da7db7268121a09be. * Stabilize StateFragmentTest such that it passes on both Robolectric and Espresso. Note that some issues were found during this: #1612 (#1611 was found a few weeks ago, but it also affects these tests). To ensure the tests can still be run, a @RunOn annotation was added to allow tests to target specific test platforms. The tests that currently fail on Robolectric due to #1611 and #1612 are disabled for that platform. The test suite as a whole has been verified to pass in its current state on both Robolectric and Espresso (on a Pixel XL). The aim of this PR is to actually enable critical state fragment tests in CI, so both StateFragmentTest and StateFragmentLocalTest are being enabled in GitHub actions. * Enable StateFragmentTest (Robolectric) & StateFragmentLocalTest for CI. * Add thorough documentation for new dispatchers. * Clean up comments & add additional documentation. * Fix lint errors. * Fix broken test after changes to FakeSystemClock. * Fix linter errors. * Use a custom executor service for Glide requests that coordinates with Oppia's test dispatchers. Note that this does not actually introduce the service--that will happen in a new branch. * Introduce new executor service which allows interop with Kotlin coroutines, plus a test to verify that it fundamentally follows one interpretation of ExecutorService's API. * Fix flaky timeout tests by improving cancellation cooperation for invokeAny() and provide longer timeouts for tests that are CPU-sensitive. * Add documentation & clean up unused code. * Lint fixes. * Significantly reorganize invokeAll() to try and make it more cooperative for cancellation, and increase timeout times in tests to reduce flakiness for time-sensitive tests. Some tests are remaining flaky, so ignoring those. Re-add maybeWithTimeoutOrNull since it actually was needed. * Lint fixes. * Post-merge module fixes. * Post-merge fixes with ratio input & add a TODO to improve speed of the new coroutine executor service. * Revert "Fixes part of #40 & #42: Generalisation Highfi Mobile Portrait + Landscape - Buttons (#1653)" This reverts commit 1bb1ffa97b2c14e002fa0371991f4f25351a79cd. * Ensure terminated tasks do not interfere with one another (timeouts should happen individually for each task during termination). This fixes a failure observed in StateFragmentLocalTest in #1630. * Ignore failing tests until #1769 is resolved. * Fix awaitTermination & improve test. Improve stack trace for test dispatcher timeouts. * Fix slow & broken tests in Robolectric for StateFragmentLocalTest. * Add missing deps for StateFragmentLocalTest. * Address reviewer comments. --- .github/workflows/main.yml | 4 +- WORKSPACE | 1 + app/BUILD.bazel | 1 + app/build.gradle | 3 +- .../app/player/state/StateFragmentTest.kt | 1045 +++++++++-------- .../player/state/StateFragmentLocalTest.kt | 47 +- .../org/oppia/testing/OppiaTestAnnotations.kt | 28 + .../java/org/oppia/testing/OppiaTestRule.kt | 56 + utility/build.gradle | 4 +- 9 files changed, 704 insertions(+), 485 deletions(-) create mode 100644 testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt create mode 100644 testing/src/main/java/org/oppia/testing/OppiaTestRule.kt 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(