diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt index 25a4ff444d5..e27c68085a0 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt @@ -35,6 +35,8 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( private val topicListController: TopicListController, @StoryHtmlParserEntityType private val entityType: String ) { + // TODO(#3479): Enable checkpointing once mechanism to resume exploration with checkpoints is + // implemented. private val routeToExplorationListener = activity as RouteToExplorationListener private var internalProfileId: Int = -1 @@ -218,7 +220,11 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( private fun playExploration(topicId: String, storyId: String, explorationId: String) { explorationDataController.startPlayingExploration( - explorationId + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = false ).observe( fragment, Observer> { result -> diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt index b8ac46fac0c..6d2fd65be2a 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt @@ -109,4 +109,6 @@ class ExplorationFragment : InjectableFragment() { } fun dismissConceptCard() = explorationFragmentPresenter.dismissConceptCard() + + fun getExplorationCheckpointState() = explorationFragmentPresenter.getExplorationCheckpointState() } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt index e51c37fdbcf..08d031174a2 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt @@ -64,6 +64,8 @@ class ExplorationFragmentPresenter @Inject constructor( fun dismissConceptCard() = getStateFragment()?.dismissConceptCard() + fun getExplorationCheckpointState() = getStateFragment()?.getExplorationCheckpointState() + private fun getStateFragment(): StateFragment? { return fragment .childFragmentManager diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt index 1eff9e90ff0..6ebd407be15 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt @@ -129,4 +129,6 @@ class StateFragment : fun revealSolution() = stateFragmentPresenter.revealSolution() fun dismissConceptCard() = stateFragmentPresenter.dismissConceptCard() + + fun getExplorationCheckpointState() = stateFragmentPresenter.getExplorationCheckpointState() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index 5748e39a4c3..12a2f076bb6 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -17,6 +17,7 @@ import nl.dionsegijn.konfetti.KonfettiView import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.AnswerOutcome +import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.Hint @@ -91,6 +92,8 @@ class StateFragmentPresenter @Inject constructor( explorationProgressController.getCurrentState().toLiveData() } + private var explorationCheckpointState: CheckpointState = CheckpointState.CHECKPOINT_UNSAVED + fun handleCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -317,6 +320,7 @@ class StateFragmentPresenter @Inject constructor( } val ephemeralState = result.getOrThrow() + explorationCheckpointState = ephemeralState.checkpointState val shouldSplit = splitScreenManager.shouldSplitScreen(ephemeralState.state.interaction.id) if (shouldSplit) { viewModel.isSplitView.set(true) @@ -524,6 +528,9 @@ class StateFragmentPresenter @Inject constructor( } } + /** Returns the checkpoint state for the current exploration. */ + fun getExplorationCheckpointState() = explorationCheckpointState + private fun markExplorationAsRecentlyPlayed() { storyProgressController.recordRecentlyPlayedChapter( profileId, diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt index 10d3a8a411c..2536ca22c16 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt @@ -78,7 +78,13 @@ class StateFragmentTestActivityPresenter @Inject constructor( // TODO(#59): With proper test ordering & isolation, this hacky clean-up should not be necessary since each test // should run with a new application instance. explorationDataController.stopPlayingExploration() - explorationDataController.startPlayingExploration(explorationId) + explorationDataController.startPlayingExploration( + profileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = false + ) .observe( activity, Observer> { result -> diff --git a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt index 5ad88a9aae9..942257c5a93 100644 --- a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt @@ -24,6 +24,9 @@ class StoryChapterSummaryViewModel( val chapterSummary: ChapterSummary, val entityType: String ) : StoryItemViewModel() { + // TODO(#3479): Enable checkpointing once mechanism to resume exploration with checkpoints is + // implemented. + val explorationId: String = chapterSummary.explorationId val name: String = chapterSummary.name val summary: String = chapterSummary.summary @@ -33,7 +36,11 @@ class StoryChapterSummaryViewModel( fun onExplorationClicked() { explorationDataController.stopPlayingExploration() explorationDataController.startPlayingExploration( - explorationId + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = false ).observe( fragment, Observer> { result -> diff --git a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt index 41f716776ba..02b6cf1d9a5 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt @@ -40,7 +40,11 @@ class ExplorationTestActivityPresenter @Inject constructor( private fun playExplorationButton() { explorationDataController.stopPlayingExploration() explorationDataController.startPlayingExploration( - EXPLORATION_ID + INTERNAL_PROFILE_ID, + TOPIC_ID, + STORY_ID, + EXPLORATION_ID, + shouldSavePartialProgress = false ).observe( activity, Observer> { result -> diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt index 479a8eebe30..c3776d53ed6 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt @@ -30,6 +30,9 @@ class TopicLessonsFragmentPresenter @Inject constructor( private val oppiaLogger: OppiaLogger, private val explorationDataController: ExplorationDataController, ) { + // TODO(#3479): Enable checkpointing once mechanism to resume exploration with checkpoints is + // implemented. + private val routeToExplorationListener = activity as RouteToExplorationListener private val routeToStoryListener = activity as RouteToStoryListener @@ -202,7 +205,11 @@ class TopicLessonsFragmentPresenter @Inject constructor( backflowScreen: Int? ) { explorationDataController.startPlayingExploration( - explorationId + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = false ).observe( fragment, Observer> { result -> diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt index f30d8e9e0cd..edbe42d6dfd 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt @@ -164,12 +164,24 @@ class ExplorationActivityTest { ApplicationProvider.getApplicationContext().inject(this) } - private fun getApplicationDependencies(id: String) { + private fun getApplicationDependencies( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean + ) { launch(ExplorationInjectionActivity::class.java).use { it.onActivity { activity -> networkConnectionUtil = activity.networkConnectionUtil explorationDataController = activity.explorationDataController - explorationDataController.startPlayingExploration(id) + explorationDataController.startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress + ) } } } @@ -207,7 +219,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) testCoroutineDispatchers.runCurrent() onView(withId(R.id.exploration_toolbar_title)) .check(matches(withText("Prototype Exploration"))) @@ -225,7 +243,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) onView(isRoot()).perform(orientationLandscape()) testCoroutineDispatchers.runCurrent() onView(withId(R.id.exploration_toolbar_title)) @@ -245,7 +269,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) testCoroutineDispatchers.runCurrent() onView(withId(R.id.action_audio_player)) @@ -265,7 +295,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) testCoroutineDispatchers.runCurrent() onView(withId(R.id.action_audio_player)).perform(click()) @@ -286,7 +322,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) testCoroutineDispatchers.runCurrent() onView(withId(R.id.action_audio_player)).perform(click()) @@ -307,7 +349,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) openActionBarOverflowOrOptionsMenu(context) onView(withText(context.getString(R.string.menu_options))).check(matches(isDisplayed())) onView(withText(context.getString(R.string.menu_help))).check(matches(isDisplayed())) @@ -325,7 +373,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) openActionBarOverflowOrOptionsMenu(context) onView(withText(context.getString(R.string.menu_help))).perform(click()) intended(hasComponent(HelpActivity::class.java.name)) @@ -344,7 +398,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) openActionBarOverflowOrOptionsMenu(context) onView(withText(context.getString(R.string.menu_options))).perform(click()) intended(hasComponent(OptionsActivity::class.java.name)) @@ -368,7 +428,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) onView(withId(R.id.action_audio_player)).check(matches(not(isDisplayed()))) } explorationDataController.stopPlayingExploration() @@ -384,7 +450,13 @@ class ExplorationActivityTest { TEST_EXPLORATION_ID_2 ) ).use { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) onView(isRoot()).perform(orientationLandscape()) onView(withId(R.id.action_audio_player)).check(matches(not(isDisplayed()))) } @@ -402,7 +474,13 @@ class ExplorationActivityTest { RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.NONE) testCoroutineDispatchers.runCurrent() onView(withId(R.id.action_audio_player)).perform(click()) @@ -422,7 +500,13 @@ class ExplorationActivityTest { RATIOS_STORY_ID_0, RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -444,7 +528,13 @@ class ExplorationActivityTest { RATIOS_STORY_ID_0, RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -467,7 +557,13 @@ class ExplorationActivityTest { RATIOS_STORY_ID_0, RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -501,7 +597,13 @@ class ExplorationActivityTest { RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -540,7 +642,13 @@ class ExplorationActivityTest { RATIOS_STORY_ID_0, RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -576,7 +684,13 @@ class ExplorationActivityTest { RATIOS_STORY_ID_0, RATIOS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(RATIOS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus( NetworkConnectionUtil.ConnectionStatus.CELLULAR ) @@ -608,7 +722,13 @@ class ExplorationActivityTest { @Test @Ignore("The ExplorationActivity takes time to finish, needs to fixed in #89.") fun testAudioWifi_ratioExp_audioIcon_audioFragHasDefaultLangAndAutoPlays() { - getApplicationDependencies(RATIOS_EXPLORATION_ID_0) + getApplicationDependencies( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) launch( createExplorationActivityIntent( @@ -650,7 +770,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) testCoroutineDispatchers.runCurrent() onView(withId(R.id.state_recycler_view)).perform( @@ -697,7 +823,13 @@ class ExplorationActivityTest { @Test @Ignore("The ExplorationActivity takes time to finish, needs to fixed in #89.") fun testAudioWifi_ratioExp_continueInteraction_audioButton_submitAns_feedbackAudioPlays() { - getApplicationDependencies(RATIOS_EXPLORATION_ID_0) + getApplicationDependencies( + internalProfileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) networkConnectionUtil.setCurrentConnectionStatus(NetworkConnectionUtil.ConnectionStatus.LOCAL) launch( createExplorationActivityIntent( @@ -752,7 +884,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) testCoroutineDispatchers.runCurrent() pressBack() onView(withText(R.string.stop_exploration_dialog_title)).inRoot(isDialog()) @@ -772,7 +910,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ).use { - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) testCoroutineDispatchers.runCurrent() onView(withContentDescription(R.string.nav_app_bar_navigate_up_description)).perform(click()) onView(withText(R.string.stop_exploration_dialog_title)).inRoot(isDialog()) @@ -793,7 +937,13 @@ class ExplorationActivityTest { FRACTIONS_EXPLORATION_ID_0 ) ) - explorationDataController.startPlayingExploration(FRACTIONS_EXPLORATION_ID_0) + explorationDataController.startPlayingExploration( + internalProfileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + shouldSavePartialProgress = false + ) testCoroutineDispatchers.runCurrent() pressBack() onView(withText(R.string.stop_exploration_dialog_cancel_button)).inRoot(isDialog()) diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt index 2be5d9be2d2..0c771d27d98 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt @@ -45,6 +45,7 @@ import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRu import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageModule import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.domain.oppialogger.loguploader.LogUploadWorkerModule @@ -180,7 +181,8 @@ class PlatformParameterIntegrationTest { ApplicationStartupListenerModule::class, LogUploadWorkerModule::class, WorkManagerConfigurationModule::class, HintsAndSolutionConfigModule::class, FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, - DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class + DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, + ExplorationStorageModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt index 79947129b31..c2aa914ad8c 100644 --- a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt @@ -102,7 +102,13 @@ class ExplorationActivityLocalTest { @Test fun testExploration_onLaunch_logsEvent() { - getApplicationDependencies(TEST_EXPLORATION_ID_2) + getApplicationDependencies( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) launch( createExplorationActivityIntent( internalProfileId, @@ -123,12 +129,24 @@ class ExplorationActivityLocalTest { } } - private fun getApplicationDependencies(id: String) { + private fun getApplicationDependencies( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean + ) { launch(ExplorationInjectionActivity::class.java).use { it.onActivity { activity -> networkConnectionUtil = activity.networkConnectionUtil explorationDataController = activity.explorationDataController - explorationDataController.startPlayingExploration(id) + explorationDataController.startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress + ) } } } diff --git a/domain/build.gradle b/domain/build.gradle index 7045a115b82..5cca1e185b6 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -26,6 +26,9 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8 + freeCompilerArgs += [ + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi" + ] } testOptions { diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt index dbac822e08e..4eb5b691dfd 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt @@ -41,12 +41,31 @@ class ExplorationDataController @Inject constructor( * This must be called only if no active exploration is being played. The previous exploration must have first been * stopped using [stopPlayingExploration] otherwise an exception will be thrown. * - * @return a one-time [LiveData] to observe whether initiating the play request succeeded. The exploration may still - * fail to load, but this provides early-failure detection. + * @param internalProfileId the ID corresponding to the profile for which exploration has to be + * played + * @param topicId the ID corresponding to the topic for which exploration has to be played + * @param storyId the ID corresponding to the story for which exploration has to be played + * @param explorationId the ID of the exploration which has to be played + * @param shouldSavePartialProgress the boolean that indicates if partial progress has to be saved + * for the current exploration + * @return a one-time [LiveData] to observe whether initiating the play request succeeded. + * The exploration may still fail to load, but this provides early-failure detection. */ - fun startPlayingExploration(explorationId: String): LiveData> { + fun startPlayingExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean + ): LiveData> { return try { - explorationProgressController.beginExplorationAsync(explorationId) + explorationProgressController.beginExplorationAsync( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress + ) MutableLiveData(AsyncResult.success(null)) } catch (e: Exception) { exceptionsController.logNonFatalException(e) @@ -61,7 +80,7 @@ class ExplorationDataController @Inject constructor( fun stopPlayingExploration(): LiveData> { return try { explorationProgressController.finishExplorationAsync() - MutableLiveData(AsyncResult.success(null)) + MutableLiveData(AsyncResult.success(null)) } catch (e: Exception) { exceptionsController.logNonFatalException(e) MutableLiveData(AsyncResult.failed(e)) diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt index 152f2ff027c..9b57321b61a 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt @@ -1,6 +1,8 @@ package org.oppia.android.domain.exploration +import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.Exploration +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.domain.state.StateDeck import org.oppia.android.domain.state.StateGraph @@ -14,8 +16,15 @@ private const val TERMINAL_INTERACTION_ID = "EndExploration" * instances, but calling code is responsible for ensuring it is properly reset. */ internal class ExplorationProgress { + internal lateinit var currentProfileId: ProfileId + internal lateinit var currentTopicId: String + internal lateinit var currentStoryId: String internal lateinit var currentExplorationId: String internal lateinit var currentExploration: Exploration + + internal var shouldSavePartialProgress: Boolean = false + internal var checkpointState = CheckpointState.CHECKPOINT_UNSAVED + internal var playStage = PlayStage.NOT_PLAYING internal val stateGraph: StateGraph by lazy { StateGraph(currentExploration.statesMap) @@ -70,6 +79,17 @@ internal class ExplorationProgress { } } + /** + * Updates the checkpointState to a new state depending upon the result of save operation for + * checkpoints. + * + * @param newCheckpointState is the latest checkpoint state that is returned upon + * completion of the save operation for checkpoints either successfully or unsuccessfully. + */ + internal fun updateCheckpointState(newCheckpointState: CheckpointState) { + checkpointState = newCheckpointState + } + companion object { internal fun isTopStateTerminal(state: State): Boolean { return state.interaction.id == TERMINAL_INTERACTION_ID diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt index fbd8bdf683e..88cc2259a75 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt @@ -3,24 +3,32 @@ package org.oppia.android.domain.exploration import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.oppia.android.app.model.AnswerOutcome +import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.Exploration +import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.Hint +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.Solution import org.oppia.android.app.model.State import org.oppia.android.app.model.UserAnswer import org.oppia.android.domain.classify.AnswerClassificationController +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController +import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController +import org.oppia.android.domain.topic.StoryProgressController import org.oppia.android.util.data.AsyncDataSubscriptionManager import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.system.OppiaClock import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.withLock private const val CURRENT_STATE_DATA_PROVIDER_ID = "current_state_data_provider_id" + /** * Controller that tracks and reports the learner's ephemeral/non-persisted progress through an * exploration. Note that this controller only supports one active exploration at a time. @@ -36,7 +44,11 @@ class ExplorationProgressController @Inject constructor( private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, private val explorationRetriever: ExplorationRetriever, private val answerClassificationController: AnswerClassificationController, - private val exceptionsController: ExceptionsController + private val exceptionsController: ExceptionsController, + private val explorationCheckpointController: ExplorationCheckpointController, + private val storyProgressController: StoryProgressController, + private val oppiaClock: OppiaClock, + private val oppiaLogger: OppiaLogger ) { // TODO(#179): Add support for parameters. // TODO(#182): Add support for refresher explorations. @@ -47,6 +59,10 @@ class ExplorationProgressController @Inject constructor( // to avoid cases in tests where the exploration load operation needs to be fully finished before // performing a post-load operation. The current state of the controller is leaking this // implementation detail to tests. + // TODO(#3467): Update the mechanism to save checkpoints to eliminate the race condition that may + // arise if the function finishExplorationAsync acquires lock before the invokeOnCompletion + // callback on the deferred returned on saving checkpoints. In this case ExplorationActivity will + // make decisions based on a value of the checkpointState which might not be up-to date. private val currentStateDataProvider = dataProviders.createInMemoryDataProviderAsync( @@ -57,13 +73,25 @@ class ExplorationProgressController @Inject constructor( private val explorationProgressLock = ReentrantLock() /** Resets this controller to begin playing the specified [Exploration]. */ - internal fun beginExplorationAsync(explorationId: String) { + internal fun beginExplorationAsync( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean + ) { explorationProgressLock.withLock { check(explorationProgress.playStage == ExplorationProgress.PlayStage.NOT_PLAYING) { "Expected to finish previous exploration before starting a new one." } - explorationProgress.currentExplorationId = explorationId + explorationProgress.apply { + currentProfileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() + currentTopicId = topicId + currentStoryId = storyId + currentExplorationId = explorationId + this.shouldSavePartialProgress = shouldSavePartialProgress + } explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.LOADING_EXPLORATION) asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) } @@ -154,6 +182,12 @@ class ExplorationProgressController @Inject constructor( ) } } finally { + // If the answer was submitted on behalf of the Continue interaction, don't save + // checkpoint because it will be saved when the learner moves to the next state. + if (!doesInteractionAutoContinue(answerOutcome.state.interaction.id)) { + saveExplorationCheckpoint() + } + // Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck // in an 'always submitting answer' situation. This can specifically happen if answer // classification throws an exception. @@ -205,6 +239,8 @@ class ExplorationProgressController @Inject constructor( ) explorationProgress.stateDeck.pushStateForHint(state, hintIndex) } finally { + // Mark a checkpoint in the exploration everytime a new hint is revealed. + saveExplorationCheckpoint() // Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck // in an 'always showing hint' situation. This can specifically happen if hint throws an // exception. @@ -249,6 +285,8 @@ class ExplorationProgressController @Inject constructor( solution = explorationProgress.stateGraph.computeSolutionForResult(state) explorationProgress.stateDeck.pushStateForSolution(state) } finally { + // Mark a checkpoint in the exploration if the solution is revealed. + saveExplorationCheckpoint() // Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck // in an 'always showing solution' situation. This can specifically happen if solution // throws an exception. @@ -321,6 +359,7 @@ class ExplorationProgressController @Inject constructor( * listen to this result for failures, and instead rely on [getCurrentState] for observing a * successful transition to another state. */ + fun moveToNextState(): LiveData> { try { explorationProgressLock.withLock { @@ -343,6 +382,12 @@ class ExplorationProgressController @Inject constructor( "Cannot navigate to a next state if an answer submission is pending." } explorationProgress.stateDeck.navigateToNextState() + + // Only mark checkpoint if current state is pending state. This ensures that checkpoints + // will not be marked on any of the completed states. + if (explorationProgress.stateDeck.isCurrentStateTopOfDeck()) { + saveExplorationCheckpoint() + } asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) } return MutableLiveData(AsyncResult.success(null)) @@ -352,6 +397,115 @@ class ExplorationProgressController @Inject constructor( } } + /** + * Checks if checkpointing is enabled, if checkpointing is enabled this function creates a + * checkpoint with the latest progress and saves it using [ExplorationCheckpointController]. + * + * This function also waits for the save operation to complete, upon completion this function + * uses the function [processSaveCheckpointResult] to mark the exploration as + * IN_PROGRESS_SAVED or IN_PROGRESS_NOT_SAVED depending upon the result. + */ + private fun saveExplorationCheckpoint() { + // Do not save checkpoints if shouldSavePartialProgress is false. This is expected to happen + // when the current exploration has been already completed previously. + if (!explorationProgress.shouldSavePartialProgress) return + val profileId: ProfileId = explorationProgress.currentProfileId + val topicId: String = explorationProgress.currentTopicId + val storyId: String = explorationProgress.currentStoryId + val explorationId: String = explorationProgress.currentExplorationId + + val checkpoint: ExplorationCheckpoint = + explorationProgress.stateDeck.createExplorationCheckpoint( + explorationProgress.currentExploration.version, + explorationProgress.currentExploration.title, + oppiaClock.getCurrentTimeMs() + ) + + val deferred = explorationCheckpointController.recordExplorationCheckpointAsync( + profileId, + explorationId, + checkpoint + ) + + deferred.invokeOnCompletion { + val checkpointState = if (it == null) { + deferred.getCompleted() + } else { + oppiaLogger.e("Lightweight checkpointing", "Failed to save checkpoint in exploration", it) + // CheckpointState is marked as CHECKPOINT_UNSAVED because the deferred did not + // complete successfully. + CheckpointState.CHECKPOINT_UNSAVED + } + processSaveCheckpointResult( + profileId, + topicId, + storyId, + explorationId, + oppiaClock.getCurrentTimeMs(), + checkpointState + ) + } + } + + /** + * Processes the result obtained upon complete execution of the function + * [saveExplorationCheckpoint]. + * + * Marks the exploration as in_progress_saved or in_progress_not_saved if it is not already marked + * correctly. This function also updates the checkpoint state of the exploration to the + * specified new checkpoint state. + * + * @param profileId is the profile id currently playing the exploration + * @param topicId is the id of the topic which contains the story with the current exploration + * @param storyId is the id of the story which contains the current exploration + * @param lastPlayedTimestamp timestamp of the time when the checkpoints state for the exploration + * was last updated + * @param newCheckpointState the latest state obtained after saving checkpoint successfully or + * unsuccessfully + */ + private fun processSaveCheckpointResult( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + lastPlayedTimestamp: Long, + newCheckpointState: CheckpointState + ) { + explorationProgressLock.withLock { + // Only processes the result of the last save operation if the checkpointState has changed. + if (explorationProgress.checkpointState != newCheckpointState) { + // Mark exploration as IN_PROGRESS_SAVED or IN_PROGRESS_NOT_SAVED if the checkpointState has + // either changed from UNSAVED to SAVED or vice versa. + if ( + explorationProgress.checkpointState != CheckpointState.CHECKPOINT_UNSAVED && + newCheckpointState == CheckpointState.CHECKPOINT_UNSAVED + ) { + markExplorationAsInProgressNotSaved( + profileId, + topicId, + storyId, + explorationId, + lastPlayedTimestamp + ) + } else if ( + explorationProgress.checkpointState == CheckpointState.CHECKPOINT_UNSAVED && + newCheckpointState != CheckpointState.CHECKPOINT_UNSAVED + ) { + markExplorationAsInProgressSaved( + profileId, + topicId, + storyId, + explorationId, + lastPlayedTimestamp + ) + } + explorationProgress.updateCheckpointState(newCheckpointState) + // Notify observers that the checkpoint state has changed. + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + } + } + } + /** * Returns a [DataProvider] monitoring the current [EphemeralState] the learner is currently * viewing. If this state corresponds to a a terminal state, then the learner has completed the @@ -412,14 +566,24 @@ class ExplorationProgressController @Inject constructor( try { // The exploration must be available for this stage since it was loaded above. finishLoadExploration(exploration!!, explorationProgress) - AsyncResult.success(explorationProgress.stateDeck.getCurrentEphemeralState()) + AsyncResult.success( + explorationProgress.stateDeck.getCurrentEphemeralState() + .toBuilder() + .setCheckpointState(explorationProgress.checkpointState) + .build() + ) } catch (e: Exception) { exceptionsController.logNonFatalException(e) AsyncResult.failed(e) } } ExplorationProgress.PlayStage.VIEWING_STATE -> - AsyncResult.success(explorationProgress.stateDeck.getCurrentEphemeralState()) + AsyncResult.success( + explorationProgress.stateDeck.getCurrentEphemeralState() + .toBuilder() + .setCheckpointState(explorationProgress.checkpointState) + .build() + ) ExplorationProgress.PlayStage.SUBMITTING_ANSWER -> AsyncResult.pending() } } @@ -434,5 +598,47 @@ class ExplorationProgressController @Inject constructor( // Advance the stage, but do not notify observers since the current state can be reported // immediately to the UI. progress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE) + + // Mark a checkpoint in the exploration once the exploration has loaded. + saveExplorationCheckpoint() + } + + /** + * Returns whether the specified interaction automatically continues the user to the next state + * upon completion. + */ + private fun doesInteractionAutoContinue(interactionId: String): Boolean = + interactionId == "Continue" + + private fun markExplorationAsInProgressSaved( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + lastPlayedTimestamp: Long + ) { + storyProgressController.recordChapterAsInProgressSaved( + profileId, + topicId, + storyId, + explorationId, + lastPlayedTimestamp + ) + } + + private fun markExplorationAsInProgressNotSaved( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + lastPlayedTimestamp: Long + ) { + storyProgressController.recordChapterAsInProgressNotSaved( + profileId, + topicId, + storyId, + explorationId, + lastPlayedTimestamp + ) } } diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt index d8a576a3ee2..a6b057dd25d 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt @@ -43,6 +43,7 @@ class ExplorationRetriever @Inject constructor( .setInitStateName(innerExplorationObject.getString("init_state_name")) .setObjective(innerExplorationObject.getString("objective")) .putAllStates(createStatesFromJsonObject(innerExplorationObject.getJSONObject("states"))) + .setVersion(explorationObject.getInt("version")) .build() } diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt index 34be03a9a9f..d7a88158329 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt @@ -2,6 +2,7 @@ package org.oppia.android.domain.exploration.lightweightcheckpointing import androidx.annotation.VisibleForTesting import kotlinx.coroutines.Deferred +import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.ExplorationCheckpointDatabase import org.oppia.android.app.model.ExplorationCheckpointDetails @@ -40,25 +41,14 @@ class ExplorationCheckpointController @Inject constructor( class ExplorationCheckpointNotFoundException(message: String) : Exception(message) /** - * These Statuses correspond to the exception and the checkpoint database states above - * such that if the deferred result contains - * - * CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_EXCEEDED, the - * [ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT] will be - * passed to a successful AsyncResult. - * - * CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_NOT_EXCEEDED, - * [ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT] will be - * passed to a successful AsyncResult. + * These Statuses correspond to the result of the deferred such that if the deferred contains * * CHECKPOINT_NOT_FOUND, the [ExplorationCheckpointNotFoundException] will be passed to a failed * AsyncResult. * - * SUCCESS corresponds to successful AsyncResult with value as null. + * SUCCESS corresponds to a successful AsyncResult. */ enum class ExplorationCheckpointActionStatus { - CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_NOT_EXCEEDED, - CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_EXCEEDED, CHECKPOINT_NOT_FOUND, SUCCESS } @@ -69,69 +59,79 @@ class ExplorationCheckpointController @Inject constructor( /** * Records an exploration checkpoint for the specified profile. * - * @return a [DataProvider] that indicates the success/failure of the save operation. - * - * Success result is returned if the checkpoint is saved successfully. The success result - * returned has the value - * [ExplorationCheckpointDatabaseState.CHECKPOINT_DATABASE_SIZE_LIMIT_EXCEEDED] - * if the database has exceeded the size limit of [explorationCheckpointDatabaseSizeLimit], - * otherwise the success result is returned with the value - * [ExplorationCheckpointDatabaseState.CHECKPOINT_DATABASE_SIZE_LIMIT_NOT_EXCEEDED]. + * @return a [Deferred] that upon completion indicates the current [CheckpointState]. + * If the size of the checkpoint database is less than the allocated limit of + * [ExplorationStorageDatabaseSize] then the deferred upon completion gives the result + * [CheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT]. If the size of the + * checkpoint database exceeded [ExplorationStorageDatabaseSize] then + * [CheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT] is returned upon successful + * completion of deferred. */ - fun recordExplorationCheckpoint( + internal fun recordExplorationCheckpointAsync( profileId: ProfileId, explorationId: String, explorationCheckpoint: ExplorationCheckpoint - ): DataProvider { - val deferred = - retrieveCacheStore(profileId).storeDataWithCustomChannelAsync( - updateInMemoryCache = true - ) { - val explorationCheckpointDatabaseBuilder = it.toBuilder() + ): Deferred { + return retrieveCacheStore(profileId).storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { + val explorationCheckpointDatabaseBuilder = it.toBuilder() - val checkpoint = explorationCheckpointDatabaseBuilder - .explorationCheckpointMap[explorationId] + val checkpoint = explorationCheckpointDatabaseBuilder + .explorationCheckpointMap[explorationId] - // add checkpoint to the map if it was not saved previously. - if (checkpoint == null) { - explorationCheckpointDatabaseBuilder - .putExplorationCheckpoint(explorationId, explorationCheckpoint) - } else { - // update the timestamp to the time when the checkpoint was saved for the first time and - // then replace the existing checkpoint in the map with the updated checkpoint. - explorationCheckpointDatabaseBuilder.putExplorationCheckpoint( - explorationId, - explorationCheckpoint.toBuilder() - .setTimestampOfFirstCheckpoint(checkpoint.timestampOfFirstCheckpoint) - .build() - ) - } + // Add checkpoint to the map if it was not saved previously. + if (checkpoint == null) { + explorationCheckpointDatabaseBuilder + .putExplorationCheckpoint(explorationId, explorationCheckpoint) + } else { + // Update the timestamp to the time when the checkpoint was saved for the first time and + // then replace the existing checkpoint in the map with the updated checkpoint. + explorationCheckpointDatabaseBuilder.putExplorationCheckpoint( + explorationId, + explorationCheckpoint.toBuilder() + .setTimestampOfFirstCheckpoint(checkpoint.timestampOfFirstCheckpoint) + .build() + ) + } - val explorationCheckpointDatabase = explorationCheckpointDatabaseBuilder.build() + val explorationCheckpointDatabase = explorationCheckpointDatabaseBuilder.build() - if (explorationCheckpointDatabase.serializedSize <= explorationCheckpointDatabaseSizeLimit) - Pair( - explorationCheckpointDatabase, - ExplorationCheckpointActionStatus.CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_NOT_EXCEEDED - ) - else - Pair( - explorationCheckpointDatabase, - ExplorationCheckpointActionStatus.CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_EXCEEDED - ) + if (explorationCheckpointDatabase.serializedSize <= explorationCheckpointDatabaseSizeLimit) { + Pair( + explorationCheckpointDatabase, + CheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT + ) + } else { + Pair( + explorationCheckpointDatabase, + CheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT + ) } + } + } + + /** + * Returns a [DataProvider] for the [Deferred] returned from [recordExplorationCheckpointAsync]. + */ + fun recordExplorationCheckpoint( + profileId: ProfileId, + explorationId: String, + explorationCheckpoint: ExplorationCheckpoint + ): DataProvider { + val deferred = recordExplorationCheckpointAsync( + profileId, + explorationId, + explorationCheckpoint + ) return dataProviders.createInMemoryDataProviderAsync( RECORD_EXPLORATION_CHECKPOINT_DATA_PROVIDER_ID ) { - return@createInMemoryDataProviderAsync getDeferredResult( - deferred = deferred, - profileId = profileId, - explorationId = null - ) + return@createInMemoryDataProviderAsync AsyncResult.success(deferred.await()) } } - /** returns the saved checkpoint for a specified explorationId and profileId. */ + /** Returns the saved checkpoint for a specified explorationId and profileId. */ fun retrieveExplorationCheckpoint( profileId: ProfileId, explorationId: String @@ -156,8 +156,10 @@ class ExplorationCheckpointController @Inject constructor( } /** + * Retrieves details about the oldest saved exploration checkpoint. + * * @return [ExplorationCheckpointDetails] which contains the explorationId, explorationTitle - * and explorationVersion of the oldest saved checkpoint for the specified profile. + * and explorationVersion of the oldest saved checkpoint for the specified profile. */ fun retrieveOldestSavedExplorationCheckpointDetails( profileId: ProfileId @@ -167,7 +169,7 @@ class ExplorationCheckpointController @Inject constructor( RETRIEVE_OLDEST_CHECKPOINT_DETAILS_DATA_PROVIDER_ID ) { explorationCheckpointDatabase -> - // find the oldest checkpoint by timestamp or null if no checkpoints is saved. + // Find the oldest checkpoint by timestamp or null if no checkpoints is saved. val oldestCheckpoint = explorationCheckpointDatabase.explorationCheckpointMap.minByOrNull { it.value.timestampOfFirstCheckpoint } @@ -189,7 +191,7 @@ class ExplorationCheckpointController @Inject constructor( } } - /** deletes the saved checkpoint for a specified explorationId and profileId. */ + /** Deletes the saved checkpoint for a specified explorationId and profileId. */ fun deleteSavedExplorationCheckpoint( profileId: ProfileId, explorationId: String @@ -198,11 +200,12 @@ class ExplorationCheckpointController @Inject constructor( updateInMemoryCache = true ) { explorationCheckpointDatabase -> - if (!explorationCheckpointDatabase.explorationCheckpointMap.containsKey(explorationId)) + if (!explorationCheckpointDatabase.explorationCheckpointMap.containsKey(explorationId)) { return@storeDataWithCustomChannelAsync Pair( explorationCheckpointDatabase, ExplorationCheckpointActionStatus.CHECKPOINT_NOT_FOUND ) + } val explorationCheckpointDatabaseBuilder = explorationCheckpointDatabase.toBuilder() @@ -231,12 +234,6 @@ class ExplorationCheckpointController @Inject constructor( profileId: ProfileId?, ): AsyncResult { return when (deferred.await()) { - ExplorationCheckpointActionStatus.CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_NOT_EXCEEDED -> - AsyncResult.success(ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT) - - ExplorationCheckpointActionStatus.CHECKPOINT_SAVED_DATABASE_SIZE_LIMIT_EXCEEDED -> - AsyncResult.success(ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT) - ExplorationCheckpointActionStatus.CHECKPOINT_NOT_FOUND -> AsyncResult.failed( ExplorationCheckpointNotFoundException( diff --git a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt index 90505ea2475..9bb1fb28bc8 100644 --- a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt +++ b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt @@ -2,7 +2,9 @@ package org.oppia.android.domain.state import org.oppia.android.app.model.AnswerAndResponse import org.oppia.android.app.model.CompletedState +import org.oppia.android.app.model.CompletedStateInCheckpoint import org.oppia.android.app.model.EphemeralState +import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.Hint import org.oppia.android.app.model.PendingState import org.oppia.android.app.model.Solution @@ -26,6 +28,9 @@ internal class StateDeck internal constructor( private val hintList: MutableList = ArrayList() private lateinit var solution: Solution private var stateIndex: Int = 0 + // The value -1 indicates that hint has not been revealed yet. + private var revealedHintIndex: Int = -1 + private var solutionIsRevealed: Boolean = false /** Resets this deck to a new, specified initial [State]. */ internal fun resetDeck(initialState: State) { @@ -34,6 +39,10 @@ internal class StateDeck internal constructor( currentDialogInteractions.clear() hintList.clear() stateIndex = 0 + // Initialize the variable revealedHintIndex with -1 to indicate that no hint has been + // revealed yet. + revealedHintIndex = -1 + solutionIsRevealed = false } /** Navigates to the previous State in the deck, or fails if this isn't possible. */ @@ -108,6 +117,10 @@ internal class StateDeck internal constructor( currentDialogInteractions.clear() hintList.clear() pendingTopState = state + // Re-initialize the variable revealedHintIndex with -1 to indicate that no hint has been + // revealed on the new pendingTopState. + revealedHintIndex = -1 + solutionIsRevealed = false } internal fun pushStateForHint(state: State, hintIndex: Int): EphemeralState { @@ -125,6 +138,8 @@ internal class StateDeck internal constructor( .build() pendingTopState = newState hintList.clear() + // Increment the value of revealHintIndex by 1 every-time a new hint is revealed. + revealedHintIndex++ return ephemeralState } @@ -139,6 +154,7 @@ internal class StateDeck internal constructor( ) .build() pendingTopState = newState + solutionIsRevealed = true return ephemeralState } @@ -172,6 +188,35 @@ internal class StateDeck internal constructor( .build() } + /** + * Returns an [ExplorationCheckpoint] which contains all the latest values of variables of the + * [StateDeck] that are used in light weight checkpointing. + */ + internal fun createExplorationCheckpoint( + explorationVersion: Int, + explorationTitle: String, + timestamp: Long + ): ExplorationCheckpoint { + return ExplorationCheckpoint.newBuilder().apply { + addAllCompletedStatesInCheckpoint( + previousStates.map { state -> + CompletedStateInCheckpoint.newBuilder().apply { + completedState = state.completedState + stateName = state.state.name + }.build() + } + ) + pendingStateName = pendingTopState.name + hintIndex = revealedHintIndex + addAllPendingUserAnswers(currentDialogInteractions) + this.solutionIsRevealed = this@StateDeck.solutionIsRevealed + this.stateIndex = this@StateDeck.stateIndex + this.explorationVersion = explorationVersion + this.explorationTitle = explorationTitle + timestampOfFirstCheckpoint = timestamp + }.build() + } + private fun getCurrentPendingState(): EphemeralState { return EphemeralState.newBuilder() .setState(pendingTopState) diff --git a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt index 796edc817d1..7b26df8c5d9 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt @@ -39,6 +39,8 @@ private const val RETRIEVE_TOPIC_PROGRESS_DATA_PROVIDER_ID = "retrieve_topic_progress_data_provider_id" private const val RETRIEVE_STORY_PROGRESS_DATA_PROVIDER_ID = "retrieve_story_progress_data_provider_id" +private const val RETRIEVE_CHAPTER_PLAY_STATE_DATA_PROVIDER_ID = + "retrieve_chapter_play_state_data_provider_id" private const val RECORD_COMPLETED_CHAPTER_PROVIDER_ID = "record_completed_chapter_provider_id" private const val RECORD_RECENTLY_PLAYED_CHAPTER_PROVIDER_ID = "record_recently_played_chapter_provider_id" @@ -126,6 +128,168 @@ class StoryProgressController @Inject constructor( } } + /** + * Records the specified chapter completed within the context of the specified exploration, story, + * topic. Returns a [DataProvider] that provides exactly one [AsyncResult] to indicate whether + * this operation has succeeded. This method will never return a pending result. + * + * @param profileId the ID corresponding to the profile for which progress needs to be stored + * @param topicId the ID corresponding to the topic for which progress needs to be stored + * @param storyId the ID corresponding to the story for which progress needs to be stored + * @param explorationId the chapter id which will marked as [ChapterPlayState.IN_PROGRESS_SAVED] + * if it has not been [ChapterPlayState.COMPLETED] already + * @param lastPlayedTimestamp the timestamp at the exploration was finished + * @return a [DataProvider] that indicates the success/failure of this record progress operation + */ + fun recordChapterAsInProgressSaved( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + lastPlayedTimestamp: Long + ): DataProvider { + val deferred = + retrieveCacheStore(profileId).storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { topicProgressDatabase -> + val previousChapterProgress = + topicProgressDatabase + .topicProgressMap[topicId]?.storyProgressMap?.get(storyId)?.chapterProgressMap?.get( + explorationId + ) + + val chapterProgressBuilder = if (previousChapterProgress != null) { + previousChapterProgress.toBuilder() + } else { + ChapterProgress.newBuilder() + .setChapterPlayState(ChapterPlayState.IN_PROGRESS_SAVED) + .setExplorationId(explorationId) + } + if (previousChapterProgress != null) { + chapterProgressBuilder.lastPlayedTimestamp = + if (previousChapterProgress.lastPlayedTimestamp < lastPlayedTimestamp && + previousChapterProgress.chapterPlayState != ChapterPlayState.COMPLETED + ) { + lastPlayedTimestamp + } else { + previousChapterProgress.lastPlayedTimestamp + } + } else { + chapterProgressBuilder.lastPlayedTimestamp = lastPlayedTimestamp + } + val storyProgressBuilder = StoryProgress.newBuilder().setStoryId(storyId) + if (topicProgressDatabase.topicProgressMap[topicId]?.storyProgressMap?.get(storyId) + != null + ) { + storyProgressBuilder.putAllChapterProgress( + topicProgressDatabase + .topicProgressMap[topicId]!!.storyProgressMap[storyId]!!.chapterProgressMap + ) + } + storyProgressBuilder.putChapterProgress(explorationId, chapterProgressBuilder.build()) + val storyProgress = storyProgressBuilder.build() + + val topicProgressBuilder = TopicProgress.newBuilder().setTopicId(topicId) + if (topicProgressDatabase.topicProgressMap[topicId] != null) { + topicProgressBuilder + .putAllStoryProgress(topicProgressDatabase.topicProgressMap[topicId]!!.storyProgressMap) + } + topicProgressBuilder.putStoryProgress(storyId, storyProgress) + val topicProgress = topicProgressBuilder.build() + + val topicDatabaseBuilder = + topicProgressDatabase.toBuilder().putTopicProgress(topicId, topicProgress) + Pair(topicDatabaseBuilder.build(), StoryProgressActionStatus.SUCCESS) + } + + return dataProviders.createInMemoryDataProviderAsync( + RECORD_RECENTLY_PLAYED_CHAPTER_PROVIDER_ID + ) { + return@createInMemoryDataProviderAsync getDeferredResult(deferred) + } + } + + /** + * Records the specified chapter completed within the context of the specified exploration, story, + * topic. Returns a [DataProvider] that provides exactly one [AsyncResult] to indicate whether + * this operation has succeeded. This method will never return a pending result. + * + * @param profileId the ID corresponding to the profile for which progress needs to be stored + * @param topicId the ID corresponding to the topic for which progress needs to be stored + * @param storyId the ID corresponding to the story for which progress needs to be stored + * @param explorationId the chapter id which will marked as [ChapterPlayState.IN_PROGRESS_NOT_SAVED] + * if it has not been [ChapterPlayState.COMPLETED] already + * @param lastPlayedTimestamp the timestamp at the exploration was finished. + * @return a [DataProvider] that indicates the success/failure of this record progress operation + */ + fun recordChapterAsInProgressNotSaved( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + lastPlayedTimestamp: Long + ): DataProvider { + val deferred = + retrieveCacheStore(profileId).storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { topicProgressDatabase -> + val previousChapterProgress = + topicProgressDatabase + .topicProgressMap[topicId]?.storyProgressMap?.get(storyId)?.chapterProgressMap?.get( + explorationId + ) + + val chapterProgressBuilder = if (previousChapterProgress != null) { + previousChapterProgress.toBuilder() + } else { + ChapterProgress.newBuilder() + .setChapterPlayState(ChapterPlayState.IN_PROGRESS_NOT_SAVED) + .setExplorationId(explorationId) + } + if (previousChapterProgress != null) { + chapterProgressBuilder.lastPlayedTimestamp = + if (previousChapterProgress.lastPlayedTimestamp < lastPlayedTimestamp && + previousChapterProgress.chapterPlayState != ChapterPlayState.COMPLETED + ) { + lastPlayedTimestamp + } else { + previousChapterProgress.lastPlayedTimestamp + } + } else { + chapterProgressBuilder.lastPlayedTimestamp = lastPlayedTimestamp + } + val storyProgressBuilder = StoryProgress.newBuilder().setStoryId(storyId) + if (topicProgressDatabase.topicProgressMap[topicId]?.storyProgressMap?.get(storyId) + != null + ) { + storyProgressBuilder.putAllChapterProgress( + topicProgressDatabase + .topicProgressMap[topicId]!!.storyProgressMap[storyId]!!.chapterProgressMap + ) + } + storyProgressBuilder.putChapterProgress(explorationId, chapterProgressBuilder.build()) + val storyProgress = storyProgressBuilder.build() + + val topicProgressBuilder = TopicProgress.newBuilder().setTopicId(topicId) + if (topicProgressDatabase.topicProgressMap[topicId] != null) { + topicProgressBuilder + .putAllStoryProgress(topicProgressDatabase.topicProgressMap[topicId]!!.storyProgressMap) + } + topicProgressBuilder.putStoryProgress(storyId, storyProgress) + val topicProgress = topicProgressBuilder.build() + + val topicDatabaseBuilder = + topicProgressDatabase.toBuilder().putTopicProgress(topicId, topicProgress) + Pair(topicDatabaseBuilder.build(), StoryProgressActionStatus.SUCCESS) + } + + return dataProviders.createInMemoryDataProviderAsync( + RECORD_RECENTLY_PLAYED_CHAPTER_PROVIDER_ID + ) { + return@createInMemoryDataProviderAsync getDeferredResult(deferred) + } + } + /** * Records the recently played chapter for a specified exploration, story, topic. Returns a * [DataProvider] that provides exactly one [AsyncResult] to indicate whether this operation has @@ -207,6 +371,24 @@ class StoryProgressController @Inject constructor( } } + /** Returns the [ChapterPlayState] [DataProvider] for a particular explorationId and profile. */ + fun retrieveChapterPlayStateByExplorationId( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String + ): DataProvider { + return retrieveStoryProgressDataProvider(profileId, topicId, storyId) + .transformAsync(RETRIEVE_CHAPTER_PLAY_STATE_DATA_PROVIDER_ID) { + val chapterProgress = it.chapterProgressMap[explorationId] + if (chapterProgress != null) { + AsyncResult.success(chapterProgress.chapterPlayState) + } else { + AsyncResult.success(ChapterPlayState.NOT_STARTED) + } + } + } + /** Returns list of [TopicProgress] [DataProvider] for a particular profile. */ internal fun retrieveTopicProgressListDataProvider( profileId: ProfileId diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt index 8aaf20d3f96..b492c678b99 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt @@ -43,6 +43,10 @@ import org.oppia.android.domain.topic.RATIOS_EXPLORATION_ID_2 import org.oppia.android.domain.topic.RATIOS_EXPLORATION_ID_3 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_4 +import org.oppia.android.domain.topic.TEST_STORY_ID_0 +import org.oppia.android.domain.topic.TEST_STORY_ID_2 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_1 import org.oppia.android.testing.FakeExceptionLogger import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.environment.TestEnvironmentConfig @@ -91,6 +95,8 @@ class ExplorationDataControllerTest { @Captor lateinit var explorationResultCaptor: ArgumentCaptor> + val internalProfileId: Int = -1 + @Before fun setUp() { setUpTestApplicationComponent() @@ -238,8 +244,20 @@ class ExplorationDataControllerTest { @Test fun testStartPlayingExploration_withoutStoppingSession_fails() { - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_4) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) + explorationDataController.startPlayingExploration( + internalProfileId, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + shouldSavePartialProgress = false + ) testCoroutineDispatchers.runCurrent() val exception = fakeExceptionLogger.getMostRecentException() diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt index cc278c9b72e..1a8c94916dd 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt @@ -30,11 +30,13 @@ import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.EphemeralState.StateTypeCase.COMPLETED_STATE import org.oppia.android.app.model.EphemeralState.StateTypeCase.PENDING_STATE import org.oppia.android.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE +import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.Fraction import org.oppia.android.app.model.Hint import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.ListOfSetsOfTranslatableHtmlContentIds import org.oppia.android.app.model.Point2d +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.RatioExpression import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.Solution @@ -51,10 +53,16 @@ import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRu import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageDatabaseSize import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_4 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_5 +import org.oppia.android.domain.topic.TEST_STORY_ID_0 +import org.oppia.android.domain.topic.TEST_STORY_ID_2 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_1 import org.oppia.android.domain.util.toAnswerString import org.oppia.android.testing.FakeExceptionLogger import org.oppia.android.testing.TestLogReportingModule @@ -62,6 +70,7 @@ import org.oppia.android.testing.environment.TestEnvironmentConfig import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets @@ -77,13 +86,15 @@ import org.oppia.android.util.logging.LogLevel import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.io.FileNotFoundException -import java.lang.AssertionError import javax.inject.Inject import javax.inject.Singleton // For context: // https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts. private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." +private const val INVALID_TOPIC_ID = "invalid_topic_id" +private const val INVALID_STORY_ID = "invalid_story_id" +private const val INVALID_EXPLORATION_ID = "invalid_exp_id" /** Tests for [ExplorationProgressController]. */ @Suppress("SameParameterValue") // To avoid ignorable warnings for test helper methods. @@ -116,6 +127,12 @@ class ExplorationProgressControllerTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + lateinit var oppiaClock: FakeOppiaClock + + @Inject + lateinit var explorationCheckpointController: ExplorationCheckpointController + @Mock lateinit var mockCurrentStateLiveDataObserver: Observer> @@ -143,6 +160,14 @@ class ExplorationProgressControllerTest { @Captor lateinit var asyncAnswerOutcomeCaptor: ArgumentCaptor> + @Mock + lateinit var mockExplorationCheckpointObserver: Observer> + + @Captor + lateinit var explorationCheckpointCaptor: ArgumentCaptor> + + private val profileId = ProfileId.newBuilder().setInternalId(0).build() + @Before fun setUp() { setUpTestApplicationComponent() @@ -166,7 +191,13 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_invalid_returnsSuccess() { val resultLiveData = - explorationDataController.startPlayingExploration("invalid_exp_id") + explorationDataController.startPlayingExploration( + profileId.internalId, + INVALID_TOPIC_ID, + INVALID_STORY_ID, + INVALID_EXPLORATION_ID, + shouldSavePartialProgress = false + ) resultLiveData.observeForever(mockAsyncResultLiveDataObserver) testCoroutineDispatchers.runCurrent() @@ -182,7 +213,13 @@ class ExplorationProgressControllerTest { explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration("invalid_exp_id") + playExploration( + profileId.internalId, + INVALID_TOPIC_ID, + INVALID_STORY_ID, + INVALID_EXPLORATION_ID, + shouldSavePartialProgress = false + ) verify( mockCurrentStateLiveDataObserver, @@ -197,7 +234,13 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_valid_returnsSuccess() { val resultLiveData = - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) resultLiveData.observeForever(mockAsyncResultLiveDataObserver) testCoroutineDispatchers.runCurrent() @@ -212,7 +255,13 @@ class ExplorationProgressControllerTest { currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) testCoroutineDispatchers.runCurrent() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) // The second-to-latest result stays pending since the exploration was loading (the actual // result is the fully loaded exploration). This is only true if the observer begins before @@ -227,7 +276,13 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playExploration_loaded_returnsInitialStatePending() { - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() @@ -247,11 +302,23 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playInvalidExploration_thenPlayValidExp_returnsInitialPendingState() { // Start with playing an invalid exploration. - playExploration("invalid_exp_id") + playExploration( + profileId.internalId, + INVALID_TOPIC_ID, + INVALID_STORY_ID, + INVALID_EXPLORATION_ID, + shouldSavePartialProgress = false + ) endExploration() // Then a valid one. - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) @@ -285,11 +352,23 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_withoutFinishingPrevious_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) // Try playing another exploration without finishing the previous one. val resultLiveData = - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) resultLiveData.observeForever(mockAsyncResultLiveDataObserver) testCoroutineDispatchers.runCurrent() @@ -306,11 +385,23 @@ class ExplorationProgressControllerTest { explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) // Start with playing a valid exploration, then stop. - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) endExploration() // Then another valid one. - playExploration(TEST_EXPLORATION_ID_4) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + shouldSavePartialProgress = false + ) // The latest result should correspond to the valid ID, and the progress controller should // gracefully recover. @@ -347,7 +438,13 @@ class ExplorationProgressControllerTest { fun testSubmitAnswer_whileLoading_failsWithError() { // Start playing an exploration, but don't wait for it to complete. subscribeToCurrentStateToAllowExplorationToLoad() - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) @@ -368,7 +465,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_correctAnswer_succeeds() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() val result = @@ -387,7 +490,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_correctAnswer_returnsOutcomeWithTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() val result = @@ -408,7 +517,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_wrongAnswer_succeeds() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() val result = @@ -427,7 +542,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_wrongAnswer_providesDefFeedbackAndSameStateTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() val result = @@ -450,7 +571,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() submitMultipleChoiceAnswer(2) @@ -474,7 +601,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() submitMultipleChoiceAnswer(0) @@ -497,7 +630,13 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterSubmittingWrongThenRightAnswer_updatesToStateWithBothAnswers() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeMultipleChoiceState() submitMultipleChoiceAnswer(0) @@ -537,7 +676,13 @@ class ExplorationProgressControllerTest { fun testMoveToNext_whileLoadingExploration_failsWithError() { // Start playing an exploration, but don't wait for it to complete. subscribeToCurrentStateToAllowExplorationToLoad() - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val moveToStateResult = explorationProgressController.moveToNextState() moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) @@ -552,7 +697,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_forPendingInitialState_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val moveToStateResult = explorationProgressController.moveToNextState() moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) @@ -569,7 +720,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_forCompletedState_succeeds() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) submitPrototypeState1Answer() val moveToStateResult = explorationProgressController.moveToNextState() @@ -585,7 +742,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) submitPrototypeState1Answer() moveToNextState() @@ -603,7 +766,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_afterMovingFromCompletedState_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) submitPrototypeState1Answer() moveToNextState() @@ -639,7 +808,13 @@ class ExplorationProgressControllerTest { fun testMoveToPrevious_whileLoadingExploration_failsWithError() { // Start playing an exploration, but don't wait for it to complete. subscribeToCurrentStateToAllowExplorationToLoad() - explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_2) + explorationDataController.startPlayingExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val moveToStateResult = explorationProgressController.moveToPreviousState() @@ -656,7 +831,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_onPendingInitialState_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) val moveToStateResult = explorationProgressController.moveToPreviousState() @@ -674,7 +855,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_onCompletedInitialState_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) submitPrototypeState1Answer() val moveToStateResult = @@ -693,7 +880,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_forStateWithCompletedPreviousState_succeeds() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() val moveToStateResult = @@ -712,7 +905,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -733,7 +932,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -754,7 +959,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_correctAnswer_returnsOutcomeWithTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeTextInputState() val result = @@ -775,7 +986,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_wrongAnswer_returnsDefaultOutcome() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeTextInputState() val result = @@ -797,7 +1014,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forFractionInput_wrongAnswer_returnsDefaultOutcome_hasHint() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeFractionInputState() submitWrongAnswerForPrototypeState2() @@ -823,7 +1046,13 @@ class ExplorationProgressControllerTest { @Test fun testRevealHint_forWrongAnswer_showHint_returnHintIsRevealed() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeFractionInputState() submitWrongAnswerForPrototypeState2() @@ -862,7 +1091,13 @@ class ExplorationProgressControllerTest { @Test fun testRevealSolution_forWrongAnswer_showSolution_returnSolutionIsRevealed() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeFractionInputState() submitWrongAnswerForPrototypeState2() @@ -894,7 +1129,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_wrongAnswer_afterAllHintsAreExhausted_showSolution() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeFractionInputState() submitWrongAnswerForPrototypeState2() @@ -923,7 +1164,13 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_secondState_submitRightAnswer_pendingStateBecomesCompleted() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeTextInputState() val result = @@ -948,7 +1195,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_withSpaces_updatesStateWithVerbatimAnswer() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeTextInputState() val result = @@ -975,7 +1228,13 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_eighthState_submitWrongAnswer_updatePendingState() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeTextInputState() val result = @@ -1003,7 +1262,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -1025,7 +1290,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() submitPrototypeState2Answer() // Submit the answer but do not proceed to the next state. @@ -1046,7 +1317,13 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterMoveToPrev_onThirdState_newObserver_receivesCompletedSecondState() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() @@ -1057,8 +1334,8 @@ class ExplorationProgressControllerTest { currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver2) testCoroutineDispatchers.runCurrent() - // The new observer should observe the completed second state since it's the current pending - // state. + // The new observer should observe the completed second state + // since it's the current pending state. verify( mockCurrentStateLiveDataObserver2, atLeastOnce() @@ -1075,7 +1352,13 @@ class ExplorationProgressControllerTest { explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) // The initial state should not have a next state. verify( @@ -1091,7 +1374,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) submitPrototypeState1Answer() @@ -1110,7 +1399,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() @@ -1128,7 +1423,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -1147,7 +1448,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -1165,7 +1472,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forNumericInput_correctAnswer_returnsOutcomeWithTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeNumericInputState() val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121.0)) @@ -1185,7 +1498,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsOutcomeWithTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) navigateToPrototypeNumericInputState() val result = explorationProgressController.submitAnswer( @@ -1207,7 +1526,13 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forContinue_returnsOutcomeWithTransition() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) // The first state of the exploration is the Continue interaction. val result = explorationProgressController.submitAnswer(createContinueButtonAnswer()) @@ -1229,7 +1554,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeExploration() @@ -1248,7 +1579,13 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() @@ -1276,7 +1613,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_onFinalState_failsWithError() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeExploration() val moveToStateResult = explorationProgressController.moveToNextState() @@ -1297,7 +1640,13 @@ class ExplorationProgressControllerTest { explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_5) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + shouldSavePartialProgress = false + ) submitImageRegionAnswer(clickX = 0.5f, clickY = 0.5f, clickedRegion = "Saturn") moveToNextState() @@ -1319,7 +1668,13 @@ class ExplorationProgressControllerTest { // Click on Jupiter before Saturn to take a slightly different (valid) path through the // exploration. (Note that this does not include actual branching). - playExploration(TEST_EXPLORATION_ID_5) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + shouldSavePartialProgress = false + ) submitImageRegionAnswer(clickX = 0.2f, clickY = 0.5f, clickedRegion = "Jupiter") submitImageRegionAnswer(clickX = 0.5f, clickY = 0.5f, clickedRegion = "Saturn") moveToNextState() @@ -1339,11 +1694,23 @@ class ExplorationProgressControllerTest { val currentStateLiveData = explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeExploration() endExploration() - playExploration(TEST_EXPLORATION_ID_5) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + shouldSavePartialProgress = false + ) submitImageRegionAnswer(clickX = 0.2f, clickY = 0.5f, clickedRegion = "Jupiter") // Verify that we're on the second-to-last state of the second exploration. @@ -1373,7 +1740,13 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError_logsException() { subscribeToCurrentStateToAllowExplorationToLoad() - playExploration(TEST_EXPLORATION_ID_2) + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = false + ) playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -1408,11 +1781,323 @@ class ExplorationProgressControllerTest { explorationProgressController.getCurrentState().toLiveData() currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) - playExploration("invalid_exp_id") + playExploration( + profileId.internalId, + INVALID_TOPIC_ID, + INVALID_STORY_ID, + INVALID_EXPLORATION_ID, + shouldSavePartialProgress = false + ) val exception = fakeExceptionLogger.getMostRecentException() assertThat(exception).isInstanceOf(FileNotFoundException::class.java) - assertThat(exception).hasMessageThat().contains("invalid_exp_id") + assertThat(exception).hasMessageThat().contains(INVALID_EXPLORATION_ID) + } + + @Test + fun testCheckpointing_loadExploration_checkCheckpointIsSaved() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + testCoroutineDispatchers.runCurrent() + + val retrieveCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + TEST_EXPLORATION_ID_2 + ).toLiveData() + + verifyOperationSucceeds(retrieveCheckpointLiveData) + } + + @Test + fun testCheckpointing_playThroughMultipleStates_verifyCheckpointHasCorrectPendingStateName() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "Continue" + ) + + playThroughPrototypeState1AndMoveToNextState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "Fractions", + ) + + playThroughPrototypeState2AndMoveToNextState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "MultipleChoice", + ) + + playThroughPrototypeState3AndMoveToNextState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "ItemSelectionMinOne", + ) + } + + @Test + fun testCheckpointing_advToFourthState_backToPrevState_verifyCheckpointHasCorrectPendingState() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + playThroughPrototypeState3AndMoveToNextState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "ItemSelectionMinOne", + ) + moveToPreviousState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "ItemSelectionMinOne", + ) + } + + @Test + fun testCheckpointing_backTwoStates_nextState_verifyCheckpointHasCorrectPendingState() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + playThroughPrototypeState3AndMoveToNextState() + moveToPreviousState() + moveToPreviousState() + moveToNextState() + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "ItemSelectionMinOne", + ) + } + + @Test + fun testCheckpointing_advanceToThirdState_submitMultipleAns_checkCheckpointIsSavedAfterEachAns() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + + // option 2 is the correct answer to the third state. + submitMultipleChoiceAnswer(choiceIndex = 0) + testCoroutineDispatchers.runCurrent() + + verifyCheckpointHasCorrectCountOfAnswers( + profileId, + TEST_EXPLORATION_ID_2, + countOfAnswers = 1 + ) + + // option 2 is the correct answer to the third state. + submitMultipleChoiceAnswer(choiceIndex = 1) + testCoroutineDispatchers.runCurrent() + + verifyCheckpointHasCorrectCountOfAnswers( + profileId, + TEST_EXPLORATION_ID_2, + countOfAnswers = 2 + ) + + // option 2 is the correct answer to the third state. + submitMultipleChoiceAnswer(choiceIndex = 2) + testCoroutineDispatchers.runCurrent() + + // count should be equal to zero because on submission of the correct answer, the + // pendingTopState changes. + verifyCheckpointHasCorrectCountOfAnswers( + profileId, + TEST_EXPLORATION_ID_2, + countOfAnswers = 0 + ) + } + + @Test + fun testCheckpointing_advToThirdState_submitAns_prevState_checkCheckpointIsSavedAfterEachAns() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + + // option 2 is the correct answer to the third state. + submitMultipleChoiceAnswer(choiceIndex = 1) + testCoroutineDispatchers.runCurrent() + // option 2 is the correct answer to the third state. + submitMultipleChoiceAnswer(choiceIndex = 1) + testCoroutineDispatchers.runCurrent() + + moveToPreviousState() + } + + @Test + fun testCheckpointing_advToThirdState_moveToPrevState_checkCheckpointHasStateIndexOfThirdState() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + moveToPreviousState() + + verifyCheckpointHasCorrectStateIndex( + profileId, + TEST_EXPLORATION_ID_2, + stateIndex = 2 + ) + } + + @Test + fun testCheckpointing_advToThirdState_prevStates_nextState_checkCheckpointHasCorrectStateIndex() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + playThroughPrototypeState2AndMoveToNextState() + moveToPreviousState() + moveToPreviousState() + moveToNextState() + + verifyCheckpointHasCorrectStateIndex( + profileId, + TEST_EXPLORATION_ID_2, + stateIndex = 2 + ) + } + + @Test + fun testCheckpointing_revealHint_checkHintIsSavedInCheckpoint() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + navigateToPrototypeFractionInputState() + submitWrongAnswerForPrototypeState2() + + verify( + mockCurrentStateLiveDataObserver, + atLeastOnce() + ).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + + val result = explorationProgressController.submitHintIsRevealed( + state = currentState.state, + hintIsRevealed = true, + hintIndex = 0, + ) + result.observeForever(mockAsyncHintObserver) + testCoroutineDispatchers.runCurrent() + + verifyCheckpointHasCorrectHintIndex( + profileId, + TEST_EXPLORATION_ID_2, + indexOfRevealedHint = 0 + ) + } + + @Test + fun testCheckpointing_revealSolution_checkCheckpointIsSaved() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + navigateToPrototypeFractionInputState() + submitWrongAnswerForPrototypeState2() + + verify( + mockCurrentStateLiveDataObserver, + atLeastOnce() + ).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + + val result = explorationProgressController.submitSolutionIsRevealed(currentState.state) + result.observeForever(mockAsyncSolutionObserver) + testCoroutineDispatchers.runCurrent() + + verifyCheckpointHasCorrectValueOfIsSolutionRevealed( + profileId, + TEST_EXPLORATION_ID_2, + isSolutionRevealed = true + ) + } + + @Test + fun testCheckpointing_onStateWithContinueInteraction_pressContinue_correctCheckpointIsSaved() { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration( + profileId.internalId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + shouldSavePartialProgress = true + ) + playThroughPrototypeState1AndMoveToNextState() + // Verify that checkpoint is saved when the exploration moves to the new pendingTopState. + verifyCheckpointHasCorrectPendingStateName( + profileId, + TEST_EXPLORATION_ID_2, + pendingStateName = "Fractions" + ) } private fun setUpTestApplicationComponent() { @@ -1431,8 +2116,22 @@ class ExplorationProgressControllerTest { .observeForever(mockCurrentStateLiveDataObserver) } - private fun playExploration(explorationId: String) { - verifyOperationSucceeds(explorationDataController.startPlayingExploration(explorationId)) + private fun playExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean + ) { + verifyOperationSucceeds( + explorationDataController.startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress + ) + ) } private fun submitContinueButtonAnswer() { @@ -1779,6 +2478,121 @@ class ExplorationProgressControllerTest { return UserAnswer.newBuilder().setAnswer(answer).setPlainAnswer(answer.toAnswerString()).build() } + private fun verifyCheckpointHasCorrectPendingStateName( + profileId: ProfileId, + explorationId: String, + pendingStateName: String + ) { + testCoroutineDispatchers.runCurrent() + reset(mockExplorationCheckpointObserver) + val explorationCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + explorationId + ).toLiveData() + explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver) + testCoroutineDispatchers.runCurrent() + + verify(mockExplorationCheckpointObserver, atLeastOnce()) + .onChanged(explorationCheckpointCaptor.capture()) + assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue() + + assertThat(explorationCheckpointCaptor.value.getOrThrow().pendingStateName) + .isEqualTo(pendingStateName) + } + + private fun verifyCheckpointHasCorrectCountOfAnswers( + profileId: ProfileId, + explorationId: String, + countOfAnswers: Int + ) { + testCoroutineDispatchers.runCurrent() + reset(mockExplorationCheckpointObserver) + val explorationCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + explorationId + ).toLiveData() + explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver) + testCoroutineDispatchers.runCurrent() + + verify(mockExplorationCheckpointObserver, atLeastOnce()) + .onChanged(explorationCheckpointCaptor.capture()) + assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue() + + assertThat(explorationCheckpointCaptor.value.getOrThrow().pendingUserAnswersCount) + .isEqualTo(countOfAnswers) + } + + private fun verifyCheckpointHasCorrectStateIndex( + profileId: ProfileId, + explorationId: String, + stateIndex: Int + ) { + testCoroutineDispatchers.runCurrent() + reset(mockExplorationCheckpointObserver) + val explorationCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + explorationId + ).toLiveData() + explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver) + testCoroutineDispatchers.runCurrent() + + verify(mockExplorationCheckpointObserver, atLeastOnce()) + .onChanged(explorationCheckpointCaptor.capture()) + assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue() + + assertThat(explorationCheckpointCaptor.value.getOrThrow().stateIndex) + .isEqualTo(stateIndex) + } + + private fun verifyCheckpointHasCorrectHintIndex( + profileId: ProfileId, + explorationId: String, + indexOfRevealedHint: Int + ) { + testCoroutineDispatchers.runCurrent() + reset(mockExplorationCheckpointObserver) + val explorationCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + explorationId + ).toLiveData() + explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver) + testCoroutineDispatchers.runCurrent() + + verify(mockExplorationCheckpointObserver, atLeastOnce()) + .onChanged(explorationCheckpointCaptor.capture()) + assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue() + + assertThat(explorationCheckpointCaptor.value.getOrThrow().hintIndex) + .isEqualTo(indexOfRevealedHint) + } + + private fun verifyCheckpointHasCorrectValueOfIsSolutionRevealed( + profileId: ProfileId, + explorationId: String, + isSolutionRevealed: Boolean + ) { + testCoroutineDispatchers.runCurrent() + reset(mockExplorationCheckpointObserver) + val explorationCheckpointLiveData = + explorationCheckpointController.retrieveExplorationCheckpoint( + profileId, + explorationId + ).toLiveData() + explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver) + testCoroutineDispatchers.runCurrent() + + verify(mockExplorationCheckpointObserver, atLeastOnce()) + .onChanged(explorationCheckpointCaptor.capture()) + assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue() + + assertThat(explorationCheckpointCaptor.value.getOrThrow().solutionIsRevealed) + .isEqualTo(isSolutionRevealed) + } + /** * Verifies that the specified live data provides at least one successful operation. This will * change test-wide mock state, and synchronizes background execution. @@ -1798,6 +2612,22 @@ class ExplorationProgressControllerTest { reset(mockAsyncResultLiveDataObserver) } + /** + * Verifies that the specified live data provides a failure result. This will change test-wide + * mock state, and synchronizes background execution. + */ + private fun verifyOperationFails(liveData: LiveData>) { + reset(mockAsyncResultLiveDataObserver) + liveData.observeForever(mockAsyncResultLiveDataObserver) + testCoroutineDispatchers.runCurrent() + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + asyncResultCaptor.value.apply { + // This bit of conditional logic is used to add better error reporting when failures occur. + assertThat(isFailure()).isTrue() + } + reset(mockAsyncResultLiveDataObserver) + } + // TODO(#89): Move this to a common test application component. @Module class TestModule { @@ -1833,6 +2663,23 @@ class ExplorationProgressControllerTest { testEnvironmentConfig.isUsingBazel() } + @Module + class TestExplorationStorageModule { + + /** + * Provides the size allocated to exploration checkpoint database. + * + * For testing, the current [ExplorationStorageDatabaseSize] is set to be 150 Bytes. + * + * The size of checkpoint for the the first state in [TEST_EXPLORATION_ID_2] is equal to + * 150 Bytes, therefore the database will exceeded the allocated limit when the second + * checkpoint is stored for [TEST_EXPLORATION_ID_2] + */ + @Provides + @ExplorationStorageDatabaseSize + fun provideExplorationStorageDatabaseSize(): Int = 150 + } + // TODO(#89): Move this to a common test application component. @Singleton @Component( @@ -1842,7 +2689,8 @@ class ExplorationProgressControllerTest { NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, DragDropSortInputModule::class, InteractionsModule::class, TestLogReportingModule::class, ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class, - RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class + RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + TestExplorationStorageModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt index 7e45d001dc5..b35b3cc228c 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt @@ -21,6 +21,7 @@ import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule +import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.ExplorationCheckpointDetails import org.oppia.android.app.model.ProfileId @@ -115,7 +116,7 @@ class ExplorationCheckpointControllerTest { verifyMockObserverIsSuccessful(mockResultObserver, resultCaptor) assertThat(resultCaptor.value.getOrThrow()).isEqualTo( - ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT + CheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT ) } @@ -128,7 +129,7 @@ class ExplorationCheckpointControllerTest { verifyMockObserverIsSuccessful(mockResultObserver, resultCaptor) assertThat(resultCaptor.value.getOrThrow()).isEqualTo( - ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT + CheckpointState.CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT ) } @@ -141,7 +142,7 @@ class ExplorationCheckpointControllerTest { verifyMockObserverIsSuccessful(mockResultObserver, resultCaptor) assertThat(resultCaptor.value.getOrThrow()).isEqualTo( - ExplorationCheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT + CheckpointState.CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT ) } @@ -290,7 +291,7 @@ class ExplorationCheckpointControllerTest { ) /** - * updates the saved checkpoint for the test exploration specified by the [index] supplied. + * Updates the saved checkpoint for the test exploration specified by the [index] supplied. * * For this function to work as intended, it has to be made sure that a checkpoint for the test * exploration specified by the index already exists in the checkpoint database of that profile. @@ -300,12 +301,11 @@ class ExplorationCheckpointControllerTest { private fun saveUpdatedCheckpoint( profileId: ProfileId, index: Int - ): DataProvider = - explorationCheckpointController.recordExplorationCheckpoint( - profileId = profileId, - explorationId = createExplorationIdForIndex(index), - explorationCheckpoint = createUpdatedCheckpoint(index) - ) + ): DataProvider = explorationCheckpointController.recordExplorationCheckpoint( + profileId = profileId, + explorationId = createExplorationIdForIndex(index), + explorationCheckpoint = createUpdatedCheckpoint(index) + ) private fun saveMultipleCheckpoints(profileId: ProfileId, numberOfCheckpoints: Int) { for (index in 0 until numberOfCheckpoints) { diff --git a/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressControllerTest.kt index b391e535254..3dd34398914 100644 --- a/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressControllerTest.kt @@ -18,9 +18,11 @@ import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.reset import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule +import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.ProfileId import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.testing.TestLogReportingModule @@ -75,6 +77,12 @@ class StoryProgressControllerTest { @Captor lateinit var recordProgressResultCaptor: ArgumentCaptor> + @Mock + lateinit var mockRetrieveChapterPlayStateObserver: Observer> + + @Captor + lateinit var retrieveChapterPlayStateCaptor: ArgumentCaptor> + private lateinit var profileId: ProfileId @Before @@ -115,12 +123,168 @@ class StoryProgressControllerTest { verifyRecordProgressSucceeded() } + @Test + fun testStoryProgressController_recordChapterAsInProgressSaved_isSuccessful() { + storyProgressController.recordChapterAsInProgressSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + } + + @Test + fun testStoryProgressController_chapterCompleted_markChapterAsSaved_playStateIsCompleted() { + storyProgressController.recordCompletedChapter( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + + storyProgressController.recordChapterAsInProgressSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + verifyChapterPlayStateIsCorrect( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + ChapterPlayState.COMPLETED + ) + } + + @Test + fun testStoryProgressController_chapterNotStarted_markChapterAsSaved_playStateIsSaved() { + storyProgressController.recordChapterAsInProgressSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + verifyChapterPlayStateIsCorrect( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + ChapterPlayState.IN_PROGRESS_SAVED + ) + } + + @Test + fun testStoryProgressController_recordChapterAsInProgressNotSaved_isSuccessful() { + storyProgressController.recordChapterAsInProgressNotSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + } + + @Test + fun testStoryProgressController_chapterCompleted_markChapterAsNotSaved_playStateIsCompleted() { + storyProgressController.recordCompletedChapter( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + + storyProgressController.recordChapterAsInProgressNotSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + verifyChapterPlayStateIsCorrect( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + ChapterPlayState.COMPLETED + ) + } + + @Test + fun testStoryProgressController_chapterNotStarted_markChapterAsSaved_playStateIsNotSaved() { + storyProgressController.recordChapterAsInProgressNotSaved( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + fakeOppiaClock.getCurrentTimeMs() + ).toLiveData().observeForever(mockRecordProgressObserver) + testCoroutineDispatchers.runCurrent() + + verifyRecordProgressSucceeded() + verifyChapterPlayStateIsCorrect( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + ChapterPlayState.IN_PROGRESS_NOT_SAVED + ) + } + + private fun verifyChapterPlayStateIsCorrect( + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + chapterPlayState: ChapterPlayState + ) { + storyProgressController.retrieveChapterPlayStateByExplorationId( + profileId, + topicId, + storyId, + explorationId + ).toLiveData().observeForever(mockRetrieveChapterPlayStateObserver) + + testCoroutineDispatchers.runCurrent() + + verify(mockRetrieveChapterPlayStateObserver, atLeastOnce()) + .onChanged(retrieveChapterPlayStateCaptor.capture()) + + assertThat(retrieveChapterPlayStateCaptor.value.isSuccess()).isTrue() + assertThat(retrieveChapterPlayStateCaptor.value.getOrThrow()).isEqualTo(chapterPlayState) + } + private fun verifyRecordProgressSucceeded() { - verify( - mockRecordProgressObserver, - atLeastOnce() - ).onChanged(recordProgressResultCaptor.capture()) + verify(mockRecordProgressObserver, atLeastOnce()) + .onChanged(recordProgressResultCaptor.capture()) assertThat(recordProgressResultCaptor.value.isSuccess()).isTrue() + reset(mockRecordProgressObserver) } // TODO(#89): Move this to a common test application component. diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index b837e0a037a..cc911ded54f 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -233,6 +233,9 @@ message EphemeralState { // message structure if additional data needs to be passed along to the terminal state card. bool terminal_state = 6; } + + // The current status of checkpointing the exploration. + CheckpointState checkpoint_state = 7; } // Corresponds to an exploration state that hasn't yet had a correct answer filled in. @@ -331,3 +334,20 @@ message HelpIndex { bool everything_revealed = 3; } } + +// Different states in which exploration checkpoint can exist. +enum CheckpointState { + // The state of checkpoint is unknown. + CHECKPOINT_STATE_UNSPECIFIED = 0; + + // Progress made in the exploration is saved and the size of the checkpoint database has + // not exceeded the allocated limit. + CHECKPOINT_SAVED_DATABASE_NOT_EXCEEDED_LIMIT = 1; + + // Progress made in the exploration is saved and the size of the checkpoint database has + // exceeded the allocated limit. + CHECKPOINT_SAVED_DATABASE_EXCEEDED_LIMIT = 2; + + // Progress made in the exploration is not saved. + CHECKPOINT_UNSAVED = 3; +}