diff --git a/app/BUILD.bazel b/app/BUILD.bazel index caf40076824..1d76b8de251 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -648,6 +648,7 @@ kt_android_library( "//third_party:androidx_databinding_databinding-runtime", "//utility", "//utility/src/main/java/org/oppia/android/util/extensions:context_extensions", + "//utility/src/main/java/org/oppia/android/util/logging/firebase:debug_event_logger", "//utility/src/main/java/org/oppia/android/util/logging/firebase:debug_module", # TODO(#59): Remove 'debug_util_module' once we completely migrate to Bazel from Gradle as # we can then directly exclude debug files from the build and thus won't be requiring this module. diff --git a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt index 74933ebcdfc..25d53ed8946 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt @@ -14,7 +14,6 @@ import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.util.extensions.getStringFromBundle import javax.inject.Inject -import kotlin.properties.Delegates const val HELP_OPTIONS_TITLE_SAVED_KEY = "HelpActivity.help_options_title" const val SELECTED_FRAGMENT_SAVED_KEY = "HelpActivity.selected_fragment" @@ -45,8 +44,6 @@ class HelpActivity : private lateinit var selectedFragment: String private lateinit var selectedHelpOptionsTitle: String - private var selectedDependencyIndex by Delegates.notNull() - private var selectedLicenseIndex by Delegates.notNull() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,9 +54,9 @@ class HelpActivity : ) selectedFragment = savedInstanceState?.getStringFromBundle(SELECTED_FRAGMENT_SAVED_KEY) ?: FAQ_LIST_FRAGMENT_TAG - selectedDependencyIndex = + val selectedDependencyIndex = savedInstanceState?.getInt(THIRD_PARTY_DEPENDENCY_INDEX_SAVED_KEY) ?: 0 - selectedLicenseIndex = savedInstanceState?.getInt(LICENSE_INDEX_SAVED_KEY) ?: 0 + val selectedLicenseIndex = savedInstanceState?.getInt(LICENSE_INDEX_SAVED_KEY) ?: 0 selectedHelpOptionsTitle = savedInstanceState?.getStringFromBundle(HELP_OPTIONS_TITLE_SAVED_KEY) ?: resourceHandler.getStringInLocale(R.string.faq_activity_title) helpActivityPresenter.handleOnCreate( diff --git a/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt index 185c3913b48..7c2debc1189 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt @@ -18,7 +18,6 @@ import org.oppia.android.app.help.thirdparty.LicenseTextViewerFragment import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListFragment import org.oppia.android.app.translation.AppLanguageResourceHandler import javax.inject.Inject -import kotlin.properties.Delegates /** The presenter for [HelpActivity]. */ @ActivityScope @@ -31,8 +30,8 @@ class HelpActivityPresenter @Inject constructor( private lateinit var selectedFragmentTag: String private lateinit var selectedHelpOptionTitle: String - private var selectedDependencyIndex by Delegates.notNull() - private var selectedLicenseIndex by Delegates.notNull() + private var selectedDependencyIndex: Int? = null + private var selectedLicenseIndex: Int? = null fun handleOnCreate( helpOptionsTitle: String, @@ -60,7 +59,7 @@ class HelpActivityPresenter @Inject constructor( val titleTextView = activity.findViewById(R.id.options_activity_selected_options_title) if (titleTextView != null) { - setMultipaneContainerTitle(helpOptionsTitle!!) + setMultipaneContainerTitle(helpOptionsTitle) } val isMultipane = activity.findViewById(R.id.multipane_options_container) != null if (isMultipane) { @@ -140,8 +139,8 @@ class HelpActivityPresenter @Inject constructor( outState.putString(HELP_OPTIONS_TITLE_SAVED_KEY, titleTextView.text.toString()) } outState.putString(SELECTED_FRAGMENT_SAVED_KEY, selectedFragmentTag) - outState.putInt(THIRD_PARTY_DEPENDENCY_INDEX_SAVED_KEY, selectedDependencyIndex) - outState.putInt(LICENSE_INDEX_SAVED_KEY, selectedLicenseIndex) + selectedDependencyIndex?.let { outState.putInt(THIRD_PARTY_DEPENDENCY_INDEX_SAVED_KEY, it) } + selectedLicenseIndex?.let { outState.putInt(LICENSE_INDEX_SAVED_KEY, it) } } private fun setUpToolbar() { @@ -156,7 +155,13 @@ class HelpActivityPresenter @Inject constructor( val currentFragment = getMultipaneOptionsFragment() if (currentFragment != null) { when (currentFragment) { - is LicenseTextViewerFragment -> handleLoadLicenseListFragment(selectedDependencyIndex) + is LicenseTextViewerFragment -> { + handleLoadLicenseListFragment( + checkNotNull(selectedDependencyIndex) { + "Expected dependency index to be selected & defined" + } + ) + } is LicenseListFragment -> handleLoadThirdPartyDependencyListFragment() } } diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt index 7a6d9942f7e..6c0917f22b4 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt @@ -2603,6 +2603,7 @@ class StateFragmentTest { targetTextViewId: Int ) { scrollToViewType(SELECTION_INTERACTION) + // First, check that the option matches what's expected by the test. onView( atPositionOnView( recyclerViewId = R.id.selection_interaction_recyclerview, diff --git a/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt index f7e45747c30..dc0ff28e020 100644 --- a/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt @@ -17,6 +17,7 @@ import androidx.test.espresso.PerformException import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewAssertion import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition @@ -33,6 +34,8 @@ import com.bumptech.glide.GlideBuilder import com.bumptech.glide.load.engine.executor.MockGlideExecutor import com.google.common.truth.Truth.assertThat import dagger.Component +import dagger.Module +import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf @@ -69,11 +72,13 @@ import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewT import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_NAVIGATION_BUTTON import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NEXT_NAVIGATION_BUTTON +import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NUMERIC_INPUT_INTERACTION import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.PREVIOUS_RESPONSES_HEADER import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMIT_ANSWER_BUTTON import org.oppia.android.app.player.state.testing.StateFragmentTestActivity import org.oppia.android.app.recyclerview.RecyclerViewMatcher +import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.topic.PracticeTabModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule @@ -113,6 +118,7 @@ import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.environment.TestEnvironmentConfig import org.oppia.android.testing.espresso.EditTextInputAction import org.oppia.android.testing.espresso.KonfettiViewMatcher.Companion.hasActiveConfetti import org.oppia.android.testing.espresso.KonfettiViewMatcher.Companion.hasExpectedNumberOfActiveSystems @@ -126,7 +132,10 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule -import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.caching.CacheAssetsLocally +import org.oppia.android.util.caching.LoadImagesFromAssets +import org.oppia.android.util.caching.LoadLessonProtosFromAssets +import org.oppia.android.util.caching.TopicListToCache import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LoggerModule @@ -251,7 +260,7 @@ class StateFragmentLocalTest { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.hint_bulb)).check(matches(not(isDisplayed()))) } @@ -261,7 +270,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait10seconds_noHintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) @@ -273,7 +282,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait30seconds_noHintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) @@ -285,7 +294,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait60seconds_hintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) @@ -297,7 +306,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait60seconds_canViewOneHint() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) openHintsAndSolutionsDialog() @@ -310,7 +319,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait120seconds_canViewOneHint() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(120)) openHintsAndSolutionsDialog() @@ -324,7 +333,7 @@ class StateFragmentLocalTest { fun testStateFragment_portrait_submitCorrectAnswer_correctTextBannerIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.congratulations_text_view)) .check(matches(isCompletelyDisplayed())) @@ -336,7 +345,7 @@ class StateFragmentLocalTest { fun testStateFragment_landscape_submitCorrectAnswer_correctTextBannerIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.congratulations_text_view)) .check(matches(isCompletelyDisplayed())) @@ -348,7 +357,7 @@ class StateFragmentLocalTest { fun testStateFragment_portrait_submitCorrectAnswer_confettiIsActive() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.congratulations_text_confetti_view)).check(matches(hasActiveConfetti())) } @@ -359,7 +368,7 @@ class StateFragmentLocalTest { fun testStateFragment_landscape_submitCorrectAnswer_confettiIsActive() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.congratulations_text_confetti_view)).check(matches(hasActiveConfetti())) } @@ -369,10 +378,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_wait60seconds_submitTwoWrongAnswers_canViewOneHint() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() openHintsAndSolutionsDialog() onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -383,9 +392,9 @@ class StateFragmentLocalTest { fun testStateFragment_submitTwoWrongAnswers_checkPreviousHeaderVisible() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(PREVIOUS_RESPONSES_HEADER)) testCoroutineDispatchers.runCurrent() @@ -397,9 +406,9 @@ class StateFragmentLocalTest { fun testStateFragment_submitTwoWrongAnswers_checkPreviousHeaderCollapsed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(PREVIOUS_RESPONSES_HEADER)) testCoroutineDispatchers.runCurrent() @@ -417,9 +426,9 @@ class StateFragmentLocalTest { fun testStateFragment_submitTwoWrongAnswers_expandResponse_checkPreviousHeaderExpanded() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(PREVIOUS_RESPONSES_HEADER)) testCoroutineDispatchers.runCurrent() @@ -438,9 +447,9 @@ class StateFragmentLocalTest { fun testStateFragment_expandCollapseResponse_checkPreviousHeaderCollapsed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(PREVIOUS_RESPONSES_HEADER)) testCoroutineDispatchers.runCurrent() @@ -475,9 +484,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitInitialWrongAnswer_noHintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() // Submitting one wrong answer isn't sufficient to show a hint. onView(withId(R.id.hint_bulb)).check(matches(not(isDisplayed()))) @@ -488,9 +497,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitInitialWrongAnswer_wait10seconds_noHintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) // Submitting one wrong answer isn't sufficient to show a hint. @@ -502,9 +511,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitInitialWrongAnswer_wait30seconds_noHintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) // Submitting one wrong answer isn't sufficient to show a hint. @@ -516,9 +525,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitTwoWrongAnswers_hintAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() // Submitting two wrong answers should make the hint immediately available. onView(withId(R.id.hint_bulb)).check(matches(isDisplayed())) @@ -529,8 +538,8 @@ class StateFragmentLocalTest { fun testStateFragment_submitTwoWrongAnswers_hintAvailable_prevState_hintNotAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - submitTwoWrongAnswers() + playThroughFractionsState1() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.hint_bulb)).check(matches(isDisplayed())) // The previous navigation button is next to a submit answer button in this state. onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SUBMIT_ANSWER_BUTTON)) @@ -545,8 +554,8 @@ class StateFragmentLocalTest { fun testStateFragment_submitTwoWrongAnswers_prevState_currentState_checkDotIconVisible() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - submitTwoWrongAnswers() + playThroughFractionsState1() + submitTwoWrongAnswersForFractionsState2() onView(withId(R.id.dot_hint)).check(matches(isDisplayed())) moveToPreviousAndBackToCurrentStateWithSubmitButton() onView(withId(R.id.dot_hint)).check(matches(isDisplayed())) @@ -557,8 +566,8 @@ class StateFragmentLocalTest { fun testStateFragment_oneUnrevealedHint_prevState_currentState_checkOneUnrevealedHintVisible() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - submitTwoWrongAnswers() + playThroughFractionsState1() + submitTwoWrongAnswersForFractionsState2() openHintsAndSolutionsDialog() onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -577,9 +586,9 @@ class StateFragmentLocalTest { fun testStateFragment_revealFirstHint_prevState_currentState_checkFirstHintRevealed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - produceAndViewFirstHint() + produceAndViewFirstHintForFractionState2() moveToPreviousAndBackToCurrentStateWithSubmitButton() openHintsAndSolutionsDialog() onView(withId(R.id.hints_and_solution_recycler_view)) @@ -607,9 +616,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitTwoWrongAnswersAndWait_canViewOneHint() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitTwoWrongAnswers() + submitTwoWrongAnswersForFractionsState2() openHintsAndSolutionsDialog() onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -620,9 +629,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_submitThreeWrongAnswers_canViewOneHint() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() - submitThreeWrongAnswersAndWait() + submitThreeWrongAnswersForFractionsState2AndWait() openHintsAndSolutionsDialog() onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -634,8 +643,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_newHintIsNoLongerAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - submitTwoWrongAnswersAndWait() + playThroughFractionsState1() + submitTwoWrongAnswersForFractionsState2AndWait() openHintsAndSolutionsDialog() pressRevealHintButton(hintPosition = 0) @@ -650,8 +659,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_wait10seconds_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) @@ -663,8 +672,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_wait30seconds_newHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) @@ -677,8 +686,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_doNotWait_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() openHintsAndSolutionsDialog() onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -692,8 +701,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_wait30seconds_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) openHintsAndSolutionsDialog() @@ -708,8 +717,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_wait60seconds_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) openHintsAndSolutionsDialog() @@ -724,11 +733,11 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_wait60seconds_submitWrongAnswer_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() openHintsAndSolutionsDialog() // After 60 seconds and one wrong answer submission, only two hints should be available. @@ -741,10 +750,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_submitWrongAnswer_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() // Submitting a single wrong answer after the previous hint won't immediately show another. onView(withId(R.id.dot_hint)).check(matches(not(isDisplayed()))) @@ -755,10 +764,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_submitWrongAnswer_wait10seconds_newHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) // Waiting 10 seconds after submitting a wrong answer should allow another hint to be shown. @@ -770,10 +779,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_submitWrongAnswer_wait10seconds_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -786,10 +795,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewHint_submitWrongAnswer_wait30seconds_canViewTwoHints() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) openHintsAndSolutionsDialog() @@ -803,8 +812,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFirstHint_configChange_secondHintIsNotAvailableImmediately() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() onView(isRoot()).perform(orientationLandscape()) testCoroutineDispatchers.runCurrent() @@ -816,8 +825,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFirstHint_configChange_wait30Seconds_secondHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() onView(isRoot()).perform(orientationLandscape()) testCoroutineDispatchers.runCurrent() @@ -832,7 +841,7 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_newHintAvailable_configChange_newHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60)) onView(withId(R.id.dot_hint)).check(matches(isDisplayed())) onView(isRoot()).perform(orientationLandscape()) @@ -845,8 +854,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFirstHint_prevState_wait30seconds_newHintIsNotAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFirstHint() + playThroughFractionsState1() + produceAndViewFirstHintForFractionState2() clickPreviousStateNavigationButton() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) onView(withId(R.id.dot_hint)).check(matches(not(isDisplayed()))) @@ -857,8 +866,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_wait10seconds_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) @@ -870,8 +879,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_wait30seconds_newHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) @@ -884,8 +893,8 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_wait30seconds_canViewSolution() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) openHintsAndSolutionsDialog() @@ -907,10 +916,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_submitWrongAnswer_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() // Submitting a wrong answer will not immediately reveal the solution. onView(withId(R.id.dot_hint)).check(matches(not(isDisplayed()))) @@ -921,10 +930,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_submitWrongAnswer_wait10s_newHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) // Submitting a wrong answer and waiting will reveal the solution. @@ -936,10 +945,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewFourHints_submitWrongAnswer_wait10s_canViewSolution() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -960,10 +969,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewSolution_clickRevealSolutionButton_showsDialog() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -989,10 +998,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewRevealSolutionDialog_clickReveal_solutionIsRevealed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -1009,10 +1018,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewRevealSolutionDialog_clickReveal_cannotViewRevealSolution() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -1029,10 +1038,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewRevealSolutionDialog_clickCancel_solutionIsNotRevealed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -1049,10 +1058,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewRevealSolutionDialog_clickCancel_canViewRevealSolution() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) openHintsAndSolutionsDialog() @@ -1069,10 +1078,10 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewSolution_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() - produceAndViewSolution(scenario, revealedHintCount = 4) + produceAndViewSolutionInFractionsState2(scenario, revealedHintCount = 4) // No hint should be indicated as available after revealing the solution. onView(withId(R.id.dot_hint)).check(matches(not(isDisplayed()))) @@ -1083,9 +1092,9 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewSolution_wait30seconds_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() - produceAndViewSolution(scenario, revealedHintCount = 4) + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() + produceAndViewSolutionInFractionsState2(scenario, revealedHintCount = 4) testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) @@ -1098,11 +1107,11 @@ class StateFragmentLocalTest { fun testStateFragment_nextState_viewSolution_submitWrongAnswer_wait10s_noNewHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { scenario -> startPlayingExploration() - playThroughState1() - produceAndViewFourHints() - produceAndViewSolution(scenario, revealedHintCount = 4) + playThroughFractionsState1() + produceAndViewFourHintsInFractionState2() + produceAndViewSolutionInFractionsState2(scenario, revealedHintCount = 4) - submitWrongAnswerToState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) // Submitting a wrong answer should not change anything since the solution's been revealed. @@ -1126,10 +1135,10 @@ class StateFragmentLocalTest { fun testStateFragment_stateWithoutSolution_viewAllHints_wrongAnswerAndWait_noHintIsAvailable() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playUpToFinalTestSecondTry() - produceAndViewThreeHintsInState13() + playUpToFractionsFinalTestSecondTry() + produceAndViewThreeHintsInFractionsState13() - submitWrongAnswerToState13() + submitWrongAnswerToFractionsState13() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) // No hint indicator should be shown since there is no solution for this state. @@ -1137,6 +1146,57 @@ class StateFragmentLocalTest { } } + // TODO(#1050): Add a test for verifying that the solution is correct for non-text & non-fraction + // interactions. + + @Test + fun testStateFragment_stateWithNumericSolution_revealHint_reopenDialog_onlyOneHintShown() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + playThroughTestState1() + playThroughTestState2() + playThroughTestState3() + playThroughTestState4() + playThroughTestState5() + // Trigger the first hint to show (via two incorrect answers), then reveal it. + produceAndViewNextHint(hintPosition = 0) { + submitNumericInput(text = "1") + submitNumericInput(text = "1") + } + + // Reopen the dialog after showing the hint. + openHintsAndSolutionsDialog() + + // Verify that the first hint is available, but not the solution. + onView(withText("Hint 1")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Solution")).inRoot(isDialog()).check(doesNotExist()) + } + } + + @Test + fun testStateFragment_stateWithNumericSolution_revealHint_triggerSolution_hintBulbShown() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + playThroughTestState1() + playThroughTestState2() + playThroughTestState3() + playThroughTestState4() + playThroughTestState5() + // Trigger the first hint to show (via two incorrect answers), then reveal it. + produceAndViewNextHint(hintPosition = 0) { + submitNumericInput(text = "1") + submitNumericInput(text = "1") + } + + // Trigger the solution to show by submitting another incorrect answer & waiting. + submitNumericInput(text = "1") + testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) + + // The new hint indicator should be shown since a solution is now available. + onView(withId(R.id.dot_hint)).check(matches(isDisplayed())) + } + } + @Test @DefineAppLanguageLocaleContext( oppiaLanguageEnumId = ENGLISH_VALUE, @@ -1304,7 +1364,7 @@ class StateFragmentLocalTest { fun testStateFragment_mobilePortrait_finishExploration_endOfSessionConfettiIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check(matches(hasActiveConfetti())) @@ -1316,7 +1376,7 @@ class StateFragmentLocalTest { fun testStateFragment_mobileLandscape_finishExploration_endOfSessionConfettiIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check(matches(hasActiveConfetti())) @@ -1330,7 +1390,7 @@ class StateFragmentLocalTest { fun testStateFragment_tabletPortrait_finishExploration_endOfSessionConfettiIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check(matches(hasActiveConfetti())) @@ -1344,7 +1404,7 @@ class StateFragmentLocalTest { fun testStateFragment_tabletLandscape_finishExploration_endOfSessionConfettiIsDisplayed() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check(matches(hasActiveConfetti())) @@ -1356,7 +1416,7 @@ class StateFragmentLocalTest { fun testStateFragment_finishExploration_changePortToLand_endOfSessionConfettiIsDisplayedAgain() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check( matches( @@ -1379,7 +1439,7 @@ class StateFragmentLocalTest { fun testStateFragment_finishExploration_changeLandToPort_endOfSessionConfettiIsDisplayedAgain() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check( matches( @@ -1401,7 +1461,7 @@ class StateFragmentLocalTest { fun testStateFragment_submitCorrectAnswer_endOfSessionConfettiDoesNotStart() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughState1() + playThroughFractionsState1() onView(withId(R.id.full_screen_confetti_view)).check(matches(not(hasActiveConfetti()))) } @@ -1412,7 +1472,7 @@ class StateFragmentLocalTest { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() // Play through all questions but do not reach the last screen of the exploration. - playThroughAllStates() + playThroughAllFractionsStates() onView(withId(R.id.full_screen_confetti_view)).check(matches(not(hasActiveConfetti()))) } @@ -1422,7 +1482,7 @@ class StateFragmentLocalTest { fun testStateFragment_reachEndOfExplorationTwice_endOfSessionConfettiIsDisplayedOnce() { launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { startPlayingExploration() - playThroughAllStates() + playThroughAllFractionsStates() clickContinueButton() onView(withId(R.id.full_screen_confetti_view)).check(matches(hasActiveConfetti())) onView(withId(R.id.full_screen_confetti_view)).check( @@ -1477,113 +1537,140 @@ class StateFragmentLocalTest { testCoroutineDispatchers.runCurrent() } - private fun playThroughState1() { + private fun playThroughFractionsState1() { onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SELECTION_INTERACTION)) onView(withSubstring("the pieces must be the same size.")).perform(click()) testCoroutineDispatchers.runCurrent() clickContinueNavigationButton() } - private fun playThroughState2() { + private fun playThroughFractionsState2() { // Correct answer to 'Matthew gets conned' submitFractionAnswer(answerText = "3/4") clickContinueNavigationButton() } - private fun playThroughState3() { + private fun playThroughFractionsState3() { // Correct answer to 'Question 1' submitFractionAnswer(answerText = "4/9") clickContinueNavigationButton() } - private fun playThroughState4() { + private fun playThroughFractionsState4() { // Correct answer to 'Question 2' submitFractionAnswer(answerText = "1/4") clickContinueNavigationButton() } - private fun playThroughState5() { + private fun playThroughFractionsState5() { // Correct answer to 'Question 3' submitFractionAnswer(answerText = "1/8") clickContinueNavigationButton() } - private fun playThroughState6() { + private fun playThroughFractionsState6() { // Correct answer to 'Question 4' submitFractionAnswer(answerText = "1/2") clickContinueNavigationButton() } - private fun playThroughState7() { + private fun playThroughFractionsState7() { // Correct answer to 'Question 5' which redirects the learner to 'Thinking in fractions Q1' submitFractionAnswer(answerText = "2/9") clickContinueNavigationButton() } - private fun playThroughState8() { + private fun playThroughFractionsState8() { // Correct answer to 'Thinking in fractions Q1' submitFractionAnswer(answerText = "7/9") clickContinueNavigationButton() } - private fun playThroughState9() { + private fun playThroughFractionsState9() { // Correct answer to 'Thinking in fractions Q2' submitFractionAnswer(answerText = "4/9") clickContinueNavigationButton() } - private fun playThroughState10() { + private fun playThroughFractionsState10() { // Correct answer to 'Thinking in fractions Q3' submitFractionAnswer(answerText = "5/8") clickContinueNavigationButton() } - private fun playThroughState11() { + private fun playThroughFractionsState11() { // Correct answer to 'Thinking in fractions Q4' which redirects the learner to 'Final Test A' submitFractionAnswer(answerText = "3/4") clickContinueNavigationButton() } - private fun playThroughState12() { + private fun playThroughFractionsState12() { // Correct answer to 'Final Test A' redirects learner to 'Happy ending' submitFractionAnswer(answerText = "2/4") clickContinueNavigationButton() } - private fun playThroughState12WithWrongAnswer() { + private fun playThroughFractionsState12WithWrongAnswer() { // Incorrect answer to 'Final Test A' redirects the learner to 'Final Test A second try' submitFractionAnswer(answerText = "1/9") clickContinueNavigationButton() } - private fun playUpToFinalTestSecondTry() { - playThroughState1() - playThroughState2() - playThroughState3() - playThroughState4() - playThroughState5() - playThroughState6() - playThroughState7() - playThroughState8() - playThroughState9() - playThroughState10() - playThroughState11() - playThroughState12WithWrongAnswer() - } - - private fun playThroughAllStates() { - playThroughState1() - playThroughState2() - playThroughState3() - playThroughState4() - playThroughState5() - playThroughState6() - playThroughState7() - playThroughState8() - playThroughState9() - playThroughState10() - playThroughState11() - playThroughState12() + private fun playUpToFractionsFinalTestSecondTry() { + playThroughFractionsState1() + playThroughFractionsState2() + playThroughFractionsState3() + playThroughFractionsState4() + playThroughFractionsState5() + playThroughFractionsState6() + playThroughFractionsState7() + playThroughFractionsState8() + playThroughFractionsState9() + playThroughFractionsState10() + playThroughFractionsState11() + playThroughFractionsState12WithWrongAnswer() + } + + private fun playThroughAllFractionsStates() { + playThroughFractionsState1() + playThroughFractionsState2() + playThroughFractionsState3() + playThroughFractionsState4() + playThroughFractionsState5() + playThroughFractionsState6() + playThroughFractionsState7() + playThroughFractionsState8() + playThroughFractionsState9() + playThroughFractionsState10() + playThroughFractionsState11() + playThroughFractionsState12() + } + + private fun playThroughTestState1() { + clickContinueButton() + } + + private fun playThroughTestState2() { + submitFractionAnswer(answerText = "1/2") + clickContinueNavigationButton() + } + + private fun playThroughTestState3() { + selectMultipleChoiceOption(optionPosition = 2, expectedOptionText = "Eagle") + clickContinueNavigationButton() + } + + private fun playThroughTestState4() { + selectMultipleChoiceOption(optionPosition = 0, expectedOptionText = "Green") + clickContinueNavigationButton() + } + + private fun playThroughTestState5() { + selectItemSelectionCheckbox(optionPosition = 0, expectedOptionText = "Red") + selectItemSelectionCheckbox(optionPosition = 2, expectedOptionText = "Green") + selectItemSelectionCheckbox(optionPosition = 3, expectedOptionText = "Blue") + clickSubmitAnswerButton() + clickContinueNavigationButton() } private fun clickContinueNavigationButton() { @@ -1600,6 +1687,12 @@ class StateFragmentLocalTest { testCoroutineDispatchers.runCurrent() } + private fun clickSubmitAnswerButton() { + onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SUBMIT_ANSWER_BUTTON)) + onView(withId(R.id.submit_answer_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + private fun clickNextStateNavigationButton() { onView(withId(R.id.next_state_navigation_button)).perform(click()) testCoroutineDispatchers.runCurrent() @@ -1654,58 +1747,117 @@ class StateFragmentLocalTest { testCoroutineDispatchers.runCurrent() } - private fun submitFractionAnswer(answerText: String) { + private fun typeFractionAnswer(answerText: String) { onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(FRACTION_INPUT_INTERACTION)) - onView(withId(R.id.fraction_input_interaction_view)).perform( - editTextInputAction.appendText(answerText) + typeTextIntoInteraction(answerText, interactionViewId = R.id.fraction_input_interaction_view) + } + + private fun submitFractionAnswer(answerText: String) { + typeFractionAnswer(answerText) + clickSubmitAnswerButton() + } + + private fun selectMultipleChoiceOption(optionPosition: Int, expectedOptionText: String) { + clickSelection( + optionPosition, + targetClickViewId = R.id.multiple_choice_radio_button, + expectedText = expectedOptionText, + targetTextViewId = R.id.multiple_choice_content_text_view + ) + } + + private fun selectItemSelectionCheckbox(optionPosition: Int, expectedOptionText: String) { + clickSelection( + optionPosition, + targetClickViewId = R.id.item_selection_checkbox, + expectedText = expectedOptionText, + targetTextViewId = R.id.item_selection_contents_text_view ) + } + + private fun typeNumericInput(text: String) { + onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(NUMERIC_INPUT_INTERACTION)) + typeTextIntoInteraction(text, interactionViewId = R.id.numeric_input_interaction_view) + } + + private fun submitNumericInput(text: String) { + typeNumericInput(text) + clickSubmitAnswerButton() + } + + private fun typeTextIntoInteraction(text: String, interactionViewId: Int) { + onView(withId(interactionViewId)).perform(editTextInputAction.appendText(text)) testCoroutineDispatchers.runCurrent() + } - onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SUBMIT_ANSWER_BUTTON)) - onView(withId(R.id.submit_answer_button)).perform(click()) + private fun clickSelection( + optionPosition: Int, + targetClickViewId: Int, + expectedText: String, + targetTextViewId: Int + ) { + onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SELECTION_INTERACTION)) + // First, check that the option matches what's expected by the test. + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetTextViewId + ) + ).check(matches(withText(containsString(expectedText)))) + // Then, click on it. + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetClickViewId + ) + ).perform(click()) testCoroutineDispatchers.runCurrent() } - private fun submitWrongAnswerToState2() { + private fun submitWrongAnswerToFractionsState2() { submitFractionAnswer(answerText = "1/2") } - private fun submitWrongAnswerToState2AndWait() { - submitWrongAnswerToState2() + private fun submitWrongAnswerToFractionsState2AndWait() { + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) } - private fun submitWrongAnswerToState13() { + private fun submitWrongAnswerToFractionsState13() { submitFractionAnswer(answerText = "1/9") } - private fun submitWrongAnswerToState13AndWait() { - submitWrongAnswerToState13() + private fun submitWrongAnswerToFractionsState13AndWait() { + submitWrongAnswerToFractionsState13() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) } - private fun submitTwoWrongAnswers() { - submitWrongAnswerToState2() - submitWrongAnswerToState2() + private fun submitTwoWrongAnswersForFractionsState2() { + submitWrongAnswerToFractionsState2() + submitWrongAnswerToFractionsState2() } - private fun submitTwoWrongAnswersAndWait() { - submitTwoWrongAnswers() + private fun submitTwoWrongAnswersForFractionsState2AndWait() { + submitTwoWrongAnswersForFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) } - private fun submitThreeWrongAnswersAndWait() { - submitWrongAnswerToState2() - submitWrongAnswerToState2() - submitWrongAnswerToState2() + private fun submitThreeWrongAnswersForFractionsState2AndWait() { + submitWrongAnswerToFractionsState2() + submitWrongAnswerToFractionsState2() + submitWrongAnswerToFractionsState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) } - private fun produceAndViewFirstHint() { + private fun produceAndViewFirstHintForFractionState2() { // Two wrong answers need to be submitted for the first hint to show up, so submit an extra one // in advance of the standard show & reveal hint flow. - submitWrongAnswerToState2() - produceAndViewNextHint(hintPosition = 0, submitAnswer = this::submitWrongAnswerToState2AndWait) + submitWrongAnswerToFractionsState2() + produceAndViewNextHint( + hintPosition = 0, submitAnswer = this::submitWrongAnswerToFractionsState2AndWait + ) } /** @@ -1719,27 +1871,39 @@ class StateFragmentLocalTest { closeHintsAndSolutionsDialog() } - private fun produceAndViewThreeHintsInState13() { - submitWrongAnswerToState13() - produceAndViewNextHint(hintPosition = 0, submitAnswer = this::submitWrongAnswerToState13AndWait) - produceAndViewNextHint(hintPosition = 1, submitAnswer = this::submitWrongAnswerToState13AndWait) - produceAndViewNextHint(hintPosition = 2, submitAnswer = this::submitWrongAnswerToState13AndWait) + private fun produceAndViewThreeHintsInFractionsState13() { + submitWrongAnswerToFractionsState13() + produceAndViewNextHint( + hintPosition = 0, submitAnswer = this::submitWrongAnswerToFractionsState13AndWait + ) + produceAndViewNextHint( + hintPosition = 1, submitAnswer = this::submitWrongAnswerToFractionsState13AndWait + ) + produceAndViewNextHint( + hintPosition = 2, submitAnswer = this::submitWrongAnswerToFractionsState13AndWait + ) } - private fun produceAndViewFourHints() { + private fun produceAndViewFourHintsInFractionState2() { // Cause three hints to show, and reveal each of them one at a time (to allow the later hints // to be shown). - produceAndViewFirstHint() - produceAndViewNextHint(hintPosition = 1, submitAnswer = this::submitWrongAnswerToState2AndWait) - produceAndViewNextHint(hintPosition = 2, submitAnswer = this::submitWrongAnswerToState2AndWait) - produceAndViewNextHint(hintPosition = 3, submitAnswer = this::submitWrongAnswerToState2AndWait) + produceAndViewFirstHintForFractionState2() + produceAndViewNextHint( + hintPosition = 1, submitAnswer = this::submitWrongAnswerToFractionsState2AndWait + ) + produceAndViewNextHint( + hintPosition = 2, submitAnswer = this::submitWrongAnswerToFractionsState2AndWait + ) + produceAndViewNextHint( + hintPosition = 3, submitAnswer = this::submitWrongAnswerToFractionsState2AndWait + ) } - private fun produceAndViewSolution( + private fun produceAndViewSolutionInFractionsState2( activityScenario: ActivityScenario, revealedHintCount: Int ) { - submitWrongAnswerToState2AndWait() + submitWrongAnswerToFractionsState2AndWait() openHintsAndSolutionsDialog() pressRevealSolutionButton(revealedHintCount) clickConfirmRevealSolutionButton(activityScenario) @@ -1855,19 +2019,40 @@ class StateFragmentLocalTest { }) } + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @LoadLessonProtosFromAssets + fun provideLoadLessonProtosFromAssets(testEnvironmentConfig: TestEnvironmentConfig): Boolean = + testEnvironmentConfig.isUsingBazel() + + @Provides + @CacheAssetsLocally + fun provideCacheAssetsLocally(): Boolean = false + + @Provides + @TopicListToCache + fun provideTopicListToCache(): List = listOf() + + @Provides + @LoadImagesFromAssets + fun provideLoadImagesFromAssets(): Boolean = false + } + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. @Singleton @Component( modules = [ - TestDispatcherModule::class, ApplicationModule::class, RobolectricModule::class, - PlatformParameterModule::class, PlatformParameterSingletonModule::class, - LoggerModule::class, ContinueModule::class, FractionInputModule::class, - ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + TestModule::class, TestDispatcherModule::class, ApplicationModule::class, + RobolectricModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::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, - AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class, + AccessibilityTestModule::class, LogStorageModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class, ApplicationStartupListenerModule::class, LogUploadWorkerModule::class, diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index 9faa5f96aac..bcf339683d2 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -50,6 +50,7 @@ DOMAIN_ASSETS = generate_assets_list_from_text_protos( "test_single_interactive_state_exp_with_one_hint_and_solution", "test_single_interactive_state_exp_with_one_hint_and_no_solution", "test_single_interactive_state_exp_with_only_solution", + "test_single_interactive_state_exp_with_solution_missing_answer", ], skills_file_names = [ "skills", diff --git a/domain/src/main/assets/test_exp_id_2.json b/domain/src/main/assets/test_exp_id_2.json index dd5637e5cfb..e108d537190 100644 --- a/domain/src/main/assets/test_exp_id_2.json +++ b/domain/src/main/assets/test_exp_id_2.json @@ -692,8 +692,20 @@ "refresher_exploration_id": "", "missing_prerequisite_skill_id": "" }, - "hints": [], - "solution": null + "hints": [{ + "hint_content": { + "content_id": "hint_1", + "html": "

11 * 11 can be rephrased as 11 * 10, then add 11 at the end.

" + } + }], + "solution": { + "answer_is_exclusive": false, + "correct_answer": "", + "explanation": { + "content_id": "solution", + "html": "

11 times 11 is 121.

" + } + } }, "classifier_model_id": "", "recorded_voiceovers": { @@ -702,11 +714,37 @@ "feedback_3": {}, "feedback_1": {}, "content": {}, - "default_outcome": {} + "default_outcome": {}, + "hint_1": {}, + "solution": {} } }, "written_translations": { "translations_mapping": { + "hint_1": { + "pt": { + "data_format": "html", + "translation": {"translation" : "

11 * 11 pode ser reformulado como 11 * 10 e, em seguida, adicione 11 no final.

"}, + "needs_update": false + }, + "ar": { + "data_format": "html", + "translation": {"translation" : "يمكن إعادة صياغة

11 * 11 على أنها 11 * 10، ثم أضف 11 في النهاية.

"}, + "needs_update": false + } + }, + "solution": { + "pt": { + "data_format": "html", + "translation": {"translation" : "

11 vezes 11 é 121.

"}, + "needs_update": false + }, + "ar": { + "data_format": "html", + "translation": {"translation" : "

11 مرات 11 هو 121.

"}, + "needs_update": false + } + }, "feedback_2": { "pt": { "data_format": "html", diff --git a/domain/src/main/assets/test_exp_id_2.textproto b/domain/src/main/assets/test_exp_id_2.textproto index 58cffe4084a..075f3bb457f 100644 --- a/domain/src/main/assets/test_exp_id_2.textproto +++ b/domain/src/main/assets/test_exp_id_2.textproto @@ -825,10 +825,54 @@ states { value { } } + recorded_voiceovers { + key: "hint_1" + value { + } + } + recorded_voiceovers { + key: "solution" + value { + } + } content { html: "

What is 11 times 11?

" content_id: "content" } + written_translations { + key: "hint_1" + value { + translation_mapping { + key: "pt" + value { + html: "

11 * 11 pode ser reformulado como 11 * 10 e, em seguida, adicione 11 no final.

" + } + } + translation_mapping { + key: "ar" + value { + html: "\331\212\331\205\331\203\331\206 \330\245\330\271\330\247\330\257\330\251 \330\265\331\212\330\247\330\272\330\251

11 * 11 \330\271\331\204\331\211 \330\243\331\206\331\207\330\247 11 * 10\330\214 \330\253\331\205 \330\243\330\266\331\201 11 \331\201\331\212 \330\247\331\204\331\206\331\207\330\247\331\212\330\251.

" + } + } + } + } + written_translations { + key: "solution" + value { + translation_mapping { + key: "pt" + value { + html: "

11 vezes 11 \303\251 121.

" + } + } + translation_mapping { + key: "ar" + value { + html: "

11 \331\205\330\261\330\247\330\252 11 \331\207\331\210 121.

" + } + } + } + } written_translations { key: "feedback_2" value { @@ -970,6 +1014,19 @@ states { rule_type: "IsGreaterThan" } } + solution { + interaction_id: "NumericInput" + explanation { + html: "

11 times 11 is 121.

" + content_id: "solution" + } + } + hint { + hint_content { + html: "

11 * 11 can be rephrased as 11 * 10, then add 11 at the end.

" + content_id: "hint_1" + } + } default_outcome { dest_state_name: "NumberInput" feedback { diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.json b/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.json new file mode 100644 index 00000000000..5ddc99450b0 --- /dev/null +++ b/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.json @@ -0,0 +1,112 @@ +{ + "exploration_id": "test_single_interactive_state_exp_with_solution_missing_answer", + "version": 1, + "exploration": { + "init_state_name": "Text", + "states": { + "Text": { + "content": { + "content_id": "content", + "html": "

In which language does Oppia mean 'to learn'?

" + }, + "interaction": { + "id": "TextInput", + "customization_args": { + "rows": { + "value": 1.0 + }, + "placeholder": { + "value": { + "content_id": "ca_placeholder_0", + "unicode_str": "Enter a language" + } + } + }, + "answer_groups": [{ + "rule_specs": [{ + "rule_type": "Equals", + "inputs": { + "x": { + "contentId": "", + "normalizedStrSet": ["finnish"] + } + } + }], + "outcome": { + "dest": "End", + "feedback": { + "content_id": "feedback_1", + "html": "

Correct!

" + }, + "labelled_as_correct": false + } + }], + "default_outcome": { + "dest": "Text", + "feedback": { + "content_id": "default_outcome", + "html": "

Not quite. Try again (or maybe use a search engine).

" + }, + "labelled_as_correct": false + }, + "hints": [], + "solution": { + "answer_is_exclusive": false, + "explanation": { + "content_id": "solution", + "html": "

'Oppia' is translated from Finnish.

" + } + } + }, + "recorded_voiceovers": { + "voiceovers_mapping": { + "feedback_1": {}, + "content": {}, + "default_outcome": {}, + "solution": {} + } + }, + "written_translations": { + "translations_mapping": { + "feedback_1": {}, + "content": {}, + "default_outcome": {}, + "solution": {} + } + } + }, + "End": { + "content": { + "content_id": "content", + "html": "Congratulations, you have finished!" + }, + "param_changes": [], + "interaction": { + "id": "EndExploration", + "customization_args": { + "recommendedExplorationIds": { + "value": [] + } + }, + "answer_groups": [], + "default_outcome": null, + "hints": [], + "solution": null + }, + "recorded_voiceovers": { + "voiceovers_mapping": { + "content": {} + } + }, + "written_translations": { + "translations_mapping": { + "content": {} + } + } + } + }, + "objective": "Test exploration.", + "language_code": "en", + "title": "Prototype exploration with only one solution and no hints" + } +} diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.textproto b/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.textproto new file mode 100644 index 00000000000..1c3bb7d9a8d --- /dev/null +++ b/domain/src/main/assets/test_single_interactive_state_exp_with_solution_missing_answer.textproto @@ -0,0 +1,140 @@ +id: "test_single_interactive_state_exp_with_solution_missing_answer" +states { + key: "Text" + value { + name: "Text" + recorded_voiceovers { + key: "feedback_1" + value { + } + } + recorded_voiceovers { + key: "content" + value { + } + } + recorded_voiceovers { + key: "default_outcome" + value { + } + } + recorded_voiceovers { + key: "solution" + value { + } + } + content { + html: "

In which language does Oppia mean \'to learn\'?

" + content_id: "content" + } + written_translations { + key: "feedback_1" + value { + } + } + written_translations { + key: "content" + value { + } + } + written_translations { + key: "default_outcome" + value { + } + } + written_translations { + key: "solution" + value { + } + } + interaction { + id: "TextInput" + answer_groups { + outcome { + dest_state_name: "End" + feedback { + html: "

Correct!

" + content_id: "feedback_1" + } + } + rule_specs { + input { + key: "x" + value { + translatable_set_of_normalized_string { + content_id: "" + normalized_strings: "finnish" + } + } + } + rule_type: "Equals" + } + } + solution { + interaction_id: "TextInput" + explanation { + html: "

'Oppia' is translated from Finnish.

" + content_id: "solution" + } + } + default_outcome { + dest_state_name: "Text" + feedback { + html: "

Not quite. Try again (or maybe use a search engine).

" + content_id: "default_outcome" + } + } + customization_args { + key: "rows" + value { + signed_int: 1 + } + } + customization_args { + key: "placeholder" + value { + custom_schema_value { + subtitled_html { + html: "Enter a language" + content_id: "ca_placeholder_0" + } + } + } + } + } + } +} +states { + key: "End" + value { + name: "End" + recorded_voiceovers { + key: "content" + value { + } + } + content { + html: "Congratulations, you have finished!" + content_id: "content" + } + written_translations { + key: "content" + value { + } + } + interaction { + id: "EndExploration" + customization_args { + key: "recommendedExplorationIds" + value { + schema_object_list { + } + } + } + } + } +} +init_state_name: "Text" +objective: "Test exploration." +title: "Prototype exploration with only one solution and no hints" +language_code: "en" diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt index 74bea300c55..a4096d930a7 100644 --- a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_I import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION import org.oppia.android.app.model.State import org.oppia.android.util.threading.BackgroundDispatcher +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import kotlin.concurrent.withLock @@ -65,7 +66,7 @@ class HintHandlerProdImpl private constructor( private var trackedWrongAnswerCount = 0 private lateinit var pendingState: State - private var hintSequenceNumber = 0 + private var hintSequenceNumber = AtomicInteger(0) private var lastRevealedHintIndex = -1 private var latestAvailableHintIndex = -1 @@ -186,7 +187,7 @@ class HintHandlerProdImpl private constructor( // Cancel any potential pending hints by advancing the sequence number. Note that this isn't // reset to 0 to ensure that all previous hint tasks are cancelled, and new tasks can be // scheduled without overlapping with past sequence numbers. - hintSequenceNumber++ + hintSequenceNumber.incrementAndGet() } private fun maybeScheduleShowHint(wrongAnswerCount: Int = trackedWrongAnswerCount) { @@ -295,17 +296,17 @@ class HintHandlerProdImpl private constructor( // Return the index of the first unrevealed hint, or the length of the list if all have been // revealed. val hintList = pendingState.interaction.hintList - val solution = pendingState.interaction.solution val hasHints = hintList.isNotEmpty() - val hasHelp = hasHints || solution.hasCorrectAnswer() + val hasSolution = pendingState.hasSolution() + val hasHelp = hasHints || hasSolution val lastUnrevealedHintIndex = lastRevealedHintIndex + 1 return if (!hasHelp) { HelpIndex.getDefaultInstance() } else if (hasHints && lastUnrevealedHintIndex < hintList.size) { HelpIndex.newBuilder().setNextAvailableHintIndex(lastUnrevealedHintIndex).build() - } else if (solution.hasCorrectAnswer() && !solutionIsRevealed) { + } else if (hasSolution && !solutionIsRevealed) { HelpIndex.newBuilder().setShowSolution(true).build() } else { HelpIndex.newBuilder().setEverythingRevealed(true).build() @@ -317,7 +318,7 @@ class HintHandlerProdImpl private constructor( * cancelling any previously pending hints initiated by calls to this method. */ private fun scheduleShowHint(delayMs: Long, helpIndexToShow: HelpIndex) { - val targetSequenceNumber = ++hintSequenceNumber + val targetSequenceNumber = hintSequenceNumber.incrementAndGet() backgroundCoroutineScope.launch { delay(delayMs) handlerLock.withLock { @@ -331,12 +332,12 @@ class HintHandlerProdImpl private constructor( * pending hints initiated by calls to [scheduleShowHint]. */ private fun showHintImmediately(helpIndexToShow: HelpIndex) { - showHint(++hintSequenceNumber, helpIndexToShow) + showHint(hintSequenceNumber.incrementAndGet(), helpIndexToShow) } private fun showHint(targetSequenceNumber: Int, nextHelpIndexToShow: HelpIndex) { // Only finish this timer if no other hints were scheduled and no cancellations occurred. - if (targetSequenceNumber == hintSequenceNumber) { + if (targetSequenceNumber == hintSequenceNumber.get()) { val previousHelpIndex = computeCurrentHelpIndex() when (nextHelpIndexToShow.indexTypeCase) { @@ -376,7 +377,7 @@ class HintHandlerProdImpl private constructor( } /** Returns whether this state has a solution to show. */ -private fun State.hasSolution(): Boolean = interaction.solution.hasCorrectAnswer() +private fun State.hasSolution(): Boolean = interaction.hasSolution() /** Returns whether this state has help that the user can see. */ internal fun State.offersHelp(): Boolean = interaction.hintList.isNotEmpty() || hasSolution() diff --git a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt index 16a374e7168..bfe0815af2f 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt @@ -60,36 +60,26 @@ class StateRetriever @Inject constructor() { // Creates an interaction from JSON private fun createInteractionFromJson(interactionJson: JSONObject): Interaction { - return Interaction.newBuilder() - .setId(interactionJson.getStringFromObject("id")) - .addAllAnswerGroups( + return Interaction.newBuilder().apply { + id = interactionJson.getStringFromObject("id") + addAllAnswerGroups( createAnswerGroupsFromJson( interactionJson.getJSONArray("answer_groups"), interactionJson.getStringFromObject("id") ) ) - .setDefaultOutcome( - createOutcomeFromJson( - interactionJson.optJSONObject("default_outcome") - ) - ) - .putAllCustomizationArgs( + defaultOutcome = createOutcomeFromJson(interactionJson.optJSONObject("default_outcome")) + putAllCustomizationArgs( createCustomizationArgsMapFromJson( interactionJson.getJSONObject("customization_args"), interactionJson.getStringFromObject("id") ) ) - .addAllHint( - createListOfHintsFromJson( - interactionJson.getJSONArray("hints") - ) - ) - .setSolution( - createSolutionFromJson( - interactionJson.optJSONObject("solution") - ) - ) - .build() + addAllHint(createListOfHintsFromJson(interactionJson.getJSONArray("hints"))) + + // Only set the solution if one has been defined. + createSolutionFromJson(interactionJson.optJSONObject("solution"))?.let { solution = it } + }.build() } // Creates the list of answer group objects from JSON @@ -159,30 +149,33 @@ class StateRetriever @Inject constructor() { } // Creates a solution object from JSON - private fun createSolutionFromJson(solutionJson: JSONObject?): Solution { - if (solutionJson == null) { - return Solution.getDefaultInstance() + private fun createSolutionFromJson(optionalSolutionJson: JSONObject?): Solution? { + return optionalSolutionJson?.let { solutionJson -> + return Solution.newBuilder().apply { + correctAnswer = createCorrectAnswer(solutionJson) + explanation = parseSubtitledHtml(solutionJson.getJSONObject("explanation")) + answerIsExclusive = solutionJson.getBoolean("answer_is_exclusive") + }.build() } - return Solution.newBuilder().apply { - correctAnswer = createCorrectAnswer(solutionJson) - explanation = parseSubtitledHtml(solutionJson.getJSONObject("explanation")) - answerIsExclusive = solutionJson.getBoolean("answer_is_exclusive") - }.build() } private fun createCorrectAnswer(containerObject: JSONObject): CorrectAnswer { val correctAnswerObject = containerObject.optJSONObject("correct_answer") - return if (correctAnswerObject != null) { - CorrectAnswer.newBuilder() - .setNumerator(correctAnswerObject.getInt("numerator")) - .setDenominator(correctAnswerObject.getInt("denominator")) - .setWholeNumber(correctAnswerObject.getInt("wholeNumber")) - .setIsNegative(correctAnswerObject.getBoolean("isNegative")) - .build() - } else { - CorrectAnswer.newBuilder() - .setCorrectAnswer(containerObject.getStringFromObject("correct_answer")) - .build() + return when { + correctAnswerObject != null -> { + CorrectAnswer.newBuilder() + .setNumerator(correctAnswerObject.getInt("numerator")) + .setDenominator(correctAnswerObject.getInt("denominator")) + .setWholeNumber(correctAnswerObject.getInt("wholeNumber")) + .setIsNegative(correctAnswerObject.getBoolean("isNegative")) + .build() + } + containerObject.optString("correct_answer", /* fallback= */ null) != null -> { + CorrectAnswer.newBuilder() + .setCorrectAnswer(containerObject.getStringFromObject("correct_answer")) + .build() + } + else -> CorrectAnswer.getDefaultInstance() // For incompatible types. } } diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt index de5ab65385a..d24fe070cbe 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt @@ -87,6 +87,11 @@ class HintHandlerProdImplTest { "test_single_interactive_state_exp_with_hints_and_solution" ) } + private val expWithSolutionMissingCorrectAnswer by lazy { + explorationRetriever.loadExploration( + "test_single_interactive_state_exp_with_solution_missing_answer" + ) + } @Before fun setUp() { @@ -1908,6 +1913,48 @@ class HintHandlerProdImplTest { ) } + @Test + fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_isEmpty() { + val state = expWithSolutionMissingCorrectAnswer.getInitialState() + hintHandler.startWatchingForHintsInNewState(state) + + val helpIndex = hintHandler.getCurrentHelpIndex() + + assertThat(helpIndex).isEqualToDefaultInstance() + } + + @Test + fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_twoWrongAnswers_canShowSolution() { + val state = expWithSolutionMissingCorrectAnswer.getInitialState() + hintHandler.startWatchingForHintsInNewState(state) + hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + + val helpIndex = hintHandler.getCurrentHelpIndex() + + assertThat(helpIndex).isEqualTo( + HelpIndex.newBuilder().apply { + showSolution = true + }.build() + ) + } + + @Test + fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_triggeredAndShown_allRevealed() { + val state = expWithSolutionMissingCorrectAnswer.getInitialState() + hintHandler.startWatchingForHintsInNewState(state) + waitFor60Seconds() + hintHandler.viewSolution() + + val helpIndex = hintHandler.getCurrentHelpIndex() + + assertThat(helpIndex).isEqualTo( + HelpIndex.newBuilder().apply { + everythingRevealed = true + }.build() + ) + } + private fun Exploration.getInitialState(): State = statesMap.getValue(initStateName) private fun triggerFirstHint() = waitFor60Seconds() diff --git a/oppia_android_application.bzl b/oppia_android_application.bzl index cdebac05354..2627fb86b8c 100644 --- a/oppia_android_application.bzl +++ b/oppia_android_application.bzl @@ -97,6 +97,47 @@ def _bundle_module_zip_into_deployable_aab_impl(ctx): runfiles = ctx.runfiles(files = [output_file]), ) +def _package_metadata_into_deployable_aab_impl(ctx): + output_aab_file = ctx.outputs.output_aab_file + input_aab_file = ctx.attr.input_aab_file.files.to_list()[0] + proguard_map_file = ctx.attr.proguard_map_file.files.to_list()[0] + + command = """ + # Extract deployable AAB to working directory. + WORKING_DIR=$(mktemp -d) + echo $WORKING_DIR + cp {0} $WORKING_DIR/temp.aab || exit 255 + + # Change the permissions of the AAB copy so that it can be overwritten later. + chmod 755 $WORKING_DIR/temp.aab || exit 255 + + # Create directory needed for storing bundle metadata. + mkdir -p $WORKING_DIR/BUNDLE-METADATA/com.android.tools.build.obfuscation + + # Copy over the Proguard map file. + cp {1} $WORKING_DIR/BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map || exit 255 + + $ Repackage the AAB file into the destination. + DEST_FILE_PATH="$(pwd)/{2}" + cd $WORKING_DIR + zip -Dur temp.aab BUNDLE-METADATA || exit 255 + cp temp.aab $DEST_FILE_PATH || exit 255 + """.format(input_aab_file.path, proguard_map_file.path, output_aab_file.path) + + # Reference: https://docs.bazel.build/versions/main/skylark/lib/actions.html#run_shell. + ctx.actions.run_shell( + outputs = [output_aab_file], + inputs = ctx.files.input_aab_file + ctx.files.proguard_map_file, + tools = [], + command = command, + mnemonic = "PackageMetadataIntoDeployableAAB", + progress_message = "Generating deployable AAB", + ) + return DefaultInfo( + files = depset([output_aab_file]), + runfiles = ctx.runfiles(files = [output_aab_file]), + ) + def _generate_apks_and_install_impl(ctx): input_file = ctx.attr.input_file.files.to_list()[0] debug_keystore_file = ctx.attr.debug_keystore.files.to_list()[0] @@ -157,7 +198,7 @@ def _generate_apks_and_install_impl(ctx): _convert_apk_to_module_aab = rule( attrs = { "input_file": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "output_file": attr.output( @@ -175,7 +216,7 @@ _convert_apk_to_module_aab = rule( _convert_module_aab_to_structured_zip = rule( attrs = { "input_file": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "output_file": attr.output( @@ -188,11 +229,11 @@ _convert_module_aab_to_structured_zip = rule( _bundle_module_zip_into_deployable_aab = rule( attrs = { "input_file": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "config_file": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "output_file": attr.output( @@ -207,14 +248,31 @@ _bundle_module_zip_into_deployable_aab = rule( implementation = _bundle_module_zip_into_deployable_aab_impl, ) +_package_metadata_into_deployable_aab = rule( + attrs = { + "input_aab_file": attr.label( + allow_single_file = True, + mandatory = True, + ), + "proguard_map_file": attr.label( + allow_single_file = True, + mandatory = True, + ), + "output_aab_file": attr.output( + mandatory = True, + ), + }, + implementation = _package_metadata_into_deployable_aab_impl, +) + _generate_apks_and_install = rule( attrs = { "input_file": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "debug_keystore": attr.label( - allow_files = True, + allow_single_file = True, mandatory = True, ), "_bundletool_tool": attr.label( @@ -227,22 +285,27 @@ _generate_apks_and_install = rule( implementation = _generate_apks_and_install_impl, ) -def oppia_android_application(name, config_file, **kwargs): +def oppia_android_application(name, config_file, proguard_generate_mapping, **kwargs): """ Creates an Android App Bundle (AAB) binary with the specified name and arguments. Args: - name: str. The name of the Android App Bundle to build. This will corresponding to the name of - the generated .aab file. + name: str. The name of the Android App Bundle to build. This will corresponding to the name + of the generated .aab file. config_file: target. The path to the .pb.json bundle configuration file for this build. - **kwargs: additional arguments. See android_binary for the exact arguments that are available. + proguard_generate_mapping: boolean. Whether to perform a Proguard optimization step & + generate Proguard mapping corresponding to the obfuscation step. + **kwargs: additional arguments. See android_binary for the exact arguments that are + available. """ binary_name = "%s_binary" % name module_aab_name = "%s_module_aab" % name module_zip_name = "%s_module_zip" % name + deployable_aab_name = "%s_deployable" % name native.android_binary( name = binary_name, tags = ["manual"], + proguard_generate_mapping = proguard_generate_mapping, **kwargs ) _convert_apk_to_module_aab( @@ -257,13 +320,30 @@ def oppia_android_application(name, config_file, **kwargs): output_file = "%s.zip" % module_zip_name, tags = ["manual"], ) - _bundle_module_zip_into_deployable_aab( - name = name, - input_file = ":%s.zip" % module_zip_name, - config_file = config_file, - output_file = "%s.aab" % name, - tags = ["manual"], - ) + if proguard_generate_mapping: + _bundle_module_zip_into_deployable_aab( + name = deployable_aab_name, + input_file = ":%s.zip" % module_zip_name, + config_file = config_file, + output_file = "%s.aab" % deployable_aab_name, + tags = ["manual"], + ) + _package_metadata_into_deployable_aab( + name = name, + input_aab_file = ":%s.aab" % deployable_aab_name, + proguard_map_file = ":%s_proguard.map" % binary_name, + output_aab_file = "%s.aab" % name, + tags = ["manual"], + ) + else: + # No extra package step is needed if there's no Proguard map file. + _bundle_module_zip_into_deployable_aab( + name = name, + input_file = ":%s.zip" % module_zip_name, + config_file = config_file, + output_file = "%s.aab" % name, + tags = ["manual"], + ) def declare_deployable_application(name, aab_target): """ diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 0700602b594..489617c1f11 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -251,3 +251,9 @@ file_content_checks { exempted_file_patterns: "scripts/.+?\\.kt" exempted_file_patterns: "utility/src/(?:((main)|(test)))/java/org/oppia/android/util/locale/.+?\\.kt" } +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "kotlin\\.properties\\.Delegates" + failure_message: "Don't use Delegates; use a lateinit var or nullable primitive var default-initialized to null, instead. Delegates uses reflection internally, have a non-trivial initialization cost, and can cause breakages on KitKat devices. See #3939 for more context." + exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index 316fd0ef432..3f576389c91 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -115,6 +115,10 @@ class RegexPatternValidationCheckTest { "Don't perform date/time formatting directly. Instead, use OppiaLocale." private val useJavaLocaleErrorMessage = "Don't use Locale directly. Instead, use LocaleController, or OppiaLocale & its subclasses." + private val doNotUseKotlinDelegatesErrorMessage = + "Don't use Delegates; use a lateinit var or nullable primitive var default-initialized to" + + " null, instead. Delegates uses reflection internally, have a non-trivial initialization" + + " cost, and can cause breakages on KitKat devices. See #3939 for more context." private val wikiReferenceNote = "Refer to https://github.com/oppia/oppia-android/wiki/Static-Analysis-Checks" + "#regexpatternvalidation-check for more details on how to fix this." @@ -1462,6 +1466,27 @@ class RegexPatternValidationCheckTest { ) } + @Test + fun testFileContent_kotlinDelegatesImport_fileContentIsNotCorrect() { + val prohibitedContent = "kotlin.properties.Delegates" + tempFolder.newFolder("testfiles", "domain", "src", "main") + val stringFilePath = "domain/src/main/SomeController.kt" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()) + .isEqualTo( + """ + $stringFilePath:1: $doNotUseKotlinDelegatesErrorMessage + $wikiReferenceNote + """.trimIndent() + ) + } + @Test fun testFileContent_nonCompatDrawables_fileContentIsNotCorrect() { val prohibitedContent = diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel index 3ba7077b23e..3a69f05bcf2 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel @@ -54,10 +54,13 @@ kt_android_library( ) kt_android_library( - name = "debug_impl", + name = "debug_event_logger", srcs = [ "DebugEventLogger.kt", ], + visibility = [ + "//app:__pkg__", + ], deps = [ "//model:event_logger_java_proto_lite", "//third_party:javax_inject_javax_inject", @@ -73,7 +76,7 @@ kt_android_library( visibility = ["//:oppia_prod_module_visibility"], deps = [ ":dagger", - ":debug_impl", + ":debug_event_logger", ":firebase_exception_logger", ], ) diff --git a/utility/src/main/java/org/oppia/android/util/parser/svg/SvgPictureDrawable.kt b/utility/src/main/java/org/oppia/android/util/parser/svg/SvgPictureDrawable.kt index c0957cfce4a..38d8f81c438 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/svg/SvgPictureDrawable.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/svg/SvgPictureDrawable.kt @@ -1,5 +1,6 @@ package org.oppia.android.util.parser.svg +import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas @@ -9,6 +10,7 @@ import android.graphics.Picture import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.drawable.Drawable +import android.os.Build import android.text.TextPaint import org.oppia.android.util.parser.image.BitmapBlurrer import org.oppia.android.util.parser.image.ImageTransformation @@ -41,7 +43,7 @@ abstract class SvgPictureDrawable( // Save current transformation state. save() - if (scalableVectorGraphic.hasTransformations()) { + if (scalableVectorGraphic.shouldBeRenderedAsBitmap()) { bitmap?.let { bitmap -> drawBitmap(bitmap, /* src= */ null, bounds, bitmapPaint) } @@ -90,7 +92,7 @@ abstract class SvgPictureDrawable( scalableVectorGraphic.renderToTextPicture(it) } ?: scalableVectorGraphic.renderToBlockPicture() intrinsicSize = scalableVectorGraphic.computeSizeSpecs(textPaint) - if (scalableVectorGraphic.hasTransformations()) { + if (scalableVectorGraphic.shouldBeRenderedAsBitmap()) { recomputeBitmap() } } @@ -120,4 +122,13 @@ abstract class SvgPictureDrawable( } } +private fun ScalableVectorGraphic.shouldBeRenderedAsBitmap() = + hasTransformations() || isUsingAndroidSdkWithSvgRenderingIssues() + private fun ScalableVectorGraphic.hasTransformations() = transformations.isNotEmpty() + +// TODO(#3961): Remove this & instead rely on native SVG rendering for older SDK versions. +// See #3938 for context on why these OS versions are being forced to bitmap rendering. +@SuppressLint("ObsoleteSdkInt") // Incorrect warning. +private fun isUsingAndroidSdkWithSvgRenderingIssues() = + Build.VERSION.SDK_INT <= Build.VERSION_CODES.M diff --git a/version.bzl b/version.bzl index 9ef6da3d3d2..e943f7fa5ab 100644 --- a/version.bzl +++ b/version.bzl @@ -5,7 +5,7 @@ Defines the latest version of the Oppia Android app. MAJOR_VERSION = 0 MINOR_VERSION = 6 -OPPIA_DEV_KITKAT_VERSION_CODE = 10 -OPPIA_DEV_VERSION_CODE = 11 -OPPIA_ALPHA_KITKAT_VERSION_CODE = 12 -OPPIA_ALPHA_VERSION_CODE = 13 +OPPIA_DEV_KITKAT_VERSION_CODE = 14 +OPPIA_DEV_VERSION_CODE = 15 +OPPIA_ALPHA_KITKAT_VERSION_CODE = 16 +OPPIA_ALPHA_VERSION_CODE = 17