From 97d1863cecc4207e9dc3c68beda812a7b7839850 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 7 Oct 2019 16:27:02 -0700 Subject: [PATCH] Fix #122: Introduce interface & partial implementation for ExplorationProgressController (#183) * Introduce first pass interface for ExplorationProgressController. * Fill in the stubbed logic for ExplorationProgressController. Still no tests to verify correctness. Also, added a method to facilitate notifying of DataProvider changes on the UI thread. * Fix lateinit issue in ExplorationProgressController due to wrongly ordered initialization. * Fix a variaty of issues in the exp progress controller, properly hook it up to the data controller, and start adding tests. * Created a separate ExplorationRetriever, hooked up AnswerClassificationController, and attempted to make ExplorationProgressController thread-safe. The thread-safety led to significant interface changes in the progress controller, and led to discovering some issues with the mediator live data approach to interop coroutines and LiveData. This locking mechanism will need to change since the optimal solution requires resolving #90. * Change the locking mechanism for ExplorationProgressController to work with the current MediatorLiveData implementation (see #90 for more context). Fix existing progress controller tests and add a few more. All current progress controller tests are passing. * Finish tests for ExplorationProgressController and add test classification support for the second test exploration (about_oppia). --- .../AnswerClassificationController.kt | 63 +- .../exploration/ExplorationDataController.kt | 321 +---- .../ExplorationProgressController.kt | 533 ++++++++ .../exploration/ExplorationRetriever.kt | 265 ++++ .../AnswerClassificationControllerTest.kt | 26 +- .../ExplorationDataControllerTest.kt | 13 +- .../ExplorationProgressControllerTest.kt | 1179 +++++++++++++++++ model/src/main/proto/exploration.proto | 112 +- .../util/data/AsyncDataSubscriptionManager.kt | 20 +- .../java/org/oppia/util/data/DataProviders.kt | 5 +- .../oppia/util/data/InMemoryBlockingCache.kt | 14 + 11 files changed, 2234 insertions(+), 317 deletions(-) create mode 100644 domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt create mode 100644 domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt create mode 100644 domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt diff --git a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt index e6dbfad25bd..d6a8b65b8f5 100644 --- a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt +++ b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt @@ -3,24 +3,77 @@ package org.oppia.domain.classify import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.Outcome +import org.oppia.app.model.State import javax.inject.Inject // TODO(#59): Restrict the visibility of this class to only other controllers. /** * Controller responsible for classifying user answers to a specific outcome based on Oppia's interaction rule engine. * This controller is not meant to be interacted with directly by the UI. Instead, UIs wanting to submit answers should - * do so via various progress controllers, like [StoryProgressController]. + * do so via various progress controllers, like [org.oppia.domain.topic.StoryProgressController]. * * This controller should only be interacted with via background threads. */ class AnswerClassificationController @Inject constructor() { + // TODO(#114): Add support for classifying answers based on an actual exploration. Also, classify() should take an + // Interaction, not a State. + /** * Classifies the specified answer in the context of the specified [Interaction] and returns the [Outcome] that best * matches the learner's answer. */ - internal fun classify(interaction: Interaction, answer: InteractionObject): Outcome { - // Assume only the default outcome is returned currently since this stubbed implementation is not actually used by - // downstream stubbed progress controllers. - return interaction.defaultOutcome + internal fun classify(currentState: State, answer: InteractionObject): Outcome { + return when (currentState.name) { + // Exp 5 + "Welcome!" -> simulateMultipleChoiceForWelcomeStateExp5(currentState, answer) + "What language" -> simulateTextInputForWhatLanguageStateExp5(currentState, answer) + "Numeric input" -> simulateNumericInputForNumericInputStateExp5(currentState, answer) + "Things you can do" -> currentState.interaction.defaultOutcome + // Exp 6 + "First State" -> currentState.interaction.defaultOutcome + "So what can I tell you" -> simulateMultipleChoiceForWelcomeStateExp6(currentState, answer) + "Example1" -> currentState.interaction.defaultOutcome // TextInput with ignored answer. + "Example3" -> currentState.interaction.defaultOutcome + "End Card" -> currentState.interaction.defaultOutcome + else -> throw Exception("Cannot submit answer to unexpected state: ${currentState.name}.") + } + } + + private fun simulateMultipleChoiceForWelcomeStateExp5(currentState: State, answer: InteractionObject): Outcome { + return when { + answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> + throw Exception("Expected non-negative int answer, not $answer.") + answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[0].outcome + answer.nonNegativeInt == 2 -> currentState.interaction.answerGroupsList[1].outcome + else -> currentState.interaction.defaultOutcome + } + } + + private fun simulateTextInputForWhatLanguageStateExp5(currentState: State, answer: InteractionObject): Outcome { + return when { + answer.objectTypeCase != InteractionObject.ObjectTypeCase.NORMALIZED_STRING -> + throw Exception("Expected string answer, not $answer.") + answer.normalizedString.toLowerCase() == "finnish" -> currentState.interaction.getAnswerGroups(6).outcome + else -> currentState.interaction.defaultOutcome + } + } + + private fun simulateNumericInputForNumericInputStateExp5(currentState: State, answer: InteractionObject): Outcome { + return when { + answer.objectTypeCase != InteractionObject.ObjectTypeCase.SIGNED_INT -> + throw Exception("Expected signed int answer, not $answer.") + answer.signedInt == 121 -> currentState.interaction.answerGroupsList.first().outcome + else -> currentState.interaction.defaultOutcome + } + } + + private fun simulateMultipleChoiceForWelcomeStateExp6(currentState: State, answer: InteractionObject): Outcome { + return when { + answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> + throw Exception("Expected non-negative int answer, not $answer.") + answer.nonNegativeInt == 3 -> currentState.interaction.answerGroupsList[1].outcome + answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[2].outcome + else -> currentState.interaction.defaultOutcome + } } } diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationDataController.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationDataController.kt index 1c98eccd1a1..142ed016b8b 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationDataController.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationDataController.kt @@ -1,305 +1,72 @@ package org.oppia.domain.exploration -import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import org.json.JSONArray -import org.json.JSONException -import javax.inject.Inject -import javax.inject.Singleton import org.oppia.app.model.Exploration import org.oppia.util.data.AsyncResult -import java.io.IOException -import org.json.JSONObject -import org.oppia.app.model.AnswerGroup -import org.oppia.app.model.Outcome -import org.oppia.app.model.Interaction -import org.oppia.app.model.InteractionObject -import org.oppia.app.model.RuleSpec -import org.oppia.app.model.State -import org.oppia.app.model.StringList -import org.oppia.app.model.SubtitledHtml import org.oppia.util.data.DataProviders +import javax.inject.Inject -const val TEST_EXPLORATION_ID_0 = "test_exp_id_0" -const val TEST_EXPLORATION_ID_1 = "test_exp_id_1" private const val EXPLORATION_DATA_PROVIDER_ID = "ExplorationDataProvider" -/** Controller for retrieving an exploration. */ -@Singleton +/** + * Controller for loading explorations by ID, or beginning to play an exploration. + * + * At most one exploration may be played at a given time, and its state will be managed by + * [ExplorationProgressController]. + */ class ExplorationDataController @Inject constructor( - private val context: Context, private val dataProviders: DataProviders + private val explorationProgressController: ExplorationProgressController, + private val explorationRetriever: ExplorationRetriever, + private val dataProviders: DataProviders ) { - - /** - * Returns an [Exploration] given an ID. - */ + /** Returns an [Exploration] given an ID. */ fun getExplorationById(id: String): LiveData> { - if (id == TEST_EXPLORATION_ID_0) { - return dataProviders.convertToLiveData( - dataProviders.createInMemoryDataProviderAsync( - EXPLORATION_DATA_PROVIDER_ID, this::retrieveWelcomeExplorationAsync - ) - ) - } - if (id == TEST_EXPLORATION_ID_1) { - return dataProviders.convertToLiveData( - dataProviders.createInMemoryDataProviderAsync( - EXPLORATION_DATA_PROVIDER_ID, this::retrieveAbboutOppiaExplorationAsync - ) - ) + val dataProvider = dataProviders.createInMemoryDataProviderAsync(EXPLORATION_DATA_PROVIDER_ID) { + retrieveExplorationById(id) } - return MutableLiveData(AsyncResult.failed(Throwable("Wrong exploration id"))) + return dataProviders.convertToLiveData(dataProvider) } - @Suppress("RedundantSuspendModifier") // DataProviders expects this function to be a suspend function. - private suspend fun retrieveWelcomeExplorationAsync(): AsyncResult { - try { - return AsyncResult.success(createExploration("welcome.json")) + /** + * 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 an exception will be thrown. + * + * @return a one-time [LiveData] to observe whether initiating the play request succeeded. The exploration may still + * fail to load, but this provides early-failure detection. + */ + fun startPlayingExploration(explorationId: String): LiveData> { + return try { + explorationProgressController.beginExplorationAsync(explorationId) + MutableLiveData(AsyncResult.success(null)) } catch (e: Exception) { - return AsyncResult.failed(e) + MutableLiveData(AsyncResult.failed(e)) } } - @Suppress("RedundantSuspendModifier") // DataProviders expects this function to be a suspend function. - private suspend fun retrieveAbboutOppiaExplorationAsync(): AsyncResult { - try { - return AsyncResult.success(createExploration("about_oppia.json")) + /** + * Finishes the most recent exploration started by [startPlayingExploration]. This method should only be called if an + * active exploration is being played, otherwise an exception will be thrown. + */ + fun stopPlayingExploration(): LiveData> { + return try { + explorationProgressController.finishExplorationAsync() + MutableLiveData(AsyncResult.success(null)) } catch (e: Exception) { - return AsyncResult.failed(e) + MutableLiveData(AsyncResult.failed(e)) } } - // Returns an exploration given an assetName - private fun createExploration(assetName: String): Exploration { - try { - val explorationObject = loadJsonFromAsset(assetName) ?: return Exploration.getDefaultInstance() - return Exploration.newBuilder() - .setTitle(explorationObject.getString("title")) - .setLanguageCode(explorationObject.getString("language_code")) - .setInitStateName(explorationObject.getString("init_state_name")) - .setObjective(explorationObject.getString("objective")) - .putAllStates(createStatesFromJsonObject(explorationObject.getJSONObject("states"))) - .build() - } catch (e: IOException) { - throw(Throwable("Failed to load and parse the json asset file. %s", e)) - } - } - - // Returns a JSON Object if it exists, else returns null - private fun getJsonObject(parentObject: JSONObject, key: String): JSONObject? { + @Suppress("RedundantSuspendModifier") // DataProviders expects this function to be a suspend function. + private suspend fun retrieveExplorationById(explorationId: String): AsyncResult { return try { - parentObject.getJSONObject(key) - } catch (e: JSONException) { - return null - } - } - - // Loads the JSON string from an asset and converts it to a JSONObject - @Throws(IOException::class) - private fun loadJsonFromAsset(assetName: String): JSONObject? { - val assetManager = context.assets - val jsonContents = assetManager.open(assetName).bufferedReader().use { it.readText() } - return JSONObject(jsonContents) - } - - // Creates the states map from JSON - private fun createStatesFromJsonObject(statesJsonObject: JSONObject?): MutableMap { - val statesMap: MutableMap = mutableMapOf() - val statesKeys = statesJsonObject?.keys() ?: return statesMap - val statesIterator = statesKeys.iterator() - while (statesIterator.hasNext()) { - val key = statesIterator.next() - statesMap[key] = createStateFromJson(statesJsonObject.getJSONObject(key)) - } - return statesMap - } - - // Creates a single state object from JSON - private fun createStateFromJson(stateJson: JSONObject?): State { - return State.newBuilder() - .setContent( - SubtitledHtml.newBuilder().setHtml( - stateJson?.getJSONObject("content")?.getString("html") - ) - ) - .setInteraction(createInteractionFromJson(stateJson?.getJSONObject("interaction"))) - .build() - } - - // Creates an interaction from JSON - private fun createInteractionFromJson(interactionJson: JSONObject?): Interaction { - if (interactionJson == null) { - return Interaction.getDefaultInstance(); - } - return Interaction.newBuilder() - .setId(interactionJson.getString("id")) - .addAllAnswerGroups( - createAnswerGroupsFromJson( - interactionJson.getJSONArray("answer_groups"), - interactionJson.getString("id") - ) - ) - .addAllConfirmedUnclassifiedAnswers( - createAnswerGroupsFromJson( - interactionJson.getJSONArray("confirmed_unclassified_answers"), - interactionJson.getString("id") - ) - ) - .setDefaultOutcome( - createOutcomeFromJson( - getJsonObject(interactionJson, "default_outcome") - ) - ) - .putAllCustomizationArgs( - createCustomizationArgsMapFromJson( - getJsonObject(interactionJson, "customization_args") - ) - ) - .build() - } - - // Creates the list of answer group objects from JSON - private fun createAnswerGroupsFromJson( - answerGroupsJson: JSONArray?, interactionId: String - ): MutableList { - val answerGroups = mutableListOf() - if (answerGroupsJson == null) { - return answerGroups - } - for (i in 0 until answerGroupsJson.length()) { - answerGroups.add( - createSingleAnswerGroupFromJson( - answerGroupsJson.getJSONObject(i), interactionId - ) - ) - } - return answerGroups - } - - // Creates a single answer group object from JSON - private fun createSingleAnswerGroupFromJson( - answerGroupJson: JSONObject, interactionId: String - ): AnswerGroup { - return AnswerGroup.newBuilder() - .setOutcome( - createOutcomeFromJson(answerGroupJson.getJSONObject("outcome")) - ) - .addAllRuleSpecs( - createRuleSpecsFromJson( - answerGroupJson.getJSONArray("rule_specs"), interactionId - ) - ) - .build() - } - - // Creates an outcome object from JSON - private fun createOutcomeFromJson(outcomeJson: JSONObject?): Outcome { - if (outcomeJson == null) { - return Outcome.getDefaultInstance() - } - return Outcome.newBuilder() - .setDestStateName(outcomeJson.getString("dest")) - .setFeedback( - SubtitledHtml.newBuilder() - .setHtml(outcomeJson.getString("feedback")) - ) - .setLabelledAsCorrect(outcomeJson.getBoolean("labelled_as_correct")) - .build() - } - - // Creates the list of rule spec objects from JSON - private fun createRuleSpecsFromJson( - ruleSpecJson: JSONArray?, interactionId: String - ): MutableList { - val ruleSpecList = mutableListOf() - if (ruleSpecJson == null) { - return ruleSpecList - } - for (i in 0 until ruleSpecJson.length()) { - ruleSpecList.add( - RuleSpec.newBuilder() - .setRuleType( - ruleSpecJson.getJSONObject(i).getString("rule_type") - ) - .setInput( - createInputFromJson( - ruleSpecJson.getJSONObject(i).getJSONObject("inputs"), - /* keyName= */"x", interactionId - ) - ) - .build() - ) - } - return ruleSpecList - } - - // Creates an input interaction object from JSON - private fun createInputFromJson( - inputJson: JSONObject?, keyName: String, interactionId: String - ): InteractionObject { - if (inputJson == null) { - return InteractionObject.getDefaultInstance() - } - return when (interactionId) { - "MultipleChoiceInput" -> InteractionObject.newBuilder() - .setNonNegativeInt(inputJson.getInt(keyName)) - .build() - "TextInput" -> InteractionObject.newBuilder() - .setNormalizedString(inputJson.getString(keyName)) - .build() - "NumericInput" -> InteractionObject.newBuilder() - .setReal(inputJson.getDouble(keyName)) - .build() - else -> InteractionObject.getDefaultInstance() - } - } - - // Creates a customization arg mapping from JSON - private fun createCustomizationArgsMapFromJson( - customizationArgsJson: JSONObject? - ): MutableMap { - val customizationArgsMap: MutableMap = mutableMapOf() - if (customizationArgsJson == null) { - return customizationArgsMap - } - val customizationArgsKeys = customizationArgsJson.keys() ?: return customizationArgsMap - val customizationArgsIterator = customizationArgsKeys.iterator() - while (customizationArgsIterator.hasNext()) { - val key = customizationArgsIterator.next() - customizationArgsMap[key] = createCustomizationArgValueFromJson( - customizationArgsJson.getJSONObject(key).get("value") - ) - } - return customizationArgsMap - } - - // Creates a customization arg value interaction object from JSON - private fun createCustomizationArgValueFromJson(customizationArgValue: Any): InteractionObject { - val interactionObjectBuilder = InteractionObject.newBuilder() - when (customizationArgValue) { - is String -> return interactionObjectBuilder - .setNormalizedString(customizationArgValue).build() - is Int -> return interactionObjectBuilder - .setSignedInt(customizationArgValue).build() - is Double -> return interactionObjectBuilder - .setReal(customizationArgValue).build() - is List<*> -> if (customizationArgValue.size > 0) { - return interactionObjectBuilder.setSetOfHtmlString( - createStringList(customizationArgValue) - ).build() - } - } - return InteractionObject.getDefaultInstance() - } - - @Suppress("UNCHECKED_CAST") // Checked cast in the if statement - private fun createStringList(value: List<*>): StringList { - val stringList = mutableListOf() - if (value[0] is String) { - stringList.addAll(value as List) - return StringList.newBuilder().addAllStringList(stringList).build() + AsyncResult.success(explorationRetriever.loadExploration(explorationId)) + } catch (e: Exception) { + AsyncResult.failed(e) } - return StringList.getDefaultInstance() } } diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt new file mode 100644 index 00000000000..f439d5463cd --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt @@ -0,0 +1,533 @@ +package org.oppia.domain.exploration + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.oppia.app.model.AnswerAndResponse +import org.oppia.app.model.AnswerOutcome +import org.oppia.app.model.CompletedState +import org.oppia.app.model.EphemeralState +import org.oppia.app.model.Exploration +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.Outcome +import org.oppia.app.model.PendingState +import org.oppia.app.model.State +import org.oppia.app.model.SubtitledHtml +import org.oppia.domain.classify.AnswerClassificationController +import org.oppia.util.data.AsyncDataSubscriptionManager +import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProviders +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock + +// TODO(#186): Use an interaction repository to retrieve whether a specific ID corresponds to a terminal interaction. +private const val TERMINAL_INTERACTION_ID = "EndExploration" + +private const val CURRENT_STATE_DATA_PROVIDER_ID = "CurrentStateDataProvider" + +/** + * Controller that tracks and reports the learner's ephemeral/non-persisted progress through an exploration. Note that + * this controller only supports one active exploration at a time. + * + * 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. + */ +@Singleton +class ExplorationProgressController @Inject constructor( + private val dataProviders: DataProviders, + private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, + private val explorationRetriever: ExplorationRetriever, + private val answerClassificationController: AnswerClassificationController +) { + // TODO(#180): Add support for hints. + // TODO(#179): Add support for parameters. + // TODO(#181): Add support for solutions. + // TODO(#182): Add support for refresher explorations. + // TODO(#90): 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. + + private val currentStateDataProvider = + dataProviders.createInMemoryDataProviderAsync(CURRENT_STATE_DATA_PROVIDER_ID, this::retrieveCurrentStateAsync) + private val explorationProgress = ExplorationProgress() + private val explorationProgressLock = ReentrantLock() + + /** Resets this controller to begin playing the specified [Exploration]. */ + internal fun beginExplorationAsync(explorationId: String) { + explorationProgressLock.withLock { + check(explorationProgress.playStage == PlayStage.NOT_PLAYING) { + "Expected to finish previous exploration before starting a new one." + } + + explorationProgress.currentExplorationId = explorationId + explorationProgress.advancePlayStageTo(PlayStage.LOADING_EXPLORATION) + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + } + } + + /** Indicates that the current exploration being played is now completed. */ + internal fun finishExplorationAsync() { + explorationProgressLock.withLock { + check(explorationProgress.playStage != PlayStage.NOT_PLAYING) { + "Cannot finish playing an exploration that hasn't yet been started" + } + explorationProgress.advancePlayStageTo(PlayStage.NOT_PLAYING) + } + } + + /** + * Submits an answer to the current state and returns how the UI should respond to this answer. The returned + * [LiveData] 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 [LiveData]. Each call to this method returns a new, distinct, [LiveData] object that must + * be observed. Note also that the returned [LiveData] is not guaranteed to begin with a pending state. + * + * If the app undergoes a configuration change, calling code should rely on the [LiveData] from [getCurrentState] to + * know whether a current answer is pending. That [LiveData] will have its state changed to pending during answer + * submission and until answer resolution. + * + * Submitting an answer should result in the learner staying in the current state, moving to a new state in the + * exploration, being shown a concept card, or being navigated to another exploration altogether. Note that once a + * correct answer is processed, the current state reported to [getCurrentState] will change from a pending state to a + * completed state since the learner 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. + * + * No assumptions should be made about the completion order of the returned [LiveData] vs. the [LiveData] from + * [getCurrentState]. Also note that the returned [LiveData] will only have a single value and not be reused after + * that point. + */ + fun submitAnswer(answer: InteractionObject): LiveData> { + try { + explorationProgressLock.withLock { + check(explorationProgress.playStage != PlayStage.NOT_PLAYING) { + "Cannot submit an answer if an exploration is not being played." + } + check(explorationProgress.playStage != PlayStage.LOADING_EXPLORATION) { + "Cannot submit an answer while the exploration is being loaded." + } + check(explorationProgress.playStage != PlayStage.SUBMITTING_ANSWER) { + "Cannot submit an answer while another answer is pending." + } + + // Notify observers that the submitted answer is currently pending. + explorationProgress.advancePlayStageTo(PlayStage.SUBMITTING_ANSWER) + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + + val topPendingState = explorationProgress.stateDeck.getPendingTopState() + val outcome = answerClassificationController.classify(topPendingState, answer) + val answerOutcome = explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) + explorationProgress.stateDeck.submitAnswer(answer, answerOutcome.feedback) + // Follow the answer's outcome to another part of the graph if it's different. + if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) { + explorationProgress.stateDeck.pushState(explorationProgress.stateGraph.getState(answerOutcome.stateName)) + } + + // Return to viewing the state. + explorationProgress.advancePlayStageTo(PlayStage.VIEWING_STATE) + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + + return MutableLiveData(AsyncResult.success(answerOutcome)) + } + } catch (e: Exception) { + return MutableLiveData(AsyncResult.failed(e)) + } + } + + /** + * Navigates to the previous state in the stack. If the learner is currently on the initial state, this method will + * throw an exception. Calling code is responsible to make sure that this method is not called when it's not possible + * to navigate to a previous card. + * + * This method cannot be called until an exploration has started and [getCurrentState] returns a non-pending result or + * an exception will be thrown. + */ + /** + * Navigates to the previous state in the graph. If the learner is currently on the initial state, 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 [LiveData] 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 vieiwng 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(): LiveData> { + try { + explorationProgressLock.withLock { + check(explorationProgress.playStage != PlayStage.NOT_PLAYING) { + "Cannot navigate to a previous state if an exploration is not being played." + } + check(explorationProgress.playStage != PlayStage.LOADING_EXPLORATION) { + "Cannot navigate to a previous state if an exploration is being loaded." + } + check(explorationProgress.playStage != PlayStage.SUBMITTING_ANSWER) { + "Cannot navigate to a previous state if an answer submission is pending." + } + explorationProgress.stateDeck.navigateToPreviousState() + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + } + return MutableLiveData(AsyncResult.success(null)) + } catch (e: Exception) { + return MutableLiveData(AsyncResult.failed(e)) + } + } + + /** + * Navigates to the next state in the graph. This method is only valid if the current [EphemeralState] reported by + * [getCurrentState] is a completed state. Calling code is responsible for ensuring this method is only called when + * it's possible to navigate forward. + * + * Note that if the current state is a pending state, the user needs to submit a correct answer 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 [LiveData] 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. + */ + fun moveToNextState(): LiveData> { + try { + explorationProgressLock.withLock { + check(explorationProgress.playStage != PlayStage.NOT_PLAYING) { + "Cannot navigate to a next state if an exploration is not being played." + } + check(explorationProgress.playStage != PlayStage.LOADING_EXPLORATION) { + "Cannot navigate to a next state if an exploration is being loaded." + } + check(explorationProgress.playStage != PlayStage.SUBMITTING_ANSWER) { + "Cannot navigate to a next state if an answer submission is pending." + } + explorationProgress.stateDeck.navigateToNextState() + asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) + } + return MutableLiveData(AsyncResult.success(null)) + } catch (e: Exception) { + return MutableLiveData(AsyncResult.failed(e)) + } + } + + /** + * Returns a [LiveData] monitoring the current [EphemeralState] the learner is currently 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 live data when the next state is navigated to. + * + * Note that the returned [LiveData] is always the same object no matter when this method is called, except + * potentially when a new exploration is started. + * + * This [LiveData] may initially be pending while the exploration object is loaded. It may also 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 caching the current state object to display since it may disappear + * temporarily during answer submission. Calling code should persist this state object across configuration changes if + * needed since it cannot rely on this [LiveData] for immediate state reconstitution after configuration changes. + * + * 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. + * + * This method is safe to be called before an exploration has started. If there is no ongoing exploration, it should + * return a pending state. + */ + fun getCurrentState(): LiveData> { + return dataProviders.convertToLiveData(currentStateDataProvider) + } + + private suspend fun retrieveCurrentStateAsync(): AsyncResult { + return try { + retrieveCurrentStateWithinCacheAsync() + } catch (e: Exception) { + AsyncResult.failed(e) + } + } + + private suspend fun retrieveCurrentStateWithinCacheAsync(): AsyncResult { + var explorationId: String? = null + lateinit var currentStage: PlayStage + explorationProgressLock.withLock { + currentStage = explorationProgress.playStage + if (currentStage == PlayStage.LOADING_EXPLORATION) { + explorationId = explorationProgress.currentExplorationId + } + } + + val exploration: Exploration? = + if (explorationId != null) explorationRetriever.loadExploration(explorationId!!) else null + + 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. + check(exploration == null || explorationProgress.currentExplorationId == explorationId) { + "Encountered race condition when retrieving exploration. ID changed from $explorationId" + + " to ${explorationProgress.currentExplorationId}" + } + check(explorationProgress.playStage == currentStage) { + "Encountered race condition when retrieving exploration. ID changed from $explorationId" + + " to ${explorationProgress.currentExplorationId}" + } + return when (explorationProgress.playStage) { + PlayStage.NOT_PLAYING -> AsyncResult.pending() + PlayStage.LOADING_EXPLORATION -> { + try { + // The exploration must be available for this stage since it was loaded above. + finishLoadExploration(exploration!!, explorationProgress) + AsyncResult.success(explorationProgress.stateDeck.getCurrentEphemeralState()) + } catch (e: Exception) { + AsyncResult.failed(e) + } + } + PlayStage.VIEWING_STATE -> AsyncResult.success(explorationProgress.stateDeck.getCurrentEphemeralState()) + PlayStage.SUBMITTING_ANSWER -> AsyncResult.pending() + } + } + } + + private fun 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.resetStateGraph(exploration.statesMap) + progress.stateDeck.resetDeck(progress.stateGraph.getState(exploration.initStateName)) + + // Advance the stage, but do not notify observers since the current state can be reported immediately to the UI. + progress.advancePlayStageTo(PlayStage.VIEWING_STATE) + } + + /** Different stages in which the progress controller can exist. */ + private enum class PlayStage { + /** No exploration is currently being played. */ + NOT_PLAYING, + + /** An exploration is being prepared to be played. */ + LOADING_EXPLORATION, + + /** The controller is currently viewing a State. */ + VIEWING_STATE, + + /** The controller is in the process of submitting an answer. */ + SUBMITTING_ANSWER + } + + /** + * Private class that encapsulates the mutable state of the progress controller. This class is thread-safe. This class + * can exist across multiple exploration instances, but calling code is responsible for ensuring it is properly reset. + */ + private class ExplorationProgress { + internal lateinit var currentExplorationId: String + internal lateinit var currentExploration: Exploration + internal var playStage = PlayStage.NOT_PLAYING + internal val stateGraph: StateGraph by lazy { + StateGraph( + currentExploration.statesMap + ) + } + internal val stateDeck: StateDeck by lazy { + StateDeck( + stateGraph.getState(currentExploration.initStateName) + ) + } + + /** + * Advances the current play stage to the specified stage, verifying that the transition is correct. + * + * Calling code should prevent this method from failing by checking state ahead of calling this method and providing + * more useful errors to UI calling code since errors thrown by this method will be more obscure. This method aims to + * ensure the internal state of the controller remains correct. This method is not meant to be covered in unit tests + * since none of the failures here should ever be exposed to controller callers. + */ + internal fun advancePlayStageTo(nextPlayStage: PlayStage) { + when (nextPlayStage) { + PlayStage.NOT_PLAYING -> { + // All transitions to NOT_PLAYING are valid except itself. Stopping playing can happen at any time. + check(playStage != PlayStage.NOT_PLAYING) { "Cannot transition to NOT_PLAYING from NOT_PLAYING" } + playStage = nextPlayStage + } + PlayStage.LOADING_EXPLORATION -> { + // An exploration can only be requested to be loaded from the initial NOT_PLAYING stage. + check(playStage == PlayStage.NOT_PLAYING) { "Cannot transition to LOADING_EXPLORATION from $playStage" } + playStage = nextPlayStage + } + PlayStage.VIEWING_STATE -> { + // A state can be viewed after loading an exploration, after viewing another state, or after submitting an + // answer. It cannot be viewed without a loaded exploration. + check(playStage == PlayStage.LOADING_EXPLORATION + || playStage == PlayStage.VIEWING_STATE + || playStage == PlayStage.SUBMITTING_ANSWER) { + "Cannot transition to VIEWING_STATE from $playStage" + } + playStage = nextPlayStage + } + PlayStage.SUBMITTING_ANSWER -> { + // An answer can only be submitted after viewing a stage. + check(playStage == PlayStage.VIEWING_STATE) { "Cannot transition to SUBMITTING_ANSWER from $playStage" } + playStage = nextPlayStage + } + } + } + } + + /** + * Graph that provides lookup access for [State]s and functionality for processing the outcome of a submitted learner + * answer. + */ + private class StateGraph internal constructor(private var stateGraph: Map) { + /** Resets this graph to the new graph represented by the specified [Map]. */ + internal fun resetStateGraph(stateGraph: Map) { + this.stateGraph = stateGraph + } + + /** Returns the [State] corresponding to the specified name. */ + internal fun getState(stateName: String): State { + return stateGraph.getValue(stateName) + } + + /** Returns an [AnswerOutcome] based on the current state and resulting [Outcome] from the learner's answer. */ + internal fun computeAnswerOutcomeForResult(currentState: State, outcome: Outcome): AnswerOutcome { + val answerOutcomeBuilder = AnswerOutcome.newBuilder() + .setFeedback(outcome.feedback) + .setLabelledAsCorrectAnswer(outcome.labelledAsCorrect) + when { + outcome.refresherExplorationId.isNotEmpty() -> + answerOutcomeBuilder.refresherExplorationId = outcome.refresherExplorationId + outcome.missingPrerequisiteSkillId.isNotEmpty() -> + answerOutcomeBuilder.missingPrerequisiteSkillId = outcome.missingPrerequisiteSkillId + outcome.destStateName == currentState.name -> answerOutcomeBuilder.setSameState(true) + else -> answerOutcomeBuilder.stateName = outcome.destStateName + } + return answerOutcomeBuilder.build() + } + } + + private class StateDeck internal constructor(initialState: State) { + private var pendingTopState: State = initialState + private val previousStates: MutableList = ArrayList() + private val currentDialogInteractions: MutableList = ArrayList() + private var stateIndex: Int = 0 + + /** Resets this deck to a new, specified initial [State]. */ + internal fun resetDeck(initialState: State) { + pendingTopState = initialState + previousStates.clear() + currentDialogInteractions.clear() + stateIndex = 0 + } + + /** Navigates to the previous State in the deck, or fails if this isn't possible. */ + internal fun navigateToPreviousState() { + check(!isCurrentStateInitial()) { "Cannot navigate to previous state; at initial state." } + stateIndex-- + } + + /** Navigates to the next State in the deck, or fails if this isn't possible. */ + internal fun navigateToNextState() { + check(!isCurrentStateTopOfDeck()) { "Cannot navigate to next state; at most recent state." } + stateIndex++ + } + + /** + * Returns the [State] corresponding to the latest card in the deck, regardless of whichever State the learner is + * currently viewing. + */ + internal fun getPendingTopState(): State { + return pendingTopState + } + + /** Returns the current [EphemeralState] the learner is viewing. */ + internal fun getCurrentEphemeralState(): EphemeralState { + // Note that the terminal state is evaluated first since it can only return true if the current state is the top + // of the deck, and that state is the terminal one. Otherwise the terminal check would never be triggered since + // the second case assumes the top of the deck must be pending. + return when { + isCurrentStateTerminal() -> getCurrentTerminalState() + stateIndex == previousStates.size -> getCurrentPendingState() + else -> getPreviousState() + } + } + + /** + * Pushes a new State onto the deck. This cannot happen if the learner isn't at the most recent State, if the + * current State is not terminal, or if the learner hasn't submitted an answer to the most recent State. This + * operation implies that the most recently submitted answer was the correct answer to the previously current State. + * This does NOT change the user's position in the deck, it just marks the current state as completed. + */ + internal fun pushState(state: State) { + check(isCurrentStateTopOfDeck()) { "Cannot push a new state unless the learner is at the most recent state." } + check(!isCurrentStateTerminal()) { "Cannot push another state after reaching a terminal state." } + check(currentDialogInteractions.size != 0) { "Cannot push another state without an answer." } + check(state.name != pendingTopState.name) { "Cannot route from the same state to itself as a new card." } + previousStates += EphemeralState.newBuilder() + .setState(pendingTopState) + .setHasPreviousState(!isCurrentStateInitial()) + .setCompletedState(CompletedState.newBuilder().addAllAnswer(currentDialogInteractions)) + .build() + currentDialogInteractions.clear() + pendingTopState = state + } + + /** + * Submits an answer & feedback dialog the learner experience in the current State. This fails if the user is not at + * 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). + */ + internal fun submitAnswer(userAnswer: InteractionObject, feedback: SubtitledHtml) { + 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) + .build() + } + + private fun getCurrentPendingState(): EphemeralState { + return EphemeralState.newBuilder() + .setState(pendingTopState) + .setHasPreviousState(!isCurrentStateInitial()) + .setPendingState(PendingState.newBuilder().addAllWrongAnswer(currentDialogInteractions)) + .build() + } + + private fun getCurrentTerminalState(): EphemeralState { + return EphemeralState.newBuilder() + .setState(pendingTopState) + .setHasPreviousState(!isCurrentStateInitial()) + .setTerminalState(true) + .build() + } + + private fun getPreviousState(): EphemeralState { + return previousStates[stateIndex] + } + + /** Returns whether the current scrolled State is the first State of the exploration. */ + private fun isCurrentStateInitial(): Boolean { + return stateIndex == 0 + } + + /** Returns whether the current scrolled State is the most recent State played by the learner. */ + private fun isCurrentStateTopOfDeck(): Boolean { + return stateIndex == previousStates.size + } + + /** Returns whether the current State is terminal. */ + private fun isCurrentStateTerminal(): Boolean { + // Cards not on top of the deck cannot be terminal/the terminal card must be the last card in the deck, if it's + // present. + return isCurrentStateTopOfDeck() && isTopOfDeckTerminal() + } + + /** Returns whether the most recent card on the deck is terminal. */ + private fun isTopOfDeckTerminal(): Boolean { + return pendingTopState.interaction.id == TERMINAL_INTERACTION_ID + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt new file mode 100644 index 00000000000..65f41980fed --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt @@ -0,0 +1,265 @@ +package org.oppia.domain.exploration + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import org.oppia.app.model.AnswerGroup +import org.oppia.app.model.Exploration +import org.oppia.app.model.Interaction +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.Outcome +import org.oppia.app.model.RuleSpec +import org.oppia.app.model.State +import org.oppia.app.model.StringList +import org.oppia.app.model.SubtitledHtml +import java.io.IOException +import javax.inject.Inject + +const val TEST_EXPLORATION_ID_5 = "test_exp_id_5" +const val TEST_EXPLORATION_ID_6 = "test_exp_id_6" + +// TODO(#59): Make this class inaccessible outside of the domain package except for tests. UI code should not be allowed +// to depend on this utility. + +/** Internal class for actually retrieving an exploration object for uses in domain controllers. */ +class ExplorationRetriever @Inject constructor(private val context: Context) { + /** Loads and returns an exploration for the specified exploration ID, or fails. */ + @Suppress("RedundantSuspendModifier") // Force callers to call this on a background thread. + internal suspend fun loadExploration(explorationId: String): Exploration { + return when (explorationId) { + TEST_EXPLORATION_ID_5 -> loadExplorationFromAsset("welcome.json") + TEST_EXPLORATION_ID_6 -> loadExplorationFromAsset("about_oppia.json") + else -> throw IllegalStateException("Invalid exploration ID: $explorationId") + } + } + + // Returns an exploration given an assetName + private fun loadExplorationFromAsset(assetName: String): Exploration { + try { + val explorationObject = loadJsonFromAsset(assetName) ?: return Exploration.getDefaultInstance() + return Exploration.newBuilder() + .setTitle(explorationObject.getString("title")) + .setLanguageCode(explorationObject.getString("language_code")) + .setInitStateName(explorationObject.getString("init_state_name")) + .setObjective(explorationObject.getString("objective")) + .putAllStates(createStatesFromJsonObject(explorationObject.getJSONObject("states"))) + .build() + } catch (e: IOException) { + throw(Throwable("Failed to load and parse the json asset file. %s", e)) + } + } + + // Returns a JSON Object if it exists, else returns null + private fun getJsonObject(parentObject: JSONObject, key: String): JSONObject? { + return parentObject.optJSONObject(key) + } + + // Loads the JSON string from an asset and converts it to a JSONObject + @Throws(IOException::class) + private fun loadJsonFromAsset(assetName: String): JSONObject? { + val assetManager = context.assets + val jsonContents = assetManager.open(assetName).bufferedReader().use { it.readText() } + return JSONObject(jsonContents) + } + + // Creates the states map from JSON + private fun createStatesFromJsonObject(statesJsonObject: JSONObject?): MutableMap { + val statesMap: MutableMap = mutableMapOf() + val statesKeys = statesJsonObject?.keys() ?: return statesMap + val statesIterator = statesKeys.iterator() + while (statesIterator.hasNext()) { + val key = statesIterator.next() + statesMap[key] = createStateFromJson(key, statesJsonObject.getJSONObject(key)) + } + return statesMap + } + + // Creates a single state object from JSON + private fun createStateFromJson(stateName: String, stateJson: JSONObject?): State { + return State.newBuilder() + .setName(stateName) + .setContent( + SubtitledHtml.newBuilder().setHtml( + stateJson?.getJSONObject("content")?.getString("html") + ) + ) + .setInteraction(createInteractionFromJson(stateJson?.getJSONObject("interaction"))) + .build() + } + + // Creates an interaction from JSON + private fun createInteractionFromJson(interactionJson: JSONObject?): Interaction { + if (interactionJson == null) { + return Interaction.getDefaultInstance() + } + return Interaction.newBuilder() + .setId(interactionJson.getString("id")) + .addAllAnswerGroups( + createAnswerGroupsFromJson( + interactionJson.getJSONArray("answer_groups"), + interactionJson.getString("id") + ) + ) + .addAllConfirmedUnclassifiedAnswers( + createAnswerGroupsFromJson( + interactionJson.getJSONArray("confirmed_unclassified_answers"), + interactionJson.getString("id") + ) + ) + .setDefaultOutcome( + createOutcomeFromJson( + getJsonObject(interactionJson, "default_outcome") + ) + ) + .putAllCustomizationArgs( + createCustomizationArgsMapFromJson( + getJsonObject(interactionJson, "customization_args") + ) + ) + .build() + } + + // Creates the list of answer group objects from JSON + private fun createAnswerGroupsFromJson( + answerGroupsJson: JSONArray?, interactionId: String + ): MutableList { + val answerGroups = mutableListOf() + if (answerGroupsJson == null) { + return answerGroups + } + for (i in 0 until answerGroupsJson.length()) { + answerGroups.add( + createSingleAnswerGroupFromJson( + answerGroupsJson.getJSONObject(i), interactionId + ) + ) + } + return answerGroups + } + + // Creates a single answer group object from JSON + private fun createSingleAnswerGroupFromJson( + answerGroupJson: JSONObject, interactionId: String + ): AnswerGroup { + return AnswerGroup.newBuilder() + .setOutcome( + createOutcomeFromJson(answerGroupJson.getJSONObject("outcome")) + ) + .addAllRuleSpecs( + createRuleSpecsFromJson( + answerGroupJson.getJSONArray("rule_specs"), interactionId + ) + ) + .build() + } + + // Creates an outcome object from JSON + private fun createOutcomeFromJson(outcomeJson: JSONObject?): Outcome { + if (outcomeJson == null) { + return Outcome.getDefaultInstance() + } + return Outcome.newBuilder() + .setDestStateName(outcomeJson.getString("dest")) + .setFeedback( + SubtitledHtml.newBuilder() + .setHtml(outcomeJson.getString("feedback")) + ) + .setLabelledAsCorrect(outcomeJson.getBoolean("labelled_as_correct")) + .build() + } + + // Creates the list of rule spec objects from JSON + private fun createRuleSpecsFromJson( + ruleSpecJson: JSONArray?, interactionId: String + ): MutableList { + val ruleSpecList = mutableListOf() + if (ruleSpecJson == null) { + return ruleSpecList + } + for (i in 0 until ruleSpecJson.length()) { + ruleSpecList.add( + RuleSpec.newBuilder() + .setRuleType( + ruleSpecJson.getJSONObject(i).getString("rule_type") + ) + .setInput( + createInputFromJson( + ruleSpecJson.getJSONObject(i).getJSONObject("inputs"), + /* keyName= */"x", interactionId + ) + ) + .build() + ) + } + return ruleSpecList + } + + // Creates an input interaction object from JSON + private fun createInputFromJson( + inputJson: JSONObject?, keyName: String, interactionId: String + ): InteractionObject { + if (inputJson == null) { + return InteractionObject.getDefaultInstance() + } + return when (interactionId) { + "MultipleChoiceInput" -> InteractionObject.newBuilder() + .setNonNegativeInt(inputJson.getInt(keyName)) + .build() + "TextInput" -> InteractionObject.newBuilder() + .setNormalizedString(inputJson.getString(keyName)) + .build() + "NumericInput" -> InteractionObject.newBuilder() + .setReal(inputJson.getDouble(keyName)) + .build() + else -> throw IllegalStateException("Encountered unexpected interaction ID: $interactionId") + } + } + + // Creates a customization arg mapping from JSON + private fun createCustomizationArgsMapFromJson( + customizationArgsJson: JSONObject? + ): MutableMap { + val customizationArgsMap: MutableMap = mutableMapOf() + if (customizationArgsJson == null) { + return customizationArgsMap + } + val customizationArgsKeys = customizationArgsJson.keys() ?: return customizationArgsMap + val customizationArgsIterator = customizationArgsKeys.iterator() + while (customizationArgsIterator.hasNext()) { + val key = customizationArgsIterator.next() + customizationArgsMap[key] = createCustomizationArgValueFromJson( + customizationArgsJson.getJSONObject(key).get("value") + ) + } + return customizationArgsMap + } + + // Creates a customization arg value interaction object from JSON + private fun createCustomizationArgValueFromJson(customizationArgValue: Any): InteractionObject { + val interactionObjectBuilder = InteractionObject.newBuilder() + when (customizationArgValue) { + is String -> return interactionObjectBuilder + .setNormalizedString(customizationArgValue).build() + is Int -> return interactionObjectBuilder + .setSignedInt(customizationArgValue).build() + is Double -> return interactionObjectBuilder + .setReal(customizationArgValue).build() + is List<*> -> if (customizationArgValue.size > 0) { + return interactionObjectBuilder.setSetOfHtmlString( + createStringList(customizationArgValue) + ).build() + } + } + return InteractionObject.getDefaultInstance() + } + + @Suppress("UNCHECKED_CAST") // Checked cast in the if statement + private fun createStringList(value: List<*>): StringList { + val stringList = mutableListOf() + if (value[0] is String) { + stringList.addAll(value as List) + return StringList.newBuilder().addAllStringList(stringList).build() + } + return StringList.getDefaultInstance() + } +} diff --git a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt index 3f41de9c952..690388c65a7 100644 --- a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt @@ -16,6 +16,7 @@ import org.oppia.app.model.AnswerGroup import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.Outcome +import org.oppia.app.model.State import org.oppia.app.model.SubtitledHtml import org.robolectric.annotation.Config import javax.inject.Inject @@ -25,7 +26,9 @@ import javax.inject.Singleton @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) class AnswerClassificationControllerTest { - private val ARBITRARY_SAMPLE_ANSWER = InteractionObject.newBuilder().setNormalizedString("Some value").build() + private val TEST_STRING_ANSWER = InteractionObject.newBuilder().setNormalizedString("Some value").build() + private val TEST_INT_2_ANSWER = InteractionObject.newBuilder().setNonNegativeInt(1).build() + private val TEST_SIGNED_INT_121_ANSWER = InteractionObject.newBuilder().setSignedInt(121).build() private val OUTCOME_0 = Outcome.newBuilder() .setDestStateName("First state") @@ -54,19 +57,21 @@ class AnswerClassificationControllerTest { .setDefaultOutcome(OUTCOME_0) .build() - val outcome = answerClassificationController.classify(interaction, ARBITRARY_SAMPLE_ANSWER) + val state = createTestState("Things you can do", interaction) + val outcome = answerClassificationController.classify(state, TEST_STRING_ANSWER) assertThat(outcome).isEqualTo(OUTCOME_0) } @Test - fun testClassify_testInteraction_withMultipleDefaultOutcomes_returnsDefaultOutcome() { + fun testClassify_testInteraction_withMultipleOutcomes_wrongAnswer_returnsDefaultOutcome() { val interaction = Interaction.newBuilder() .setDefaultOutcome(OUTCOME_1) .addAnswerGroups(AnswerGroup.newBuilder().setOutcome(OUTCOME_2)) .build() - val outcome = answerClassificationController.classify(interaction, ARBITRARY_SAMPLE_ANSWER) + val state = createTestState("Welcome!", interaction) + val outcome = answerClassificationController.classify(state, TEST_INT_2_ANSWER) assertThat(outcome).isEqualTo(OUTCOME_1) } @@ -80,13 +85,22 @@ class AnswerClassificationControllerTest { val interaction2 = Interaction.newBuilder() .setDefaultOutcome(OUTCOME_2) .build() - answerClassificationController.classify(interaction1, ARBITRARY_SAMPLE_ANSWER) + val state1 = createTestState("Numeric input", interaction1) + answerClassificationController.classify(state1, TEST_SIGNED_INT_121_ANSWER) - val outcome = answerClassificationController.classify(interaction2, ARBITRARY_SAMPLE_ANSWER) + val state2 = createTestState("Things you can do", interaction2) + val outcome = answerClassificationController.classify(state2, TEST_STRING_ANSWER) assertThat(outcome).isEqualTo(OUTCOME_2) } + private fun createTestState(stateName: String, interaction: Interaction): State { + return State.newBuilder() + .setName(stateName) + .setInteraction(interaction) + .build() + } + private fun setUpTestApplicationComponent() { DaggerAnswerClassificationControllerTest_TestApplicationComponent.builder() .setApplication(ApplicationProvider.getApplicationContext()) diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt index 754e2fbe6c0..da49561b8cd 100644 --- a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt @@ -46,9 +46,6 @@ import javax.inject.Qualifier import javax.inject.Singleton import kotlin.coroutines.EmptyCoroutineContext -const val TEST_EXPLORATION_ID_0 = "test_exp_id_0" -const val TEST_EXPLORATION_ID_1 = "test_exp_id_1" - /** Tests for [ExplorationDataController]. */ @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) @@ -108,9 +105,9 @@ class ExplorationDataControllerTest { @Test @ExperimentalCoroutinesApi fun testController_providesInitialLiveDataForTheWelcomeExploration() = runBlockingTest(coroutineContext) { - val explorationLiveData = explorationDataController.getExplorationById(TEST_EXPLORATION_ID_0) + val explorationLiveData = explorationDataController.getExplorationById(TEST_EXPLORATION_ID_5) advanceUntilIdle() - explorationLiveData!!.observeForever(mockExplorationObserver) + explorationLiveData.observeForever(mockExplorationObserver) val expectedExplorationStateSet = listOf( "END", "Estimate 100", "Numeric input", "Things you can do", "Welcome!", "What language" @@ -129,9 +126,9 @@ class ExplorationDataControllerTest { @Test @ExperimentalCoroutinesApi fun testController_providesInitialLiveDataForTheAboutOppiaExploration() = runBlockingTest(coroutineContext) { - val explorationLiveData = explorationDataController.getExplorationById(TEST_EXPLORATION_ID_1) + val explorationLiveData = explorationDataController.getExplorationById(TEST_EXPLORATION_ID_6) advanceUntilIdle() - explorationLiveData!!.observeForever(mockExplorationObserver) + explorationLiveData.observeForever(mockExplorationObserver) val expectedExplorationStateSet = listOf( "About this website", "Contact", "Contribute", "Credits", "END", "End Card", "Example1", "Example3", "First State", "Site License", "So what can I tell you" @@ -152,7 +149,7 @@ class ExplorationDataControllerTest { fun testController_returnsNullForNonExistentExploration() = runBlockingTest(coroutineContext) { val explorationLiveData = explorationDataController.getExplorationById("NON_EXISTENT_TEST") advanceUntilIdle() - explorationLiveData!!.observeForever(mockExplorationObserver) + explorationLiveData.observeForever(mockExplorationObserver) verify(mockExplorationObserver, atLeastOnce()).onChanged(explorationResultCaptor.capture()) assertThat(explorationResultCaptor.value.isFailure()).isTrue() } diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt new file mode 100644 index 00000000000..0738bfdbbd0 --- /dev/null +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt @@ -0,0 +1,1179 @@ +package org.oppia.domain.exploration + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.atLeast +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.app.model.AnswerOutcome +import org.oppia.app.model.EphemeralState +import org.oppia.app.model.EphemeralState.StateTypeCase.COMPLETED_STATE +import org.oppia.app.model.EphemeralState.StateTypeCase.PENDING_STATE +import org.oppia.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE +import org.oppia.app.model.Exploration +import org.oppia.app.model.InteractionObject +import org.oppia.util.data.AsyncResult +import org.oppia.util.threading.BackgroundDispatcher +import org.oppia.util.threading.BlockingDispatcher +import org.robolectric.annotation.Config +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.EmptyCoroutineContext + +// For context: +// https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts. +private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." + +/** Tests for [ExplorationProgressController]. */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ExplorationProgressControllerTest { + // TODO(#114): Add much more thorough tests for the integration pathway. + + // TODO(#59): Once AsyncDataSubscriptionManager can be replaced with a fake, add the following tests once careful + // testing timing can be controlled: + // - testMoveToNext_whileSubmittingAnswer_failsWithError + // - testGetCurrentState_whileSubmittingCorrectMultiChoiceAnswer_updatesToPending + // - testSubmitAnswer_whileSubmittingAnotherAnswer_failsWithError + // - testMoveToPrevious_whileSubmittingAnswer_failsWithError + + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + lateinit var explorationDataController: ExplorationDataController + + @Inject + lateinit var explorationProgressController: ExplorationProgressController + + @Inject + lateinit var explorationRetriever: ExplorationRetriever + + @ExperimentalCoroutinesApi + @Inject + @field:TestDispatcher + lateinit var testDispatcher: TestCoroutineDispatcher + + @Mock + lateinit var mockCurrentStateLiveDataObserver: Observer> + + @Mock + lateinit var mockCurrentStateLiveDataObserver2: Observer> + + @Mock + lateinit var mockAsyncResultLiveDataObserver: Observer> + + @Mock + lateinit var mockAsyncAnswerOutcomeObserver: Observer> + + @Captor + lateinit var currentStateResultCaptor: ArgumentCaptor> + + @Captor + lateinit var asyncResultCaptor: ArgumentCaptor> + + @Captor + lateinit var asyncAnswerOutcomeCaptor: ArgumentCaptor> + + @ExperimentalCoroutinesApi + private val coroutineContext by lazy { + EmptyCoroutineContext + testDispatcher + } + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + setUpTestApplicationComponent() + + // Require coroutines to be flushed to avoid synchronous execution that could interfere with testing ordered async + // logic that behaves differently in prod. + testDispatcher.pauseDispatcher() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_noExploration_isPending() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + advanceUntilIdle() + + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isPending()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testPlayExploration_invalid_returnsSuccess() = runBlockingTest(coroutineContext) { + val resultLiveData = explorationDataController.startPlayingExploration("invalid_exp_id") + resultLiveData.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // An invalid exploration is not known until it's fully loaded, and that's observed via getCurrentState. + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_playInvalidExploration_returnsFailure() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + + playExploration("invalid_exp_id") + + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isFailure()).isTrue() + assertThat(currentStateResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Invalid exploration ID: invalid_exp_id") + } + + @Test + @ExperimentalCoroutinesApi + fun testPlayExploration_valid_returnsSuccess() = runBlockingTest(coroutineContext) { + val resultLiveData = explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_5) + resultLiveData.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_playExploration_returnsPendingResultFromLoadingExploration() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + advanceUntilIdle() + + playExploration(TEST_EXPLORATION_ID_5) + + // The second-to-latest result stays pending since the exploration was loading (the actual result is the fully + // loaded exploration). This is only true if the observer begins before starting to load the exploration. + verify(mockCurrentStateLiveDataObserver, atLeast(2)).onChanged(currentStateResultCaptor.capture()) + val results = currentStateResultCaptor.allValues + assertThat(results[results.size - 2].isPending()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_playExploration_loaded_returnsInitialStatePending() = runBlockingTest( + coroutineContext + ) { + val exploration = getTestExploration5() + playExploration(TEST_EXPLORATION_ID_5) + + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + advanceUntilIdle() + + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + assertThat(currentStateResultCaptor.value.getOrThrow().stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentStateResultCaptor.value.getOrThrow().hasPreviousState).isFalse() + assertThat(currentStateResultCaptor.value.getOrThrow().state.name).isEqualTo(exploration.initStateName) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_playInvalidExploration_thenPlayValidExp_returnsInitialPendingState() = runBlockingTest( + coroutineContext + ) { + val exploration = getTestExploration5() + // Start with playing an invalid exploration. + playExploration("invalid_exp_id") + endExploration() + + // Then a valid one. + playExploration(TEST_EXPLORATION_ID_5) + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + advanceUntilIdle() + + // The latest result should correspond to the valid ID, and the progress controller should gracefully recover. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + assertThat(currentStateResultCaptor.value.getOrThrow().stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentStateResultCaptor.value.getOrThrow().hasPreviousState).isFalse() + assertThat(currentStateResultCaptor.value.getOrThrow().state.name).isEqualTo(exploration.initStateName) + } + + @Test + @ExperimentalCoroutinesApi + fun testFinishExploration_beforePlaying_failWithError() = runBlockingTest(coroutineContext) { + val resultLiveData = explorationDataController.stopPlayingExploration() + resultLiveData.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot finish playing an exploration that hasn't yet been started") + } + + @Test + @ExperimentalCoroutinesApi + fun testPlayExploration_withoutFinishingPrevious_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + // Try playing another exploration without finishing the previous one. + val resultLiveData = explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_5) + resultLiveData.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Expected to finish previous exploration before starting a new one.") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_playSecondExploration_afterFinishingPrevious_loaded_returnsInitialState() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + // Start with playing a valid exploration, then stop. + playExploration(TEST_EXPLORATION_ID_5) + endExploration() + + // Then another valid one. + playExploration(TEST_EXPLORATION_ID_6) + + // The latest result should correspond to the valid ID, and the progress controller should gracefully recover. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + assertThat(currentStateResultCaptor.value.getOrThrow().stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentStateResultCaptor.value.getOrThrow().hasPreviousState).isFalse() + assertThat(currentStateResultCaptor.value.getOrThrow().state.name).isEqualTo(getTestExploration6().initStateName) + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_beforePlaying_failsWithError() = runBlockingTest(coroutineContext) { + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission failed. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + assertThat(asyncAnswerOutcomeCaptor.value.isFailure()).isTrue() + assertThat(asyncAnswerOutcomeCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot submit an answer if an exploration is not being played.") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_whileLoading_failsWithError() = runBlockingTest(coroutineContext) { + // Start playing an exploration, but don't wait for it to complete. + subscribeToCurrentStateToAllowExplorationToLoad() + explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_5) + + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission failed. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + assertThat(asyncAnswerOutcomeCaptor.value.isFailure()).isTrue() + assertThat(asyncAnswerOutcomeCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot submit an answer while the exploration is being loaded.") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forMultipleChoice_correctAnswer_succeeds() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + assertThat(asyncAnswerOutcomeCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forMultipleChoice_correctAnswer_returnsOutcomeWithTransition() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.STATE_NAME) + assertThat(answerOutcome.feedback.html).isEqualTo("Yes!") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forMultipleChoice_wrongAnswer_succeeds() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + assertThat(asyncAnswerOutcomeCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forMultipleChoice_wrongAnswer_providesDefaultFeedbackAndNewStateTransition() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val result = explorationProgressController.submitAnswer(createMultipleChoiceAnswer(1)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.STATE_NAME) + assertThat(answerOutcome.feedback.html).contains("Hm, it certainly looks like it") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterSubmittingCorrectMultiChoiceAnswer_becomesCompletedState() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + + submitMultipleChoiceAnswer(0) + + // Verify that the current state updates. It should stay pending, and the wrong answer should be appended. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + assertThat(currentState.completedState.answerCount).isEqualTo(1) + assertThat(currentState.completedState.getAnswer(0).userAnswer.nonNegativeInt).isEqualTo(0) + assertThat(currentState.completedState.getAnswer(0).feedback.html).isEqualTo("Yes!") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterSubmittingWrongMultiChoiceAnswer_updatesPendingState() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + + submitMultipleChoiceAnswer(2) + + // Verify that the current state updates. It should now be completed with the correct answer. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentState.pendingState.wrongAnswerCount).isEqualTo(1) + assertThat(currentState.pendingState.getWrongAnswer(0).userAnswer.nonNegativeInt).isEqualTo(2) + assertThat(currentState.pendingState.getWrongAnswer(0).feedback.html).contains("Have another go?") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterSubmittingWrongThenRightAnswer_updatesToStateWithBothAnswers() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswer(2) + + submitMultipleChoiceAnswer(0) + + // Verify that the current state updates. It should now be completed with both the wrong and correct answers. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + assertThat(currentState.completedState.answerCount).isEqualTo(2) + assertThat(currentState.completedState.getAnswer(0).userAnswer.nonNegativeInt).isEqualTo(2) + assertThat(currentState.completedState.getAnswer(0).feedback.html).contains("Have another go?") + assertThat(currentState.completedState.getAnswer(1).userAnswer.nonNegativeInt).isEqualTo(0) + assertThat(currentState.completedState.getAnswer(1).feedback.html).isEqualTo("Yes!") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_beforePlaying_failsWithError() = runBlockingTest(coroutineContext) { + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to a next state if an exploration is not being played.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_whileLoadingExploration_failsWithError() = runBlockingTest(coroutineContext) { + // Start playing an exploration, but don't wait for it to complete. + subscribeToCurrentStateToAllowExplorationToLoad() + explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_5) + + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to a next state if an exploration is being loaded.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_forPendingInitialState_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Verify that we can't move ahead since the current state isn't yet completed. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to next state; at most recent state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_forCompletedState_succeeds() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswer(0) + + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_forCompletedState_movesToNextState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswer(0) + + moveToNextState() + + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.state.name).isEqualTo("What language") + assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_afterMovingFromCompletedState_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswer(0) + moveToNextState() + + // Try skipping past the current state. + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Verify we can't move ahead since the new state isn't yet completed. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to next state; at most recent state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_beforePlaying_failsWithError() = runBlockingTest(coroutineContext) { + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to a previous state if an exploration is not being played.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_whileLoadingExploration_failsWithError() = runBlockingTest(coroutineContext) { + // Start playing an exploration, but don't wait for it to complete. + subscribeToCurrentStateToAllowExplorationToLoad() + explorationDataController.startPlayingExploration(TEST_EXPLORATION_ID_5) + + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to a previous state if an exploration is being loaded.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_onPendingInitialState_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Verify we can't move behind since the current state is the initial exploration state. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to previous state; at initial state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_onCompletedInitialState_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswer(0) + + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Still can't navigate behind for a completed initial state since there's no previous state. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to previous state; at initial state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_forStateWithCompletedPreviousState_succeeds() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Verify that we can navigate to the previous state since the current state is complete and not initial. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_forCompletedState_movesToPreviousState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + moveToPreviousState() + + // Since the answer submission and forward navigation should work (see earlier tests), verify that the move to the + // previous state does return us back to the initial exploration state (which is now completed). + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.state.name).isEqualTo("Welcome!") + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + moveToPreviousState() + + val moveToStateResult = explorationProgressController.moveToPreviousState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // The first previous navigation should succeed (see above), but the second will fail since we're back at the + // initial state. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to previous state; at initial state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forTextInput_correctAnswer_returnsOutcomeWithTransition() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + val result = explorationProgressController.submitAnswer(createTextInputAnswer("Finnish")) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.STATE_NAME) + assertThat(answerOutcome.feedback.html).contains("Yes! Oppia is the Finnish word for learn.") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forTextInput_wrongAnswer_returnsDefaultOutcome() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + val result = explorationProgressController.submitAnswer(createTextInputAnswer("Klingon")) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer was wrong, and that there's no handler for it so the default outcome is returned. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.SAME_STATE) + assertThat(answerOutcome.feedback.html).contains("Sorry, nope") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_secondState_submitRightAnswer_pendingStateBecomesCompleted() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + val result = explorationProgressController.submitAnswer(createTextInputAnswer("Finnish")) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the current state updates. It should stay pending, and the wrong answer should be appended. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + assertThat(currentState.completedState.answerCount).isEqualTo(1) + assertThat(currentState.completedState.getAnswer(0).userAnswer.normalizedString).isEqualTo("Finnish") + assertThat(currentState.completedState.getAnswer(0).feedback.html).contains("Yes! Oppia is the Finnish word") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_secondState_submitWrongAnswer_updatePendingState() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + val result = explorationProgressController.submitAnswer(createTextInputAnswer("Klingon")) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the current state updates. It should now be completed with the correct answer. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentState.pendingState.wrongAnswerCount).isEqualTo(1) + assertThat(currentState.pendingState.getWrongAnswer(0).userAnswer.normalizedString).isEqualTo("Klingon") + assertThat(currentState.pendingState.getWrongAnswer(0).feedback.html).contains("Sorry, nope") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterMovePreviousAndNext_returnsCurrentState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + + moveToPreviousState() + moveToNextState() + + // The current state should stay the same. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.state.name).isEqualTo("What language") + assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterMoveNextAndPrevious_returnsCurrentState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswer("Finnish") // Submit the answer but do not proceed to the next state. + + moveToNextState() + moveToPreviousState() + + // The current state should stay the same. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.state.name).isEqualTo("What language") + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterMoveToPrevious_onThirdState_newObserver_receivesCompletedSecondState() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) // First state -> second + submitTextInputAnswerAndMoveToNextState("Finnish") // Second state -> third + + // Move to the previous state and register a new observer. + moveToPreviousState() // Third state -> second + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver2) + advanceUntilIdle() + + // The new observer should observe the completed second state since it's the current pending state. + verify(mockCurrentStateLiveDataObserver2, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.state.name).isEqualTo("What language") + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forNumericInput_correctAnswer_returnsOutcomeWithTransition() = runBlockingTest( + coroutineContext + ) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer submission was successful. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.STATE_NAME) + assertThat(answerOutcome.feedback.html).contains("Yes, that's correct: 11 times 11 is 121.") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsDefaultOutcome() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(0)) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the answer was wrong, and that there's no handler for it so the default outcome is returned. + // TODO(#114): Update this test to target a non-default outcome since the default outcome *should* be impossible to + // encounter. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.SAME_STATE) + assertThat(answerOutcome.feedback.html).contains("If you got here, something's gone a bit wrong") + } + + @Test + @ExperimentalCoroutinesApi + fun testSubmitAnswer_forContinue_returnsOutcomeWithTransition() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + submitNumericInputAnswerAndMoveToNextState(121) + + val result = explorationProgressController.submitAnswer(createContinueButtonAnswer()) + result.observeForever(mockAsyncAnswerOutcomeObserver) + advanceUntilIdle() + + // Verify that the continue button succeeds by default. + verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) + val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() + assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.STATE_NAME) + assertThat(answerOutcome.feedback.html).isEmpty() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_fifthState_isTerminalState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + submitNumericInputAnswerAndMoveToNextState(121) + + submitContinueButtonAnswerAndMoveToNextState() + + // Verify that the fifth state is terminal. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(TERMINAL_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterMoveToPrevious_onThirdState_updatesToCompletedSecondState() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + + moveToPreviousState() + + // Verify that the current state is the second state, and is completed. It should also have the previously submitted + // answer, allowing learners to potentially view past answers. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(COMPLETED_STATE) + assertThat(currentState.state.name).isEqualTo("What language") + assertThat(currentState.completedState.getAnswer(0).userAnswer.normalizedString).isEqualTo("Finnish") + } + + @Test + @ExperimentalCoroutinesApi + fun testMoveToNext_onFinalState_failsWithError() = runBlockingTest(coroutineContext) { + subscribeToCurrentStateToAllowExplorationToLoad() + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + submitNumericInputAnswerAndMoveToNextState(121) + submitContinueButtonAnswerAndMoveToNextState() + + val moveToStateResult = explorationProgressController.moveToNextState() + moveToStateResult.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + // Verify we can't navigate past the last state of the exploration. + verify(mockAsyncResultLiveDataObserver, atLeastOnce()).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isFailure()).isTrue() + assertThat(asyncResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot navigate to next state; at most recent state.") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterPlayingFullSecondExploration_returnsTerminalState() = runBlockingTest(coroutineContext) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + + playExploration(TEST_EXPLORATION_ID_6) + submitContinueButtonAnswerAndMoveToNextState() + submitMultipleChoiceAnswerAndMoveToNextState(3) // Those were all the questions I had! + submitContinueButtonAnswerAndMoveToNextState() + + // Verify that we're now on the final state. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(TERMINAL_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterPlayingFullSecondExploration_diffPath_returnsTerminalState() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + + playExploration(TEST_EXPLORATION_ID_6) + submitContinueButtonAnswerAndMoveToNextState() + submitMultipleChoiceAnswerAndMoveToNextState(0) // How do your explorations work? + submitTextInputAnswerAndMoveToNextState("Oppia Otter") // Can I ask your name? + submitContinueButtonAnswerAndMoveToNextState() + submitMultipleChoiceAnswerAndMoveToNextState(3) // Those were all the questions I had! + submitContinueButtonAnswerAndMoveToNextState() + + // Verify that a different path can also result in reaching the end state. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(TERMINAL_STATE) + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentState_afterPlayingThroughPreviousExplorations_returnsStateFromSecondExp() = runBlockingTest( + coroutineContext + ) { + val currentStateLiveData = explorationProgressController.getCurrentState() + currentStateLiveData.observeForever(mockCurrentStateLiveDataObserver) + playThroughExploration5() + + playExploration(TEST_EXPLORATION_ID_6) + submitContinueButtonAnswerAndMoveToNextState() + submitMultipleChoiceAnswerAndMoveToNextState(3) // Those were all the questions I had! + + // Verify that we're on the second-to-last state of the second exploration. + verify(mockCurrentStateLiveDataObserver, atLeastOnce()).onChanged(currentStateResultCaptor.capture()) + assertThat(currentStateResultCaptor.value.isSuccess()).isTrue() + val currentState = currentStateResultCaptor.value.getOrThrow() + assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(currentState.state.name).isEqualTo("End Card") // This state is not in the other test exp. + } + + private suspend fun getTestExploration5(): Exploration { + return explorationRetriever.loadExploration(TEST_EXPLORATION_ID_5) + } + + private suspend fun getTestExploration6(): Exploration { + return explorationRetriever.loadExploration(TEST_EXPLORATION_ID_6) + } + + private fun setUpTestApplicationComponent() { + DaggerExplorationProgressControllerTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + /** + * Creates a blank subscription to the current state to ensure that requests to load the exploration complete, + * otherwise post-load operations may fail. An observer is required since the current mediator live data + * implementation will only lazily load data based on whether there's an active subscription. + */ + private fun subscribeToCurrentStateToAllowExplorationToLoad() { + explorationProgressController.getCurrentState().observeForever(mockCurrentStateLiveDataObserver) + } + + @ExperimentalCoroutinesApi + private fun playExploration(explorationId: String) { + explorationDataController.startPlayingExploration(explorationId) + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun submitMultipleChoiceAnswer(choiceIndex: Int) { + explorationProgressController.submitAnswer(createMultipleChoiceAnswer(choiceIndex)) + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun submitTextInputAnswer(textAnswer: String) { + explorationProgressController.submitAnswer(createTextInputAnswer(textAnswer)) + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun submitNumericInputAnswer(numericAnswer: Int) { + explorationProgressController.submitAnswer(createNumericInputAnswer(numericAnswer)) + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun submitContinueButtonAnswer() { + explorationProgressController.submitAnswer(createContinueButtonAnswer()) + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun submitMultipleChoiceAnswerAndMoveToNextState(choiceIndex: Int) { + submitMultipleChoiceAnswer(choiceIndex) + moveToNextState() + } + + @ExperimentalCoroutinesApi + private fun submitTextInputAnswerAndMoveToNextState(textAnswer: String) { + submitTextInputAnswer(textAnswer) + moveToNextState() + } + + @ExperimentalCoroutinesApi + private fun submitNumericInputAnswerAndMoveToNextState(numericAnswer: Int) { + submitNumericInputAnswer(numericAnswer) + moveToNextState() + } + + @ExperimentalCoroutinesApi + private fun submitContinueButtonAnswerAndMoveToNextState() { + submitContinueButtonAnswer() + moveToNextState() + } + + @ExperimentalCoroutinesApi + private fun moveToNextState() { + explorationProgressController.moveToNextState() + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun moveToPreviousState() { + explorationProgressController.moveToPreviousState() + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun endExploration() { + explorationDataController.stopPlayingExploration() + testDispatcher.advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + private fun playThroughExploration5() { + playExploration(TEST_EXPLORATION_ID_5) + submitMultipleChoiceAnswerAndMoveToNextState(0) + submitTextInputAnswerAndMoveToNextState("Finnish") + submitNumericInputAnswerAndMoveToNextState(121) + submitContinueButtonAnswerAndMoveToNextState() + endExploration() + } + + private fun createMultipleChoiceAnswer(choiceIndex: Int): InteractionObject { + return InteractionObject.newBuilder().setNonNegativeInt(choiceIndex).build() + } + + private fun createTextInputAnswer(textAnswer: String): InteractionObject { + return InteractionObject.newBuilder().setNormalizedString(textAnswer).build() + } + + private fun createNumericInputAnswer(numericAnswer: Int): InteractionObject { + return InteractionObject.newBuilder().setSignedInt(numericAnswer).build() + } + + private fun createContinueButtonAnswer() = createTextInputAnswer(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) + + @Qualifier annotation class TestDispatcher + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestDispatcher + fun provideTestDispatcher(): TestCoroutineDispatcher { + return TestCoroutineDispatcher() + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @BlockingDispatcher + fun provideBlockingDispatcher(@TestDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(explorationProgressControllerTest: ExplorationProgressControllerTest) + } +} diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index cad39768f50..338994bbd94 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -13,15 +13,18 @@ option java_multiple_files = true; // Structure for a single exploration. // Maps from: data/src/main/java/org/oppia/data/backends/gae/model/GaeExploration.kt message Exploration { + // The ID of the exploration. + string id = 1; + // Mapping from a state name to a state object - map states = 1; - repeated ParamChange param_changes = 2; - repeated ParamSpec param_specs = 3; - string init_state_name = 4; - string objective = 5; - bool correctness_feedback_enabled = 6; - string title = 7; - string language_code = 8; + map states = 2; + repeated ParamChange param_changes = 3; + repeated ParamSpec param_specs = 4; + string init_state_name = 5; + string objective = 6; + bool correctness_feedback_enabled = 7; + string title = 8; + string language_code = 9; } // Structure for a param change. @@ -46,18 +49,20 @@ enum ObjectType { // Structure for a single state // Maps from: data/src/main/java/org/oppia/data/backends/gae/model/GaeState.kt message State { + // The name of the State. + string name = 1; // Mapping from content_id to a VoiceoverMapping - map recorded_voiceovers = 1; - SubtitledHtml content = 2; + map recorded_voiceovers = 2; + SubtitledHtml content = 3; // Mapping from content_id to a TranslationMapping - map written_translations = 3; - repeated ParamChange param_changes = 4; - string classifier_model_id = 5; - Interaction interaction = 6; + map written_translations = 4; + repeated ParamChange param_changes = 5; + string classifier_model_id = 6; + Interaction interaction = 7; // Boolean indicating whether the creator wants to ask for answer details // from the learner about why they picked a particular answer while // playing the exploration. - bool solicit_answer_details = 7; + bool solicit_answer_details = 8; } // Structure for customization args for ParamChange objects. @@ -132,3 +137,80 @@ message RuleSpec { InteractionObject input = 1; string rule_type = 2; } + +/* The following structures are specific to ephemeral exploration sessions and should never be persisted on disk. */ + +// Corresponds to an exploration state that exists ephemerally in the UI and will disappear once the user finishes the +// exploration or navigates away from it. This model contains additional information to help the UI display an exact +// representation of their interaction with the lesson. +message EphemeralState { + // The actual state to display to the user. + State state = 1; + + // Whether there is a state prior to this one that the learner has already completed, or if this is the initial state + // of the exploration. + bool has_previous_state = 2; + + // Different types this state can take depending on whether the learner needs to finish an existing card, has + // navigated to a previous card, or has reached the end of the exploration. + oneof state_type { + // A pending state that requires a correct answer to continue. + PendingState pending_state = 3; + + // A previous state completed by the learner. + CompletedState completed_state = 4; + + // This value is always true in the case where the state type is terminal. This type may change in the future to a + // message structure if additional data needs to be passed along to the terminal state card. + bool terminal_state = 5; + } +} + +// Corresponds to an exploration state that hasn't yet had a correct answer filled in. +message PendingState { + // A list of previous wrong answers that led back to this state, and Oppia's responses. These responses are in the + // order the learner submitted them. + repeated AnswerAndResponse wrong_answer = 1; +} + +// Corresponds to an exploration state that the learner has previous completed. +message CompletedState { + // The list of answers and responses that were used to finish this state. These answers are in the order that the + // learner submitted them, so the last answer is guaranteed to be the correct answer. + repeated AnswerAndResponse answer = 1; +} + +message AnswerAndResponse { + // A previous answer the learner submitted. + InteractionObject user_answer = 1; + + // Oppia's response to the answer the learner submitted. + SubtitledHtml feedback = 2; +} + +message AnswerOutcome { + // Oppia's feedback to the learner's most recent answer. + SubtitledHtml feedback = 1; + + // Whether the answer the learner submitted is the correct answer. Note that this is only an indication of the + // correctness, and not all correct answers are guaranteed to be labelled as correct. + bool labelled_as_correct_answer = 2; + + // One of several destinations the learner should be routed to as a result of submitting this answer. + oneof destination { + // Indicates that the learner should not progress past the current state. + bool same_state = 3; + + // Indicates that the learner should move to the next state. This contains the name of that state, but this isn't + // meant to be used by the UI directly beyond for logging purposes. + string state_name = 4; + + // Indicates that the learner should be shown a concept card corresponding to the specified skill ID to refresh the + // concept before proceeding. + string missing_prerequisite_skill_id = 5; + + // Indicates that the learner needs to play the specified exploration ID to refresh missing topics and then return + // to restart the current exploration. + string refresher_exploration_id = 6; + } +} diff --git a/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt b/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt index 67adb7bc92e..64d176d2361 100644 --- a/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt +++ b/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt @@ -1,8 +1,10 @@ package org.oppia.util.data +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.oppia.util.threading.BackgroundDispatcher import org.oppia.util.threading.ConcurrentQueueMap import org.oppia.util.threading.dequeue import org.oppia.util.threading.enqueue @@ -17,9 +19,12 @@ internal typealias ObserveAsyncChange = suspend () -> Unit * changes to custom [DataProvider]s. */ @Singleton -class AsyncDataSubscriptionManager @Inject constructor() { +class AsyncDataSubscriptionManager @Inject constructor( + @BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher +) { private val subscriptionMap = ConcurrentQueueMap() private val associatedIds = ConcurrentQueueMap() + private val backgroundCoroutineScope = CoroutineScope(backgroundDispatcher) /** Subscribes the specified callback function to the specified [DataProvider] ID. */ internal fun subscribe(id: Any, observeChange: ObserveAsyncChange) { @@ -51,18 +56,25 @@ class AsyncDataSubscriptionManager @Inject constructor() { * Notifies all subscribers of the specified [DataProvider] id that the provider has been changed and should be * re-queried for its latest state. */ - @Suppress("DeferredResultUnused") // Exceptions on the main thread will cause app crashes. No action needed. suspend fun notifyChange(id: Any) { // Ensure observed changes are called specifically on the main thread since that's what NotifiableAsyncLiveData // expects. // TODO(#90): Update NotifiableAsyncLiveData so that observeChange() can occur on background threads to avoid any // load on the UI thread until the final data value is ready for delivery. val scope = CoroutineScope(Dispatchers.Main) - scope.async { + scope.launch { subscriptionMap.getQueue(id).forEach { observeChange -> observeChange() } } // Also notify all children observing this parent. associatedIds.getQueue(id).forEach { childId -> notifyChange(childId) } } + + /** + * Same as [notifyChange] except this may be called on the main thread since it will notify changes on a background + * thread. + */ + fun notifyChangeAsync(id: Any) { + backgroundCoroutineScope.launch { notifyChange(id) } + } } diff --git a/utility/src/main/java/org/oppia/util/data/DataProviders.kt b/utility/src/main/java/org/oppia/util/data/DataProviders.kt index 94fe26441f9..82cc4f13aed 100644 --- a/utility/src/main/java/org/oppia/util/data/DataProviders.kt +++ b/utility/src/main/java/org/oppia/util/data/DataProviders.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oppia.util.threading.BackgroundDispatcher @@ -205,7 +206,7 @@ class DataProviders @Inject constructor( private fun dequeuePendingCoroutineLiveData() { coroutineLiveDataLock.withLock { pendingCoroutineLiveData?.let { - removeSource(it) + removeSource(it) // This can trigger onInactive() situations for long-standing operations, leading to them being cancelled. pendingCoroutineLiveData = null } } @@ -234,7 +235,7 @@ class DataProviders @Inject constructor( override fun onInactive() { super.onInactive() - runningJob?.cancel() + runningJob?.cancel() // This can cancel downstream operations that may want to complete side effects. runningJob = null } } diff --git a/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt b/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt index f84fb92092a..dedce02c969 100644 --- a/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt +++ b/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt @@ -12,6 +12,9 @@ import javax.inject.Singleton * An in-memory cache that provides blocking CRUD operations such that each operation is guaranteed to operate exactly * after any prior started operations began, and before any future operations. This class is thread-safe. Note that it's * safe to execute long-running operations in lambdas passed into the methods of this class. + * + * This cache is primarily intended to be used with immutable payloads, but mutable payloads can be used if calling code + * takes caution to restrict all read/write access to those mutable values to operations invoked by this class. */ class InMemoryBlockingCache private constructor(blockingDispatcher: CoroutineDispatcher, initialValue: T?) { private val blockingScope = CoroutineScope(blockingDispatcher) @@ -92,6 +95,17 @@ class InMemoryBlockingCache private constructor(blockingDispatcher: Cor } } + /** + * Returns a [Deferred] in the same way and for the same conditions as [updateIfPresentAsync] except the provided + * function is expected to update the cache in-place and return a custom value to propagate to the result of the + * [Deferred] object. + */ + fun updateInPlaceIfPresentAsync(update: suspend (T) -> O): Deferred { + return blockingScope.async { + update(checkNotNull(value) { "Expected to update the cache only after it's been created" }) + } + } + /** * Returns a [Deferred] that executes when this cache has been fully cleared, or if it's already been cleared. */