From df0f06497b178749fd4787e6e160968807851845 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Sat, 26 Mar 2022 23:59:45 -0700 Subject: [PATCH] Fix #3622, #4238, #3861, part of #4044: Fix state player deadlock by migrating progress controllers over to a command-queue structure (#4239) ## Explanation Fix #3622 Fix #4238 Fix #3861 Fix part of #4044 (see below for how this relates to the broader math expressions project) This PR fixes a deadlock that's possible in both ``QuestionAssessmentProgressController`` and ``ExplorationProgressController`` wherein the internal locks used to synchronize session state can conflate with the locks of the controllers' ``HintHandler``s (which calls back into the controller within the lock). While this seems really difficult to hit when playing a training session or exploration, ``StateFragmentTest`` seems to very consistently hit the deadlock after only 10-15 tests (so it essentially can't be run without the fixes introduced here). While I could've tried rearranging the locks to be more cooperative, it's actually difficult to guarantee that deadlocks won't happen without rebuilding the internal locking mechanism for controller data. I chose the latter by introducing a command queue-esque pattern using a Kotlin actor with ``StateFlow``s for pushing state back to callers (see https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html; actors are a powerful way to leverage the lightweight nature of coroutines to manage cross-thread state machines). To simplify the implementation, this PR also introduces conversion methods from ``StateFlow``s to ``DataProvider``s (I could see us leveraging these flows more in the future, maybe as a long-term replacement to ``DataProvider``s with some refinement). One side effect of using a command queue to manage what is effectively a session state is that state can now fairly easily leak between sessions. Efforts have been taken to minimize the chance of this happening (mainly ignoring events that arrive from a previously ended session), but a better long-term solution would be to create new session controllers for each session (which should be something possible with custom scopes once we integrate Dagger Hilt in #1720). Due to a general push toward using ``DataProvider``s much more heavily in these controllers rather than ``LiveData``, ``AsyncResult`` needed to be refactored in order for some of the internal transformed chains in the controllers to work correctly (which is where #4237 comes in). This also permanently fixes #3861 and other potential errors that could happen by ensuring that the provider state can continue even if internal errors are encountered rather than entering a permanently broken state (#4230 mitigates the immediate repro steps from the bug by fixing the underlying issue that caused the exception, whereas this PR focuses on preventing those scenarios from causing the bug in question). ### Some explanations for the threading approach I was originally going to make hint handler also a command queue, but it became a bit complicated. To keep things simpler, I removed the threading restricting on it so that it can only be accessed on a single thread (similarly to the internal progress objects used by both controllers) which helps avoid needing a lock without worrying about data races. Note that this command queue style implementation of ``HintHandler`` is available to view in the past commits in this PR if it's interesting for future development (since it's a simpler command queue than the ones used by the controllers due to having an inherently simpler internal state machine). This PR demonstrates a general tendency to move the app toward a 'notify-and-pull' model (e.g. ``DataProvider``s) vs. a push-subscribe pattern. The former is easier to synchronize, parallelize, ensure lifecycle safety, and is often more performant since operations only need to execute when their results are needed. Note that both controllers' internal computation behaviors are completely different with this new approach. Previously, the internal progress state machine would only be fully processed when the current ephemeral state/question was retrieved from the UI layer (i.e. lazily), but now it's computed shortly after the change (i.e. eagerly) since that works a bit better with the ``Flow``s being used for internal cross-thread communication. This technically means the controllers are slightly less efficient since execution may happen in cases when the state/question is no longer being viewed by the user, but it should realistically make a small difference since we have to thread hop for the command queue, anyway, and computing the ephemeral state/question should be cheap. This has the benefit of a potentially snappier load experience in the frontend, though, since the ephemeral state/question will be available sooner. All operations in the controllers now execute their operations regardless of whether they're observed (which should better match callers' expectations). Note that the internal state machine complexity can probably be drastically simplified now that the command queue can also act as a state machine, but I didn't want to risk regressions by removing it (I was trying to keep the logical flow as similar as possible to the existing behavior). ### Updates to dependencies The coroutines core dependency needed to be updated since the older version didn't include ``StateFlow`` which was necessary for providing a sync-back mechanism for the controller command queues. ### Stability checks I verified that both ``QuestionPlayerActivity`` and ``StateFragmentTest`` pass on Espresso (where the latter deadlocked prior to this PR); see the screenshots below. Further, I tested ``ExplorationProgressControllerTest`` and ``QuestionAssessmentProgressControllerTest`` 100x times to verify both are not flaky (since some flakes were discovered during development). Both test suites passed all 100 runs. ### Bazel app module tests This PR introduces support for dedicated app module tests in Bazel (rather than bundling them as part of the broader list of app module tests). This required a few changes in .bzl and .bazel files, but the change is otherwise straightforward and extremely beneficial for team members that rely on the Android Studio with Bazel plugin for regular development. ### Miscellaneous I tried introducing a Bazel emulator test while developing this (as part of #59) but it ran into a desugar issue with Mockito and Robolectric (due to them both being pulled into the test binary that's being built to send to the device) which AGP apparently works around. See https://github.com/bazelbuild/bazel/issues/13684 for a Bazel issue tracking the same issue. This fixes #3622 since it eliminates the lock entirely in favor of using actors for safe cross-thread communication. The ``MediatorLiveData`` specific part of this issue's TODO comment isn't relevant anymore, either, since we replaced ``MediatorLiveData`` with a custom notifying ``LiveData`` within ``DataProviders`` a while back. This fixes #4238 fully by ensuring any exceptions caused within the state flow of an exploration or questions session are captured and logged, but don't actually break the internal state machine of the session. This issue was discovered during the development of #2173 due to an exception being thrown in the classifier. While that issue was fixed, a test couldn't actually be added for the #4238 fix since it's difficult to intentionally trigger exceptions during answer classification. ### A note on checkpoints in ``ExplorationProgressController`` The checkpointing behavior in ``ExplorationProgressController`` became more complex as a result of these changes. In particular, checkpointing behaves as follows: - Something changes in the play session - A checkpoint recomputation is requested (which results in a new checkpoint being computed and then saved to disk) - The checkpoint is 'processed' by updating the play state of the exploration (i.e. in ``StoryProgressController``) - The ephemeral state is recomputed since it contains the checkpoint and the checkpoint has changed Each of these more or less require a command queue "hop" which means other operations can occur between them. As a result, #3467 can still occur since an exploration can be finished before the process happens (though the behavior has changed: the progress simply won't be processed which means an exploration might not be indicated as played, but the checkpointing should almost always be saved before the exploration session end is processed). Fixing this isn't straightforward since the exploration's play state can't be changed until the checkpoint is confirmed as saved which either requires updating ``StoryProgressController`` to also account for the presence of checkpoints, or to use more flows internally to combine the save-and-process operation into one in a way that doesn't lock up ``ExplorationProgressController`` (I'm currently not exactly sure how one would do this, but I haven't thought about it in depth yet). ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only This PR does not intentionally change and user-perceivable behaviors in the questions or state players except potentially fixing deadlocks/ANRs that could occur while playing. There could be regressions in the players, and they may potentially be more performant after this change, but neither can easily be demonstrated via videos or screenshots. There are no accessibility behavior changes to demonstrate, either. However, two key tests have been run and verified as passing on Espresso to demonstrate that the deadlock has been fixed. Specifically: | QuestionPlayerActivityTest | StateFragmentTest | |------|------| | ![deadlock_fix_pr_question_player_activity_test_passing](https://user-images.githubusercontent.com/12983742/158529405-6e19a602-19b8-48ab-814b-980831c57cb4.png) | ![deadlock_fix_pr_state_fragment_test_passing](https://user-images.githubusercontent.com/12983742/158529650-cc6ac45f-dbee-46b6-a732-d2e4a1a1fbcb.png) | Commit history: * Change parameterized method delimiter. * Use utility directly in test. * Post-merge fixes. This adjusts for the removal of ComparableOperationList (i.e. no wrapper proto). * Add first round of tests. This includes fixes to the converter itself as it wasn't distributing both product inversions and negation correctly in several cases. Tests should now be covering these cases. * Finish initial test suite. Still needs to be cleaned up, but after converter refactoring attempts. * Simplify operation sorting comparators. * Remove old tests. * Add remaining missing tests. * KDocs & test exemption. * Renames & lint fixes. * Post-merge fixes. * Add tests. * KDocs + exemptions. Also, clean up polynomial sorting. * Lint fixes. * Post-merge fixes. Also, mark methods/classes that need tests. * Add extension tests. * Add classifier tests. * Use more intentional epsilons for float comparing. * Treat en-dash as a subtraction symbol. * Add explicit platform selection for paramerized. This adds explicit platform selection support rather than it being automatic based on deps. While less flexible for shared tests, this offers better control for tests that don't want to to use Robolectric for local tests. This also adds a JUnit-only test runner, and updates MathTokenizerTest to use it (which led to an almost 40x decrease in runtime). * Exemption fixes. Also, fix name for the AndroidJUnit4 runner. * Remove failing test. * Fix unary expression precedence. Also, use ParameterizedJunitTestRunner for MathExpressionParserTest. * Fixes & add more test cases. * Post-merge fixes & test changes. Also, update RealExtensionsTest to use the faster JUnit runner. * Use utility directly in LaTeX tests. * Post-merge fixes. Also, update ExpressionToComparableOperationConverterTest to use the fast JUnit-only runner. * Post-merge fixes. Also, update PolynomialExtensionsTest to use fast JUnit-only runner. * Post-merge fixes. Also, update float interval per new tests. * Lint & other check fixes. * Replace deprecated term. * Post-merge fixes. * Add full test suites for alg exp classifiers. * Lint & static check fixes. * Fix test on Gradle. * Fix test for Gradle. * Add tests for math equations. And, post-merge fixes. * Static check & lint fixes. * Post-merge fixes. Verified CI checks & all unit tests are passing. * Split up tests. Also, adds dedicated BUILD.bazel file for new test. * Add missing test in Bazel, and fix it. * Correct order for genrule. * Add full test suite. * Clean up + KDocs + exemption. * Lint fixes. * Post-merge fix. * Cache KotliTeX renders. Directly rendering LaTeX through KotliTeX is way too slow, so this introduces a custom flow through Glide that computes a PNG for the LaTeX on a background thread and then caches it as part of Glide's cache to speed up re-renders of the LaTeX. We may need to manage/prune the cache over time, but for now we'll just rely on Glide's internal behaviors. This also documents some new tests that should be added, but it's not comprehensive. * Add tests, docs, and exemptions. * Update to fixed version of KotliTeX. The newer version correctly computes the bounds for rendered LaTeX. * Lint fixes. * Add new dependency licenses. This isn't done yet (some of the licenses still need to be fixed). * Fix license links. Note that the kxml one was tricky since its Maven entry says it's licensed under BSD and CC0 1.0, and its SourceForge link says the same plus LGPL 2.0. However, the source code and GitHub version of the project license it under MIT, and this seems to predate the others so it seems like the most correct license to use in this case and the one that we're using to represent the dependency. * Fix Gradle build. This uses a version of KotliTeX that builds correctly on Jitpack for Gradle, and fixes the StaticLayout creation to use an alignment constant that builds on Gradle (I'm not sure why there's a difference here between Gradle & Bazel, but the previous constant isn't part of the enum per official Android docs). * Create the math drawable synchronously. This requires exposing new injectors broadly in the app since the math model loader doesn't have access to the dependency injection graph directly. * Remove new deps from Maven list. They were incorrectly pulled in by KotliTeX. * Add argument partitioning. This fixes cases where argument calls may be very large and fail to execute due to exceeding system limitations. * Make allowance for empty cases to fix tests. These tests correspond to real scenarios. * Lint fixes. * Address reviewer comment. Clarifies the documentation in the test runner around parameter injection. * Fix broken build. * Fix broken build post-merge. * Fix broken post-merge classifier. * Address reviewer comment. * Post-merge build fixes. * Post-merge build fixes for new classifiers. * Post-merge build fixes. * Correct reference document link. * Ensure LaTeX isn't stretched or cut-off. The comments in-code have much more specifics on the approach. * Add and fix missing test (was broken on Gradle). * Update to newer version of Kotlin coroutines. This version includes StateFlow which will be a really useful mechanism for helping to avoid using critical sections. * First attempt to fix deadlock. This uses StateFlows and an actor to avoid the deadlock. This is missing necessary hint changes that are coming in a later commit. Tests currently fail, and questions haven't yet been migrated (and won't until the fixes are verified). * Attempt to make hint handler multi-threadable. This is a midway solution that's being committed for posterity, but will be reverted in favor of a solution that forces hint handler to be unsafe across multiple threads (since it's much simpler, and works given that all users of it now synchronize state management using an actor). * Finish fixing state player. This includes a fix for accessibility string handling (since the new flow triggers one of these to fail). It also adds a Bazel file for StateFragmentTest (I spent some time trying to get Espresso tests working with Bazel but ran into a compatibility issue). StateFragmentTest has been verified to pass on Robolectric and Espresso with this change. This sets up the project for fixing questions in the next commit. * First pass on migrating question controller. This also includes a migration for exploration & question domain tests to use the test monitor utility. The question tests currently fail since there's a bug in AsyncResult where it won't support null values during transformations. * Refactor AsyncResult into a sealed class. This also introduces an AsyncResultSubject, and more or less fully fixes issue #3813 in all tests. * Refactor AsyncResult into a sealed class. This also introduces an AsyncResultSubject, and more or less fully fixes issue #3813 in all tests. This is a cherry-pick from the fix-progress-controller-deadlock branch since it ended up being quite large (it made more sense to split it into a pre-requisite PR). Conflicts: app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/BUILD.bazel testing/src/main/java/org/oppia/android/testing/data/DataProviderTestMonitor.kt utility/src/main/java/org/oppia/android/util/data/DataProviders.kt * Post-merge fixes and updates for consistency. * Post-merge fixes. * TODO has been addressed. * Fix documentation & add tests. * Lint fixes. * Lint & post-merge fixes. Questions-related tests still fail and need to be fixed now that AsyncResult has been refactored to support null values. * Post-merge test fixes. The core affected UI/domain tests have now been verified as working on Robolectric. The full test suite is next. * Add documentation & tests. Also, fix a bug in the automatic StateFlow DataProviders wherein they wouldn't notify correctly on changes. This led to some simplifications in the exploration & question progress controllers. * Lint fixes. * Fix gradle tests. I've verified in this commit that all Gradle tests build & run locally (at least on Robolectric). * Fix Proguard build. This required bringing kotlinx-coroutines-android up-to-date with kotlinx-coroutines-core (since that was updated on this branch). New but reasonable Proguard warning exemptions also needed to be added. * Post-merge fix. * More post-merge fixes. * Fix TODO comment. * Post-merge lint fixes. * Post-merge fix. * Fix exploration routing issue. The underlying problem was that the PR inadvertently changed the behavior of comparing two results wherein results of different times would be considered different and be re-delivered (which happened to break exploration routing, and likely a variety of other places). This introduces a new method for checking equivelance rather than confusingly assuming that users of AsyncResult don't care about the result's time during comparison checking. New tests have been added to verify the functionality works as expected (and fails when expected), and I manually verified that the exploration routing issue was fixed. More details on the specific user-facing issue will be added to the PR as a comment. * Post-merge fixes. * Update KotliTeX version. This version doesn't have debug drawing enabled. * Fix lifecycle breakage. I noticed downstream that quickly navigating back and forth to enter & exit an exploration can cause the progress controller to get into a bad state that either leads to a broken experience (stacked explorations or a blank state), or a crash. The issue was coming from DataProviders being shared across sessions even though the underlying state was different. This change ensures that providers stay completely independent, similar to the core session state. * Update play session controllers. This ensures that the command queue itself is fully isolated to avoid delayed events potentially leaking across sessions (now that the session ID barrier has been removed). --- app/BUILD.bazel | 12 +- app/app_test.bzl | 30 +- app/build.gradle | 4 +- .../itemviewmodel/SubmittedAnswerViewModel.kt | 2 +- .../android/app/player/state/BUILD.bazel | 46 + config/proguard/kotlin-proguard-rules.pro | 18 + data/build.gradle | 2 +- domain/build.gradle | 2 +- .../exploration/ExplorationDataController.kt | 30 +- .../ExplorationProgressController.kt | 1199 +++++++++++------ .../domain/hintsandsolution/HintHandler.kt | 48 +- .../hintsandsolution/HintHandlerDebugImpl.kt | 50 +- .../hintsandsolution/HintHandlerProdImpl.kt | 190 ++- .../QuestionAssessmentProgressController.kt | 968 +++++++++---- .../question/QuestionTrainingController.kt | 28 +- .../oppia/android/domain/state/StateDeck.kt | 3 +- .../android/domain/exploration/BUILD.bazel | 89 ++ .../ExplorationDataControllerTest.kt | 26 +- .../ExplorationProgressControllerTest.kt | 79 +- .../domain/hintsandsolution/BUILD.bazel | 114 ++ .../HintHandlerDebugImplTest.kt | 208 +-- .../HintHandlerProdImplTest.kt | 936 +++++++------ .../HintsAndSolutionDebugModuleTest.kt | 2 + .../HintsAndSolutionProdModuleTest.kt | 4 +- .../oppia/android/domain/question/BUILD.bazel | 91 ++ ...uestionAssessmentProgressControllerTest.kt | 73 +- .../QuestionTrainingControllerTest.kt | 26 +- oppia_android_test.bzl | 15 +- scripts/assets/maven_dependencies.textproto | 8 +- third_party/maven_install.json | 128 +- third_party/versions.bzl | 4 +- utility/build.gradle | 1 + .../util/data/AsyncDataSubscriptionManager.kt | 3 +- .../oppia/android/util/data/DataProviders.kt | 56 + .../org/oppia/android/util/data/BUILD.bazel | 1 + .../android/util/data/DataProvidersTest.kt | 567 +++++++- 36 files changed, 3374 insertions(+), 1689 deletions(-) create mode 100644 app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/hintsandsolution/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel 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