diff --git a/app/BUILD.bazel b/app/BUILD.bazel index e26c3cfb15d..f67cca04269 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -36,6 +36,7 @@ package_group( package_group( name = "app_testing_visibility", packages = [ + "//app/src/sharedTest/...", "//app/src/test/...", ], ) @@ -46,6 +47,12 @@ filegroup( visibility = ["//:oppia_testing_visibility"], ) +filegroup( + name = "data_binder_mapper_impl", + srcs = ["src/test/java/DataBinderMapperImpl.java"], + visibility = [":app_testing_visibility"], +) + # Source files for the migrated source files library. The files inside the migrated source files # library are dependencies in app module that have their own libraries. # Place your files here if: @@ -812,7 +819,10 @@ kt_android_library( "src/sharedTest/java/org/oppia/android/app/utility/ProgressMatcher.kt", "src/sharedTest/java/org/oppia/android/app/utility/TabMatcher.kt", ], - visibility = ["//app:__subpackages__"], + visibility = [ + ":app_testing_visibility", + "//app:__subpackages__", + ], deps = [ ":app", "//testing", diff --git a/app/app_test.bzl b/app/app_test.bzl index 4d76856ae10..c3fe4fe8c32 100644 --- a/app/app_test.bzl +++ b/app/app_test.bzl @@ -5,29 +5,39 @@ Macros for app module tests. load("//:oppia_android_test.bzl", "oppia_android_module_level_test") # TODO(#1620): Remove module-specific test macros once Gradle is removed -def app_test(name, processed_src, test_path_prefix, filtered_tests, deps, **kwargs): +def app_test( + name, + deps, + processed_src = None, + test_class = None, + test_path_prefix = None, + filtered_tests = [], + **kwargs): """ Creates individual tests for test files in the app module. Args: - name: str. The relative path to the Kotlin test file. - processed_src: str. The source to a processed version of the test that should be used + name: str. The relative path to the Kotlin test file, or the name of the suite. + processed_src: str|None. The source to a processed version of the test that should be used instead of the original. - test_path_prefix: str. The prefix of the test path (which is used to extract the qualified - class name of the test suite). - filtered_tests: list of str. The test files that should not have tests defined for them. + test_class: str|None. The fully qualified test class that will be run (relative to + src/test/java). + test_path_prefix: str|None. The prefix of the test path (which is used to extract the + qualified class name of the test suite). deps: list of str. The list of dependencies needed to build and run this test. + filtered_tests: list of str. The test files that should not have tests defined for them. **kwargs: additional parameters passed in. """ oppia_android_module_level_test( name = name, - processed_src = processed_src, filtered_tests = filtered_tests, - test_path_prefix = test_path_prefix, deps = deps, + processed_src = processed_src, + test_class = test_class, + test_path_prefix = test_path_prefix, custom_package = "org.oppia.android.app.test", - test_manifest = "src/test/AndroidManifest.xml", - additional_srcs = ["src/test/java/DataBinderMapperImpl.java"], + test_manifest = "//app:test_manifest", + additional_srcs = ["//app:data_binder_mapper_impl"], enable_data_binding = True, **kwargs ) diff --git a/app/build.gradle b/app/build.gradle index 42d7f18bf0d..49adb870cce 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -173,8 +173,8 @@ dependencies { 'de.hdodenhof:circleimageview:3.0.1', 'nl.dionsegijn:konfetti:1.2.5', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", - 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1', - 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1', + 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1', + 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1', 'org.mockito:mockito-core:2.7.22', ) compileOnly( diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt index 704b25833d1..9e90beb624e 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt @@ -25,7 +25,7 @@ class SubmittedAnswerViewModel( ) private var accessibleAnswer: String? = DEFAULT_ACCESSIBLE_ANSWER - fun setSubmittedAnswer(submittedAnswer: CharSequence, accessibleAnswer: String? = null) { + fun setSubmittedAnswer(submittedAnswer: CharSequence, accessibleAnswer: String?) { this.submittedAnswer.set(submittedAnswer) this.accessibleAnswer = accessibleAnswer updateSubmittedAnswerContentDescription() diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel b/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel new file mode 100644 index 00000000000..631bf8ec398 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel @@ -0,0 +1,46 @@ +""" +Tests for the core state player experience. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//app:app_test.bzl", "app_test") +load("//app:test_with_resources.bzl", "test_with_resources") + +app_test( + name = "StateFragmentTest", + processed_src = test_with_resources("StateFragmentTest.kt"), + test_class = "org.oppia.android.app.player.state.StateFragmentTest", + deps = [ + ":dagger", + "//app", + "//app:test_deps", + "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", + "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", + "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/espresso:edit_text_input_action", + "//testing/src/main/java/org/oppia/android/testing/junit:initialize_default_locale_rule", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:coroutine_executor_service", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_github_bumptech_glide_mocks", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/accessibility:test_module", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_util_module", + ], +) + +dagger_rules() diff --git a/config/proguard/kotlin-proguard-rules.pro b/config/proguard/kotlin-proguard-rules.pro index d5315c8fb1d..0ed1a7d6a17 100644 --- a/config/proguard/kotlin-proguard-rules.pro +++ b/config/proguard/kotlin-proguard-rules.pro @@ -5,3 +5,21 @@ # run into runtime issues if something is unintentionally removed. -dontwarn android.support.annotation.Keep -dontwarn android.support.annotation.VisibleForTesting + +# https://github.com/Kotlin/kotlinx.coroutines/issues/2046 describes some of the classes which are +# safe to ignore due to kotlinx.coroutines dependencies. All of the following are sourced from: +# https://github.com/Kotlin/kotlinx.coroutines/blob/bc120a/kotlinx-coroutines-core/jvm/resources/META-INF/com.android.tools/proguard/coroutines.pro +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-keepclassmembers class kotlin.coroutines.SafeContinuation { + volatile ; +} +-dontwarn java.lang.ClassValue +-dontwarn java.lang.instrument.ClassFileTransformer +-dontwarn java.lang.instrument.Instrumentation +-dontwarn sun.misc.SignalHandler +-dontwarn sun.misc.Signal +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement diff --git a/data/build.gradle b/data/build.gradle index 9c6ce1210e0..b90bcc469fc 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -70,7 +70,7 @@ dependencies { 'com.google.protobuf:protobuf-javalite:3.17.3', 'com.squareup.moshi:moshi-kotlin:1.11.0', 'com.squareup.okhttp3:okhttp:4.7.2', - 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2', + 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1', ) compileOnly( 'jakarta.xml.bind:jakarta.xml.bind-api:2.3.2', diff --git a/domain/build.gradle b/domain/build.gradle index fbb6cb79fca..894b47c3dc8 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -112,7 +112,7 @@ dependencies { 'com.squareup.okhttp3:mockwebserver:4.7.2', 'junit:junit:4.12', "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version", - 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2', + 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', 'org.mockito:mockito-core:2.19.0', 'org.robolectric:robolectric:4.5', 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 1fbb83eec34..2873d326892 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 @@ -40,24 +40,26 @@ class ExplorationDataController @Inject constructor( /** * Begins playing an exploration of the specified ID. * - * This method is not expected to fail. - * * [ExplorationProgressController] should be used to manage the play state, and monitor the load * success/failure of the exploration. * - * This must be called only if no active exploration is being played. The previous exploration - * must have first been stopped using [stopPlayingExploration], otherwise the operation will fail. + * This can be called even if a session is currently active as it will force initiate a new play + * session, resetting any data from the previous session (though any pending unsaved checkpoint + * progress is guaranteed to be saved from the previous session, first). + * + * [stopPlayingExploration] may be optionally called to clean up the session--see the + * documentation for that method for details. * * @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 + * @param shouldSavePartialProgress indicates if partial progress should be saved for the new play + * session * @param explorationCheckpoint the checkpoint which may be used to resume the exploration - * @return a one-time [DataProvider] to observe whether initiating the play request succeeded. - * The exploration may still fail to load, but this provides early-failure detection. + * @return a [DataProvider] to observe whether initiating the play request, or future play + * requests, succeeded */ fun startPlayingExploration( internalProfileId: Int, @@ -68,7 +70,7 @@ class ExplorationDataController @Inject constructor( explorationCheckpoint: ExplorationCheckpoint ): DataProvider { return explorationProgressController.beginExplorationAsync( - internalProfileId, + ProfileId.newBuilder().apply { internalId = internalProfileId }.build(), topicId, storyId, explorationId, @@ -79,10 +81,16 @@ class ExplorationDataController @Inject constructor( /** * Finishes the most recent exploration started by [startPlayingExploration], and returns a - * one-off [DataProvider] indicating whether the operation succeeded. + * [DataProvider] indicating whether the operation succeeded. * * This method should only be called if an active exploration is being played, otherwise the - * operation will fail. + * resulting provider will fail. Note that this doesn't actually need to be called between + * sessions unless the caller wants to ensure other providers monitored from + * [ExplorationProgressController] are reset to a proper out-of-session state. + * + * Note that the returned provider monitors the long-term stopping state of exploration sessions + * and will be reset to 'pending' when a session is currently active, or before any session has + * started. */ fun stopPlayingExploration(): DataProvider = explorationProgressController.finishExplorationAsync() 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 11c403a259c..2b7d5a58bba 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 @@ -1,5 +1,14 @@ package org.oppia.android.domain.exploration +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.oppia.android.app.model.AnswerOutcome import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.EphemeralState @@ -9,23 +18,25 @@ import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.UserAnswer import org.oppia.android.domain.classify.AnswerClassificationController +import org.oppia.android.domain.exploration.ExplorationProgress.PlayStage.LOADING_EXPLORATION +import org.oppia.android.domain.exploration.ExplorationProgress.PlayStage.NOT_PLAYING +import org.oppia.android.domain.exploration.ExplorationProgress.PlayStage.SUBMITTING_ANSWER +import org.oppia.android.domain.exploration.ExplorationProgress.PlayStage.VIEWING_STATE import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController import org.oppia.android.domain.hintsandsolution.HintHandler 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.domain.translation.TranslationController -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.data.DataProviders.Companion.transformAsync -import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.system.OppiaClock -import java.util.concurrent.locks.ReentrantLock +import org.oppia.android.util.threading.BackgroundDispatcher +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -import kotlin.concurrent.withLock private const val BEGIN_EXPLORATION_RESULT_PROVIDER_ID = "ExplorationProgressController.begin_exploration_result" @@ -42,6 +53,15 @@ private const val MOVE_TO_PREVIOUS_STATE_RESULT_PROVIDER_ID = private const val MOVE_TO_NEXT_STATE_RESULT_PROVIDER_ID = "ExplorationProgressController.move_to_next_state_result" private const val CURRENT_STATE_PROVIDER_ID = "ExplorationProgressController.current_state" +private const val LOCALIZED_STATE_PROVIDER_ID = "ExplorationProgressController.localized_state" + +/** + * A default session ID to be used before a session has been initialized. + * + * This session ID will never match, so messages that are received with this ID will never be + * processed. + */ +private const val DEFAULT_SESSION_ID = "default_session_id" /** * Controller that tracks and reports the learner's ephemeral/non-persisted progress through an @@ -49,12 +69,20 @@ private const val CURRENT_STATE_PROVIDER_ID = "ExplorationProgressController.cur * * The current exploration session is started via the exploration data controller. * - * This class is thread-safe, but the order of applied operations is arbitrary. Calling code should - * take care to ensure that uses of this class do not specifically depend on ordering. + * This class is not safe to use across multiple threads, and should only ever be interacted with + * via the main thread. The controller makes use of multiple threads to offload all state + * operations, so calls into this controller should return quickly and will never block. Each method + * returns a [DataProvider] that can be observed for the future result of the method's corresponding + * operation. + * + * Note that operations are guaranteed to execute in the order of controller method calls, internal + * state is always kept internally consistent (so long-running [DataProvider] subscriptions for a + * particular play session will receive updates), and state can never leak across session + * boundaries (though re-subscription will be necessary to observe state in a new play session--see + * [submitAnswer] and [getCurrentState] method KDocs for more details). */ @Singleton class ExplorationProgressController @Inject constructor( - private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, private val explorationRetriever: ExplorationRetriever, private val answerClassificationController: AnswerClassificationController, private val exceptionsController: ExceptionsController, @@ -64,108 +92,94 @@ class ExplorationProgressController @Inject constructor( private val oppiaLogger: OppiaLogger, private val hintHandlerFactory: HintHandler.Factory, private val translationController: TranslationController, - private val dataProviders: DataProviders -) : HintHandler.HintMonitor { + private val dataProviders: DataProviders, + @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher +) { // TODO(#179): Add support for parameters. - // TODO(#3622): Update the internal locking of this controller to use something like an in-memory - // blocking cache to simplify state locking. However, doing this correctly requires a fix in - // MediatorLiveData to avoid unexpected cancellations in chained cross-scope coroutines. Note - // that this is also essential to ensure post-load operations can be queued before load completes - // 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 explorationProgress = ExplorationProgress() - private val explorationProgressLock = ReentrantLock() - private lateinit var hintHandler: HintHandler + // TODO(#606): Replace this with a profile scope to avoid this hacky workaround (which is needed + // for getCurrentState). + private lateinit var profileId: ProfileId + + private var mostRecentSessionId: String? = null + private val activeSessionId: String + get() = mostRecentSessionId ?: DEFAULT_SESSION_ID + + private var mostRecentEphemeralStateFlow = + createAsyncResultStateFlow( + AsyncResult.Failure(IllegalStateException("Exploration is not yet initialized.")) + ) + + private var mostRecentCommandQueue: SendChannel>? = null /** * Resets this controller to begin playing the specified [Exploration], and returns a * [DataProvider] indicating whether the start was successful. + * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. */ internal fun beginExplorationAsync( - internalProfileId: Int, + profileId: ProfileId, topicId: String, storyId: String, explorationId: String, shouldSavePartialProgress: Boolean, explorationCheckpoint: ExplorationCheckpoint ): DataProvider { - return explorationProgressLock.withLock { - try { - check(explorationProgress.playStage == ExplorationProgress.PlayStage.NOT_PLAYING) { - "Expected to finish previous exploration before starting a new one." - } - - explorationProgress.apply { - currentProfileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() - currentTopicId = topicId - currentStoryId = storyId - currentExplorationId = explorationId - this.shouldSavePartialProgress = shouldSavePartialProgress - checkpointState = CheckpointState.CHECKPOINT_UNSAVED - this.explorationCheckpoint = explorationCheckpoint - } - hintHandler = hintHandlerFactory.create(this) - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.LOADING_EXPLORATION) - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - return@withLock dataProviders.createInMemoryDataProvider( - BEGIN_EXPLORATION_RESULT_PROVIDER_ID - ) { null } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return@withLock dataProviders.createInMemoryDataProviderAsync( - BEGIN_EXPLORATION_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } + val ephemeralStateFlow = createAsyncResultStateFlow() + val sessionId = UUID.randomUUID().toString().also { + mostRecentSessionId = it + mostRecentEphemeralStateFlow = ephemeralStateFlow + mostRecentCommandQueue = createControllerCommandActor() + } + val beginExplorationResultFlow = createAsyncResultStateFlow() + val message = + ControllerMessage.InitializeController( + profileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress, + explorationCheckpoint, + ephemeralStateFlow, + sessionId, + beginExplorationResultFlow + ) + this.profileId = profileId + sendCommandForOperation(message) { + "Failed to schedule command for initializing the exploration progress controller." } + return beginExplorationResultFlow.convertToSessionProvider(BEGIN_EXPLORATION_RESULT_PROVIDER_ID) } /** * Indicates that the current exploration being played is now completed, and returns a * [DataProvider] indicating whether the cleanup was successful. + * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer] with one additional caveat: this method does not actually need to be called when + * a session is over. Calling it ensures all other [DataProvider]s reset to a correct + * out-of-session state, but subsequent calls to [beginExplorationAsync] will reset the session. */ internal fun finishExplorationAsync(): DataProvider { - return explorationProgressLock.withLock { - try { - check(explorationProgress.playStage != ExplorationProgress.PlayStage.NOT_PLAYING) { - "Cannot finish playing an exploration that hasn't yet been started" - } - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.NOT_PLAYING) - return@withLock dataProviders.createInMemoryDataProvider( - FINISH_EXPLORATION_RESULT_PROVIDER_ID - ) { null } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return@withLock dataProviders.createInMemoryDataProviderAsync( - FINISH_EXPLORATION_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } - } - } - - override fun onHelpIndexChanged() { - explorationProgressLock.withLock { - saveExplorationCheckpoint() + val finishExplorationResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.FinishExploration(activeSessionId, finishExplorationResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for cleaning up after finishing the exploration." } - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) + return finishExplorationResultFlow.convertToSessionProvider( + FINISH_EXPLORATION_RESULT_PROVIDER_ID + ) } /** * Submits an answer to the current state and returns how the UI should respond to this answer. * - * The returned [DataProvider] will only have at most two results posted: a pending result, and - * then a completed success/failure result. Failures in this case represent a failure of the app - * (possibly due to networking conditions). The app should report this error in a consumable way - * to the user so that they may take action on it. No additional values will be reported to the - * [DataProvider]. Each call to this method returns a new, distinct, [DataProvider] object that - * must be observed. Note also that the returned [DataProvider] is not guaranteed to begin with a - * pending state. - * * If the app undergoes a configuration change, calling code should rely on the [DataProvider] * from [getCurrentState] to know whether a current answer is pending. That [DataProvider] will * have its state changed to pending during answer submission and until answer resolution. @@ -177,193 +191,68 @@ class ExplorationProgressController @Inject constructor( * completed that card. The learner can then proceed from the current completed state to the next * pending state using [moveToNextState]. * - * This method cannot be called until an exploration has started and [getCurrentState] returns a - * non-pending result or the result will fail. Calling code must also take care not to allow users - * to submit an answer while a previous answer is pending. That scenario will also result in a - * failed answer submission. + * ### Lifecycle behavior + * The returned [DataProvider] will initially be pending until the operation completes (unless + * called before a session is started). Note that a different provider is returned for each call, + * though it's tied to the same session so it can be monitored medium-term (i.e. for the duration + * of the play session, but not past it). Furthermore, the returned provider does not actually + * need to be monitored in order for the operation to complete, though it's recommended since + * [getCurrentState] can only be used to monitor the effects of the operation, not whether the + * operation itself succeeded. + * + * If this is called before a session begins it will return a provider that stays failing with no + * updates. The operation will also silently fail rather than queue up in these circumstances, so + * starting a session will not trigger an answer submission from an older call. + * + * Multiple subsequent calls during a valid session will queue up and have results delivered in + * order (though based on the eventual consistency nature of [DataProvider]s no assumptions can be + * made about whether all results will actually be received--[getCurrentState] should be used as + * the source of truth for the current state of the session). * * No assumptions should be made about the completion order of the returned [DataProvider] vs. the - * [DataProvider] from [getCurrentState]. Also note that the returned [DataProvider] will only - * have a single value and not be reused after that point. + * [DataProvider] from [getCurrentState]. */ fun submitAnswer(userAnswer: UserAnswer): DataProvider { - try { - explorationProgressLock.withLock { - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.NOT_PLAYING - ) { - "Cannot submit an answer if an exploration is not being played." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.LOADING_EXPLORATION - ) { - "Cannot submit an answer while the exploration is being loaded." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.SUBMITTING_ANSWER - ) { - "Cannot submit an answer while another answer is pending." - } - - // Notify observers that the submitted answer is currently pending. - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.SUBMITTING_ANSWER) - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - - lateinit var answerOutcome: AnswerOutcome - try { - val topPendingState = explorationProgress.stateDeck.getPendingTopState() - val outcome = - answerClassificationController.classify( - topPendingState.interaction, - userAnswer.answer, - userAnswer.writtenTranslationContext - ).outcome - answerOutcome = - explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) - explorationProgress.stateDeck.submitAnswer(userAnswer, answerOutcome.feedback) - - // Follow the answer's outcome to another part of the graph if it's different. - val ephemeralState = computeBaseCurrentEphemeralState() - when { - answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME -> { - val newState = explorationProgress.stateGraph.getState(answerOutcome.stateName) - explorationProgress.stateDeck.pushState(newState, prohibitSameStateName = true) - hintHandler.finishState(newState) - } - ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.PENDING_STATE -> { - // Schedule, or show immediately, a new hint or solution based on the current - // ephemeral state of the exploration because a new wrong answer was submitted. - hintHandler.handleWrongAnswerSubmission(ephemeralState.pendingState.wrongAnswerCount) - } - } - } finally { - if (!doesInteractionAutoContinue(answerOutcome.state.interaction.id)) { - // If the answer was not submitted on behalf of the Continue interaction, update the - // hint state and save checkpoint because it will be saved when the learner moves to the - // next state. - 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. - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE) - } - - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - - return dataProviders.createInMemoryDataProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID) { - answerOutcome - } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync(SUBMIT_ANSWER_RESULT_PROVIDER_ID) { - AsyncResult.Failure(e) - } - } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.SubmitAnswer(userAnswer, activeSessionId, submitResultFlow) + sendCommandForOperation(message) { "Failed to schedule command for answer submission." } + return submitResultFlow.convertToSessionProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID) } /** * Notifies the controller that the user wishes to reveal a hint. * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * * @param hintIndex index of the hint that was revealed in the hint list of the current pending * state - * @return a one-time [DataProvider] that indicates success/failure of the operation (the actual - * payload of the result isn't relevant) + * @return a [DataProvider] that indicates success/failure of the operation (the actual payload of + * the result isn't relevant) */ fun submitHintIsRevealed(hintIndex: Int): DataProvider { - try { - explorationProgressLock.withLock { - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.NOT_PLAYING - ) { - "Cannot submit an answer if an exploration is not being played." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.LOADING_EXPLORATION - ) { - "Cannot submit an answer while the exploration is being loaded." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.SUBMITTING_ANSWER - ) { - "Cannot submit an answer while another answer is pending." - } - try { - hintHandler.viewHint(hintIndex) - } finally { - // 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. - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE) - } - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - return dataProviders.createInMemoryDataProvider(SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID) { - null - } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.HintIsRevealed(hintIndex, activeSessionId, submitResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for revealing hint: $hintIndex." } + return submitResultFlow.convertToSessionProvider(SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID) } /** * Notifies the controller that the user has revealed the solution to the current state. * - * @return a one-time [DataProvider] that indicates success/failure of the operation (the actual - * payload of the result isn't relevant) + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * + * @return a [DataProvider] that indicates success/failure of the operation (the actual payload of + * the result isn't relevant) */ fun submitSolutionIsRevealed(): DataProvider { - try { - explorationProgressLock.withLock { - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.NOT_PLAYING - ) { - "Cannot submit an answer if an exploration is not being played." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.LOADING_EXPLORATION - ) { - "Cannot submit an answer while the exploration is being loaded." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.SUBMITTING_ANSWER - ) { - "Cannot submit an answer while another answer is pending." - } - try { - hintHandler.viewSolution() - } finally { - // 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. - explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE) - } - - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - return dataProviders.createInMemoryDataProvider( - SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID - ) { null } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.SolutionIsRevealed(activeSessionId, submitResultFlow) + sendCommandForOperation(message) { "Failed to schedule command for revealing the solution." } + return submitResultFlow.convertToSessionProvider(SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID) } /** @@ -371,46 +260,22 @@ class ExplorationProgressController @Inject constructor( * this method will throw an exception. Calling code is responsible for ensuring this method is * only called when it's possible to navigate backward. * - * @return a one-time [DataProvider] indicating whether the movement to the previous state was - * successful, or a failure if state navigation was attempted at an invalid time in the state - * graph (e.g. if currently viewing the initial state of the exploration). It's recommended - * that calling code only listen to this result for failures, and instead rely on - * [getCurrentState] for observing a successful transition to another state. + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * + * @return a [DataProvider] indicating whether the movement to the previous state was successful, + * or a failure if state navigation was attempted at an invalid time in the state graph (e.g. + * if currently viewing the initial state of the exploration). It's recommended that calling + * code only listen to this result for failures, and instead rely on [getCurrentState] for + * observing a successful transition to another state. */ fun moveToPreviousState(): DataProvider { - try { - explorationProgressLock.withLock { - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.NOT_PLAYING - ) { - "Cannot navigate to a previous state if an exploration is not being played." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.LOADING_EXPLORATION - ) { - "Cannot navigate to a previous state if an exploration is being loaded." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.SUBMITTING_ANSWER - ) { - "Cannot navigate to a previous state if an answer submission is pending." - } - hintHandler.navigateToPreviousState() - explorationProgress.stateDeck.navigateToPreviousState() - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - } - return dataProviders.createInMemoryDataProvider(MOVE_TO_PREVIOUS_STATE_RESULT_PROVIDER_ID) { - null - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - MOVE_TO_PREVIOUS_STATE_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } + val moveResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.MoveToPreviousState(activeSessionId, moveResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for moving to the previous state." } + return moveResultFlow.convertToSessionProvider(MOVE_TO_PREVIOUS_STATE_RESULT_PROVIDER_ID) } /** @@ -422,58 +287,24 @@ class ExplorationProgressController @Inject constructor( * that routes to a later state via [submitAnswer] in order for the current state to change to a * completed state before forward navigation can occur. * - * @return a one-time [DataProvider] indicating whether the movement to the next state was - * successful, or a failure if state navigation was attempted at an invalid time in the state - * graph (e.g. if the current state is pending or terminal). It's recommended that calling - * code only listen to this result for failures, and instead rely on [getCurrentState] for - * observing a successful transition to another state. + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * + * @return a [DataProvider] indicating whether the movement to the next state was successful (see + * [moveToPreviousState] for details on potential failure cases) */ fun moveToNextState(): DataProvider { - try { - explorationProgressLock.withLock { - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.NOT_PLAYING - ) { - "Cannot navigate to a next state if an exploration is not being played." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.LOADING_EXPLORATION - ) { - "Cannot navigate to a next state if an exploration is being loaded." - } - check( - explorationProgress.playStage != - ExplorationProgress.PlayStage.SUBMITTING_ANSWER - ) { - "Cannot navigate to a next state if an answer submission is pending." - } - explorationProgress.stateDeck.navigateToNextState() - - if (explorationProgress.stateDeck.isCurrentStateTopOfDeck()) { - hintHandler.navigateBackToLatestPendingState() - - // Only mark checkpoint if current state is pending state. This ensures that checkpoints - // will not be marked on any of the completed states. - saveExplorationCheckpoint() - } - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_PROVIDER_ID) - } - return dataProviders.createInMemoryDataProvider(MOVE_TO_NEXT_STATE_RESULT_PROVIDER_ID) { - null - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync(MOVE_TO_NEXT_STATE_RESULT_PROVIDER_ID) { - AsyncResult.Failure(e) - } - } + val moveResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.MoveToNextState(activeSessionId, moveResultFlow) + sendCommandForOperation(message) { "Failed to schedule command for moving to the next state." } + return moveResultFlow.convertToSessionProvider(MOVE_TO_NEXT_STATE_RESULT_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 + * viewing. + * + * If this state corresponds to a a terminal state, then the learner has completed the * exploration. Note that [moveToPreviousState] and [moveToNextState] will automatically update * observers of this data provider when the next state is navigated to. * @@ -486,76 +317,421 @@ class ExplorationProgressController @Inject constructor( * * The underlying state returned by this function can only be changed by calls to * [moveToNextState] and [moveToPreviousState], or the exploration data controller if another - * exploration is loaded. UI code can be confident only calls from the UI layer will trigger state - * changes here to ensure atomicity between receiving and making state changes. + * exploration is loaded. UI code cannot assume that only calls from the UI layer will trigger + * state changes here since internal domain processes may also affect state (such as hint timers). + * + * This method is safe to be called before the exploration has started, but the returned provider + * is tied to the current play session (similar to the provider returned by [submitAnswer]), so + * the returned [DataProvider] prior to [beginExplorationAsync] being called will be a permanently + * failing provider. Furthermore, the returned provider will not be updated after the play session + * has ended (either due to [finishExplorationAsync] being called, or a new session starting). + * There will be a [DataProvider] available immediately after [beginExplorationAsync] returns, + * though it may not ever provide useful data if the start of the session failed (which can only + * be observed via the provider returned by [beginExplorationAsync]). * - * This method is safe to be called before an exploration has started. If there is no ongoing - * exploration, it should return a pending state. + * This method does not actually need to be called for the [EphemeralState] to be computed; it's + * always computed eagerly by other state-changing methods regardless of whether there's an active + * subscription to this method's returned [DataProvider]. */ fun getCurrentState(): DataProvider { - return translationController.getWrittenTranslationContentLocale( - explorationProgress.currentProfileId - ).transformAsync(CURRENT_STATE_PROVIDER_ID) { contentLocale -> - return@transformAsync retrieveCurrentStateAsync(contentLocale) + val writtenTranslationContentLocale = + translationController.getWrittenTranslationContentLocale(profileId) + val ephemeralStateDataProvider = + mostRecentEphemeralStateFlow.convertToSessionProvider(CURRENT_STATE_PROVIDER_ID) + return writtenTranslationContentLocale.combineWith( + ephemeralStateDataProvider, LOCALIZED_STATE_PROVIDER_ID + ) { locale, ephemeralState -> + ephemeralState.toBuilder().apply { + // Augment the state to include translation information (which may not necessarily be + // up-to-date in the state deck). + writtenTranslationContext = + translationController.computeWrittenTranslationContext( + state.writtenTranslationsMap, locale + ) + }.build() } } - private suspend fun retrieveCurrentStateAsync( - writtenTranslationContentLocale: OppiaLocale.ContentLocale - ): AsyncResult { - return try { - retrieveCurrentStateWithinCacheAsync(writtenTranslationContentLocale) - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - AsyncResult.Failure(e) + private fun createControllerCommandActor(): SendChannel> { + lateinit var controllerState: ControllerState + // Use an unlimited capacity buffer so that commands can be sent asynchronously without blocking + // the main thread or scheduling an extra coroutine. + @Suppress("JoinDeclarationAndAssignment") // Warning is incorrect in this case. + lateinit var commandQueue: SendChannel> + commandQueue = CoroutineScope( + backgroundCoroutineDispatcher + ).actor(capacity = Channel.UNLIMITED) { + for (message in channel) { + try { + @Suppress("UNUSED_VARIABLE") // A variable is used to create an exhaustive when statement. + val unused = when (message) { + is ControllerMessage.InitializeController -> { + // Ensure the state is completely recreated for each session to avoid leaking state + // across sessions. + controllerState = + ControllerState( + ExplorationProgress(), message.sessionId, message.ephemeralStateFlow, commandQueue + ).also { + it.beginExplorationImpl( + message.callbackFlow, + message.profileId, + message.topicId, + message.storyId, + message.explorationId, + message.shouldSavePartialProgress, + message.explorationCheckpoint + ) + } + } + is ControllerMessage.FinishExploration -> { + try { + // Ensure finish is always executed even if the controller state isn't yet + // initialized. + controllerState.finishExplorationImpl(message.callbackFlow) + } finally { + // Ensure the actor ends since the session requires no further message processing. + break + } + } + is ControllerMessage.SubmitAnswer -> + controllerState.submitAnswerImpl(message.callbackFlow, message.userAnswer) + is ControllerMessage.HintIsRevealed -> { + controllerState.submitHintIsRevealedImpl(message.callbackFlow, message.hintIndex) + } + is ControllerMessage.SolutionIsRevealed -> + controllerState.submitSolutionIsRevealedImpl(message.callbackFlow) + is ControllerMessage.MoveToPreviousState -> + controllerState.moveToPreviousStateImpl(message.callbackFlow) + is ControllerMessage.MoveToNextState -> + controllerState.moveToNextStateImpl(message.callbackFlow) + is ControllerMessage.ProcessSavedCheckpointResult -> { + controllerState.processSaveCheckpointResult( + message.profileId, + message.topicId, + message.storyId, + message.explorationId, + message.lastPlayedTimestamp, + message.newCheckpointState + ) + } + is ControllerMessage.SaveCheckpoint -> controllerState.saveExplorationCheckpoint() + is ControllerMessage.RecomputeStateAndNotify -> + controllerState.recomputeCurrentStateAndNotifyImpl() + } + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + oppiaLogger.w( + "ExplorationProgressController", + "Encountered exception while processing command: $message", + e + ) + } + } + } + return commandQueue + } + + private fun sendCommandForOperation( + message: ControllerMessage, + lazyFailureMessage: () -> String + ) { + // TODO(#4119): Switch this to use trySend(), instead, which is much cleaner and doesn't require + // catching an exception. + val flowResult: AsyncResult = try { + val commandQueue = mostRecentCommandQueue + when { + commandQueue == null -> + AsyncResult.Failure(IllegalStateException("Session isn't initialized yet.")) + !commandQueue.offer(message) -> + AsyncResult.Failure(IllegalStateException(lazyFailureMessage())) + // Ensure that the result is first reset since there will be a delay before the message is + // processed (if there's a flow). + else -> AsyncResult.Pending() + } + } catch (e: Exception) { AsyncResult.Failure(e) } + + // This must be assigned separately since flowResult should always be calculated, even if + // there's no callbackFlow to report it. + message.callbackFlow?.value = flowResult + } + + private suspend fun ControllerState.beginExplorationImpl( + beginExplorationResultFlow: MutableStateFlow>, + profileId: ProfileId, + topicId: String, + storyId: String, + explorationId: String, + shouldSavePartialProgress: Boolean, + explorationCheckpoint: ExplorationCheckpoint + ) { + tryOperation(beginExplorationResultFlow) { + check(explorationProgress.playStage == NOT_PLAYING) { + "Expected to finish previous exploration before starting a new one." + } + + explorationProgress.apply { + currentProfileId = profileId + currentTopicId = topicId + currentStoryId = storyId + currentExplorationId = explorationId + this.shouldSavePartialProgress = shouldSavePartialProgress + checkpointState = CheckpointState.CHECKPOINT_UNSAVED + this.explorationCheckpoint = explorationCheckpoint + } + hintHandler = hintHandlerFactory.create() + hintHandler.getCurrentHelpIndex().onEach { + // Fire an event to save the latest progress state in a checkpoint to avoid cross-thread + // synchronization being required (since the state of hints/solutions has changed). + commandQueue.send(ControllerMessage.SaveCheckpoint(sessionId)) + recomputeCurrentStateAndNotifyAsync() + }.launchIn(CoroutineScope(backgroundCoroutineDispatcher)) + explorationProgress.advancePlayStageTo(LOADING_EXPLORATION) } } - @Suppress("RedundantSuspendModifier") // Function is 'suspend' to restrict calling some methods. - private suspend fun retrieveCurrentStateWithinCacheAsync( - writtenTranslationContentLocale: OppiaLocale.ContentLocale - ): AsyncResult { - val explorationId: String? = explorationProgressLock.withLock { - if (explorationProgress.playStage == ExplorationProgress.PlayStage.LOADING_EXPLORATION) { - explorationProgress.currentExplorationId - } else null + private suspend fun ControllerState?.finishExplorationImpl( + finishExplorationResultFlow: MutableStateFlow> + ) { + checkNotNull(this) { "Cannot finish playing an exploration that hasn't yet been started" } + tryOperation(finishExplorationResultFlow, recomputeState = false) { + explorationProgress.advancePlayStageTo(NOT_PLAYING) } + } - val exploration = explorationId?.let(explorationRetriever::loadExploration) - - explorationProgressLock.withLock { - // It's possible for the exploration ID or stage to change between critical sections. However, - // this is the only way to ensure the exploration is loaded since suspended functions cannot - // be called within a mutex. Note that it's also possible for the stage to change between - // critical sections, sometimes due to this suspend function being called multiple times and a - // former call finishing the exploration load. - check( - exploration == null || - explorationProgress.currentExplorationId == explorationId - ) { - "Encountered race condition when retrieving exploration. ID changed from $explorationId" + - " to ${explorationProgress.currentExplorationId}" + private suspend fun ControllerState.submitAnswerImpl( + submitAnswerResultFlow: MutableStateFlow>, + userAnswer: UserAnswer + ) { + tryOperation(submitAnswerResultFlow) { + check(explorationProgress.playStage != NOT_PLAYING) { + "Cannot submit an answer if an exploration is not being played." } - return when (explorationProgress.playStage) { - ExplorationProgress.PlayStage.NOT_PLAYING -> AsyncResult.Pending() - ExplorationProgress.PlayStage.LOADING_EXPLORATION -> { - try { - // The exploration must be available for this stage since it was loaded above. - finishLoadExploration(exploration!!, explorationProgress) - AsyncResult.Success(computeCurrentEphemeralState(writtenTranslationContentLocale)) - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - AsyncResult.Failure(e) + check(explorationProgress.playStage != LOADING_EXPLORATION) { + "Cannot submit an answer while the exploration is being loaded." + } + check(explorationProgress.playStage != SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + + // Notify observers that the submitted answer is currently pending. + explorationProgress.advancePlayStageTo(SUBMITTING_ANSWER) + recomputeCurrentStateAndNotifySync() + + var answerOutcome: AnswerOutcome? = null + try { + val topPendingState = explorationProgress.stateDeck.getPendingTopState() + val outcome = + answerClassificationController.classify( + topPendingState.interaction, + userAnswer.answer, + userAnswer.writtenTranslationContext + ).outcome + answerOutcome = + explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) + explorationProgress.stateDeck.submitAnswer( + userAnswer, answerOutcome.feedback, answerOutcome.labelledAsCorrectAnswer + ) + + // Follow the answer's outcome to another part of the graph if it's different. + val ephemeralState = computeBaseCurrentEphemeralState() + when { + answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME -> { + val newState = explorationProgress.stateGraph.getState(answerOutcome.stateName) + explorationProgress.stateDeck.pushState(newState, prohibitSameStateName = true) + hintHandler.finishState(newState) } + ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.PENDING_STATE -> { + // Schedule, or show immediately, a new hint or solution based on the current + // ephemeral state of the exploration because a new wrong answer was submitted. + hintHandler.handleWrongAnswerSubmission(ephemeralState.pendingState.wrongAnswerCount) + } + } + } finally { + if (answerOutcome != null && + !doesInteractionAutoContinue(answerOutcome.state.interaction.id) + ) { + // If the answer was not submitted on behalf of the Continue interaction, update the + // hint state and save checkpoint because it will be saved when the learner moves to the + // next state. + 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. + explorationProgress.advancePlayStageTo(VIEWING_STATE) + } + + return@tryOperation checkNotNull(answerOutcome) { "Expected answer outcome." } + } + } + + private suspend fun ControllerState.submitHintIsRevealedImpl( + submitHintRevealedResultFlow: MutableStateFlow>, + hintIndex: Int + ) { + tryOperation(submitHintRevealedResultFlow) { + check(explorationProgress.playStage != NOT_PLAYING) { + "Cannot submit an answer if an exploration is not being played." + } + check(explorationProgress.playStage != LOADING_EXPLORATION) { + "Cannot submit an answer while the exploration is being loaded." + } + check(explorationProgress.playStage != SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + try { + hintHandler.viewHint(hintIndex) + } finally { + // 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. + explorationProgress.advancePlayStageTo(VIEWING_STATE) + } + } + } + + private suspend fun ControllerState.submitSolutionIsRevealedImpl( + submitSolutionRevealedResultFlow: MutableStateFlow> + ) { + tryOperation(submitSolutionRevealedResultFlow) { + check(explorationProgress.playStage != NOT_PLAYING) { + "Cannot submit an answer if an exploration is not being played." + } + check(explorationProgress.playStage != LOADING_EXPLORATION) { + "Cannot submit an answer while the exploration is being loaded." + } + check(explorationProgress.playStage != SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + try { + hintHandler.viewSolution() + } finally { + // 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. + explorationProgress.advancePlayStageTo(VIEWING_STATE) + } + } + } + + private suspend fun ControllerState.moveToPreviousStateImpl( + moveToPreviousStateResultFlow: MutableStateFlow> + ) { + tryOperation(moveToPreviousStateResultFlow) { + check(explorationProgress.playStage != NOT_PLAYING) { + "Cannot navigate to a previous state if an exploration is not being played." + } + check(explorationProgress.playStage != LOADING_EXPLORATION) { + "Cannot navigate to a previous state if an exploration is being loaded." + } + check(explorationProgress.playStage != SUBMITTING_ANSWER) { + "Cannot navigate to a previous state if an answer submission is pending." + } + hintHandler.navigateToPreviousState() + explorationProgress.stateDeck.navigateToPreviousState() + } + } + + private suspend fun ControllerState.moveToNextStateImpl( + moveToNextStateResultFlow: MutableStateFlow> + ) { + tryOperation(moveToNextStateResultFlow) { + check(explorationProgress.playStage != NOT_PLAYING) { + "Cannot navigate to a next state if an exploration is not being played." + } + check(explorationProgress.playStage != LOADING_EXPLORATION) { + "Cannot navigate to a next state if an exploration is being loaded." + } + check(explorationProgress.playStage != SUBMITTING_ANSWER) { + "Cannot navigate to a next state if an answer submission is pending." + } + explorationProgress.stateDeck.navigateToNextState() + + if (explorationProgress.stateDeck.isCurrentStateTopOfDeck()) { + hintHandler.navigateBackToLatestPendingState() + + // Only mark checkpoint if current state is pending state. This ensures that checkpoints + // will not be marked on any of the completed states. + saveExplorationCheckpoint() + } + } + } + + private suspend fun ControllerState.tryOperation( + resultFlow: MutableStateFlow>, + recomputeState: Boolean = true, + operation: suspend ControllerState.() -> T + ) { + try { + resultFlow.emit(AsyncResult.Success(operation())) + if (recomputeState) { + recomputeCurrentStateAndNotifySync() + } + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + resultFlow.emit(AsyncResult.Failure(e)) + } + } + + /** + * Immediately recomputes the current state & notifies it's been changed. + * + * This should only be called when the caller can guarantee that the current [ControllerState] is + * correct and up-to-date (i.e. that this is being called via a direct call path from the actor). + * + * All other cases must use [recomputeCurrentStateAndNotifyAsync]. + */ + private suspend fun ControllerState.recomputeCurrentStateAndNotifySync() { + recomputeCurrentStateAndNotifyImpl() + } + + /** + * Sends a message to recompute the current state & notify it's been changed. + * + * This must be used in cases when the current [ControllerState] may no longer be up-to-date to + * ensure state isn't leaked across play sessions. + */ + private suspend fun ControllerState.recomputeCurrentStateAndNotifyAsync() { + commandQueue.send(ControllerMessage.RecomputeStateAndNotify(sessionId)) + } + + private suspend fun ControllerState.recomputeCurrentStateAndNotifyImpl() { + ephemeralStateFlow.emit(retrieveCurrentStateAsync()) + } + + private suspend fun ControllerState.retrieveCurrentStateAsync(): AsyncResult { + return try { + retrieveStateWithinCache() + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + AsyncResult.Failure(e) + } + } + + private suspend fun ControllerState.retrieveStateWithinCache(): AsyncResult { + return when (explorationProgress.playStage) { + NOT_PLAYING -> AsyncResult.Pending() + LOADING_EXPLORATION -> { + try { + val exploration = + explorationRetriever.loadExploration(explorationProgress.currentExplorationId) + finishLoadExploration(exploration, explorationProgress) + AsyncResult.Success(computeCurrentEphemeralState()) + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + AsyncResult.Failure(e) } - ExplorationProgress.PlayStage.VIEWING_STATE -> - AsyncResult.Success(computeCurrentEphemeralState(writtenTranslationContentLocale)) - ExplorationProgress.PlayStage.SUBMITTING_ANSWER -> AsyncResult.Pending() } + VIEWING_STATE -> AsyncResult.Success(computeCurrentEphemeralState()) + SUBMITTING_ANSWER -> AsyncResult.Pending() } } - private fun finishLoadExploration(exploration: Exploration, progress: ExplorationProgress) { + private suspend fun ControllerState.finishLoadExploration( + exploration: Exploration, + progress: ExplorationProgress + ) { // The exploration must be initialized first since other lazy fields depend on it being inited. progress.currentExploration = exploration progress.stateGraph.reset(exploration.statesMap) @@ -576,30 +752,24 @@ 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) + progress.advancePlayStageTo(VIEWING_STATE) // Mark a checkpoint in the exploration once the exploration has loaded. saveExplorationCheckpoint() } - private fun computeBaseCurrentEphemeralState(): EphemeralState = - explorationProgress.stateDeck.getCurrentEphemeralState(computeCurrentHelpIndex()) + private fun ControllerState.computeBaseCurrentEphemeralState(): EphemeralState = + explorationProgress.stateDeck.getCurrentEphemeralState(retrieveCurrentHelpIndex()) - private fun computeCurrentEphemeralState( - writtenTranslationContentLocale: OppiaLocale.ContentLocale - ): EphemeralState { + private fun ControllerState.computeCurrentEphemeralState(): EphemeralState { return computeBaseCurrentEphemeralState().toBuilder().apply { - // Ensure that the state has an up-to-date checkpoint state & translation context (which may - // not necessarily be up-to-date in the state deck). + // Ensure that the state has an up-to-date checkpoint state. checkpointState = explorationProgress.checkpointState - writtenTranslationContext = - translationController.computeWrittenTranslationContext( - state.writtenTranslationsMap, writtenTranslationContentLocale - ) }.build() } - private fun computeCurrentHelpIndex(): HelpIndex = hintHandler.getCurrentHelpIndex() + private fun ControllerState.retrieveCurrentHelpIndex(): HelpIndex = + hintHandler.getCurrentHelpIndex().value /** * Checks if checkpointing is enabled, if checkpointing is enabled this function creates a @@ -608,8 +778,11 @@ class ExplorationProgressController @Inject constructor( * 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. + * + * Note that while this is changing internal ephemeral state, it does not notify of changes (it + * instead expects callers to do this when it's best to notify frontend observers of the changes). */ - private fun saveExplorationCheckpoint() { + private fun ControllerState.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 @@ -623,7 +796,7 @@ class ExplorationProgressController @Inject constructor( explorationProgress.currentExploration.version, explorationProgress.currentExploration.title, oppiaClock.getCurrentTimeMs(), - computeCurrentHelpIndex() + retrieveCurrentHelpIndex() ) val deferred = explorationCheckpointController.recordExplorationCheckpointAsync( @@ -641,14 +814,22 @@ class ExplorationProgressController @Inject constructor( // complete successfully. CheckpointState.CHECKPOINT_UNSAVED } - processSaveCheckpointResult( - profileId, - topicId, - storyId, - explorationId, - oppiaClock.getCurrentTimeMs(), - checkpointState - ) + + // Schedule an event to process the checkpoint results in a synchronized environment to avoid + // needing to lock on ControllerState. + val processEvent = + ControllerMessage.ProcessSavedCheckpointResult( + profileId, + topicId, + storyId, + explorationId, + oppiaClock.getCurrentTimeMs(), + checkpointState, + sessionId + ) + sendCommandForOperation(processEvent) { + "Failed to schedule command for processing a saved checkpoint." + } } } @@ -668,7 +849,7 @@ class ExplorationProgressController @Inject constructor( * @param newCheckpointState the latest state obtained after saving checkpoint successfully or * unsuccessfully */ - private fun processSaveCheckpointResult( + private suspend fun ControllerState.processSaveCheckpointResult( profileId: ProfileId, topicId: String, storyId: String, @@ -676,38 +857,37 @@ class ExplorationProgressController @Inject constructor( 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_PROVIDER_ID) + // 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) + + // The ephemeral state technically changes when a checkpoint is successfully saved. + recomputeCurrentStateAndNotifySync() } } @@ -749,4 +929,155 @@ class ExplorationProgressController @Inject constructor( lastPlayedTimestamp ) } + + private fun createAsyncResultStateFlow(initialValue: AsyncResult = AsyncResult.Pending()) = + MutableStateFlow(initialValue) + + private fun StateFlow>.convertToSessionProvider( + baseId: String + ): DataProvider = dataProviders.run { + convertAsyncToAutomaticDataProvider("${baseId}_$activeSessionId") + } + + /** + * Represents the current synchronized state of the controller. + * + * This object's instance is tied directly to a single exploration session, and it's not + * thread-safe so all access must be synchronized. + * + * @property explorationProgress the [ExplorationProgress] corresponding to the session + * @property sessionId the GUID corresponding to the session + * @property ephemeralStateFlow the [MutableStateFlow] that the updated [EphemeralState] is + * delivered to + * @property commandQueue the actor command queue executing all messages that change this state + */ + private class ControllerState( + val explorationProgress: ExplorationProgress, + val sessionId: String, + val ephemeralStateFlow: MutableStateFlow>, + val commandQueue: SendChannel> + ) { + /** + * The [HintHandler] used to monitor and trigger hints in the play session corresponding to this + * controller state. + */ + lateinit var hintHandler: HintHandler + } + + /** + * Represents a message that can be sent to [mostRecentCommandQueue] to process changes to + * [ControllerState] (since all changes must be synchronized). + * + * Messages are expected to be resolved serially (though their scheduling can occur across + * multiple threads, so order cannot be guaranteed until they're enqueued). + */ + private sealed class ControllerMessage { + /** + * The session ID corresponding to this message (the message is expected to be ignored if it + * doesn't correspond to an active session). + */ + abstract val sessionId: String + + /** + * The [DataProvider]-tied [MutableStateFlow] that represents the result of the operation + * corresponding to this message, or ``null`` if the caller doesn't care about observing the + * result. + */ + abstract val callbackFlow: MutableStateFlow>? + + /** [ControllerMessage] for initializing a new play session. */ + data class InitializeController( + val profileId: ProfileId, + val topicId: String, + val storyId: String, + val explorationId: String, + val shouldSavePartialProgress: Boolean, + val explorationCheckpoint: ExplorationCheckpoint, + val ephemeralStateFlow: MutableStateFlow>, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] for ending the current play session. */ + data class FinishExploration( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] for submitting a new [UserAnswer]. */ + data class SubmitAnswer( + val userAnswer: UserAnswer, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] for indicating that the user revealed the hint corresponding to + * [hintIndex]. + */ + data class HintIsRevealed( + val hintIndex: Int, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] for indicating that the user revealed the solution for the current state. + */ + data class SolutionIsRevealed( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] to move to the previous state in the exploration. */ + data class MoveToPreviousState( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] to move to the next state in the exploration. */ + data class MoveToNextState( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] to indicate that the session's current partial completion progress should + * be saved to disk. + * + * Note that this does not actually guarantee an update to the tracked progress of the + * exploration (see [ProcessSavedCheckpointResult]). + */ + data class SaveCheckpoint( + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + + /** + * [ControllerMessage] to ensure a successfully saved checkpoint is reflected in other parts of + * the app (e.g. that an exploration is considered 'in-progress' in such circumstances). + */ + data class ProcessSavedCheckpointResult( + val profileId: ProfileId, + val topicId: String, + val storyId: String, + val explorationId: String, + val lastPlayedTimestamp: Long, + val newCheckpointState: CheckpointState, + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + + /** + * [ControllerMessage] which recomputes the current [EphemeralState] and notifies subscribers of + * the [DataProvider] returned by [getCurrentState] of the change. + * + * This is only used in cases where an external operation trigger changes that are only + * reflected when recomputing the state (e.g. a new hint needing to be shown). + */ + data class RecomputeStateAndNotify( + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + } } diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt index 3f8f6495568..e836f381541 100644 --- a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt +++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt @@ -1,5 +1,6 @@ package org.oppia.android.domain.hintsandsolution +import kotlinx.coroutines.flow.StateFlow import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.State @@ -15,11 +16,8 @@ import org.oppia.android.app.model.State * 3. Later hints are not available until all previous hints have been revealed * 4. The solution cannot be revealed until all previous hints have been revealed * - * Implementations of this class are safe to access across multiple threads, but care must be taken - * when calling back into this class from [HintMonitor] since that could cause deadlocks. Note also - * that this class may block the calling thread. While the operations this class performs - * synchronously should be quick, care should be taken about heavy usage of this class on the main - * thread as it may introduce janky behavior. + * Implementations of this class are not safe to access across multiple threads, and will block the + * calling thread. */ interface HintHandler { @@ -27,7 +25,7 @@ interface HintHandler { * Starts watching for potential hints to be shown (e.g. if a user doesn't submit an answer after * a certain amount of time). This is meant to only be called once at the beginning of a state. */ - fun startWatchingForHintsInNewState(state: State) + suspend fun startWatchingForHintsInNewState(state: State) /** * Starts watching for potential hints to be shown when the exploration is resumed (e.g. if a @@ -38,54 +36,51 @@ interface HintHandler { * @param helpIndex the cached state of hints/solution from the checkpoint * @param state the restored pending state * */ - fun resumeHintsForSavedState(trackedWrongAnswerCount: Int, helpIndex: HelpIndex, state: State) + suspend fun resumeHintsForSavedState( + trackedWrongAnswerCount: Int, + helpIndex: HelpIndex, + state: State + ) /** * Indicates that the current state has ended and a new one should start being monitored. This * will cancel any previously pending background operations and potentially starts new ones * corresponding to the new state. */ - fun finishState(newState: State) + suspend fun finishState(newState: State) /** * Notifies the handler that a wrong answer was submitted. * * @param wrongAnswerCount the total number of wrong answers submitted to date */ - fun handleWrongAnswerSubmission(wrongAnswerCount: Int) + suspend fun handleWrongAnswerSubmission(wrongAnswerCount: Int) /** Notifies the handler that the user revealed a hint corresponding to the specified index. */ - fun viewHint(hintIndex: Int) + suspend fun viewHint(hintIndex: Int) /** Notifies the handler that the user revealed the the solution for the current state. */ - fun viewSolution() + suspend fun viewSolution() /** * Notifies the handler that the user navigated to a previously completed state. This will * potentially cancel any ongoing operations to avoid the hint counter continuing when navigating * an earlier state. */ - fun navigateToPreviousState() + suspend fun navigateToPreviousState() /** * Notifies the handler that the user has navigated back to the latest (pending) state. Note that * this may resume background operations, but it does not guarantee that those start at the same * time that they left off at (counters may be reset upon returning to the latest state). */ - fun navigateBackToLatestPendingState() + suspend fun navigateBackToLatestPendingState() - /** Returns the [HelpIndex] corresponding to the current pending state. */ - fun getCurrentHelpIndex(): HelpIndex - - /** A callback interface for monitoring changes to [HintHandler]. */ - interface HintMonitor { - /** - * Called when the [HelpIndex] managed by the [HintHandler] has changed. To get the latest - * state, call [HintHandler.getCurrentHelpIndex]. Note that this method may be called on a - * background thread. - */ - fun onHelpIndexChanged() - } + /** + * Returns a [StateFlow] of the [HelpIndex] corresponding to the current pending state which can + * be used to actively monitor the index state, if desired. + */ + fun getCurrentHelpIndex(): StateFlow /** * Factory for creating new [HintHandler]s. @@ -96,9 +91,8 @@ interface HintHandler { /** * Creates a new [HintHandler]. * - * @param hintMonitor a [HintMonitor] to observe async changes to hints/solution * @return a new [HintHandler] for managing hint/solution state for a specific play session */ - fun create(hintMonitor: HintMonitor): HintHandler + fun create(): HintHandler } } diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImpl.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImpl.kt index 67f84d2389d..e9bcd8b9ed1 100644 --- a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImpl.kt @@ -1,58 +1,50 @@ package org.oppia.android.domain.hintsandsolution +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.State import org.oppia.android.domain.devoptions.ShowAllHintsAndSolutionController -import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject -import kotlin.concurrent.withLock /** * Debug implementation of [HintHandler] that conditionally always shows hints & solutions if * 'Show all hints and solution' functionality is enabled in the developer options menu. * If this functionality is disabled then it will fall back to [HintHandlerProdImpl]. */ -class HintHandlerDebugImpl private constructor( - private val hintMonitor: HintHandler.HintMonitor -) : HintHandler { +class HintHandlerDebugImpl private constructor() : HintHandler { + private val helpIndexFlow by lazy { MutableStateFlow(HelpIndex.getDefaultInstance()) } - private val handlerLock = ReentrantLock() - - private lateinit var pendingState: State - - override fun startWatchingForHintsInNewState(state: State) { - handlerLock.withLock { - pendingState = state - hintMonitor.onHelpIndexChanged() - } + override suspend fun startWatchingForHintsInNewState(state: State) { + recomputeHelpIndex(state) } - override fun resumeHintsForSavedState( + override suspend fun resumeHintsForSavedState( trackedWrongAnswerCount: Int, helpIndex: HelpIndex, state: State ) {} - override fun finishState(newState: State) { - handlerLock.withLock { - startWatchingForHintsInNewState(newState) - } + override suspend fun finishState(newState: State) { + startWatchingForHintsInNewState(newState) } - override fun handleWrongAnswerSubmission(wrongAnswerCount: Int) {} + override suspend fun handleWrongAnswerSubmission(wrongAnswerCount: Int) {} // This is never called as everything is already revealed when the state is loaded. - override fun viewHint(hintIndex: Int) {} + override suspend fun viewHint(hintIndex: Int) {} // This is never called as everything is already revealed when the state is loaded. - override fun viewSolution() {} + override suspend fun viewSolution() {} + + override suspend fun navigateToPreviousState() {} - override fun navigateToPreviousState() {} + override suspend fun navigateBackToLatestPendingState() {} - override fun navigateBackToLatestPendingState() {} + override fun getCurrentHelpIndex(): StateFlow = helpIndexFlow - override fun getCurrentHelpIndex(): HelpIndex { - return if (!pendingState.offersHelp()) { + private fun recomputeHelpIndex(pendingState: State) { + helpIndexFlow.value = if (!pendingState.offersHelp()) { // If this state has no help to show, do nothing. HelpIndex.getDefaultInstance() } else { @@ -67,11 +59,11 @@ class HintHandlerDebugImpl private constructor( private val hintHandlerProdImplFactory: HintHandlerProdImpl.FactoryProdImpl, private val showAllHintsAndSolutionController: ShowAllHintsAndSolutionController ) : HintHandler.Factory { - override fun create(hintMonitor: HintHandler.HintMonitor): HintHandler { + override fun create(): HintHandler { return if (!showAllHintsAndSolutionController.getShowAllHintsAndSolution()) { - hintHandlerProdImplFactory.create(hintMonitor) + hintHandlerProdImplFactory.create() } else { - HintHandlerDebugImpl(hintMonitor) + HintHandlerDebugImpl() } } } diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt index a4096d930a7..71fdcae2641 100644 --- a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImpl.kt @@ -3,6 +3,8 @@ package org.oppia.android.domain.hintsandsolution import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED @@ -12,10 +14,7 @@ import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_I import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION import org.oppia.android.app.model.State import org.oppia.android.util.threading.BackgroundDispatcher -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject -import kotlin.concurrent.withLock /** * Production implementation of [HintHandler] that implements hints & solutions in parity with the @@ -58,139 +57,119 @@ class HintHandlerProdImpl private constructor( private val delayShowInitialHintMs: Long, private val delayShowAdditionalHintsMs: Long, private val delayShowAdditionalHintsFromWrongAnswerMs: Long, - backgroundCoroutineDispatcher: CoroutineDispatcher, - private val hintMonitor: HintHandler.HintMonitor + private val backgroundCoroutineDispatcher: CoroutineDispatcher ) : HintHandler { - private val handlerLock = ReentrantLock() - private val backgroundCoroutineScope = CoroutineScope(backgroundCoroutineDispatcher) + private val helpIndexFlow by lazy { MutableStateFlow(HelpIndex.getDefaultInstance()) } private var trackedWrongAnswerCount = 0 private lateinit var pendingState: State - private var hintSequenceNumber = AtomicInteger(0) + private var hintSequenceNumber = 0 private var lastRevealedHintIndex = -1 private var latestAvailableHintIndex = -1 private var solutionIsAvailable = false private var solutionIsRevealed = false - override fun startWatchingForHintsInNewState(state: State) { - handlerLock.withLock { - pendingState = state - hintMonitor.onHelpIndexChanged() - maybeScheduleShowHint(wrongAnswerCount = 0) - } + override suspend fun startWatchingForHintsInNewState(state: State) { + pendingState = state + updateHelpIndex() + maybeScheduleShowHint(wrongAnswerCount = 0) } - override fun resumeHintsForSavedState( + override suspend fun resumeHintsForSavedState( trackedWrongAnswerCount: Int, helpIndex: HelpIndex, state: State ) { - handlerLock.withLock { - when (helpIndex.indexTypeCase) { - NEXT_AVAILABLE_HINT_INDEX -> { - lastRevealedHintIndex = helpIndex.nextAvailableHintIndex - 1 - latestAvailableHintIndex = helpIndex.nextAvailableHintIndex - solutionIsAvailable = false - solutionIsRevealed = false - } - LATEST_REVEALED_HINT_INDEX -> { - lastRevealedHintIndex = helpIndex.latestRevealedHintIndex - latestAvailableHintIndex = helpIndex.latestRevealedHintIndex - solutionIsAvailable = false - solutionIsRevealed = false - } - SHOW_SOLUTION, EVERYTHING_REVEALED -> { - // 1 is subtracted from the hint count because hints are indexed from 0. - lastRevealedHintIndex = state.interaction.hintCount - 1 - latestAvailableHintIndex = state.interaction.hintCount - 1 - solutionIsAvailable = true - solutionIsRevealed = helpIndex.indexTypeCase == EVERYTHING_REVEALED - } - else -> { - lastRevealedHintIndex = -1 - latestAvailableHintIndex = -1 - solutionIsAvailable = false - solutionIsRevealed = false - } + when (helpIndex.indexTypeCase) { + NEXT_AVAILABLE_HINT_INDEX -> { + lastRevealedHintIndex = helpIndex.nextAvailableHintIndex - 1 + latestAvailableHintIndex = helpIndex.nextAvailableHintIndex + solutionIsAvailable = false + solutionIsRevealed = false + } + LATEST_REVEALED_HINT_INDEX -> { + lastRevealedHintIndex = helpIndex.latestRevealedHintIndex + latestAvailableHintIndex = helpIndex.latestRevealedHintIndex + solutionIsAvailable = false + solutionIsRevealed = false + } + SHOW_SOLUTION, EVERYTHING_REVEALED -> { + // 1 is subtracted from the hint count because hints are indexed from 0. + lastRevealedHintIndex = state.interaction.hintCount - 1 + latestAvailableHintIndex = state.interaction.hintCount - 1 + solutionIsAvailable = true + solutionIsRevealed = helpIndex.indexTypeCase == EVERYTHING_REVEALED + } + else -> { + lastRevealedHintIndex = -1 + latestAvailableHintIndex = -1 + solutionIsAvailable = false + solutionIsRevealed = false } - pendingState = state - this.trackedWrongAnswerCount = trackedWrongAnswerCount - hintMonitor.onHelpIndexChanged() - maybeScheduleShowHint(wrongAnswerCount = trackedWrongAnswerCount) } + pendingState = state + this.trackedWrongAnswerCount = trackedWrongAnswerCount + updateHelpIndex() + maybeScheduleShowHint(wrongAnswerCount = trackedWrongAnswerCount) } - override fun finishState(newState: State) { - handlerLock.withLock { - reset() - startWatchingForHintsInNewState(newState) - } + override suspend fun finishState(newState: State) { + reset() + startWatchingForHintsInNewState(newState) } - override fun handleWrongAnswerSubmission(wrongAnswerCount: Int) { - handlerLock.withLock { - maybeScheduleShowHint(wrongAnswerCount) - } + override suspend fun handleWrongAnswerSubmission(wrongAnswerCount: Int) { + maybeScheduleShowHint(wrongAnswerCount) } - override fun viewHint(hintIndex: Int) { - handlerLock.withLock { - val helpIndex = computeCurrentHelpIndex() - check( - helpIndex.indexTypeCase == NEXT_AVAILABLE_HINT_INDEX && - helpIndex.nextAvailableHintIndex == hintIndex - ) { - "Cannot reveal hint for current index: ${helpIndex.indexTypeCase} (trying to reveal hint:" + - " $hintIndex)" - } - - cancelPendingTasks() - lastRevealedHintIndex = lastRevealedHintIndex.coerceAtLeast(hintIndex) - hintMonitor.onHelpIndexChanged() - maybeScheduleShowHint() + override suspend fun viewHint(hintIndex: Int) { + val helpIndex = computeCurrentHelpIndex() + check( + helpIndex.indexTypeCase == NEXT_AVAILABLE_HINT_INDEX && + helpIndex.nextAvailableHintIndex == hintIndex + ) { + "Cannot reveal hint for current index: ${helpIndex.indexTypeCase} (trying to reveal hint:" + + " $hintIndex)" } - } - override fun viewSolution() { - handlerLock.withLock { - val helpIndex = computeCurrentHelpIndex() - check(helpIndex.indexTypeCase == SHOW_SOLUTION) { - "Cannot reveal solution for current index: ${helpIndex.indexTypeCase}" - } + cancelPendingTasks() + lastRevealedHintIndex = lastRevealedHintIndex.coerceAtLeast(hintIndex) + updateHelpIndex() + maybeScheduleShowHint() + } - cancelPendingTasks() - solutionIsRevealed = true - hintMonitor.onHelpIndexChanged() + override suspend fun viewSolution() { + val helpIndex = computeCurrentHelpIndex() + check(helpIndex.indexTypeCase == SHOW_SOLUTION) { + "Cannot reveal solution for current index: ${helpIndex.indexTypeCase}" } + + cancelPendingTasks() + solutionIsRevealed = true + updateHelpIndex() } - override fun navigateToPreviousState() { + override suspend fun navigateToPreviousState() { // Cancel tasks from the top pending state to avoid hint counters continuing after navigating // away. - handlerLock.withLock { - cancelPendingTasks() - } + cancelPendingTasks() } - override fun navigateBackToLatestPendingState() { - handlerLock.withLock { - maybeScheduleShowHint() - } + override suspend fun navigateBackToLatestPendingState() { + maybeScheduleShowHint() } - override fun getCurrentHelpIndex(): HelpIndex = handlerLock.withLock { - computeCurrentHelpIndex() - } + override fun getCurrentHelpIndex(): StateFlow = helpIndexFlow private fun cancelPendingTasks() { // Cancel any potential pending hints by advancing the sequence number. Note that this isn't // reset to 0 to ensure that all previous hint tasks are cancelled, and new tasks can be // scheduled without overlapping with past sequence numbers. - hintSequenceNumber.incrementAndGet() + hintSequenceNumber++ } - private fun maybeScheduleShowHint(wrongAnswerCount: Int = trackedWrongAnswerCount) { + private suspend fun maybeScheduleShowHint(wrongAnswerCount: Int = trackedWrongAnswerCount) { if (!pendingState.offersHelp()) { // If this state has no help to show, do nothing. return @@ -318,12 +297,10 @@ class HintHandlerProdImpl private constructor( * cancelling any previously pending hints initiated by calls to this method. */ private fun scheduleShowHint(delayMs: Long, helpIndexToShow: HelpIndex) { - val targetSequenceNumber = hintSequenceNumber.incrementAndGet() - backgroundCoroutineScope.launch { + val targetSequenceNumber = ++hintSequenceNumber + CoroutineScope(backgroundCoroutineDispatcher).launch { delay(delayMs) - handlerLock.withLock { - showHint(targetSequenceNumber, helpIndexToShow) - } + showHint(targetSequenceNumber, helpIndexToShow) } } @@ -331,13 +308,13 @@ class HintHandlerProdImpl private constructor( * Immediately indicates the specified hint is ready to be shown, cancelling any previously * pending hints initiated by calls to [scheduleShowHint]. */ - private fun showHintImmediately(helpIndexToShow: HelpIndex) { - showHint(hintSequenceNumber.incrementAndGet(), helpIndexToShow) + private suspend fun showHintImmediately(helpIndexToShow: HelpIndex) { + showHint(++hintSequenceNumber, helpIndexToShow) } - private fun showHint(targetSequenceNumber: Int, nextHelpIndexToShow: HelpIndex) { + private suspend fun showHint(targetSequenceNumber: Int, nextHelpIndexToShow: HelpIndex) { // Only finish this timer if no other hints were scheduled and no cancellations occurred. - if (targetSequenceNumber == hintSequenceNumber.get()) { + if (targetSequenceNumber == hintSequenceNumber) { val previousHelpIndex = computeCurrentHelpIndex() when (nextHelpIndexToShow.indexTypeCase) { @@ -351,11 +328,13 @@ class HintHandlerProdImpl private constructor( // Only indicate the hint is available if its index is actually new (including if it // becomes null such as in the case of the solution becoming available). if (nextHelpIndexToShow != previousHelpIndex) { - hintMonitor.onHelpIndexChanged() + updateHelpIndex() } } } + private suspend fun updateHelpIndex() = helpIndexFlow.emit(computeCurrentHelpIndex()) + /** Production implementation of [HintHandler.Factory]. */ class FactoryProdImpl @Inject constructor( @DelayShowInitialHintMillis private val delayShowInitialHintMs: Long, @@ -364,13 +343,12 @@ class HintHandlerProdImpl private constructor( private val delayShowAdditionalHintsFromWrongAnswerMs: Long, @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher ) : HintHandler.Factory { - override fun create(hintMonitor: HintHandler.HintMonitor): HintHandler { + override fun create(): HintHandler { return HintHandlerProdImpl( delayShowInitialHintMs, delayShowAdditionalHintsMs, delayShowAdditionalHintsFromWrongAnswerMs, - backgroundCoroutineDispatcher, - hintMonitor + backgroundCoroutineDispatcher ) } } diff --git a/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt b/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt index 8a06f674b31..e33741aa254 100644 --- a/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt @@ -1,5 +1,14 @@ package org.oppia.android.domain.question +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.oppia.android.app.model.AnsweredQuestionOutcome import org.oppia.android.app.model.EphemeralQuestion import org.oppia.android.app.model.EphemeralState @@ -11,10 +20,10 @@ import org.oppia.android.app.model.UserAssessmentPerformance import org.oppia.android.domain.classify.AnswerClassificationController import org.oppia.android.domain.classify.ClassificationResult.OutcomeWithMisconception import org.oppia.android.domain.hintsandsolution.HintHandler +import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController import org.oppia.android.domain.question.QuestionAssessmentProgress.TrainStage import org.oppia.android.domain.translation.TranslationController -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 @@ -22,10 +31,10 @@ import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.data.DataProviders.Companion.transformNested import org.oppia.android.util.data.DataProviders.NestedTransformedDataProvider import org.oppia.android.util.locale.OppiaLocale -import java.util.concurrent.locks.ReentrantLock +import org.oppia.android.util.threading.BackgroundDispatcher +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -import kotlin.concurrent.withLock private const val BEGIN_SESSION_RESULT_PROVIDER_ID = "QuestionAssessmentProgressController.begin_session_result" @@ -43,11 +52,23 @@ private const val CURRENT_QUESTION_PROVIDER_ID = "QuestionAssessmentProgressController.current_question" private const val CALCULATE_SCORES_PROVIDER_ID = "QuestionAssessmentProgressController.calculate_scores" +private const val MONITORED_QUESTION_LIST_PROVIDER_ID = "" + + "QuestionAssessmentProgressController.monitored_question_list" private const val LOCALIZED_QUESTION_PROVIDER_ID = "QuestionAssessmentProgressController.localized_question" +private const val EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID = + "QuestionAssessmentProgressController.ephemeral_question_from_updated_question_list" private const val EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID = "QuestionAssessmentProgressController.create_empty_questions_list_data_provider_id" +/** + * A default session ID to be used before a session has been initialized. + * + * This session ID will never match, so messages that are received with this ID will never be + * processed. + */ +private const val DEFAULT_SESSION_ID = "default_session_id" + /** * Controller that tracks and reports the learner's ephemeral/non-persisted progress through a * practice training session. Note that this controller only supports one active training session at @@ -55,106 +76,113 @@ private const val EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID = * * The current training session is started via the question training controller. * - * This class is thread-safe, but the order of applied operations is arbitrary. Calling code should - * take care to ensure that uses of this class do not specifically depend on ordering. + * This class is not safe to use across multiple threads, and should only ever be interacted with + * via the main thread. The controller makes use of multiple threads to offload all state + * operations, so calls into this controller should return quickly and will never block. Each method + * returns a [DataProvider] that can be observed for the future result of the method's corresponding + * operation. + * + * Note that operations are guaranteed to execute in the order of controller method calls, internal + * state is always kept internally consistent (so long-running [DataProvider] subscriptions for a + * particular play session will receive updates), and state can never leak across session + * boundaries (though re-subscription will be necessary to observe state in a new play session--see + * [submitAnswer] and [getCurrentQuestion] method KDocs for more details). */ @Singleton class QuestionAssessmentProgressController @Inject constructor( private val dataProviders: DataProviders, - private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, private val answerClassificationController: AnswerClassificationController, private val exceptionsController: ExceptionsController, private val hintHandlerFactory: HintHandler.Factory, - private val translationController: TranslationController -) : HintHandler.HintMonitor { + private val translationController: TranslationController, + private val oppiaLogger: OppiaLogger, + @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher +) { // TODO(#247): Add support for populating the list of skill IDs to review at the end of the // training session. // TODO(#248): Add support for the assessment ending prematurely due to learner demonstrating // sufficient proficiency. - private val progress = QuestionAssessmentProgress() - private lateinit var hintHandler: HintHandler - private val progressLock = ReentrantLock() + // TODO(#606): Replace this with a profile scope to avoid this hacky workaround (which is needed + // for getCurrentQuestion). + private lateinit var profileId: ProfileId + + private var mostRecentSessionId: String? = null + private val activeSessionId: String + get() = mostRecentSessionId ?: DEFAULT_SESSION_ID + + private var mostRecentEphemeralQuestionFlow = + createAsyncResultStateFlow( + AsyncResult.Failure(IllegalStateException("Training session is not yet initialized.")) + ) + + private var mostRecentCommandQueue: SendChannel>? = null @Inject internal lateinit var scoreCalculatorFactory: QuestionAssessmentCalculation.Factory - private val currentQuestionDataProvider: NestedTransformedDataProvider = + private val monitoredQuestionListDataProvider: NestedTransformedDataProvider = createCurrentQuestionDataProvider(createEmptyQuestionsListDataProvider()) /** * Begins a training session based on the specified question list data provider and [ProfileId], * and returns a [DataProvider] indicating whether the session was successfully started. + * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. */ internal fun beginQuestionTrainingSession( questionsListDataProvider: DataProvider>, profileId: ProfileId ): DataProvider { - return progressLock.withLock { - try { - check(progress.trainStage == TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot start a new training session until the previous one is completed." - } - progress.currentProfileId = profileId - - hintHandler = hintHandlerFactory.create(this) - progress.advancePlayStageTo(TrainStage.LOADING_TRAINING_SESSION) - currentQuestionDataProvider.setBaseDataProvider( - questionsListDataProvider, - this::retrieveCurrentQuestionAsync - ) - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - return@withLock dataProviders.createInMemoryDataProvider(BEGIN_SESSION_RESULT_PROVIDER_ID) { - null - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return@withLock dataProviders.createInMemoryDataProviderAsync( - BEGIN_SESSION_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } + // Prepare to compute the session state by setting up the provided question list provider to be + // used later in the ephemeral question data provider. + val ephemeralQuestionFlow = createAsyncResultStateFlow() + val sessionId = UUID.randomUUID().toString().also { + mostRecentSessionId = it + mostRecentEphemeralQuestionFlow = ephemeralQuestionFlow + mostRecentCommandQueue = createControllerCommandActor() + } + monitoredQuestionListDataProvider.setBaseDataProvider(questionsListDataProvider) { + maybeSendReceiveQuestionListEvent(mostRecentCommandQueue, it) } + this.profileId = profileId + val beginSessionResultFlow = createAsyncResultStateFlow() + val initializeMessage: ControllerMessage<*> = + ControllerMessage.StartInitializingController( + profileId, ephemeralQuestionFlow, sessionId, beginSessionResultFlow + ) + sendCommandForOperation(initializeMessage) { + "Failed to schedule command for initializing the question assessment progress controller." + } + return beginSessionResultFlow.convertToSessionProvider(BEGIN_SESSION_RESULT_PROVIDER_ID) } /** * Ends the current training session and returns a [DataProvider] that indicates whether it was * successfully ended. + * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer] with one additional caveat: this method does not actually need to be called when + * a session is over. Calling it ensures all other [DataProvider]s reset to a correct + * out-of-session state, but subsequent calls to [beginQuestionTrainingSession] will reset the + * session. */ internal fun finishQuestionTrainingSession(): DataProvider { - return progressLock.withLock { - try { - check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot stop a new training session which wasn't started." - } - progress.advancePlayStageTo(TrainStage.NOT_IN_TRAINING_SESSION) - currentQuestionDataProvider.setBaseDataProvider( - createEmptyQuestionsListDataProvider(), this::retrieveCurrentQuestionAsync - ) - return@withLock dataProviders.createInMemoryDataProvider( - FINISH_SESSION_RESULT_PROVIDER_ID - ) { null } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return@withLock dataProviders.createInMemoryDataProviderAsync( - FINISH_SESSION_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } + // Reset the base questions list provider so that the ephemeral question has no question list to + // reference (since the session finished). + monitoredQuestionListDataProvider.setBaseDataProvider(createEmptyQuestionsListDataProvider()) { + maybeSendReceiveQuestionListEvent(commandQueue = null, it) } - } - - override fun onHelpIndexChanged() { - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) + val finishSessionResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.FinishSession(activeSessionId, finishSessionResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for finishing the question session." + } + return finishSessionResultFlow.convertToSessionProvider(FINISH_SESSION_RESULT_PROVIDER_ID) } /** - * Submits an answer to the current question and returns how the UI should respond to this answer. - * - * The returned [DataProvider] will only have at most two results posted: a pending result, and - * then a completed success/failure result. Failures in this case represent a failure of the app - * (possibly due to networking conditions). The app should report this error in a consumable way - * to the user so that they may take action on it. No additional values will be reported to the - * [DataProvider]. Each call to this method returns a new, distinct, [DataProvider] object that - * must be observed. Note also that the returned [DataProvider] is not guaranteed to begin with a - * pending state. + * Submits an answer to the current state and returns how the UI should respond to this answer. * * If the app undergoes a configuration change, calling code should rely on the [DataProvider] * from [getCurrentQuestion] to know whether a current answer is pending. That [DataProvider] will @@ -166,171 +194,68 @@ class QuestionAssessmentProgressController @Inject constructor( * completed question since the learner completed that question card. The learner can then proceed * from the current completed question to the next pending question using [moveToNextQuestion]. * - * This method cannot be called until a training session has started and [getCurrentQuestion] - * returns a non-pending result or the result will fail. Calling code must also take care not to - * allow users to submit an answer while a previous answer is pending. That scenario will also - * result in a failed answer submission. + * ### Lifecycle behavior + * The returned [DataProvider] will initially be pending until the operation completes (unless + * called before a session is started). Note that a different provider is returned for each call, + * though it's tied to the same session so it can be monitored medium-term (i.e. for the duration + * of the play session, but not past it). Furthermore, the returned provider does not actually + * need to be monitored in order for the operation to complete, though it's recommended since + * [getCurrentQuestion] can only be used to monitor the effects of the operation, not whether the + * operation itself succeeded. + * + * If this is called before a session begins it will return a provider that stays failing with no + * updates. The operation will also silently fail rather than queue up in these circumstances, so + * starting a session will not trigger an answer submission from an older call. + * + * Multiple subsequent calls during a valid session will queue up and have results delivered in + * order (though based on the eventual consistency nature of [DataProvider]s no assumptions can be + * made about whether all results will actually be received--[getCurrentQuestion] should be used + * as the source of truth for the current state of the session). * * No assumptions should be made about the completion order of the returned [DataProvider] vs. the - * [DataProvider] from [getCurrentQuestion]. Also note that the returned [DataProvider] will only - * have a single value and not be reused after that point. + * [DataProvider] from [getCurrentQuestion]. */ fun submitAnswer(answer: UserAnswer): DataProvider { - try { - progressLock.withLock { - check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot submit an answer if a training session has not yet begun." - } - check(progress.trainStage != TrainStage.LOADING_TRAINING_SESSION) { - "Cannot submit an answer while the training session is being loaded." - } - check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { - "Cannot submit an answer while another answer is pending." - } - - // Notify observers that the submitted answer is currently pending. - progress.advancePlayStageTo(TrainStage.SUBMITTING_ANSWER) - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - - lateinit var answeredQuestionOutcome: AnsweredQuestionOutcome - try { - val topPendingState = progress.stateDeck.getPendingTopState() - val classificationResult = - answerClassificationController.classify( - topPendingState.interaction, answer.answer, answer.writtenTranslationContext - ) - answeredQuestionOutcome = - progress.stateList.computeAnswerOutcomeForResult(classificationResult.outcome) - progress.stateDeck.submitAnswer(answer, answeredQuestionOutcome.feedback) - - // Track the number of answers the user submitted, including any misconceptions - val misconception = if (classificationResult is OutcomeWithMisconception) { - classificationResult.taggedSkillId - } else null - progress.trackAnswerSubmitted(misconception) - - // Do not proceed unless the user submitted the correct answer. - if (answeredQuestionOutcome.isCorrectAnswer) { - progress.completeCurrentQuestion() - val newState = if (!progress.isAssessmentCompleted()) { - // Only push the next state if the assessment isn't completed. - progress.getNextState() - } else { - // Otherwise, push a synthetic state for the end of the session. - State.getDefaultInstance() - } - progress.stateDeck.pushState(newState, prohibitSameStateName = false) - hintHandler.finishState(newState) - } else { - // Schedule a new hints or solution or show a new hint or solution immediately based on - // the current ephemeral state of the training session because a new wrong answer was - // submitted. - hintHandler.handleWrongAnswerSubmission( - computeBaseCurrentEphemeralState().pendingState.wrongAnswerCount - ) - } - } finally { - // 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. - progress.advancePlayStageTo(TrainStage.VIEWING_STATE) - } - - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - - return dataProviders.createInMemoryDataProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID) { - answeredQuestionOutcome - } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync(SUBMIT_ANSWER_RESULT_PROVIDER_ID) { - AsyncResult.Failure(e) - } - } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.SubmitAnswer(answer, activeSessionId, submitResultFlow) + sendCommandForOperation(message) { "Failed to schedule command for submitting an answer." } + return submitResultFlow.convertToSessionProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID) } /** * Notifies the controller that the user wishes to reveal a hint. * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * * @param hintIndex index of the hint that was revealed in the hint list of the current pending * state - * @return a one-time [DataProvider] that indicates success/failure of the operation (the actual - * payload of the result isn't relevant) + * @return a [DataProvider] that indicates success/failure of the operation (the actual payload of + * the result isn't relevant) */ fun submitHintIsRevealed(hintIndex: Int): DataProvider { - try { - progressLock.withLock { - check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot submit an answer if a training session has not yet begun." - } - check(progress.trainStage != TrainStage.LOADING_TRAINING_SESSION) { - "Cannot submit an answer while the training session is being loaded." - } - check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { - "Cannot submit an answer while another answer is pending." - } - try { - progress.trackHintViewed() - hintHandler.viewHint(hintIndex) - } finally { - // 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. - progress.advancePlayStageTo(TrainStage.VIEWING_STATE) - } - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - return dataProviders.createInMemoryDataProvider(SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID) { - null - } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } - } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.HintIsRevealed(hintIndex, activeSessionId, submitResultFlow) + sendCommandForOperation(message) { "Failed to schedule command for submitting a hint reveal" } + return submitResultFlow.convertToSessionProvider(SUBMIT_HINT_REVEALED_RESULT_PROVIDER_ID) } /** * Notifies the controller that the user has revealed the solution to the current state. * - * @return a one-time [DataProvider] that indicates success/failure of the operation (the actual - * payload of the result isn't relevant) + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * + * @return a [DataProvider] that indicates success/failure of the operation (the actual payload of + * the result isn't relevant) */ fun submitSolutionIsRevealed(): DataProvider { - try { - progressLock.withLock { - check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot submit an answer if a training session has not yet begun." - } - check(progress.trainStage != TrainStage.LOADING_TRAINING_SESSION) { - "Cannot submit an answer while the training session is being loaded." - } - check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { - "Cannot submit an answer while another answer is pending." - } - try { - progress.trackSolutionViewed() - hintHandler.viewSolution() - } finally { - // 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. - progress.advancePlayStageTo(TrainStage.VIEWING_STATE) - } - - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - return dataProviders.createInMemoryDataProvider( - SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID - ) { null } - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } + val submitResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.SolutionIsRevealed(activeSessionId, submitResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for submitting a solution reveal" } + return submitResultFlow.convertToSessionProvider(SUBMIT_SOLUTION_REVEALED_RESULT_PROVIDER_ID) } /** @@ -341,47 +266,31 @@ class QuestionAssessmentProgressController @Inject constructor( * Note that if the current question is pending, the user needs to submit a correct answer via * [submitAnswer] before forward navigation can occur. * - * @return a one-time [DataProvider] indicating whether the movement to the next question was - * successful, or a failure if question navigation was attempted at an invalid time (such as - * if the current question is pending or terminal). It's recommended that calling code only - * listen to this result for failures, and instead rely on [getCurrentQuestion] for observing - * a successful transition to another question. + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer]. + * + * @return a [DataProvider] indicating whether the movement to the next question was successful, + * or a failure if question navigation was attempted at an invalid time (such as if the + * current question is pending or terminal). It's recommended that calling code only listen to + * this result for failures, and instead rely on [getCurrentQuestion] for observing a + * successful transition to another question. */ fun moveToNextQuestion(): DataProvider { - try { - progressLock.withLock { - check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) { - "Cannot navigate to a next question if a training session has not begun." - } - check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { - "Cannot navigate to a next question if an answer submission is pending." - } - progress.stateDeck.navigateToNextState() - // Track whether the learner has moved to a new card. - if (progress.isViewingMostRecentQuestion()) { - // Update the hint state and maybe schedule new help when user moves to the pending top - // state. - hintHandler.navigateBackToLatestPendingState() - progress.processNavigationToNewQuestion() - } - asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_QUESTION_PROVIDER_ID) - } - return dataProviders.createInMemoryDataProvider(MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID) { - null - } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - return dataProviders.createInMemoryDataProviderAsync( - MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID - ) { AsyncResult.Failure(e) } + val moveResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.MoveToNextQuestion(activeSessionId, moveResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for moving to the next question." } + return moveResultFlow.convertToSessionProvider(MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID) } /** * Returns a [DataProvider] monitoring the current [EphemeralQuestion] the learner is currently - * viewing. If this state corresponds to a a terminal state, then the learner has completed the - * training session. Note that [moveToNextQuestion] will automatically update observers of this - * data provider when the next question is navigated to. + * viewing. + * + * If this state corresponds to a terminal state, then the learner has completed the training + * session. Note that [moveToNextQuestion] will automatically update observers of this data + * provider when the next question is navigated to. * * This [DataProvider] may switch from a completed to a pending result during transient operations * like submitting an answer via [submitAnswer]. Calling code should be made resilient to this by @@ -392,18 +301,38 @@ class QuestionAssessmentProgressController @Inject constructor( * * The underlying question returned by this function can only be changed by calls to * [moveToNextQuestion], or the question training controller if another question session begins. - * UI code can be confident only calls from the UI layer will trigger changes here to ensure - * atomicity between receiving and making question state changes. + * UI code cannot assume that only calls from the UI layer will trigger state changes here since + * internal domain processes may also affect state (such as hint timers). * - * This method is safe to be called before a training session has started. If there is no ongoing - * session, it should return a pending state, which means the returned value can switch from a - * success or failure state back to pending. + * This method is safe to be called before a training session has started, but the returned + * provider is tied to the current play session (similar to the provider returned by + * [submitAnswer]), so the returned [DataProvider] prior to [beginQuestionTrainingSession] being + * called will be a permanently failing provider. Furthermore, the returned provider will not be + * updated after the play session has ended (either due to [finishQuestionTrainingSession] being + * called, or a new session starting). There will be a [DataProvider] available immediately after + * [beginQuestionTrainingSession] returns, though it may not ever provide useful data if the start + * of the session failed (which can only be observed via the provider returned by + * [beginQuestionTrainingSession]). + * + * This method does not actually need to be called for the [EphemeralQuestion] to be computed; + * it's always computed eagerly by other state-changing methods regardless of whether there's an + * active subscription to this method's returned [DataProvider]. */ - fun getCurrentQuestion(): DataProvider = progressLock.withLock { - val providerId = LOCALIZED_QUESTION_PROVIDER_ID - return translationController.getWrittenTranslationContentLocale( - progress.currentProfileId - ).combineWith(currentQuestionDataProvider, providerId) { contentLocale, currentQuestion -> + fun getCurrentQuestion(): DataProvider { + val writtenTranslationContentLocale = + translationController.getWrittenTranslationContentLocale(profileId) + val ephemeralQuestionDataProvider = + mostRecentEphemeralQuestionFlow.convertToSessionProvider(CURRENT_QUESTION_PROVIDER_ID) + + // Combine ephemeral question with the monitored question list to ensure that changes to the + // questions list trigger a recompute of the ephemeral question. + val questionDataProvider = + monitoredQuestionListDataProvider.combineWith( + ephemeralQuestionDataProvider, EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID + ) { _, currentQuestion -> currentQuestion } + return writtenTranslationContentLocale.combineWith( + questionDataProvider, LOCALIZED_QUESTION_PROVIDER_ID + ) { contentLocale, currentQuestion -> return@combineWith augmentEphemeralQuestion(contentLocale, currentQuestion) } } @@ -414,63 +343,365 @@ class QuestionAssessmentProgressController @Inject constructor( * * This method should only be called at the end of a practice session, after all the questions * have been completed. + * + * The returned [DataProvider] has the same lifecycle considerations as the provider returned by + * [submitAnswer], which in practice means that subsequent calls to this function may result in + * multiple [UserAssessmentPerformance]s being computed and sent to the returned [DataProvider], + * though per the eventual consistency property of [DataProvider]s it's expected that the final + * result received will always be corresponding to the most recent call to this method. */ - fun calculateScores(skillIdList: List): DataProvider = - progressLock.withLock { - dataProviders.createInMemoryDataProviderAsync(CALCULATE_SCORES_PROVIDER_ID) { - retrieveUserAssessmentPerformanceAsync(skillIdList) + fun calculateScores(skillIdList: List): DataProvider { + val scoresResultFlow = createAsyncResultStateFlow() + val message = ControllerMessage.CalculateScores(skillIdList, activeSessionId, scoresResultFlow) + sendCommandForOperation(message) { + "Failed to schedule command for moving to the next question." + } + return scoresResultFlow.convertToSessionProvider(CALCULATE_SCORES_PROVIDER_ID) + } + + private fun createControllerCommandActor(): SendChannel> { + lateinit var controllerState: ControllerState + // Use an unlimited capacity buffer so that commands can be sent asynchronously without blocking + // the main thread or scheduling an extra coroutine. + @Suppress("JoinDeclarationAndAssignment") // Warning is incorrect in this case. + lateinit var commandQueue: SendChannel> + commandQueue = CoroutineScope( + backgroundCoroutineDispatcher + ).actor(capacity = Channel.UNLIMITED) { + for (message in channel) { + try { + @Suppress("UNUSED_VARIABLE") // A variable is used to create an exhaustive when statement. + val unused = when (message) { + is ControllerMessage.StartInitializingController -> { + // Ensure the state is completely recreated for each session to avoid leaking state + // across sessions. + controllerState = + ControllerState( + QuestionAssessmentProgress(), + message.sessionId, + message.ephemeralQuestionFlow, + commandQueue + ).also { + it.beginQuestionTrainingSessionImpl(message.callbackFlow, message.profileId) + } + } + is ControllerMessage.ReceiveQuestionList -> + controllerState.handleUpdatedQuestionsList(message.questionsList) + is ControllerMessage.FinishSession -> { + try { + // Ensure finish is always executed even if the controller state isn't yet + // initialized. + controllerState.finishQuestionTrainingSessionImpl(message.callbackFlow) + } finally { + // Ensure the actor ends since the session requires no further message processing. + break + } + } + is ControllerMessage.SubmitAnswer -> + controllerState.submitAnswerImpl(message.callbackFlow, message.userAnswer) + is ControllerMessage.HintIsRevealed -> + controllerState.submitHintIsRevealedImpl(message.callbackFlow, message.hintIndex) + is ControllerMessage.MoveToNextQuestion -> + controllerState.moveToNextQuestion(message.callbackFlow) + is ControllerMessage.SolutionIsRevealed -> + controllerState.submitSolutionIsRevealedImpl(message.callbackFlow) + is ControllerMessage.CalculateScores -> { + controllerState.recomputeUserAssessmentPerformanceAndNotify( + message.callbackFlow, message.skillIdList + ) + } + is ControllerMessage.RecomputeQuestionAndNotify -> + controllerState.recomputeCurrentQuestionAndNotifyImpl() + } + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + oppiaLogger.w( + "QuestionAssessmentProgressController", + "Encountered exception while processing command: $message.", + e + ) + } } } + return commandQueue + } + + private fun sendCommandForOperation( + message: ControllerMessage, + lazyFailureMessage: () -> String + ) { + // TODO(#4119): Switch this to use trySend(), instead, which is much cleaner and doesn't require + // catching an exception. + val flowResult: AsyncResult = try { + val commandQueue = mostRecentCommandQueue + when { + commandQueue == null -> + AsyncResult.Failure(IllegalStateException("Session isn't initialized yet.")) + !commandQueue.offer(message) -> + AsyncResult.Failure(IllegalStateException(lazyFailureMessage())) + // Ensure that the result is first reset since there will be a delay before the message is + // processed (if there's a flow). + else -> AsyncResult.Pending() + } + } catch (e: Exception) { AsyncResult.Failure(e) } - private fun computeBaseCurrentEphemeralState(): EphemeralState = - progress.stateDeck.getCurrentEphemeralState(hintHandler.getCurrentHelpIndex()) + // This must be assigned separately since flowResult should always be calculated, even if + // there's no callbackFlow to report it. + message.callbackFlow?.value = flowResult + } - @Suppress("RedundantSuspendModifier") - private suspend fun retrieveUserAssessmentPerformanceAsync( - skillIdList: List - ): AsyncResult { - progressLock.withLock { - val scoreCalculator = - scoreCalculatorFactory.create(skillIdList, progress.questionSessionMetrics) - return AsyncResult.Success(scoreCalculator.computeAll()) + private suspend fun maybeSendReceiveQuestionListEvent( + commandQueue: SendChannel>?, + questionsList: List + ): AsyncResult { + // Only send the message if there's a queue to send it to (which there might not be for cases + // where a play session isn't active). + commandQueue?.send(ControllerMessage.ReceiveQuestionList(questionsList, activeSessionId)) + return AsyncResult.Success(null) + } + + private suspend fun ControllerState.beginQuestionTrainingSessionImpl( + beginSessionResultFlow: MutableStateFlow>, + profileId: ProfileId + ) { + tryOperation(beginSessionResultFlow) { + progress.currentProfileId = profileId + + hintHandler = hintHandlerFactory.create() + hintHandler.getCurrentHelpIndex().onEach { + recomputeCurrentQuestionAndNotifyAsync() + }.launchIn(CoroutineScope(backgroundCoroutineDispatcher)) + progress.advancePlayStageTo(TrainStage.LOADING_TRAINING_SESSION) } } + private suspend fun ControllerState.handleUpdatedQuestionsList(questionsList: List) { + // The questions list is possibly changed which may affect the computed ephemeral question. + if (!this.isQuestionsListInitialized || this.questionsList != questionsList) { + this.questionsList = questionsList + // Only notify if the questions list is different (otherwise an infinite notify loop might be + // started). + recomputeCurrentQuestionAndNotifySync() + } + } + + private suspend fun ControllerState?.finishQuestionTrainingSessionImpl( + finishSessionResultFlow: MutableStateFlow> + ) { + checkNotNull(this) { "Cannot stop a new training session which wasn't started." } + tryOperation(finishSessionResultFlow) { + progress.advancePlayStageTo(TrainStage.NOT_IN_TRAINING_SESSION) + } + } + + private suspend fun ControllerState.submitAnswerImpl( + submitAnswerResultFlow: MutableStateFlow>, + answer: UserAnswer + ) { + tryOperation(submitAnswerResultFlow) { + check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + + // Notify observers that the submitted answer is currently pending. + progress.advancePlayStageTo(TrainStage.SUBMITTING_ANSWER) + recomputeCurrentQuestionAndNotifySync() + + val answeredQuestionOutcome: AnsweredQuestionOutcome + try { + val topPendingState = progress.stateDeck.getPendingTopState() + val classificationResult = + answerClassificationController.classify( + topPendingState.interaction, answer.answer, answer.writtenTranslationContext + ) + answeredQuestionOutcome = + progress.stateList.computeAnswerOutcomeForResult(classificationResult.outcome) + progress.stateDeck.submitAnswer( + answer, answeredQuestionOutcome.feedback, answeredQuestionOutcome.isCorrectAnswer + ) + + // Track the number of answers the user submitted, including any misconceptions + val misconception = if (classificationResult is OutcomeWithMisconception) { + classificationResult.taggedSkillId + } else null + progress.trackAnswerSubmitted(misconception) + + // Do not proceed unless the user submitted the correct answer. + if (answeredQuestionOutcome.isCorrectAnswer) { + progress.completeCurrentQuestion() + val newState = if (!progress.isAssessmentCompleted()) { + // Only push the next state if the assessment isn't completed. + progress.getNextState() + } else { + // Otherwise, push a synthetic state for the end of the session. + State.getDefaultInstance() + } + progress.stateDeck.pushState(newState, prohibitSameStateName = false) + hintHandler.finishState(newState) + } else { + // Schedule a new hints or solution or show a new hint or solution immediately based on + // the current ephemeral state of the training session because a new wrong answer was + // submitted. + hintHandler.handleWrongAnswerSubmission( + computeBaseCurrentEphemeralState().pendingState.wrongAnswerCount + ) + } + } finally { + // 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. + progress.advancePlayStageTo(TrainStage.VIEWING_STATE) + } + + return@tryOperation answeredQuestionOutcome + } + } + + private suspend fun ControllerState.submitHintIsRevealedImpl( + submitHintRevealedResultFlow: MutableStateFlow>, + hintIndex: Int + ) { + tryOperation(submitHintRevealedResultFlow) { + check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + try { + progress.trackHintViewed() + hintHandler.viewHint(hintIndex) + } finally { + // 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. + progress.advancePlayStageTo(TrainStage.VIEWING_STATE) + } + } + } + + private suspend fun ControllerState.submitSolutionIsRevealedImpl( + submitSolutionRevealedResultFlow: MutableStateFlow> + ) { + tryOperation(submitSolutionRevealedResultFlow) { + check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + try { + progress.trackSolutionViewed() + hintHandler.viewSolution() + } finally { + // 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. + progress.advancePlayStageTo(TrainStage.VIEWING_STATE) + } + } + } + + private suspend fun ControllerState.moveToNextQuestion( + moveToNextQuestionResultFlow: MutableStateFlow> + ) { + tryOperation(moveToNextQuestionResultFlow) { + check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) { + "Cannot navigate to a next question if an answer submission is pending." + } + progress.stateDeck.navigateToNextState() + // Track whether the learner has moved to a new card. + if (progress.isViewingMostRecentQuestion()) { + // Update the hint state and maybe schedule new help when user moves to the pending top + // state. + hintHandler.navigateBackToLatestPendingState() + progress.processNavigationToNewQuestion() + } + } + } + + private fun ControllerState.computeBaseCurrentEphemeralState(): EphemeralState = + progress.stateDeck.getCurrentEphemeralState(hintHandler.getCurrentHelpIndex().value) + private fun createCurrentQuestionDataProvider( questionsListDataProvider: DataProvider> - ): NestedTransformedDataProvider { - return questionsListDataProvider.transformNested( - CURRENT_QUESTION_PROVIDER_ID, - this::retrieveCurrentQuestionAsync + ): NestedTransformedDataProvider { + return questionsListDataProvider.transformNested(MONITORED_QUESTION_LIST_PROVIDER_ID) { + maybeSendReceiveQuestionListEvent(commandQueue = null, it) + } + } + + private suspend fun ControllerState.tryOperation( + resultFlow: MutableStateFlow>, + operation: suspend ControllerState.() -> T + ) { + try { + resultFlow.emit(AsyncResult.Success(operation())) + recomputeCurrentQuestionAndNotifySync() + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + resultFlow.emit(AsyncResult.Failure(e)) + } + } + + /** + * Immediately recomputes the current question & notifies it's been changed. + * + * This should only be called when the caller can guarantee that the current [ControllerState] is + * correct and up-to-date (i.e. that this is being called via a direct call path from the actor). + * + * All other cases must use [recomputeCurrentQuestionAndNotifyAsync]. + */ + private suspend fun ControllerState.recomputeCurrentQuestionAndNotifySync() { + recomputeCurrentQuestionAndNotifyImpl() + } + + /** + * Sends a message to recompute the current question & notify it's been changed. + * + * This must be used in cases when the current [ControllerState] may no longer be up-to-date to + * ensure state isn't leaked across training sessions. + */ + private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyAsync() { + commandQueue.send(ControllerMessage.RecomputeQuestionAndNotify(sessionId)) + } + + private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyImpl() { + ephemeralQuestionFlow.emit( + if (isQuestionsListInitialized) { + // Only compute the ephemeral question if there's a questions list loaded (otherwise the + // controller is in a pending state). + retrieveCurrentQuestionAsync(questionsList) + } else AsyncResult.Pending() ) } - @Suppress("RedundantSuspendModifier") // 'suspend' expected by DataProviders. - private suspend fun retrieveCurrentQuestionAsync( + private suspend fun ControllerState.recomputeUserAssessmentPerformanceAndNotify( + calculateScoresFlow: MutableStateFlow>, + skillIdList: List + ) { + val calculator = scoreCalculatorFactory.create(skillIdList, progress.questionSessionMetrics) + calculateScoresFlow.emit(AsyncResult.Success(calculator.computeAll())) + } + + private suspend fun ControllerState.retrieveCurrentQuestionAsync( questionsList: List ): AsyncResult { - progressLock.withLock { - return try { - when (progress.trainStage) { - TrainStage.NOT_IN_TRAINING_SESSION -> AsyncResult.Pending() - TrainStage.LOADING_TRAINING_SESSION -> { - // If the assessment hasn't yet been initialized, initialize it - // now that a list of questions is available. - initializeAssessment(questionsList) - progress.advancePlayStageTo(TrainStage.VIEWING_STATE) - AsyncResult.Success( - retrieveEphemeralQuestionState(questionsList) - ) - } - TrainStage.VIEWING_STATE -> AsyncResult.Success( + return try { + when (progress.trainStage) { + TrainStage.NOT_IN_TRAINING_SESSION -> AsyncResult.Pending() + TrainStage.LOADING_TRAINING_SESSION -> { + // If the assessment hasn't yet been initialized, initialize it + // now that a list of questions is available. + initializeAssessment(questionsList) + progress.advancePlayStageTo(TrainStage.VIEWING_STATE) + AsyncResult.Success( retrieveEphemeralQuestionState(questionsList) ) - TrainStage.SUBMITTING_ANSWER -> AsyncResult.Pending() } - } catch (e: Exception) { - exceptionsController.logNonFatalException(e) - AsyncResult.Failure(e) + TrainStage.VIEWING_STATE -> + AsyncResult.Success( + retrieveEphemeralQuestionState(questionsList) + ) + TrainStage.SUBMITTING_ANSWER -> AsyncResult.Pending() } + } catch (e: Exception) { + exceptionsController.logNonFatalException(e) + AsyncResult.Failure(e) } } @@ -492,7 +723,7 @@ class QuestionAssessmentProgressController @Inject constructor( }.build() } - private fun retrieveEphemeralQuestionState( + private fun ControllerState.retrieveEphemeralQuestionState( questionsList: List ): EphemeralQuestion { val currentQuestionIndex = progress.getCurrentQuestionIndex() @@ -507,17 +738,164 @@ class QuestionAssessmentProgressController @Inject constructor( return ephemeralQuestionBuilder.build() } - private fun initializeAssessment(questionsList: List) { + private suspend fun ControllerState.initializeAssessment(questionsList: List) { check(questionsList.isNotEmpty()) { "Cannot start a training session with zero questions." } progress.initialize(questionsList) // Update hint state to schedule task to show new help. hintHandler.startWatchingForHintsInNewState(progress.stateDeck.getCurrentState()) } - /** Returns a temporary [DataProvider] that always provides an empty list of [Question]s. */ + /** Returns a [DataProvider] that always provides an empty list of [Question]s. */ private fun createEmptyQuestionsListDataProvider(): DataProvider> { return dataProviders.createInMemoryDataProvider(EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID) { listOf() } } + + private fun createAsyncResultStateFlow(initialValue: AsyncResult = AsyncResult.Pending()) = + MutableStateFlow(initialValue) + + private fun StateFlow>.convertToSessionProvider( + baseId: String + ): DataProvider = dataProviders.run { + convertAsyncToAutomaticDataProvider("${baseId}_$activeSessionId") + } + + /** + * Represents the current synchronized state of the controller. + * + * This object's instance is tied directly to a single training session, and it's not thread-safe + * so all access must be synchronized. + * + * @property progress the [QuestionAssessmentProgress] corresponding to the session + * @property sessionId the GUID corresponding to the session + * @property ephemeralQuestionFlow the [MutableStateFlow] that the updated [EphemeralQuestion] is + * delivered to + * @property commandQueue the actor command queue executing all messages that change this state + */ + private class ControllerState( + val progress: QuestionAssessmentProgress = QuestionAssessmentProgress(), + val sessionId: String, + val ephemeralQuestionFlow: MutableStateFlow>, + val commandQueue: SendChannel> + ) { + /** + * The [HintHandler] used to monitor and trigger hints in the training session corresponding to + * this controller state. + */ + lateinit var hintHandler: HintHandler + + /** + * The list of [Question]s currently being played in the training session. + * + * Because this is updated based on [ControllerMessage.ReceiveQuestionList], it may not be + * initialized at the beginning of a training session. Callers should check + * [isQuestionsListInitialized] prior to accessing this field. + */ + lateinit var questionsList: List + + /** Indicates whether [questionsList] is initialized with values. */ + val isQuestionsListInitialized: Boolean + get() = ::questionsList.isInitialized + } + + /** + * Represents a message that can be sent to [mostRecentCommandQueue] to process changes to + * [ControllerState] (since all changes must be synchronized). + * + * Messages are expected to be resolved serially (though their scheduling can occur across + * multiple threads, so order cannot be guaranteed until they're enqueued). + */ + private sealed class ControllerMessage { + /** + * The session ID corresponding to this message (the message is expected to be ignored if it + * doesn't correspond to an active session). + */ + abstract val sessionId: String + + /** + * The [DataProvider]-tied [MutableStateFlow] that represents the result of the operation + * corresponding to this message, or ``null`` if the caller doesn't care about observing the + * result. + */ + abstract val callbackFlow: MutableStateFlow>? + + /** [ControllerMessage] for initializing a new training session. */ + data class StartInitializingController( + val profileId: ProfileId, + val ephemeralQuestionFlow: MutableStateFlow>, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] for finishing the initialization of the training session by providing a + * list of [Question]s to play. + */ + data class ReceiveQuestionList( + val questionsList: List, + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + + /** [ControllerMessage] for ending the current training session. */ + data class FinishSession( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] for submitting a new [UserAnswer]. */ + data class SubmitAnswer( + val userAnswer: UserAnswer, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] for indicating that the user revealed the hint corresponding to + * [hintIndex]. + */ + data class HintIsRevealed( + val hintIndex: Int, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] for indicating that the user revealed the solution for the current + * question. + */ + data class SolutionIsRevealed( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** [ControllerMessage] to move to the next question in the training session. */ + data class MoveToNextQuestion( + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] to calculate the current scores of the training session by computing a + * new [UserAssessmentPerformance]. + */ + data class CalculateScores( + val skillIdList: List, + override val sessionId: String, + override val callbackFlow: MutableStateFlow> + ) : ControllerMessage() + + /** + * [ControllerMessage] which recomputes the current [EphemeralQuestion] and notifies subscribers + * of the [DataProvider] returned by [getCurrentQuestion] of the change. + * + * This is only used in cases where an external operation trigger changes that are only + * reflected when recomputing the question (e.g. a new hint needing to be shown). + */ + data class RecomputeQuestionAndNotify( + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + } } diff --git a/domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingController.kt b/domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingController.kt index f55e83bb117..a26a3f8c220 100644 --- a/domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingController.kt +++ b/domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingController.kt @@ -32,16 +32,17 @@ class QuestionTrainingController @Inject constructor( private val seedRandom = Random(questionTrainingSeed) /** - * Begins a question training session given a list of skill Ids and a total number of questions. + * Begins a question training session given a list of skill IDs and a total number of questions. * - * This method is not expected to fail. [QuestionAssessmentProgressController] should be used to - * manage the play state, and monitor the load success/failure of the training session. + * [QuestionAssessmentProgressController] should be used to manage the play state, and monitor the + * load success/failure of the training session. The questions used in the training session will + * be a randomized selection among all questions corresponding to the provided skill IDs. * - * Questions will be shuffled and then the training session will begin. + * This can be called even if a session is currently active as it will force initiate a new play + * session, resetting any data from the previous session. * - * @return a one-time [DataProvider] to observe whether initiating the play request succeeded. - * Note that the training session may still fail to load, but this provides early-failure - * detection. + * @return a [DataProvider] to observe whether initiating the play request, or future play + * requests, succeeded */ fun startQuestionTrainingSession( profileId: ProfileId, @@ -112,9 +113,16 @@ class QuestionTrainingController @Inject constructor( } /** - * Finishes the most recent training session started by [startQuestionTrainingSession]. This - * method should only be called if there is a training session is being played, otherwise an - * exception will be thrown. + * Finishes the most recent training session started by [startQuestionTrainingSession]. + * + * This method should only be called if an active training session is being played, otherwise the + * resulting provider will fail. Note that this doesn't actually need to be called between + * sessions unless the caller wants to ensure other providers monitored from + * [QuestionAssessmentProgressController] are reset to a proper out-of-session state. + * + * Note that the returned provider monitors the long-term stopping state of training sessions and + * will be reset to 'pending' when a session is currently active, or before any session has + * started. */ fun stopQuestionTrainingSession(): DataProvider = questionAssessmentProgressController.finishQuestionTrainingSession() 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 390849950df..83d984f1872 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 @@ -135,12 +135,13 @@ class StateDeck constructor( * the most recent State in the deck, or if the most recent State is terminal (since no answer can be submitted to a * terminal interaction). */ - fun submitAnswer(userAnswer: UserAnswer, feedback: SubtitledHtml) { + fun submitAnswer(userAnswer: UserAnswer, feedback: SubtitledHtml, isCorrectAnswer: Boolean) { check(isCurrentStateTopOfDeck()) { "Cannot submit an answer except to the most recent state." } check(!isCurrentStateTerminal()) { "Cannot submit an answer to a terminal state." } currentDialogInteractions += AnswerAndResponse.newBuilder() .setUserAnswer(userAnswer) .setFeedback(feedback) + .setIsCorrectAnswer(isCorrectAnswer) .build() } diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel new file mode 100644 index 00000000000..eee95811580 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel @@ -0,0 +1,89 @@ +""" +Tests for lightweight exploration player domain components. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "ExplorationDataControllerTest", + srcs = ["ExplorationDataControllerTest.kt"], + custom_package = "org.oppia.android.domain.exploration", + test_class = "org.oppia.android.domain.exploration.ExplorationDataControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction:continue_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/dragAndDropSortInput:drag_and_drop_sort_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/fractioninput:fraction_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/imageClickInput:image_click_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/itemselectioninput:item_selection_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/mathequationinput:math_equation_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/multiplechoiceinput:multiple_choice_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numberwithunits:number_with_units_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericexpressioninput:numeric_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "ExplorationProgressControllerTest", + srcs = ["ExplorationProgressControllerTest.kt"], + custom_package = "org.oppia.android.domain.exploration", + test_class = "org.oppia.android.domain.exploration.ExplorationProgressControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction:continue_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/dragAndDropSortInput:drag_and_drop_sort_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/fractioninput:fraction_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/imageClickInput:image_click_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/itemselectioninput:item_selection_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/mathequationinput:math_equation_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/multiplechoiceinput:multiple_choice_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numberwithunits:number_with_units_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericexpressioninput:numeric_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() 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 461e240ed31..8e483a33554 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 @@ -173,19 +173,16 @@ class ExplorationDataControllerTest { } @Test - fun testStopPlayingExploration_withoutStartingSession_fails() { - explorationDataController.stopPlayingExploration() - testCoroutineDispatchers.runCurrent() + fun testStopPlayingExploration_withoutStartingSession_returnsFailure() { + val resultProvider = explorationDataController.stopPlayingExploration() - val exception = fakeExceptionLogger.getMostRecentException() - - assertThat(exception).isInstanceOf(java.lang.IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Cannot finish playing an exploration that hasn't yet been started") + val result = monitorFactory.waitForNextFailureResult(resultProvider) + assertThat(result).isInstanceOf(java.lang.IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test - fun testStartPlayingExploration_withoutStoppingSession_fails() { + fun testStartPlayingExploration_withoutStoppingSession_succeeds() { explorationDataController.startPlayingExploration( internalProfileId, TEST_TOPIC_ID_0, @@ -194,7 +191,8 @@ class ExplorationDataControllerTest { shouldSavePartialProgress = false, explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() ) - explorationDataController.startPlayingExploration( + + val dataProvider = explorationDataController.startPlayingExploration( internalProfileId, TEST_TOPIC_ID_1, TEST_STORY_ID_2, @@ -202,13 +200,9 @@ class ExplorationDataControllerTest { shouldSavePartialProgress = false, explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() ) - testCoroutineDispatchers.runCurrent() - - val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(java.lang.IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Expected to finish previous exploration before starting a new one.") + // The new session overwrites the previous. + monitorFactory.waitForNextSuccessfulResult(dataProvider) } // TODO(#89): Move this to a common test application component. 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 4ed0eade1d2..0d18fa54953 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 @@ -70,6 +70,7 @@ import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.data.AsyncResultSubject.Companion.assertThat import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.environment.TestEnvironmentConfig import org.oppia.android.testing.robolectric.RobolectricModule @@ -120,8 +121,7 @@ class ExplorationProgressControllerTest { // - testSubmitAnswer_whileSubmittingAnotherAnswer_failsWithError // - testMoveToPrevious_whileSubmittingAnswer_failsWithError - @get:Rule - val oppiaTestRule = OppiaTestRule() + @get:Rule val oppiaTestRule = OppiaTestRule() @Inject lateinit var context: Context @Inject lateinit var explorationDataController: ExplorationDataController @@ -246,17 +246,17 @@ class ExplorationProgressControllerTest { } @Test - fun testFinishExploration_beforePlaying_failWithError() { + fun testFinishExploration_beforePlaying_isFailure() { val resultDataProvider = explorationDataController.stopPlayingExploration() - val error = monitorFactory.waitForNextFailureResult(resultDataProvider) - assertThat(error) - .hasMessageThat() - .contains("Cannot finish playing an exploration that hasn't yet been started") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(resultDataProvider) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test - fun testPlayExploration_withoutFinishingPrevious_failsWithError() { + fun testPlayExploration_withoutFinishingPrevious_succeeds() { playExploration( profileId.internalId, TEST_TOPIC_ID_0, @@ -278,10 +278,8 @@ class ExplorationProgressControllerTest { explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() ) - val error = monitorFactory.waitForNextFailureResult(resultDataProvider) - assertThat(error) - .hasMessageThat() - .contains("Expected to finish previous exploration before starting a new one.") + // The new session will overwrite the previous. + monitorFactory.waitForNextSuccessfulResult(resultDataProvider) } @Test @@ -317,14 +315,13 @@ class ExplorationProgressControllerTest { } @Test - fun testSubmitAnswer_beforePlaying_failsWithError() { - val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + fun testSubmitAnswer_beforePlaying_isFailure() { + val resultProvider = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) - // Verify that the answer submission failed. - val error = monitorFactory.waitForNextFailureResult(result) - assertThat(error) - .hasMessageThat() - .contains("Cannot submit an answer if an exploration is not being played.") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(resultProvider) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -483,13 +480,14 @@ class ExplorationProgressControllerTest { } @Test - fun testMoveToNext_beforePlaying_failsWithError() { + fun testMoveToNext_beforePlaying_isFailure() { val moveToStateResult = explorationProgressController.moveToNextState() + val monitor = monitorFactory.createMonitor(moveToStateResult) - val error = monitorFactory.waitForNextFailureResult(moveToStateResult) - assertThat(error) - .hasMessageThat() - .contains("Cannot navigate to a next state if an exploration is not being played.") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(moveToStateResult) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -575,14 +573,13 @@ class ExplorationProgressControllerTest { } @Test - fun testMoveToPrevious_beforePlaying_failsWithError() { + fun testMoveToPrevious_beforePlaying_isFailure() { val moveToStateResult = explorationProgressController.moveToPreviousState() - testCoroutineDispatchers.runCurrent() - val error = monitorFactory.waitForNextFailureResult(moveToStateResult) - assertThat(error) - .hasMessageThat() - .contains("Cannot navigate to a previous state if an exploration is not being played.") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(moveToStateResult) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -1487,17 +1484,6 @@ class ExplorationProgressControllerTest { assertThat(ephemeralState.state.name).isEqualTo("ImageClickInput") } - @Test - fun testMoveToNext_beforePlaying_failsWithError_logsException() { - explorationProgressController.moveToNextState() - testCoroutineDispatchers.runCurrent() - - val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Cannot navigate to a next state if an exploration is not being played.") - } - @Test fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError_logsException() { playExploration( @@ -1522,17 +1508,6 @@ class ExplorationProgressControllerTest { .contains("Cannot navigate to previous state; at initial state.") } - @Test - fun testSubmitAnswer_beforePlaying_failsWithError_logsException() { - explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) - testCoroutineDispatchers.runCurrent() - - val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Cannot submit an answer if an exploration is not being played.") - } - @Test fun testGetCurrentState_playInvalidExploration_returnsFailure_logsException() { playExploration( diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/BUILD.bazel new file mode 100644 index 00000000000..0ea8177d80d --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/BUILD.bazel @@ -0,0 +1,114 @@ +""" +Tests for hints and solution domain components. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "HelpIndexExtensionsTest", + srcs = ["HelpIndexExtensionsTest.kt"], + custom_package = "org.oppia.android.domain.hintsandsolution", + test_class = "org.oppia.android.domain.hintsandsolution.HelpIndexExtensionsTest", + test_manifest = "//domain:test_manifest", + deps = [ + "//testing", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + ], +) + +oppia_android_test( + name = "HintHandlerDebugImplTest", + srcs = ["HintHandlerDebugImplTest.kt"], + custom_package = "org.oppia.android.domain.hintsandsolution", + test_class = "org.oppia.android.domain.hintsandsolution.HintHandlerDebugImplTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +oppia_android_test( + name = "HintHandlerProdImplTest", + srcs = ["HintHandlerProdImplTest.kt"], + custom_package = "org.oppia.android.domain.hintsandsolution", + test_class = "org.oppia.android.domain.hintsandsolution.HintHandlerProdImplTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +oppia_android_test( + name = "HintsAndSolutionDebugModuleTest", + srcs = ["HintsAndSolutionDebugModuleTest.kt"], + custom_package = "org.oppia.android.domain.hintsandsolution", + test_class = "org.oppia.android.domain.hintsandsolution.HintsAndSolutionDebugModuleTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + ], +) + +oppia_android_test( + name = "HintsAndSolutionProdModuleTest", + srcs = ["HintsAndSolutionProdModuleTest.kt"], + custom_package = "org.oppia.android.domain.hintsandsolution", + test_class = "org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModuleTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + ], +) + +dagger_rules() diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt index c900bfd795c..47b9d5ec575 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt @@ -10,11 +10,17 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.reset import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions @@ -37,6 +43,7 @@ import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.threading.BlockingDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -48,25 +55,18 @@ import javax.inject.Singleton @LooperMode(LooperMode.Mode.PAUSED) @Config(application = HintHandlerDebugImplTest.TestApplication::class) class HintHandlerDebugImplTest { - @Rule @JvmField val mockitoRule: MockitoRule = MockitoJUnit.rule() - @Mock - lateinit var mockHintMonitor: HintHandler.HintMonitor - - @Inject - lateinit var hintHandlerDebugImplFactory: HintHandlerDebugImpl.FactoryDebugImpl - - @Inject - lateinit var explorationRetriever: ExplorationRetriever - - @Inject - lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Mock lateinit var mockHelpIndexFlowMonitor: Runnable + @Inject lateinit var hintHandlerDebugImplFactory: HintHandlerDebugImpl.FactoryDebugImpl + @Inject lateinit var explorationRetriever: ExplorationRetriever + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var showAllHintsAndSolutionController: ShowAllHintsAndSolutionController + @field:[Inject BlockingDispatcher] lateinit var blockingCoroutineDispatcher: CoroutineDispatcher - @Inject - lateinit var showAllHintsAndSolutionController: ShowAllHintsAndSolutionController + private lateinit var blockingCoroutineScope: CoroutineScope private val expWithNoHintsOrSolution by lazy { explorationRetriever.loadExploration("test_single_interactive_state_exp_no_hints_no_solution") @@ -87,6 +87,7 @@ class HintHandlerDebugImplTest { @Before fun setUp() { setUpTestApplicationComponent() + blockingCoroutineScope = CoroutineScope(blockingCoroutineDispatcher) } @Test @@ -94,7 +95,7 @@ class HintHandlerDebugImplTest { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = false) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() assertThat(hintHandler).isInstanceOf(HintHandlerProdImpl::class.java) } @@ -104,7 +105,7 @@ class HintHandlerDebugImplTest { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() assertThat(hintHandler).isInstanceOf(HintHandlerDebugImpl::class.java) } @@ -112,51 +113,54 @@ class HintHandlerDebugImplTest { /* Tests for startWatchingForHintsInNewState */ @Test - fun testStartWatchingForHints_showAllHelpsEnabled_stateWithoutHints_callsMonitor() { + fun testStartWatchingForHints_showAllHelpsEnabled_stateWithoutHints_changesHelpIndex() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - verify(mockHintMonitor).onHelpIndexChanged() + // Verify that the help index has changed. + verify(mockHelpIndexFlowMonitor).run() } @Test fun testStartWatchingForHints_showAllHelpsEnabled_stateWithoutHints_helpIndexIsEmpty() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testStartWatchingForHints_showAllHelpsEnabled_stateWithHints_callsMonitor() { + fun testStartWatchingForHints_showAllHelpsEnabled_stateWithHints_changesHelpIndex() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor, atLeastOnce()).run() } @Test fun testStartWatchingForHints_showAllHelpsEnabled_stateWithHints_everythingIsRevealed() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -166,48 +170,49 @@ class HintHandlerDebugImplTest { /* Tests for finishState */ @Test - fun testFinishState_showAllHelpsEnabled_defaultState_callsMonitor() { + fun testFinishState_showAllHelpsEnabled_defaultState_changesHelpIndex() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + reset(mockHelpIndexFlowMonitor) // Simulate the default instance case (which can occur specifically for questions). - hintHandler.finishState(State.getDefaultInstance()) + hintHandler.finishStateSync(State.getDefaultInstance()) - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testFinishState_showAllHelpsEnabled_defaultState_helpIndexIsEmpty() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) // Simulate the default instance case (which can occur specifically for questions). - hintHandler.finishState(State.getDefaultInstance()) + hintHandler.finishStateSync(State.getDefaultInstance()) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testFinishState_showAllHelpsEnabled_newStateWithHints_everythingIsRevealed() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) // Note that this is slightly suspect: normally, a state would be sourced from an independent // question or from the same exploration. This tactic is taken to simplify the data structure // requirements for the test, and because it should be more or less functionally equivalent. - hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState()) + hintHandler.finishStateSync(expWithOneHintAndNoSolution.getInitialState()) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -220,27 +225,28 @@ class HintHandlerDebugImplTest { fun testWrongAnswerSubmission_showAllHelpsEnabled_stateWithHints_monitorNotCalled() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + reset(mockHelpIndexFlowMonitor) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testWrongAnswerSubmission_showAllHelpsEnabled_stateWithHints_everythingIsRevealed() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -251,18 +257,19 @@ class HintHandlerDebugImplTest { fun testWrongAnswerSubmission_showAllHelpsEnabled_twice_stateWithHints_monitorNotCalled() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + reset(mockHelpIndexFlowMonitor) // Simulate two answers being submitted subsequently. - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) // The monitor here is not called because all helps are already revealed and thus there is no // new interaction on submitting wrong answer. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* Tests for navigateToPreviousState */ @@ -271,35 +278,37 @@ class HintHandlerDebugImplTest { fun testNavigateToPreviousState_showAllHelpsEnabled_monitorNotCalled() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) - hintHandler.navigateToPreviousState() + hintHandler.startWatchingForHintsInNewStateSync(state) + reset(mockHelpIndexFlowMonitor) + hintHandler.navigateToPreviousStateSync() // The monitor should not be called since the user navigated away from the pending state and all // helps are already revealed and there is nothing to monitor now. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testNavigateToPreviousState_showAllHelpsEnabled_multipleTimes_monitorNotCalled() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = false) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + reset(mockHelpIndexFlowMonitor) // Simulate navigating back three states. - hintHandler.navigateToPreviousState() - hintHandler.navigateToPreviousState() - hintHandler.navigateToPreviousState() + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateToPreviousStateSync() // The monitor should not be called since the pending state isn't visible and all helps are // already revealed and there is nothing to monitor now. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* Tests for navigateBackToLatestPendingState */ @@ -308,17 +317,18 @@ class HintHandlerDebugImplTest { fun testNavigateBackToLatestPendingState_showAllHelpsEnabled_fromPrevState_monitorNotCalled() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = false) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() + hintHandler.monitorHelpIndex() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.navigateToPreviousState() - reset(mockHintMonitor) - hintHandler.navigateBackToLatestPendingState() + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.navigateToPreviousStateSync() + reset(mockHelpIndexFlowMonitor) + hintHandler.navigateBackToLatestPendingStateSync() // The monitor should not be called after returning to the pending state as all helps are // already revealed and there is nothing to monitor now. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* @@ -331,23 +341,23 @@ class HintHandlerDebugImplTest { fun testGetCurrentHelpIndex_showAllHelpsEnabled_stateWithoutHints_helpIndexIsEmpty() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testGetCurrentHelpIndex_showAllHelpsEnabled_stateWithHints_everythingIsRevealed() { showAllHintsAndSolutionController.setShowAllHintsAndSolution(isEnabled = true) // Use the direct HintHandler factory to avoid testing the module setup. - val hintHandler = hintHandlerDebugImplFactory.create(mockHintMonitor) + val hintHandler = hintHandlerDebugImplFactory.create() val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - val helpIndex = hintHandler.getCurrentHelpIndex() + hintHandler.startWatchingForHintsInNewStateSync(state) + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -356,6 +366,38 @@ class HintHandlerDebugImplTest { ) } + private fun HintHandler.startWatchingForHintsInNewStateSync( + state: State + ) = runSynchronouslyInBackground { startWatchingForHintsInNewState(state) } + + private fun HintHandler.finishStateSync(newState: State) = runSynchronouslyInBackground { + finishState(newState) + } + + private fun HintHandler.handleWrongAnswerSubmissionSync( + wrongAnswerCount: Int + ) = runSynchronouslyInBackground { handleWrongAnswerSubmission(wrongAnswerCount) } + + private fun HintHandler.navigateToPreviousStateSync() = runSynchronouslyInBackground { + navigateToPreviousState() + } + + private fun HintHandler.navigateBackToLatestPendingStateSync() = runSynchronouslyInBackground { + navigateBackToLatestPendingState() + } + + private fun HintHandler.monitorHelpIndex() { + reset(mockHelpIndexFlowMonitor) + getCurrentHelpIndex().onEach { + mockHelpIndexFlowMonitor.run() + }.launchIn(blockingCoroutineScope) + } + + private fun runSynchronouslyInBackground(operation: suspend () -> Unit) { + blockingCoroutineScope.launch { operation() } + testCoroutineDispatchers.runCurrent() + } + private fun Exploration.getInitialState(): State = statesMap.getValue(initStateName) private fun setUpTestApplicationComponent() { diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt index d24fe070cbe..b2e6bc12f28 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt @@ -10,6 +10,12 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.junit.Before import org.junit.Rule import org.junit.Test @@ -37,6 +43,7 @@ import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.threading.BlockingDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.util.concurrent.TimeUnit @@ -45,6 +52,7 @@ import javax.inject.Singleton /** Tests for [HintHandlerProdImpl]. */ @Suppress("FunctionName") +@ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config(application = HintHandlerProdImplTest.TestApplication::class) @@ -53,19 +61,15 @@ class HintHandlerProdImplTest { @JvmField val mockitoRule: MockitoRule = MockitoJUnit.rule() - @Mock - lateinit var mockHintMonitor: HintHandler.HintMonitor - - @Inject - lateinit var hintHandlerProdImplFactory: HintHandlerProdImpl.FactoryProdImpl - - @Inject - lateinit var explorationRetriever: ExplorationRetriever - - @Inject - lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Mock lateinit var mockHelpIndexFlowMonitor: Runnable + @Inject lateinit var hintHandlerProdImplFactory: HintHandlerProdImpl.FactoryProdImpl + @Inject lateinit var explorationRetriever: ExplorationRetriever + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @field:[Inject BlockingDispatcher] lateinit var blockingCoroutineDispatcher: CoroutineDispatcher + private lateinit var blockingCoroutineScope: CoroutineScope private lateinit var hintHandler: HintHandler + private val expWithNoHintsOrSolution by lazy { explorationRetriever.loadExploration("test_single_interactive_state_exp_no_hints_no_solution") } @@ -96,111 +100,118 @@ class HintHandlerProdImplTest { @Before fun setUp() { setUpTestApplicationComponent() + blockingCoroutineScope = CoroutineScope(blockingCoroutineDispatcher) + // Use the direct HintHandler factory to avoid testing the module setup. - hintHandler = hintHandlerProdImplFactory.create(mockHintMonitor) + hintHandler = hintHandlerProdImplFactory.create() } /* Tests for startWatchingForHintsInNewState */ @Test - fun testStartWatchingForHints_stateWithoutHints_callsMonitor() { + fun testStartWatchingForHints_stateWithoutHints_doesNotChangeHelpIndex() { val state = expWithNoHintsOrSolution.getInitialState() + hintHandler.monitorHelpIndex() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - verify(mockHintMonitor).onHelpIndexChanged() + // There's nothing to show, so the help index won't change. + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testStartWatchingForHints_stateWithoutHints_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testStartWatchingForHints_stateWithoutHints_wait60Seconds_monitorNotCalledAgain() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() waitFor60Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testStartWatchingForHints_stateWithoutHints_wait60Seconds_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testStartWatchingForHints_stateWithHints_callsMonitor() { + fun testStartWatchingForHints_stateWithHints_doesNotChangeHelpIndex() { val state = expWithHintsAndSolution.getInitialState() + hintHandler.monitorHelpIndex() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - verify(mockHintMonitor).onHelpIndexChanged() + // The default state is default instance and doesn't change due to starting a new state without + // any additional activity. + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testStartWatchingForHints_stateWithHints_helpIndexIsEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testStartWatchingForHints_stateWithHints_wait10Seconds_doesNotCallMonitorAgain() { + fun testStartWatchingForHints_stateWithHints_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testStartWatchingForHints_stateWithHints_wait30Seconds_doesNotCallMonitorAgain() { + fun testStartWatchingForHints_stateWithHints_wait30Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() waitFor30Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testStartWatchingForHints_stateWithHints_wait60Seconds_callsMonitorAgain() { + fun testStartWatchingForHints_stateWithHints_wait60Seconds_changesHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() waitFor60Seconds() // Verify that the monitor is called again (since there's a hint now available). - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testStartWatchingForHints_stateWithHints_wait60Seconds_firstHintIsAvailable() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build() @@ -210,60 +221,66 @@ class HintHandlerProdImplTest { /* Tests for resumeHintsForSavedState */ @Test - fun testResumeHint_stateWithoutHints_noTrackedAnswers_noHintVisible_callsMonitor() { + fun testResumeHint_stateWithoutHints_noTrackedAnswers_noHintVisible_doesNotChangeHelpIndex() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.monitorHelpIndex() + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - verify(mockHintMonitor).onHelpIndexChanged() + // Nothing should be called since the help index didn't actually change due to there being + // nothing to show. + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testResumeHint_stateWithoutHints_noTrackedAnswer_noHintVisible_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testResumeHint_stateWithoutHint_twoTrackedAnswer_noHintVisible_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 2, HelpIndex.getDefaultInstance(), state ) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testResumeHint_stateWithoutHints_noTrackedAnswers_wait60Seconds_monitorNotCalledAgain() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor60Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testResumeHint_stateWithoutHints_twoTrackedAns_noHintVisible_wait60Sec_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 2, HelpIndex.getDefaultInstance(), state @@ -271,92 +288,94 @@ class HintHandlerProdImplTest { waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testResumeHint_stateWithHints_noTrackedAnswers_noHintVisible_callsMonitor() { + fun testResumeHint_stateWithHints_noTrackedAnswers_noHintVisible_doesNotChangeHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.monitorHelpIndex() + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.getDefaultInstance(), state = state ) - verify(mockHintMonitor).onHelpIndexChanged() + // There's nothing to restore, so show nothing. + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testResumeHints_stateWithHints_noTrackedAnswers_noHintVisible_helpIndexIsEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testResumeHints_stateWithHints_noHintVisible_wait10Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_noHintVisible_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_noHintVisible_wait30Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_noHintVisible_wait30Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_noHintVisible_wait60Seconds_callsMonitorAgain() { + fun testResumeHints_stateWithHints_noHintVisible_wait60Seconds_changesHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor60Seconds() // Verify that the monitor is called again (since there's a hint now available). - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testResumeHints_stateWithHints_noHintVisible_wait60Seconds_helpIndexHasNewAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, HelpIndex.getDefaultInstance(), state ) - reset(mockHintMonitor) waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build() @@ -364,106 +383,106 @@ class HintHandlerProdImplTest { } @Test - fun testResumeHints_stateWithHints_hintVisible_wait10Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_hintVisible_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_hintVisible_wait30Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_hintVisible_wait30Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_hintVisible_wait60Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_hintVisible_wait60Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor60Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_hintRevealed_wait10Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_hintRevealed_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { latestRevealedHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithOneHint_hintRevealed_wait30Seconds_callsMonitorAgain() { + fun testResumeHints_stateWithOneHint_hintRevealed_wait30Seconds_changesHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { latestRevealedHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() // Verify that the monitor is called again (since there's a solution now available). - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testResumeHints_stateWithOneHint_hintRevealed_wait30Seconds_helpIndexHasSolution() { val state = expWithOneHintAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { latestRevealedHintIndex = 0 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { showSolution = true }.build() @@ -471,38 +490,38 @@ class HintHandlerProdImplTest { } @Test - fun testResumeHints_stateWithTwoHints_secondHintRevealed_wait30Seconds_callsMonitorAgain() { + fun testResumeHints_stateWithTwoHints_secondHintRevealed_wait30Seconds_changesHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { latestRevealedHintIndex = 1 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() // Verify that the monitor is called again (since there's a solution now available). - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testResumeHints_stateWithTwoHints_secondHintRevealed_wait30Seconds_helpIndexHasSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { latestRevealedHintIndex = 1 }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { showSolution = true }.build() @@ -510,9 +529,11 @@ class HintHandlerProdImplTest { } @Test - fun testResumeHint_stateWithHints_solutionVisible_callsMonitor() { + fun testResumeHints_stateWithHints_solutionVisible_changesHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.monitorHelpIndex() + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { showSolution = true @@ -520,13 +541,13 @@ class HintHandlerProdImplTest { state ) - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test - fun testResumeHint_stateWithHints_solutionVisible_helpIndexHasSolution() { + fun testResumeHints_stateWithHints_solutionVisible_helpIndexHasSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { showSolution = true @@ -534,7 +555,7 @@ class HintHandlerProdImplTest { state ) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { showSolution = true }.build() @@ -542,60 +563,62 @@ class HintHandlerProdImplTest { } @Test - fun testResumeHints_stateWithHints_solutionVisible_wait10Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_solutionVisible_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { showSolution = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_solutionVisible_wait30Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_solutionVisible_wait30Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { showSolution = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_solutionVisible_wait60Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_solutionVisible_wait60Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { showSolution = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor60Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHint_stateWithHints_everythingVisible_callsMonitor() { + fun testResumeHint_stateWithHints_everythingVisible_changesHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.monitorHelpIndex() + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { everythingRevealed = true @@ -603,13 +626,14 @@ class HintHandlerProdImplTest { state ) - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testResumeHint_stateWithHints_everythingVisible_helpIndexShowsEverything() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { everythingRevealed = true @@ -617,7 +641,7 @@ class HintHandlerProdImplTest { state ) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -625,144 +649,159 @@ class HintHandlerProdImplTest { } @Test - fun testResumeHints_stateWithHints_everythingVisible_wait10Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_everythingVisible_wait10Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { everythingRevealed = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_everythingVisible_wait30Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_everythingVisible_wait30Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { everythingRevealed = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test - fun testResumeHints_stateWithHints_everythingVisible_wait60Seconds_doesNotCallMonitorAgain() { + fun testResumeHints_stateWithHints_everythingVisible_wait60Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.resumeHintsForSavedState( + hintHandler.resumeHintsForSavedStateSync( trackedWrongAnswerCount = 0, helpIndex = HelpIndex.newBuilder().apply { everythingRevealed = true }.build(), state ) - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* Tests for finishState */ @Test - fun testFinishState_defaultState_callsMonitor() { + fun testFinishState_defaultState_noLogicalChangeToHints_doesNotChangeHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() // Simulate the default instance case (which can occur specifically for questions). - hintHandler.finishState(State.getDefaultInstance()) + hintHandler.finishStateSync(State.getDefaultInstance()) - verify(mockHintMonitor).onHelpIndexChanged() + // The flow monitor isn't notified if the help index value stays the same. + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testFinishState_defaultState_helpIndexIsEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) + + // Simulate the default instance case (which can occur specifically for questions). + hintHandler.finishStateSync(State.getDefaultInstance()) + + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() + } + + @Test + fun testFinishState_nonDefaultState_toDefaultState_changesHelpIndex() { + val state = expWithHintsAndSolution.getInitialState() + hintHandler.startWatchingForHintsInNewStateSync(state) + waitFor60Seconds() + hintHandler.monitorHelpIndex() // Simulate the default instance case (which can occur specifically for questions). - hintHandler.finishState(State.getDefaultInstance()) + hintHandler.finishStateSync(State.getDefaultInstance()) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + // The monitor should be called in this case since the help index has changed. + verify(mockHelpIndexFlowMonitor).run() } @Test fun testFinishState_defaultState_wait60Seconds_monitorNotCalledAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.finishState(State.getDefaultInstance()) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.finishStateSync(State.getDefaultInstance()) + hintHandler.monitorHelpIndex() waitFor60Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testFinishState_defaultState_wait60Seconds_helpIndexStaysEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.finishState(State.getDefaultInstance()) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.finishStateSync(State.getDefaultInstance()) + hintHandler.monitorHelpIndex() waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testFinishState_newStateWithHints_helpIndexIsEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) // Note that this is slightly suspect: normally, a state would be sourced from an independent // question or from the same exploration. This tactic is taken to simplify the data structure // requirements for the test, and because it should be more or less functionally equivalent. - hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState()) + hintHandler.finishStateSync(expWithOneHintAndNoSolution.getInitialState()) // The help index should be reset. - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test - fun testFinishState_newStateWithHints_wait60Seconds_callsMonitorAgain() { + fun testFinishState_newStateWithHints_wait60Seconds_changesHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState()) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.finishStateSync(expWithOneHintAndNoSolution.getInitialState()) + hintHandler.monitorHelpIndex() waitFor60Seconds() // The index should be called again now that there's a new index. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testFinishState_previousStateFullyRevealed_newStateWithHints_wait60Seconds_indexHasNewHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealEverythingInMultiHintState() - hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState()) + hintHandler.finishStateSync(expWithOneHintAndNoSolution.getInitialState()) waitFor60Seconds() // A new hint index should be revealed despite the entire previous state being completed (since // the handler has been reset). - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build() @@ -770,17 +809,17 @@ class HintHandlerProdImplTest { } @Test - fun testFinishState_newStateWithoutHints_wait60Seconds_doesNotCallMonitorAgain() { + fun testFinishState_newStateWithoutHints_wait60Seconds_doesNotChangeHelpIndexAgain() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealEverythingInMultiHintState() - hintHandler.finishState(expWithNoHintsOrSolution.getInitialState()) - reset(mockHintMonitor) + hintHandler.finishStateSync(expWithNoHintsOrSolution.getInitialState()) + hintHandler.monitorHelpIndex() waitFor60Seconds() // Since the new state doesn't have any hints, the index will not change. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* Tests for handleWrongAnswerSubmission */ @@ -788,70 +827,70 @@ class HintHandlerProdImplTest { @Test fun testWrongAnswerSubmission_stateWithHints_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testWrongAnswerSubmission_stateWithHints_helpIndexStaysEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } @Test fun testWrongAnswerSubmission_stateWithHints_wait10seconds_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testWrongAnswerSubmission_stateWithHints_wait30seconds_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.monitorHelpIndex() waitFor30Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testWrongAnswerSubmission_stateWithHints_wait60seconds_monitorCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.monitorHelpIndex() waitFor60Seconds() // A hint should now be available, so the monitor will be notified. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testWrongAnswerSubmission_stateWithHints_wait60seconds_helpIndexHasAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor60Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build() @@ -861,27 +900,28 @@ class HintHandlerProdImplTest { @Test fun testWrongAnswerSubmission_twice_stateWithHints_monitorCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() // Simulate two answers being submitted subsequently. - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - // Submitting two wrong answers subsequently should immediately result in a hint being available. - verify(mockHintMonitor).onHelpIndexChanged() + // Submitting two wrong answers subsequently should immediately result in a hint being + // available. + verify(mockHelpIndexFlowMonitor).run() } @Test fun testWrongAnswerSubmission_twice_stateWithHints_helpIndexHasAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) // Simulate two answers being submitted subsequently. - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 0 }.build() @@ -891,28 +931,28 @@ class HintHandlerProdImplTest { @Test fun testWrongAnswerSubmission_twice_stateWithoutHints_monitorNotCalled() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() // Simulate two answers being submitted subsequently. - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) // No notification should happen since the state doesn't have any hints. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testWrongAnswerSubmission_twice_stateWithoutHints_helpIndexIsEmpty() { val state = expWithNoHintsOrSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) // Simulate two answers being submitted subsequently. - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) // No hint is available since the state has no hints. - assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance() + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualToDefaultInstance() } /* Tests for viewHint */ @@ -920,10 +960,10 @@ class HintHandlerProdImplTest { @Test fun testViewHint_noHintAvailable_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) } // No hint is available to reveal. @@ -931,28 +971,27 @@ class HintHandlerProdImplTest { } @Test - fun testViewHint_hintAvailable_callsMonitor() { + fun testViewHint_hintAvailable_changesHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) // Viewing the hint should trigger a change in the help index. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testViewHint_hintAvailable_helpIndexUpdatedToShowHintShown() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - reset(mockHintMonitor) - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { latestRevealedHintIndex = 0 }.build() @@ -962,40 +1001,40 @@ class HintHandlerProdImplTest { @Test fun testViewHint_hintAvailable_multiHintState_wait10Seconds_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - hintHandler.viewHint(hintIndex = 0) - reset(mockHintMonitor) + hintHandler.viewHintSync(hintIndex = 0) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewHint_hintAvailable_multiHintState_wait30Seconds_monitorCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - hintHandler.viewHint(hintIndex = 0) - reset(mockHintMonitor) + hintHandler.viewHintSync(hintIndex = 0) + hintHandler.monitorHelpIndex() waitFor30Seconds() // 30 seconds is long enough to trigger a second hint to be available. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testViewHint_hintAvailable_multiHintState_wait30Seconds_helpIndexHasNewAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) waitFor30Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { nextAvailableHintIndex = 1 }.build() @@ -1005,12 +1044,12 @@ class HintHandlerProdImplTest { @Test fun testViewHint_hintAvailable_multiHintState_allHintsRevealed_indexShowsLastRevealedHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { latestRevealedHintIndex = 1 }.build() @@ -1020,13 +1059,13 @@ class HintHandlerProdImplTest { @Test fun testViewHint_multiHintState_allHintsRevealed_triggerSolution_indexShowsSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { showSolution = true }.build() @@ -1036,37 +1075,37 @@ class HintHandlerProdImplTest { @Test fun testViewHint_hintAvailable_oneHintState_withSolution_wait10Sec_monitorNotCalled() { val state = expWithOneHintAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewHint_hintAvailable_oneHintState_withSolution_wait30Sec_monitorCalled() { val state = expWithOneHintAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() // The solution should now be available. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testViewHint_hintAvailable_oneHintState_withSolution_wait30Sec_indexShowsSolution() { val state = expWithOneHintAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() waitFor30Seconds() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { showSolution = true }.build() @@ -1076,36 +1115,36 @@ class HintHandlerProdImplTest { @Test fun testViewHint_hintAvailable_oneHintState_noSolution_wait10Sec_monitorNotCalled() { val state = expWithOneHintAndNoSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor10Seconds() - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewHint_hintAvailable_oneHintState_noSolution_wait30Sec_monitorNotCalled() { val state = expWithOneHintAndNoSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() waitFor30Seconds() // The index is still unchanged since there's nothing left to see. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewHint_latestHintViewed_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) } // No hint is available to reveal since it's already been revealed. @@ -1115,12 +1154,12 @@ class HintHandlerProdImplTest { @Test fun testViewHint_solutionAvailable_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) } // No hint is available to reveal since all hints have been revealed. @@ -1130,13 +1169,13 @@ class HintHandlerProdImplTest { @Test fun testViewHint_everythingRevealed_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerAndRevealSolution() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) } // No hint is available to reveal since everything has been revealed. @@ -1148,10 +1187,10 @@ class HintHandlerProdImplTest { @Test fun testViewSolution_nothingAvailable_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } // The solution is not yet available to be seen (no hints have been viewed). @@ -1161,11 +1200,11 @@ class HintHandlerProdImplTest { @Test fun testViewSolution_hintAvailable_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } // The solution is not yet available to be seen (one hint is available, but hasn't been viewed). @@ -1175,11 +1214,11 @@ class HintHandlerProdImplTest { @Test fun testViewSolution_hintViewed_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } // The solution is not yet available to be seen (one hint was viewed, but the solution isn't @@ -1190,12 +1229,12 @@ class HintHandlerProdImplTest { @Test fun testViewSolution_allHintsViewed_solutionNotTriggered_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } // The solution is not yet available to be seen since the user hasn't triggered the solution to @@ -1204,31 +1243,31 @@ class HintHandlerProdImplTest { } @Test - fun testViewSolution_solutionAvailable_callsMonitor() { + fun testViewSolution_solutionAvailable_changesHelpIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - reset(mockHintMonitor) + hintHandler.monitorHelpIndex() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() // The help index should change when the solution is revealed. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testViewSolution_solutionAvailable_helpIndexUpdatedToShowEverything() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -1238,61 +1277,61 @@ class HintHandlerProdImplTest { @Test fun testViewSolution_solutionAvailable_wait10Sec_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - hintHandler.viewSolution() - reset(mockHintMonitor) + hintHandler.viewSolutionSync() + hintHandler.monitorHelpIndex() waitFor10Seconds() // There's nothing left to be revealed. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewSolution_solutionAvailable_wait30Sec_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - hintHandler.viewSolution() - reset(mockHintMonitor) + hintHandler.viewSolutionSync() + hintHandler.monitorHelpIndex() waitFor30Seconds() // There's nothing left to be revealed. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewSolution_solutionAvailable_wait60Sec_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerSolution() - hintHandler.viewSolution() - reset(mockHintMonitor) + hintHandler.viewSolutionSync() + hintHandler.monitorHelpIndex() waitFor60Seconds() // There's nothing left to be revealed. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testViewSolution_everythingViewed_throwsException() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() triggerAndRevealSolution() val exception = assertThrows(IllegalStateException::class) { - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } // The solution has already been revealed. @@ -1304,30 +1343,30 @@ class HintHandlerProdImplTest { @Test fun testNavigateToPreviousState_pendingHint_wait60Sec_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() - hintHandler.navigateToPreviousState() + hintHandler.navigateToPreviousStateSync() waitFor60Seconds() // The monitor should not be called since the user navigated away from the pending state. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testNavigateToPreviousState_multipleTimes_pendingHint_wait60Sec_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.monitorHelpIndex() // Simulate navigating back three states. - hintHandler.navigateToPreviousState() - hintHandler.navigateToPreviousState() - hintHandler.navigateToPreviousState() + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateToPreviousStateSync() waitFor60Seconds() // The monitor should not be called since the pending state isn't visible. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* Tests for navigateBackToLatestPendingState */ @@ -1335,44 +1374,44 @@ class HintHandlerProdImplTest { @Test fun testNavigateBackToLatestPendingState_fromPreviousState_pendingHint_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.navigateToPreviousState() - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.navigateToPreviousStateSync() + hintHandler.monitorHelpIndex() - hintHandler.navigateBackToLatestPendingState() + hintHandler.navigateBackToLatestPendingStateSync() // The monitor should not be called immediately after returning to the pending state. - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } @Test fun testNavigateBackToLatestPendingState_fromPreviousState_pendingHint_wait60Sec_monitorCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.navigateToPreviousState() - hintHandler.navigateBackToLatestPendingState() - reset(mockHintMonitor) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateBackToLatestPendingStateSync() + hintHandler.monitorHelpIndex() waitFor60Seconds() // The hint should not be available since the user has waited for the counter to finish. - verify(mockHintMonitor).onHelpIndexChanged() + verify(mockHelpIndexFlowMonitor).run() } @Test fun testNavigateBackToLatestPendingState_fromPreviousState_waitRemainingTime_monitorNotCalled() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor30Seconds() - hintHandler.navigateToPreviousState() - hintHandler.navigateBackToLatestPendingState() - reset(mockHintMonitor) + hintHandler.navigateToPreviousStateSync() + hintHandler.navigateBackToLatestPendingStateSync() + hintHandler.monitorHelpIndex() waitFor30Seconds() // Waiting half the necessary time is insufficient to show the hint (since the timer is not // resumed, it's reset after returning the pending state). - verifyNoMoreInteractions(mockHintMonitor) + verifyNoMoreInteractions(mockHelpIndexFlowMonitor) } /* @@ -1384,9 +1423,9 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_initialState_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1394,10 +1433,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_wait10Sec_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1405,10 +1444,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_wait30Sec_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1416,10 +1455,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_wait60Sec_hasAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1431,10 +1470,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_oneWrongAnswer_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1442,11 +1481,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_oneWrongAnswer_wait10Sec_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1454,11 +1493,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_oneWrongAnswer_wait30Sec_isEmpty() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1466,11 +1505,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_oneWrongAnswer_wait60Sec_hasAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor60Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1482,11 +1521,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_twoWrongAnswers_hasAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1498,11 +1537,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_withAvailableHint_anotherWrongAnswer_hasSameAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerFirstHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1514,10 +1553,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_hasShownHintIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1529,11 +1568,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_wait10Sec_hasShownHintIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1545,11 +1584,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_wait30Sec_hasNewAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1561,11 +1600,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_oneWrongAnswer_hasShownHintIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1577,12 +1616,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_oneWrongAnswer_wait10Sec_hasNewAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1594,12 +1633,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_twoWrongAnswers_hasShownHintIndex() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value // Multiple wrong answers do not force a hint to be shown except for the first hint. assertThat(helpIndex).isEqualTo( @@ -1612,13 +1651,13 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_viewAvailableHint_twoWrongAnswers_wait10Sec_hasNewAvailableHint() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1630,12 +1669,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_noSolution_everythingRevealed() { val state = expWithOneHintAndNoSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() // All hints have been viewed for this state, so nothing remains. - assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo( + assertThat(hintHandler.getCurrentHelpIndex().value).isEqualTo( HelpIndex.newBuilder().apply { everythingRevealed = true }.build() @@ -1645,11 +1684,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_lastIndexViewed() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1661,12 +1700,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_wait10Sec_lastIndexViewed() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1678,12 +1717,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_wait30Sec_canShowSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1695,13 +1734,13 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_wait30Sec_revealSolution_everythingRevealed() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() waitFor30Seconds() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1713,12 +1752,12 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_oneWrongAnswer_lastIndexViewed() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1730,13 +1769,13 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_oneWrongAnswer_wait10Sec_canShowSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1748,13 +1787,13 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_twoWrongAnswers_lastIndexViewed() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value // Multiple subsequent wrong answers only affects the first hint. assertThat(helpIndex).isEqualTo( @@ -1767,14 +1806,14 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_allHintsViewed_twoWrongAnswers_wait10Sec_canShowSolution() { val state = expWithHintsAndSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) triggerAndRevealFirstHint() triggerAndRevealSecondHint() - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1786,9 +1825,9 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1796,10 +1835,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_wait10Sec_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1807,10 +1846,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_wait30Sec_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1818,10 +1857,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_wait60Sec_canShowSolution() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1833,10 +1872,10 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1844,11 +1883,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait10Sec_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor10Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1856,11 +1895,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait30Sec_isEmpty() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor30Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1868,11 +1907,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait60Sec_canShowSolution() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) waitFor60Seconds() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1884,11 +1923,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_twoWrongAnswers_canShowSolution() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1900,11 +1939,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_triggeredAndRevealed_everythingIsRevealed() { val state = expWithNoHintsAndOneSolution.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1916,9 +1955,9 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_isEmpty() { val state = expWithSolutionMissingCorrectAnswer.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualToDefaultInstance() } @@ -1926,11 +1965,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_twoWrongAnswers_canShowSolution() { val state = expWithSolutionMissingCorrectAnswer.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1) - hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2) + hintHandler.startWatchingForHintsInNewStateSync(state) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 1) + hintHandler.handleWrongAnswerSubmissionSync(wrongAnswerCount = 2) - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1942,11 +1981,11 @@ class HintHandlerProdImplTest { @Test fun testGetCurrentHelpIndex_onlySolution_missingCorrectAnswer_triggeredAndShown_allRevealed() { val state = expWithSolutionMissingCorrectAnswer.getInitialState() - hintHandler.startWatchingForHintsInNewState(state) + hintHandler.startWatchingForHintsInNewStateSync(state) waitFor60Seconds() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() - val helpIndex = hintHandler.getCurrentHelpIndex() + val helpIndex = hintHandler.getCurrentHelpIndex().value assertThat(helpIndex).isEqualTo( HelpIndex.newBuilder().apply { @@ -1955,6 +1994,57 @@ class HintHandlerProdImplTest { ) } + private fun HintHandler.startWatchingForHintsInNewStateSync( + state: State + ) = runSynchronouslyInBackground { startWatchingForHintsInNewState(state) } + + private fun HintHandler.resumeHintsForSavedStateSync( + trackedWrongAnswerCount: Int, + helpIndex: HelpIndex, + state: State + ) = runSynchronouslyInBackground { + resumeHintsForSavedState(trackedWrongAnswerCount, helpIndex, state) + } + + private fun HintHandler.finishStateSync(newState: State) = runSynchronouslyInBackground { + finishState(newState) + } + + private fun HintHandler.handleWrongAnswerSubmissionSync( + wrongAnswerCount: Int + ) = runSynchronouslyInBackground { handleWrongAnswerSubmission(wrongAnswerCount) } + + private fun HintHandler.viewHintSync(hintIndex: Int) = runSynchronouslyInBackground { + viewHint(hintIndex) + } + + private fun HintHandler.viewSolutionSync() = runSynchronouslyInBackground { viewSolution() } + + private fun HintHandler.navigateToPreviousStateSync() = runSynchronouslyInBackground { + navigateToPreviousState() + } + + private fun HintHandler.navigateBackToLatestPendingStateSync() = runSynchronouslyInBackground { + navigateBackToLatestPendingState() + } + + private fun HintHandler.monitorHelpIndex() { + getCurrentHelpIndex().onEach { + mockHelpIndexFlowMonitor.run() + }.launchIn(blockingCoroutineScope) + + // Allow the initial onEach call to change the mock, then reset it so that it's in a clean + // state. + testCoroutineDispatchers.runCurrent() + reset(mockHelpIndexFlowMonitor) + } + + private fun runSynchronouslyInBackground(operation: suspend () -> Unit) { + val result = blockingCoroutineScope.async { operation() } + testCoroutineDispatchers.runCurrent() + result.getCompletionExceptionOrNull()?.let { throw it } + } + private fun Exploration.getInitialState(): State = statesMap.getValue(initStateName) private fun triggerFirstHint() = waitFor60Seconds() @@ -1965,17 +2055,17 @@ class HintHandlerProdImplTest { private fun triggerAndRevealFirstHint() { triggerFirstHint() - hintHandler.viewHint(hintIndex = 0) + hintHandler.viewHintSync(hintIndex = 0) } private fun triggerAndRevealSecondHint() { triggerSecondHint() - hintHandler.viewHint(hintIndex = 1) + hintHandler.viewHintSync(hintIndex = 1) } private fun triggerAndRevealSolution() { triggerSolution() - hintHandler.viewSolution() + hintHandler.viewSolutionSync() } private fun triggerAndRevealEverythingInMultiHintState() { diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionDebugModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionDebugModuleTest.kt index f26f840d4cc..edbee8a92f2 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionDebugModuleTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionDebugModuleTest.kt @@ -9,6 +9,7 @@ import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module +import kotlinx.coroutines.ObsoleteCoroutinesApi import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,6 +39,7 @@ class HintsAndSolutionDebugModuleTest { } @Test + @ObsoleteCoroutinesApi fun testHintHandlerFactoryInjection_providesFactoryDebugImpl() { assertThat(hintHandlerFactory).isInstanceOf(FactoryDebugImpl::class.java) } diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt index f56bd0ee4c7..6277dfa2eaf 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt @@ -38,9 +38,7 @@ class HintsAndSolutionProdModuleTest { @Test fun testHintHandlerFactoryInjection_constructNewHandler_providesFactoryForProdImplHandler() { - val hintHandler = hintHandlerFactory.create(object : HintHandler.HintMonitor { - override fun onHelpIndexChanged() {} - }) + val hintHandler = hintHandlerFactory.create() assertThat(hintHandler).isInstanceOf(HintHandlerProdImpl::class.java) } diff --git a/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel new file mode 100644 index 00000000000..c45786a6961 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel @@ -0,0 +1,91 @@ +""" +Tests for lightweight exploration player domain components. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "QuestionAssessmentProgressControllerTest", + srcs = ["QuestionAssessmentProgressControllerTest.kt"], + custom_package = "org.oppia.android.domain.question", + test_class = "org.oppia.android.domain.question.QuestionAssessmentProgressControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction:continue_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/dragAndDropSortInput:drag_and_drop_sort_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/fractioninput:fraction_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/imageClickInput:image_click_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/itemselectioninput:item_selection_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/mathequationinput:math_equation_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/multiplechoiceinput:multiple_choice_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numberwithunits:number_with_units_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericexpressioninput:numeric_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "QuestionTrainingControllerTest", + srcs = ["QuestionTrainingControllerTest.kt"], + custom_package = "org.oppia.android.domain.question", + test_class = "org.oppia.android.domain.question.QuestionTrainingControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain", + "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction:continue_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/dragAndDropSortInput:drag_and_drop_sort_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/fractioninput:fraction_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/imageClickInput:image_click_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/itemselectioninput:item_selection_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/mathequationinput:math_equation_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/multiplechoiceinput:multiple_choice_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numberwithunits:number_with_units_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericexpressioninput:numeric_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt index b906664be8f..9c002d08c19 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt @@ -55,6 +55,7 @@ import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.data.AsyncResultSubject.Companion.assertThat import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers @@ -179,29 +180,15 @@ class QuestionAssessmentProgressControllerTest { } @Test - fun testStopTrainingSession_withoutStartingSession_fails() { + fun testStopTrainingSession_withoutStartingSession_isFailure() { setUpTestApplicationWithSeed(questionSeed = 0) val stopDataProvider = questionTrainingController.stopQuestionTrainingSession() - val error = monitorFactory.waitForNextFailureResult(stopDataProvider) - assertThat(error) - .hasMessageThat() - .contains("Cannot stop a new training session which wasn't started") - } - - @Test - fun testStartTrainingSession_withoutFinishingPrevious_fails() { - setUpTestApplicationWithSeed(questionSeed = 0) - questionTrainingController.startQuestionTrainingSession(profileId1, TEST_SKILL_ID_LIST_012) - - val initiationDataProvider = - questionTrainingController.startQuestionTrainingSession(profileId1, TEST_SKILL_ID_LIST_02) - - val error = monitorFactory.waitForNextFailureResult(initiationDataProvider) - assertThat(error) - .hasMessageThat() - .contains("Cannot start a new training session until the previous one is completed") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(stopDataProvider) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -235,17 +222,16 @@ class QuestionAssessmentProgressControllerTest { } @Test - fun testSubmitAnswer_beforePlaying_failsWithError() { + fun testSubmitAnswer_beforePlaying_isFailure() { setUpTestApplicationWithSeed(questionSeed = 0) val submitAnswerProvider = questionAssessmentProgressController.submitAnswer(createMultipleChoiceAnswer(0)) - // Verify that the answer submission failed. - val failure = monitorFactory.waitForNextFailureResult(submitAnswerProvider) - assertThat(failure) - .hasMessageThat() - .contains("Cannot submit an answer if a training session has not yet begun.") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(submitAnswerProvider) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -358,15 +344,15 @@ class QuestionAssessmentProgressControllerTest { } @Test - fun testMoveToNext_beforePlaying_failsWithError() { + fun testMoveToNext_beforePlaying_isFailure() { setUpTestApplicationWithSeed(questionSeed = 0) val moveToQuestionResult = questionAssessmentProgressController.moveToNextQuestion() - val error = monitorFactory.waitForNextFailureResult(moveToQuestionResult) - assertThat(error) - .hasMessageThat() - .contains("Cannot navigate to a next question if a training session has not begun.") + // The operation should be failing since the session hasn't started. + val result = monitorFactory.waitForNextFailureResult(moveToQuestionResult) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } @Test @@ -611,20 +597,6 @@ class QuestionAssessmentProgressControllerTest { .contains("Cannot navigate to next state; at most recent state.") } - @Test - fun testSubmitAnswer_beforePlaying_failsWithError_logsException() { - setUpTestApplicationWithSeed(questionSeed = 0) - - questionAssessmentProgressController.submitAnswer(createMultipleChoiceAnswer(0)) - testCoroutineDispatchers.runCurrent() - - val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(IllegalStateException::class.java) - assertThat(exception) - .hasMessageThat() - .contains("Cannot submit an answer if a training session has not yet begun.") - } - @Test fun testSubmitAnswer_forTextInput_wrongAnswer_returnsDefaultOutcome_showHint() { setUpTestApplicationWithSeed(questionSeed = 0) @@ -687,7 +659,8 @@ class QuestionAssessmentProgressControllerTest { waitForGetCurrentQuestionSuccessfulLoad() submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer) submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer) - monitorFactory.waitForNextSuccessfulResult( + // The actual reveal will fail due to it being invalid. + monitorFactory.ensureDataProviderExecutes( questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0) ) submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer) @@ -1398,7 +1371,9 @@ class QuestionAssessmentProgressControllerTest { } private fun submitAnswer(answer: UserAnswer): EphemeralQuestion { - questionAssessmentProgressController.submitAnswer(answer) + monitorFactory.waitForNextSuccessfulResult( + questionAssessmentProgressController.submitAnswer(answer) + ) return waitForGetCurrentQuestionSuccessfulLoad() } @@ -1420,8 +1395,10 @@ class QuestionAssessmentProgressControllerTest { } private fun moveToNextQuestion(): EphemeralQuestion { - questionAssessmentProgressController.moveToNextQuestion() - testCoroutineDispatchers.runCurrent() + // This operation might fail for some tests. + monitorFactory.ensureDataProviderExecutes( + questionAssessmentProgressController.moveToNextQuestion() + ) return waitForGetCurrentQuestionSuccessfulLoad() } diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt index 5b181a11894..b0c460a4f39 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt @@ -167,30 +167,14 @@ class QuestionTrainingControllerTest { } @Test - fun testController_startTrainingSession_noSkills_fails_logsException() { + fun testStopTrainingSession_withoutStartingSession_returnsFailure() { setUpTestApplicationComponent(questionSeed = 0) - questionTrainingController.startQuestionTrainingSession(profileId1, listOf()) - questionTrainingController.startQuestionTrainingSession(profileId1, listOf()) - testCoroutineDispatchers.runCurrent() - - val exception = fakeExceptionLogger.getMostRecentException() - - assertThat(exception).isInstanceOf(IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Cannot start a new training session until the previous one is completed.") - } - - @Test - fun testStopTrainingSession_withoutStartingSession_fails_logsException() { - setUpTestApplicationComponent(questionSeed = 0) - questionTrainingController.stopQuestionTrainingSession() - testCoroutineDispatchers.runCurrent() - val exception = fakeExceptionLogger.getMostRecentException() + val resultProvider = questionTrainingController.stopQuestionTrainingSession() - assertThat(exception).isInstanceOf(IllegalStateException::class.java) - assertThat(exception).hasMessageThat() - .contains("Cannot stop a new training session which wasn't started") + val result = monitorFactory.waitForNextFailureResult(resultProvider) + assertThat(result).isInstanceOf(IllegalStateException::class.java) + assertThat(result).hasMessageThat().contains("Session isn't initialized yet.") } private fun setUpTestApplicationComponent(questionSeed: Long) { diff --git a/oppia_android_test.bzl b/oppia_android_test.bzl index b5a1318faa6..876f28975c5 100644 --- a/oppia_android_test.bzl +++ b/oppia_android_test.bzl @@ -10,6 +10,7 @@ def oppia_android_module_level_test( filtered_tests, deps, processed_src = None, + test_class = None, test_path_prefix = "src/test/java/", additional_srcs = [], **kwargs): @@ -20,18 +21,22 @@ def oppia_android_module_level_test( name: str. The relative path to the Kotlin test file. filtered_tests: list of str. The test files that should not have tests defined for them. deps: list of str. The list of dependencies needed to build and run this test. - processed_src: str. The source to a processed version of the test that should be used + processed_src: str|None. The source to a processed version of the test that should be used instead of the original. - test_path_prefix: str. The prefix of the test path (which is used to extract the qualified - class name of the test suite). + test_class: str|None. The fully qualified test class that will be run (relative to + src/test/java). + test_path_prefix: str|None. The prefix of the test path (which is used to extract the + qualified class name of the test suite). additional_srcs: list of str. Additional source files to build into the test binary. **kwargs: additional parameters to pass to oppia_android_test. """ if name not in filtered_tests: oppia_android_test( - name = name[:name.find(".kt")], + name = name[:name.find(".kt")] if "/" in name else name, srcs = [processed_src or name] + additional_srcs, - test_class = _remove_prefix_suffix(name, test_path_prefix, ".kt").replace("/", "."), + test_class = ( + test_class or _remove_prefix_suffix(name, test_path_prefix, ".kt").replace("/", ".") + ), deps = deps, **kwargs ) diff --git a/scripts/assets/maven_dependencies.textproto b/scripts/assets/maven_dependencies.textproto index 4688880c6d8..30bf55efea4 100644 --- a/scripts/assets/maven_dependencies.textproto +++ b/scripts/assets/maven_dependencies.textproto @@ -809,8 +809,8 @@ maven_dependency { } } maven_dependency { - artifact_name: "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4" - artifact_version: "1.3.4" + artifact_name: "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" + artifact_version: "1.4.1" license { license_name: "The Apache Software License, Version 2.0" original_link: "https://www.apache.org/licenses/LICENSE-2.0.txt" @@ -820,8 +820,8 @@ maven_dependency { } } maven_dependency { - artifact_name: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4" - artifact_version: "1.3.4" + artifact_name: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1" + artifact_version: "1.4.1" license { license_name: "The Apache Software License, Version 2.0" original_link: "https://www.apache.org/licenses/LICENSE-2.0.txt" diff --git a/third_party/maven_install.json b/third_party/maven_install.json index 91d4e52cdcb..dc089f40c88 100644 --- a/third_party/maven_install.json +++ b/third_party/maven_install.json @@ -1,8 +1,8 @@ { "dependency_tree": { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", - "__INPUT_ARTIFACTS_HASH": -1596014461, - "__RESOLVED_ARTIFACTS_HASH": -552959652, + "__INPUT_ARTIFACTS_HASH": 1268058241, + "__RESOLVED_ARTIFACTS_HASH": -1495709444, "conflict_resolution": { "androidx.core:core:1.0.1": "androidx.core:core:1.3.0", "androidx.recyclerview:recyclerview:1.0.0": "androidx.recyclerview:recyclerview:1.1.0", @@ -12,8 +12,6 @@ "junit:junit:4.12": "junit:junit:4.13.2", "org.jetbrains.kotlin:kotlin-reflect:1.3.41": "org.jetbrains.kotlin:kotlin-reflect:1.5.0", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72": "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.10", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2": "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1": "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", "org.mockito:mockito-core:2.19.0": "org.mockito:mockito-core:3.9.0" }, "dependencies": [ @@ -1676,9 +1674,9 @@ { "coord": "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0", "dependencies": [ + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1", "androidx.lifecycle:lifecycle-common:2.2.0", "androidx.annotation:annotation:1.1.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", "androidx.lifecycle:lifecycle-livedata:aar:2.2.0", "androidx.lifecycle:lifecycle-livedata-core:aar:2.2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:aar:2.2.0", @@ -1690,7 +1688,7 @@ "androidx.lifecycle:lifecycle-livedata:aar:2.2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:aar:2.2.0", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1" ], "file": "v1/https/maven.google.com/androidx/lifecycle/lifecycle-livedata-ktx/2.2.0/lifecycle-livedata-ktx-2.2.0.aar", "mirror_urls": [ @@ -1706,9 +1704,9 @@ { "coord": "androidx.lifecycle:lifecycle-livedata-ktx:jar:sources:2.2.0", "dependencies": [ + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1", "androidx.arch.core:core-runtime:aar:sources:2.1.0", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", "androidx.lifecycle:lifecycle-livedata-core:aar:sources:2.2.0", "androidx.lifecycle:lifecycle-livedata:aar:sources:2.2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:aar:sources:2.2.0", @@ -1720,7 +1718,7 @@ "androidx.lifecycle:lifecycle-livedata:aar:sources:2.2.0", "androidx.lifecycle:lifecycle-livedata-core-ktx:aar:sources:2.2.0", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1" ], "file": "v1/https/maven.google.com/androidx/lifecycle/lifecycle-livedata-ktx/2.2.0/lifecycle-livedata-ktx-2.2.0-sources.jar", "mirror_urls": [ @@ -1922,13 +1920,13 @@ "dependencies": [ "androidx.annotation:annotation:1.1.0", "androidx.lifecycle:lifecycle-viewmodel:aar:2.2.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0" ], "directDependencies": [ "androidx.lifecycle:lifecycle-viewmodel:aar:2.2.0", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" ], "file": "v1/https/maven.google.com/androidx/lifecycle/lifecycle-viewmodel-ktx/2.2.0/lifecycle-viewmodel-ktx-2.2.0.aar", "mirror_urls": [ @@ -1944,15 +1942,15 @@ { "coord": "androidx.lifecycle:lifecycle-viewmodel-ktx:jar:sources:2.2.0", "dependencies": [ - "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.3.4", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.4.1", "androidx.lifecycle:lifecycle-viewmodel:aar:sources:2.2.0", "androidx.annotation:annotation:jar:sources:1.1.0" ], "directDependencies": [ "androidx.lifecycle:lifecycle-viewmodel:aar:sources:2.2.0", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.4.1" ], "file": "v1/https/maven.google.com/androidx/lifecycle/lifecycle-viewmodel-ktx/2.2.0/lifecycle-viewmodel-ktx-2.2.0-sources.jar", "mirror_urls": [ @@ -3888,16 +3886,14 @@ { "coord": "androidx.work:work-runtime-ktx:2.4.0", "dependencies": [ - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", - "org.jetbrains.kotlin:kotlin-stdlib-common:1.5.0", - "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4", - "androidx.work:work-runtime:aar:2.4.0" + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1", + "androidx.work:work-runtime:aar:2.4.0", + "org.jetbrains.kotlin:kotlin-stdlib:1.5.0" ], "directDependencies": [ "androidx.work:work-runtime:aar:2.4.0", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" ], "file": "v1/https/maven.google.com/androidx/work/work-runtime-ktx/2.4.0/work-runtime-ktx-2.4.0.aar", "mirror_urls": [ @@ -3913,16 +3909,14 @@ { "coord": "androidx.work:work-runtime-ktx:aar:sources:2.4.0", "dependencies": [ - "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", - "org.jetbrains.kotlin:kotlin-stdlib-common:jar:sources:1.5.0", "androidx.work:work-runtime:aar:sources:2.4.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.3.4" + "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.4.1" ], "directDependencies": [ "androidx.work:work-runtime:aar:sources:2.4.0", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.4.1" ], "file": "v1/https/maven.google.com/androidx/work/work-runtime-ktx/2.4.0/work-runtime-ktx-2.4.0-sources.jar", "mirror_urls": [ @@ -8201,51 +8195,51 @@ "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-test/1.3.72/kotlin-test-1.3.72-sources.jar" }, { - "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4", + "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1", "dependencies": [ + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1", "org.jetbrains.kotlin:kotlin-stdlib-common:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0" ], "directDependencies": [ "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1" ], - "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar", + "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar", "mirror_urls": [ - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar", - "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar", - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar" + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar", + "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar", + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar" ], - "sha256": "f36ea75c31934bfad0682cfc435cce922e28b3bffa5af26cf86f07db13008f8a", - "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4.jar" + "sha256": "d4cadb673b2101f1ee5fbc147956ac78b1cfd9cc255fb53d3aeb88dff11d99ca", + "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1.jar" }, { - "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.3.4", + "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-android:jar:sources:1.4.1", "dependencies": [ - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1", "org.jetbrains.kotlin:kotlin-stdlib-common:jar:sources:1.5.0" ], "directDependencies": [ "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4" + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1" ], - "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar", + "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar", "mirror_urls": [ - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar", - "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar", - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar" + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar", + "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar", + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar" ], - "sha256": "0c58bb608c84609a7fc2409722739e958b26955962c917bbf1701db1ffa17f66", - "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.3.4/kotlinx-coroutines-android-1.3.4-sources.jar" + "sha256": "b2370993da3e0a183109d58004d7fde48af9dbba93c6774299fda9069ebb5eeb", + "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-android/1.4.1/kotlinx-coroutines-android-1.4.1-sources.jar" }, { - "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", + "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1", "dependencies": [ "org.jetbrains.kotlin:kotlin-stdlib-common:1.5.0", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0" @@ -8254,19 +8248,19 @@ "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", "org.jetbrains.kotlin:kotlin-stdlib-common:1.5.0" ], - "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar", + "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar", "mirror_urls": [ - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar", - "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar", - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar" + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar", + "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar", + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar" ], - "sha256": "17bec6112d93f5fcb11c27ecc8a14b48e30a5689ccf42c95025b89ba2210c28f", - "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4.jar" + "sha256": "6d2f87764b6638f27aff12ed380db4b63c9d46ba55dc32683a650598fa5a3e22", + "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1.jar" }, { - "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", + "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1", "dependencies": [ "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", "org.jetbrains.kotlin:kotlin-stdlib-common:jar:sources:1.5.0" @@ -8275,16 +8269,16 @@ "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", "org.jetbrains.kotlin:kotlin-stdlib-common:jar:sources:1.5.0" ], - "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar", + "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar", "mirror_urls": [ - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar", - "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar", - "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar", - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar" + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar", + "https://maven.fabric.io/public/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar", + "https://maven.google.com/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar", + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar" ], - "sha256": "4ec13fd64ce1494448cb5448952c7c006503d7715cf9fc4d5a7a6b4024a2cd9a", - "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.3.4/kotlinx-coroutines-core-1.3.4-sources.jar" + "sha256": "bb339efebc2d9141401f1aa43a035abe929210e362cfff13d03c6b7b11dc0469", + "url": "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core/1.4.1/kotlinx-coroutines-core-1.4.1-sources.jar" }, { "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.2.2", @@ -8319,13 +8313,13 @@ { "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2", "dependencies": [ - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1", "org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.2.2", "org.jetbrains.kotlin:kotlin-stdlib:1.5.0" ], "directDependencies": [ "org.jetbrains.kotlin:kotlin-stdlib:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4", + "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1", "org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.2.2" ], "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-test/1.2.2/kotlinx-coroutines-test-1.2.2.jar", @@ -8342,13 +8336,13 @@ { "coord": "org.jetbrains.kotlinx:kotlinx-coroutines-test:jar:sources:1.2.2", "dependencies": [ - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1", "org.jetbrains.kotlinx:kotlinx-coroutines-debug:jar:sources:1.2.2" ], "directDependencies": [ "org.jetbrains.kotlin:kotlin-stdlib:jar:sources:1.5.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.3.4", + "org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:sources:1.4.1", "org.jetbrains.kotlinx:kotlinx-coroutines-debug:jar:sources:1.2.2" ], "file": "v1/https/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-test/1.2.2/kotlinx-coroutines-test-1.2.2-sources.jar", diff --git a/third_party/versions.bzl b/third_party/versions.bzl index 04d30fd1043..a2619ccfc24 100644 --- a/third_party/versions.bzl +++ b/third_party/versions.bzl @@ -69,8 +69,8 @@ MAVEN_PRODUCTION_DEPENDENCY_VERSIONS = { "javax.inject:javax.inject": "1", "nl.dionsegijn:konfetti": "1.2.5", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar": "1.3.72", - "org.jetbrains.kotlinx:kotlinx-coroutines-android": "1.3.2", - "org.jetbrains.kotlinx:kotlinx-coroutines-core": "1.2.1", + "org.jetbrains.kotlinx:kotlinx-coroutines-android": "1.4.1", + "org.jetbrains.kotlinx:kotlinx-coroutines-core": "1.4.1", "org.jetbrains:annotations:jar": "13.0", } diff --git a/utility/build.gradle b/utility/build.gradle index 99271dbc3ae..1ea4e0aeda0 100644 --- a/utility/build.gradle +++ b/utility/build.gradle @@ -73,6 +73,7 @@ dependencies { 'com.google.firebase:firebase-crashlytics:17.0.0', 'com.google.protobuf:protobuf-javalite:3.17.3', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", + 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1', ) compileOnly( 'jakarta.xml.bind:jakarta.xml.bind-api:2.3.2', diff --git a/utility/src/main/java/org/oppia/android/util/data/AsyncDataSubscriptionManager.kt b/utility/src/main/java/org/oppia/android/util/data/AsyncDataSubscriptionManager.kt index 9d02e77b2a8..a109845301b 100644 --- a/utility/src/main/java/org/oppia/android/util/data/AsyncDataSubscriptionManager.kt +++ b/utility/src/main/java/org/oppia/android/util/data/AsyncDataSubscriptionManager.kt @@ -24,7 +24,6 @@ class AsyncDataSubscriptionManager @Inject constructor( private val subscriptionLock = ReentrantLock() private val subscriptionMap = mutableMapOf>() private val associatedIds = mutableMapOf>() - private val backgroundCoroutineScope = CoroutineScope(backgroundDispatcher) /** Subscribes the specified callback function to the specified [DataProvider] ID. */ fun subscribe(id: Any, observeChange: ObserveAsyncChange) { @@ -94,7 +93,7 @@ class AsyncDataSubscriptionManager @Inject constructor( * changes on a background thread. */ fun notifyChangeAsync(id: Any) { - backgroundCoroutineScope.launch { notifyChange(id) } + CoroutineScope(backgroundDispatcher).launch { notifyChange(id) } } /** diff --git a/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt b/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt index 26a4c8b2fd9..e86c139c122 100644 --- a/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt +++ b/utility/src/main/java/org/oppia/android/util/data/DataProviders.kt @@ -6,6 +6,9 @@ import dagger.Reusable import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import org.oppia.android.util.logging.ExceptionLogger @@ -216,6 +219,59 @@ class DataProviders @Inject constructor( } } + /** + * Returns a [DataProvider] that sources its data from this [StateFlow] with the specified [id]. + * + * Note that changes to the [StateFlow] will **not** result in changes to the returned + * [DataProvider] (though callers can still notify that [id] has changes to re-pull data from the + * [StateFlow]). If callers want the provider to automatically update with the [StateFlow] they + * should use [convertToAutomaticDataProvider]. + * + * The returned [DataProvider] will always be in a passing state. + */ + fun StateFlow.convertToSimpleDataProvider(id: Any): DataProvider = + createInMemoryDataProvider(id) { value } + + /** + * Returns a [DataProvider] for this [StateFlow] in the same way as [convertToSimpleDataProvider] + * except the [StateFlow] has a payload of an [AsyncResult] to affect the pending/success/failure + * state of the provider. + */ + fun StateFlow>.convertAsyncToSimpleDataProvider(id: Any): DataProvider = + createInMemoryDataProviderAsync(id) { value } + + /** + * Returns a [DataProvider] for this [StateFlow] in the same way as [convertToSimpleDataProvider] + * except changes to the [StateFlow]'s [StateFlow.value] will result in notifications being + * triggered for the returned [DataProvider]. + * + * Note that the subscription to the [StateFlow] never expires and will remain for the lifetime of + * the flow (at least until both the [StateFlow] and [DataProvider] go out of memory and are + * cleaned up). + */ + fun StateFlow.convertToAutomaticDataProvider(id: Any): DataProvider { + return convertToSimpleDataProvider(id).also { + onEach { + // Synchronously notify subscribers whenever the flow's state changes. + asyncDataSubscriptionManager.notifyChange(id) + }.launchIn(CoroutineScope(backgroundDispatcher)) + } + } + + /** + * Returns a [DataProvider] for this [StateFlow] in the same way as + * [convertToAutomaticDataProvider] with the same async behavior as + * [convertAsyncToSimpleDataProvider]. + */ + fun StateFlow>.convertAsyncToAutomaticDataProvider(id: Any): DataProvider { + return convertAsyncToSimpleDataProvider(id).also { + onEach { + // Synchronously notify subscribers whenever the flow's state changes. + asyncDataSubscriptionManager.notifyChange(id) + }.launchIn(CoroutineScope(backgroundDispatcher)) + } + } + /** * A [DataProvider] that acts in the same way as [transformAsync] except the underlying base data * provider can change. diff --git a/utility/src/test/java/org/oppia/android/util/data/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/data/BUILD.bazel index ba600e1b24d..b2890356c40 100644 --- a/utility/src/test/java/org/oppia/android/util/data/BUILD.bazel +++ b/utility/src/test/java/org/oppia/android/util/data/BUILD.bazel @@ -54,6 +54,7 @@ oppia_android_test( ":dagger", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:async_result_subject", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", "//testing/src/main/java/org/oppia/android/testing/threading:test_module", "//testing/src/main/java/org/oppia/android/testing/time:test_module", diff --git a/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt b/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt index 27085a29b38..b7b4832a180 100644 --- a/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt +++ b/utility/src/test/java/org/oppia/android/util/data/DataProvidersTest.kt @@ -13,6 +13,8 @@ import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,6 +31,7 @@ import org.mockito.junit.MockitoRule import org.oppia.android.testing.FakeExceptionLogger import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.data.AsyncResultSubject.Companion.assertThat +import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule @@ -69,41 +72,23 @@ private const val COMBINED_STR_VALUE_02 = "I used to be indecisive. At least I t @LooperMode(LooperMode.Mode.PAUSED) @Config(application = DataProvidersTest.TestApplication::class) class DataProvidersTest { + @field:[Rule JvmField] val mockitoRule: MockitoRule = MockitoJUnit.rule() - @Rule - @JvmField - val mockitoRule: MockitoRule = MockitoJUnit.rule() - - @Inject - lateinit var context: Context - - @Inject - lateinit var dataProviders: DataProviders - - @Inject - lateinit var asyncDataSubscriptionManager: AsyncDataSubscriptionManager - - @Inject - lateinit var fakeExceptionLogger: FakeExceptionLogger - - @Inject - lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var context: Context + @Inject lateinit var dataProviders: DataProviders + @Inject lateinit var asyncDataSubscriptionManager: AsyncDataSubscriptionManager + @Inject lateinit var fakeExceptionLogger: FakeExceptionLogger + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory @Inject @field:BackgroundDispatcher lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher - @Mock - lateinit var mockStringLiveDataObserver: Observer> - - @Mock - lateinit var mockIntLiveDataObserver: Observer> - - @Captor - lateinit var stringResultCaptor: ArgumentCaptor> - - @Captor - lateinit var intResultCaptor: ArgumentCaptor> + @Mock lateinit var mockStringLiveDataObserver: Observer> + @Mock lateinit var mockIntLiveDataObserver: Observer> + @Captor lateinit var stringResultCaptor: ArgumentCaptor> + @Captor lateinit var intResultCaptor: ArgumentCaptor> @Inject lateinit var fakeSystemClock: FakeSystemClock @@ -2738,10 +2723,526 @@ class DataProvidersTest { assertThat(exception).hasMessageThat().contains("Combine failure") } - private fun transformString(str: String): Int { - return str.length + @Test + fun testConvertToSimpleDataProvider_singletonFlow_providerReturnsFlowValue() { + val singletonFlow: StateFlow = MutableStateFlow("test str") + + val dataProvider = dataProviders.run { + singletonFlow.convertToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + assertThat(result).isEqualTo("test str") + } + + @Test + fun testConvertToSimpleDataProvider_singletonFlow_notifyProvider_providerNotChanged() { + val singletonFlow: StateFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + singletonFlow.convertToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider should not be changed again since the flow didn't change. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertToSimpleDataProvider_mutableFlow_flowChanges_providerNotChanged() { + val mutableFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + mutableFlow.convertToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = "new str" + + // The provider should not be changed again since the flow isn't being monitored. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertToSimpleDataProvider_mutableFlow_flowChanges_notifyProvider_providerChanged() { + val mutableFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + mutableFlow.convertToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = "new str" + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The flow changed and it was explicitly notified, so the new value should be received. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_singletonFlow_pending_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = MutableStateFlow(AsyncResult.Pending()) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToSimpleDataProvider_singletonFlow_success_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Success("test str")) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + assertThat(result).isEqualTo("test str") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_singletonFlow_failure_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Failure(IllegalStateException("Some internal failure"))) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + + val error = monitorFactory.waitForNextFailureResult(dataProvider) + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_singletonFlow_notifyProvider_providerNotChanged() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider should not be changed again since the flow didn't change. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertAsyncToSimpleDataProvider_mutableFlow_flowChanges_providerNotChanged() { + val mutableFlow = MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + + // The provider should not be changed again since the flow isn't being monitored. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertAsyncToSimpleDataProvider_mutableFlow_flowChanges_notifyProv_providerChanged() { + val mutableFlow = MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The flow changed and it was explicitly notified, so the new value should be received. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowPendingToSuccess_andNotify_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Pending()) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The provider's value has changed. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowPendingToFailure_andNotify_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Pending()) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Failure(IllegalStateException("Some internal failure")) + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The provider's value has changed. + val error = monitor.ensureNextResultIsFailing() + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") + } + + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowSuccessToPending_andNotify_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Pending() + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider's value has changed. + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowFailureToPending_andNotify_providerChanged() { + val mutableFlow = + MutableStateFlow>( + AsyncResult.Failure(IllegalStateException("Some internal failure")) + ) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Pending() + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider's value has changed. + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowSuccessToFailure_andNotify_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Failure(IllegalStateException("Some internal failure")) + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The provider's value has changed. + val error = monitor.ensureNextResultIsFailing() + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") } + @Test + fun testConvertAsyncToSimpleDataProvider_changeFlowFailureToSuccess_andNotify_providerChanged() { + val mutableFlow = + MutableStateFlow>( + AsyncResult.Failure(IllegalStateException("Some internal failure")) + ) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToSimpleDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The provider's value has changed. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertToAutomaticDataProvider_singletonFlow_providerReturnsFlowValue() { + val singletonFlow: StateFlow = MutableStateFlow("test str") + + val dataProvider = dataProviders.run { + singletonFlow.convertToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + assertThat(result).isEqualTo("test str") + } + + @Test + fun testConvertToAutomaticDataProvider_singletonFlow_notifyProvider_providerNotChanged() { + val singletonFlow: StateFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + singletonFlow.convertToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider should not be changed again since the flow didn't change. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertToAutomaticDataProvider_mutableFlow_flowChanges_providerChanged() { + val mutableFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + mutableFlow.convertToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = "new str" + + // The provider should be changed since it's being automatically monitored. + val result = monitor.waitForNextSuccessResult() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertToAutomaticDataProvider_mutableFlow_flowChanges_notifyProvider_providerChanged() { + val mutableFlow = MutableStateFlow("test str") + val dataProvider = dataProviders.run { + mutableFlow.convertToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = "new str" + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The flow changed and it was explicitly notified, so the new value should be received. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToAutoDataProvider_singletonFlow_pending_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = MutableStateFlow(AsyncResult.Pending()) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToAutoDataProvider_singletonFlow_success_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Success("test str")) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + assertThat(result).isEqualTo("test str") + } + + @Test + fun testConvertAsyncToAutoDataProvider_singletonFlow_failure_providerReturnsFlowValue() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Failure(IllegalStateException("Some internal failure"))) + + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + + val error = monitorFactory.waitForNextFailureResult(dataProvider) + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") + } + + @Test + fun testConvertAsyncToAutoDataProvider_singletonFlow_notifyProvider_providerNotChanged() { + val singletonFlow: StateFlow> = + MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + singletonFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + + // The provider should not be changed again since the flow didn't change. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testConvertAsyncToAutoDataProvider_mutableFlow_flowChanges_providerChanged() { + val mutableFlow = MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + + // The provider should be changed since it's being automatically monitored. + val result = monitor.waitForNextSuccessResult() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToAutoDataProvider_mutableFlow_flowChanges_notifyProvider_providerChanged() { + val mutableFlow = MutableStateFlow(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + asyncDataSubscriptionManager.notifyChangeAsync(BASE_PROVIDER_ID_0) + testCoroutineDispatchers.runCurrent() + + // The flow changed and it was explicitly notified, so the new value should be received. + val result = monitor.ensureNextResultIsSuccess() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowPendingToSuccess_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Pending()) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + + // The provider's value has changed. + val result = monitor.waitForNextSuccessResult() + assertThat(result).isEqualTo("new str") + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowPendingToFailure_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Pending()) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Failure(IllegalStateException("Some internal failure")) + + // The provider's value has changed. + val error = monitor.waitForNextFailingResult() + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowSuccessToPending_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Pending() + + // The provider's value has changed. + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowFailureToPending_providerChanged() { + val mutableFlow = + MutableStateFlow>( + AsyncResult.Failure(IllegalStateException("Some internal failure")) + ) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Pending() + + // The provider's value has changed. + val result = monitor.waitForNextResult() + assertThat(result).isPending() + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowSuccessToFailure_providerChanged() { + val mutableFlow = MutableStateFlow>(AsyncResult.Success("test str")) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Failure(IllegalStateException("Some internal failure")) + + // The provider's value has changed. + val error = monitor.waitForNextFailingResult() + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error).hasMessageThat().contains("Some internal failure") + } + + @Test + fun testConvertAsyncToAutoDataProvider_changeFlowFailureToSuccess_providerChanged() { + val mutableFlow = + MutableStateFlow>( + AsyncResult.Failure(IllegalStateException("Some internal failure")) + ) + val dataProvider = dataProviders.run { + mutableFlow.convertAsyncToAutomaticDataProvider(BASE_PROVIDER_ID_0) + } + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + mutableFlow.value = AsyncResult.Success("new str") + + // The provider's value has changed. + val result = monitor.waitForNextSuccessResult() + assertThat(result).isEqualTo("new str") + } + + private fun transformString(str: String): Int = str.length + /** * Transforms the specified string into an integer in the same way as [transformString], except in * a blocking context using [backgroundCoroutineDispatcher]. @@ -2762,9 +3263,7 @@ class DataProvidersTest { return AsyncResult.Success(deferred.getCompleted()) } - private fun combineStrings(str1: String, str2: String): String { - return "$str1 $str2" - } + private fun combineStrings(str1: String, str2: String): String = "$str1 $str2" /** * Combines the specified strings into a new string in the same way as [combineStrings], except in