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. */