diff --git a/domain/src/main/assets/sample_questions.json b/domain/src/main/assets/sample_questions.json new file mode 100644 index 00000000000..8ea79c3bf95 --- /dev/null +++ b/domain/src/main/assets/sample_questions.json @@ -0,0 +1,444 @@ +{ + "questions": [ + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

What fraction does 'quarter' represent? 

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "Contains", + "inputs": { + "x": "1/4" + } + } + ], + "outcome": { + "dest": null, + "feedback": { + "html": "

That's correct!

", + "content_id": "feedback_1" + }, + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": { + "placeholder": { + "value": "Write your fraction as x/y" + }, + "rows": { + "value": 1 + } + }, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "TextInput", + "solution": { + "answer_is_exclusive": false, + "correct_answer": "1/4", + "explanation": { + "html": "

A quarter is 1/4th of something

", + "content_id": "solution" + } + } + }, + "param_changes": [], + "solicit_answer_details": false, + "writtenTranslations": { + "translationsMapping": { + "content": {}, + "hint_1": {}, + "feedback_1": {}, + "default_outcome": {}, + "solution": {} + }, + "_writtenTranslationObjectFactory": {} + } + }, + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

If we talk about wanting  of a cake, what does the 7 represent?

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "Equals", + "inputs": { + "x": 1 + } + } + ], + "outcome": { + "dest": null, + "feedback": { + "html": "

That's correct!

", + "content_id": "feedback_1" + }, + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": { + "choices": { + "value": [ + "

The number of pieces of cake I want.

", + "

The number of pieces the whole cake is cut into.

", + "

None of the above.

" + ] + } + }, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "MultipleChoiceInput", + "solution": null + }, + "param_changes": [], + "solicit_answer_details": false + }, + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

What is the numerator of  equal to?

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "IsInclusivelyBetween", + "inputs": { + "a": 3, + "b": 3 + } + } + ], + "outcome": { + "dest": null, + "feedback": "

That's correct!

", + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": {}, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "NumericInput", + "solution": { + "answer_is_exclusive": false, + "correct_answer": 3, + "explanation": { + "html": "

The solution comes out to be  and the numerator of that is 3.

", + "content_id": "solution" + } + } + }, + "param_changes": [], + "solicit_answer_details": false + }, + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

What fraction does 'half' represent? 

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "Contains", + "inputs": { + "x": "1/2" + } + } + ], + "outcome": { + "dest": null, + "feedback": { + "html": "

That's correct!

", + "content_id": "feedback_1" + }, + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": { + "placeholder": { + "value": "Write your fraction as x/y" + }, + "rows": { + "value": 1 + } + }, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "TextInput", + "solution": { + "answer_is_exclusive": false, + "correct_answer": "1/2", + "explanation": { + "html": "

A half is 1/2 of something

", + "content_id": "solution" + } + } + }, + "param_changes": [], + "solicit_answer_details": false, + "writtenTranslations": { + "translationsMapping": { + "content": {}, + "hint_1": {}, + "feedback_1": {}, + "default_outcome": {}, + "solution": {} + }, + "_writtenTranslationObjectFactory": {} + } + }, + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

If we talk about wanting  of a cake, what does the 10 represent?

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "Equals", + "inputs": { + "x": 1 + } + } + ], + "outcome": { + "dest": null, + "feedback": { + "html": "

That's correct!

", + "content_id": "feedback_1" + }, + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": { + "choices": { + "value": [ + "

The number of pieces of cake I want.

", + "

The number of pieces the whole cake is cut into.

", + "

None of the above.

" + ] + } + }, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "MultipleChoiceInput", + "solution": null + }, + "param_changes": [], + "solicit_answer_details": false + }, + { + "name": "question", + "classifier_model_id": null, + "content": { + "html": "

What is the numerator of  equal to?

", + "content_id": "content" + }, + "interaction": { + "confirmed_unclassified_answers": [], + "answer_groups": [ + { + "rule_specs": [ + { + "rule_type": "IsInclusivelyBetween", + "inputs": { + "a": 5, + "b": 5 + } + } + ], + "outcome": { + "dest": null, + "feedback": "

That's correct!

", + "labelled_as_correct": true, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "trainingData": [], + "taggedSkillMisconceptionId": null + } + ], + "confirmedUnclassifiedAnswers": [], + "customizationArgs": {}, + "defaultOutcome": { + "dest": null, + "feedback": { + "html": "", + "content_id": "default_outcome" + }, + "labelled_as_correct": false, + "param_changes": [], + "refresher_exploration_id": null, + "missing_prerequisite_skill_id": null + }, + "hints": [ + { + "hintContent": { + "html": "

Hint text will appear here

", + "content_id": "hint_1" + } + } + ], + "id": "NumericInput", + "solution": { + "answer_is_exclusive": false, + "correct_answer": 5, + "explanation": { + "html": "

The solution comes out to be  and the numerator of that is 5.

", + "content_id": "solution" + } + } + }, + "param_changes": [], + "solicit_answer_details": false + } + ] +} diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt index 7073a821654..5687229965c 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt @@ -1,19 +1,10 @@ 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 org.oppia.app.model.Voiceover -import org.oppia.app.model.VoiceoverMapping +import org.oppia.domain.util.JsonAssetRetriever +import org.oppia.domain.util.StateRetriever import java.io.IOException import javax.inject.Inject @@ -24,7 +15,9 @@ const val TEST_EXPLORATION_ID_6 = "test_exp_id_6" // 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) { +class ExplorationRetriever @Inject constructor( + private val jsonAssetRetriever: JsonAssetRetriever, + private val stateRetriever: StateRetriever) { /** 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 { @@ -38,7 +31,7 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { // Returns an exploration given an assetName private fun loadExplorationFromAsset(assetName: String): Exploration { try { - val explorationObject = loadJsonFromAsset(assetName) ?: return Exploration.getDefaultInstance() + val explorationObject = jsonAssetRetriever.loadJsonFromAsset(assetName) ?: return Exploration.getDefaultInstance() return Exploration.newBuilder() .setTitle(explorationObject.getString("title")) .setLanguageCode(explorationObject.getString("language_code")) @@ -51,19 +44,6 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { } } - // 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() @@ -71,223 +51,9 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { val statesIterator = statesKeys.iterator() while (statesIterator.hasNext()) { val key = statesIterator.next() - statesMap[key] = createStateFromJson(key, statesJsonObject.getJSONObject(key)) + statesMap[key] = stateRetriever.createStateFromJson(key, statesJsonObject.getJSONObject(key)) } return statesMap } - // Creates a single state object from JSON - private fun createStateFromJson(stateName: String, stateJson: JSONObject?): State { - val state = State.newBuilder() - .setName(stateName) - .setContent( - SubtitledHtml.newBuilder().setHtml( - stateJson?.getJSONObject("content")?.getString("html") - ).setContentId( - stateJson?.getJSONObject("content")?.optString("content_id") - ) - ) - .setInteraction(createInteractionFromJson(stateJson?.getJSONObject("interaction"))) - - if (stateJson != null && stateJson.has("recorded_voiceovers")) { - addVoiceOverMappings(stateJson.getJSONObject("recorded_voiceovers"), state) - } - - return state.build() - } - - // Adds VoiceoverMappings to state builder - private fun addVoiceOverMappings(recordedVoiceovers: JSONObject, stateBuilder: State.Builder) { - val voiceoverMappingJson = recordedVoiceovers.getJSONObject("voiceovers_mapping") - voiceoverMappingJson?.let { - for (key in it.keys()) { - val voiceoverMapping = VoiceoverMapping.newBuilder() - val voiceoverJson = it.getJSONObject(key) - for (lang in voiceoverJson.keys()) { - voiceoverMapping.putVoiceoverMapping(lang, createVoiceOverFromJson(voiceoverJson.getJSONObject(lang))) - } - stateBuilder.putRecordedVoiceovers(key, voiceoverMapping.build()) - } - } - } - - // Creates a Voiceover from Json - private fun createVoiceOverFromJson(voiceoverJson: JSONObject): Voiceover { - return Voiceover.newBuilder() - .setNeedsUpdate(voiceoverJson.getBoolean("needs_update")) - .setFileName(voiceoverJson.getString("filename")) - .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()) { - val ruleSpecBuilder = RuleSpec.newBuilder() - ruleSpecBuilder.ruleType = ruleSpecJson.getJSONObject(i).getString("rule_type") - val inputsJson = ruleSpecJson.getJSONObject(i).getJSONObject("inputs") - val inputKeysIterator = inputsJson.keys() - while (inputKeysIterator.hasNext()) { - val inputName = inputKeysIterator.next() - ruleSpecBuilder.putInput(inputName, createInputFromJson(inputsJson, inputName, interactionId)) - } - ruleSpecList.add(ruleSpecBuilder.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().addAllHtml(stringList).build() - } - return StringList.getDefaultInstance() - } } diff --git a/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt b/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt index 509913b03ac..1c91397dd0d 100644 --- a/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt +++ b/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt @@ -1,8 +1,7 @@ package org.oppia.domain.question -import androidx.lifecycle.LiveData import org.oppia.app.model.Question -import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProvider import javax.inject.Inject import javax.inject.Singleton @@ -18,10 +17,9 @@ import javax.inject.Singleton @Singleton class QuestionAssessmentProgressController @Inject constructor( ) { - fun beginQuestionTrainingSession(questionsList: LiveData>>) { + fun beginQuestionTrainingSession(questionsList: DataProvider>) { } fun finishQuestionTrainingSession() { - } } diff --git a/domain/src/main/java/org/oppia/domain/question/QuestionConstantsProvider.kt b/domain/src/main/java/org/oppia/domain/question/QuestionConstantsProvider.kt new file mode 100644 index 00000000000..bb05d27bf24 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/question/QuestionConstantsProvider.kt @@ -0,0 +1,23 @@ +package org.oppia.domain.question + +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@Qualifier +annotation class QuestionCountPerTrainingSession + +@Qualifier +annotation class QuestionTrainingSeed + +/** Provider to return any constants required during the training session. */ +@Module +class QuestionModule { + @Provides + @QuestionCountPerTrainingSession + fun provideQuestionCountPerTrainingSession(): Int = 10 + + @Provides + @QuestionTrainingSeed + fun provideQuestionTrainingSeed(): Long = System.currentTimeMillis() +} diff --git a/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt b/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt index b65ddb693d1..ac775d4fc5b 100644 --- a/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt +++ b/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt @@ -3,25 +3,27 @@ package org.oppia.domain.question import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.oppia.app.model.Question -import org.oppia.domain.topic.TEST_SKILL_ID_0 -import org.oppia.domain.topic.TEST_SKILL_ID_1 -import org.oppia.domain.topic.TEST_SKILL_ID_2 +import org.oppia.domain.topic.TopicController import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProvider import org.oppia.util.data.DataProviders import javax.inject.Inject import javax.inject.Singleton +import kotlin.random.Random -private const val QUESTION_DATA_PROVIDER_ID = "QuestionDataProvider" -const val TEST_QUESTION_ID_0 = "question_id_0" -const val TEST_QUESTION_ID_1 = "question_id_1" -const val TEST_QUESTION_ID_2 = "question_id_2" +const val TRAINING_QUESTIONS_PROVIDER = "TrainingQuestionsProvider" /** Controller for retrieving a set of questions. */ @Singleton class QuestionTrainingController @Inject constructor( private val questionAssessmentProgressController: QuestionAssessmentProgressController, - private val dataProviders: DataProviders + private val topicController: TopicController, + private val dataProviders: DataProviders, + @QuestionCountPerTrainingSession private val questionCountPerSession: Int, + @QuestionTrainingSeed private val questionTrainingSeed: Long ) { + + private val random = Random(questionTrainingSeed) /** * Begins a question training session given a list of skill Ids and a total number of questions. * @@ -33,16 +35,43 @@ class QuestionTrainingController @Inject constructor( * @return a one-time [LiveData] to observe whether initiating the play request succeeded. * The training session may still fail to load, but this provides early-failure detection. */ - fun startQuestionTrainingSession(skillIdsList: List): LiveData> { + fun startQuestionTrainingSession(skillIdsList: List): LiveData>> { return try { - val questionsList = retrieveQuestionsForSkillIds(skillIdsList) - questionAssessmentProgressController.beginQuestionTrainingSession(questionsList) - MutableLiveData(AsyncResult.success(null)) + val retrieveQuestionsDataProvider = retrieveQuestionsForSkillIds(skillIdsList) + questionAssessmentProgressController.beginQuestionTrainingSession( + retrieveQuestionsDataProvider + ) + dataProviders.convertToLiveData(retrieveQuestionsDataProvider) } catch (e: Exception) { MutableLiveData(AsyncResult.failed(e)) } } + private fun retrieveQuestionsForSkillIds(skillIdsList: List): DataProvider> { + val questionsDataProvider = topicController.retrieveQuestionsForSkillIds(skillIdsList) + return dataProviders.transform(TRAINING_QUESTIONS_PROVIDER, questionsDataProvider) { + getFilteredQuestionsForTraining( + skillIdsList, it.shuffled(random), + questionCountPerSession / skillIdsList.size + ) + } + } + + // Attempts to fetch equal number of questions per skill. Removes any duplicates and limits the questions to be + // equal to TOTAL_QUESTIONS_PER_TOPIC questions. + private fun getFilteredQuestionsForTraining( + skillIdsList: List, questionsList: List, numQuestionsPerSkill: Int + ): List { + val trainingQuestions = mutableListOf() + for (skillId in skillIdsList) { + trainingQuestions.addAll(questionsList.filter { + it.linkedSkillIdsList.contains(skillId) && + !trainingQuestions.contains(it) + }.distinctBy { it.questionId }.take(numQuestionsPerSkill + 1)) + } + return trainingQuestions.take(questionCountPerSession) + } + /** * Finishes the most recent training session started by [startQuestionTrainingSession]. * This method should only be called if there is a training session is being played, @@ -56,46 +85,4 @@ class QuestionTrainingController @Inject constructor( MutableLiveData(AsyncResult.failed(e)) } } - - private fun retrieveQuestionsForSkillIds(skillIdsList: List): LiveData>> { - val dataProvider = dataProviders.createInMemoryDataProviderAsync(QUESTION_DATA_PROVIDER_ID) { - loadQuestionsForSkillIds(skillIdsList) - } - return dataProviders.convertToLiveData(dataProvider) - } - - // Loads and returns the questions given a list of skill ids. - @Suppress("RedundantSuspendModifier") // DataProviders expects this function to be a suspend function. - private suspend fun loadQuestionsForSkillIds(skillIdsList: List): AsyncResult> { - return try { - AsyncResult.success(loadQuestions(skillIdsList)) - } catch (e: Exception) { - AsyncResult.failed(e) - } - } - - @Suppress("RedundantSuspendModifier") // Force callers to call this on a background thread. - private suspend fun loadQuestions(skillIdsList: List): List { - val questionsList = mutableListOf() - for (skillId in skillIdsList) { - when (skillId) { - TEST_SKILL_ID_0 -> questionsList.add( - Question.newBuilder() - .setQuestionId(TEST_QUESTION_ID_0) - .build()) - TEST_SKILL_ID_1 -> questionsList.add( - Question.newBuilder() - .setQuestionId(TEST_QUESTION_ID_1) - .build()) - TEST_SKILL_ID_2 -> questionsList.add( - Question.newBuilder() - .setQuestionId(TEST_QUESTION_ID_2) - .build()) - else -> { - throw IllegalStateException("Invalid skill ID: $skillId") - } - } - } - return questionsList - } } diff --git a/domain/src/main/java/org/oppia/domain/topic/TopicController.kt b/domain/src/main/java/org/oppia/domain/topic/TopicController.kt index f3419feba35..b621eb5c078 100644 --- a/domain/src/main/java/org/oppia/domain/topic/TopicController.kt +++ b/domain/src/main/java/org/oppia/domain/topic/TopicController.kt @@ -2,6 +2,7 @@ package org.oppia.domain.topic import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import org.json.JSONArray import org.oppia.app.model.ChapterPlayState import java.lang.IllegalArgumentException import javax.inject.Inject @@ -10,6 +11,7 @@ import org.oppia.app.model.ChapterSummary import org.oppia.app.model.ConceptCard import org.oppia.app.model.LessonThumbnail import org.oppia.app.model.LessonThumbnailGraphic +import org.oppia.app.model.Question import org.oppia.app.model.SkillSummary import org.oppia.app.model.StorySummary import org.oppia.app.model.SubtitledHtml @@ -18,17 +20,33 @@ import org.oppia.app.model.Translation import org.oppia.app.model.TranslationMapping import org.oppia.app.model.Voiceover import org.oppia.app.model.VoiceoverMapping +import org.oppia.domain.util.JsonAssetRetriever +import org.oppia.domain.util.StateRetriever import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProvider +import org.oppia.util.data.DataProviders const val TEST_SKILL_ID_0 = "test_skill_id_0" const val TEST_SKILL_ID_1 = "test_skill_id_1" const val TEST_SKILL_ID_2 = "test_skill_id_2" const val TEST_SKILL_CONTENT_ID_0 = "test_skill_content_id_0" const val TEST_SKILL_CONTENT_ID_1 = "test_skill_content_id_1" +const val TEST_QUESTION_ID_0 = "question_id_0" +const val TEST_QUESTION_ID_1 = "question_id_1" +const val TEST_QUESTION_ID_2 = "question_id_2" +const val TEST_QUESTION_ID_3 = "question_id_3" +const val TEST_QUESTION_ID_4 = "question_id_4" +const val TEST_QUESTION_ID_5 = "question_id_5" + +private const val QUESTION_DATA_PROVIDER_ID = "QuestionDataProvider" /** Controller for retrieving all aspects of a topic. */ @Singleton -class TopicController @Inject constructor() { +class TopicController @Inject constructor( + private val dataProviders: DataProviders, + private val jsonAssetRetriever: JsonAssetRetriever, + private val stateRetriever: StateRetriever +) { /** Returns the [Topic] corresponding to the specified topic ID, or a failed result if no such topic exists. */ fun getTopic(topicId: String): LiveData> { return MutableLiveData( @@ -66,6 +84,124 @@ class TopicController @Inject constructor() { ) } + fun retrieveQuestionsForSkillIds(skillIdsList: List): DataProvider> { + return dataProviders.createInMemoryDataProvider(QUESTION_DATA_PROVIDER_ID) { + loadQuestionsForSkillIds(skillIdsList) + } + } + + // Loads and returns the questions given a list of skill ids. + private fun loadQuestionsForSkillIds(skillIdsList: List): List { + return loadQuestions(skillIdsList) + } + + private fun loadQuestions(skillIdsList: List): List { + val questionsList = mutableListOf() + val questionsJSON = jsonAssetRetriever.loadJsonFromAsset( + "sample_questions.json" + )?.getJSONArray("questions") + for (skillId in skillIdsList) { + when (skillId) { + TEST_SKILL_ID_0 -> questionsList.addAll( + mutableListOf( + createTestQuestion0(questionsJSON), + createTestQuestion1(questionsJSON), + createTestQuestion2(questionsJSON) + ) + ) + TEST_SKILL_ID_1 -> questionsList.addAll( + mutableListOf( + createTestQuestion0(questionsJSON), + createTestQuestion3(questionsJSON) + ) + ) + TEST_SKILL_ID_2 -> questionsList.addAll( + mutableListOf( + createTestQuestion2(questionsJSON), + createTestQuestion4(questionsJSON), + createTestQuestion5(questionsJSON) + ) + ) + else -> { + throw IllegalStateException("Invalid skill ID: $skillId") + } + } + } + return questionsList + } + + private fun createTestQuestion0(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_0) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(0) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1)) + .build() + } + + private fun createTestQuestion1(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_1) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(1) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_0)) + .build() + } + + private fun createTestQuestion2(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_2) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(2) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_0, TEST_SKILL_ID_2)) + .build() + } + + private fun createTestQuestion3(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_3) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(0) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_1)) + .build() + } + + private fun createTestQuestion4(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_4) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(1) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_2)) + .build() + } + + private fun createTestQuestion5(questionsJson: JSONArray?): Question { + return Question.newBuilder() + .setQuestionId(TEST_QUESTION_ID_5) + .setQuestionState( + stateRetriever.createStateFromJson( + "question", questionsJson?.getJSONObject(2) + ) + ) + .addAllLinkedSkillIds(mutableListOf(TEST_SKILL_ID_2)) + .build() + } + private fun createTestTopic0(): Topic { return Topic.newBuilder() .setTopicId(TEST_TOPIC_ID_0) diff --git a/domain/src/main/java/org/oppia/domain/util/JsonAssetRetriever.kt b/domain/src/main/java/org/oppia/domain/util/JsonAssetRetriever.kt new file mode 100644 index 00000000000..a572a492461 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/JsonAssetRetriever.kt @@ -0,0 +1,17 @@ +package org.oppia.domain.util + +import android.content.Context +import org.json.JSONObject +import java.io.IOException +import javax.inject.Inject + +/** Utility that retrieves JSON assets and converts them to JSON objects. */ +class JsonAssetRetriever @Inject constructor(private val context: Context) { + + /** Loads the JSON string from an asset and converts it to a JSONObject */ + fun loadJsonFromAsset(assetName: String): JSONObject? { + val assetManager = context.assets + val jsonContents = assetManager.open(assetName).bufferedReader().use { it.readText() } + return JSONObject(jsonContents) + } +} diff --git a/domain/src/main/java/org/oppia/domain/util/StateRetriever.kt b/domain/src/main/java/org/oppia/domain/util/StateRetriever.kt new file mode 100644 index 00000000000..29a331cba38 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/StateRetriever.kt @@ -0,0 +1,207 @@ +package org.oppia.domain.util + +import org.json.JSONArray +import org.json.JSONObject +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.RuleSpec +import org.oppia.app.model.State +import org.oppia.app.model.StringList +import org.oppia.app.model.SubtitledHtml +import javax.inject.Inject + +/** Utility that helps create a [State] object given its JSON representation. */ +class StateRetriever @Inject constructor() { + + /** Creates a single state object from JSON */ + 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() + } + + // Returns a JSON Object if it exists, else returns null + private fun getJsonObject(parentObject: JSONObject, key: String): JSONObject? { + return parentObject.optJSONObject(key) + } + + // 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()) { + val ruleSpecBuilder = RuleSpec.newBuilder() + ruleSpecBuilder.ruleType = ruleSpecJson.getJSONObject(i).getString("rule_type") + val inputsJson = ruleSpecJson.getJSONObject(i).getJSONObject("inputs") + val inputKeysIterator = inputsJson.keys() + while (inputKeysIterator.hasNext()) { + val inputName = inputKeysIterator.next() + ruleSpecBuilder.putInput(inputName, createInputFromJson(inputsJson, inputName, interactionId)) + } + ruleSpecList.add(ruleSpecBuilder.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().addAllHtml(stringList).build() + } + return StringList.getDefaultInstance() + } +} diff --git a/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt b/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt index 3eb95a2fbd0..2b41311cc37 100644 --- a/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt @@ -32,6 +32,11 @@ 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.Question +import org.oppia.domain.topic.TEST_QUESTION_ID_0 +import org.oppia.domain.topic.TEST_QUESTION_ID_1 +import org.oppia.domain.topic.TEST_QUESTION_ID_2 +import org.oppia.domain.topic.TEST_QUESTION_ID_3 import org.oppia.domain.topic.TEST_SKILL_ID_0 import org.oppia.domain.topic.TEST_SKILL_ID_1 import org.oppia.util.data.AsyncResult @@ -47,8 +52,6 @@ import javax.inject.Qualifier import javax.inject.Singleton import kotlin.coroutines.EmptyCoroutineContext -const val TEST_TOPIC_ID_0 = "test_topic_id_0" - /** Tests for [QuestionTrainingController]. */ @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) @@ -65,10 +68,10 @@ class QuestionTrainingControllerTest { lateinit var questionTrainingController: QuestionTrainingController @Mock - lateinit var mockQuestionListObserver: Observer> + lateinit var mockQuestionListObserver: Observer>> @Captor - lateinit var questionListResultCaptor: ArgumentCaptor> + lateinit var questionListResultCaptor: ArgumentCaptor>> @Inject @field:TestDispatcher @@ -109,11 +112,42 @@ class QuestionTrainingControllerTest { @ExperimentalCoroutinesApi fun testController_successfullyStartsQuestionSessionForExistingSkillIds() = runBlockingTest(coroutineContext) { val questionListLiveData = questionTrainingController.startQuestionTrainingSession( - listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1)) + listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1) + ) + advanceUntilIdle() + questionListLiveData.observeForever(mockQuestionListObserver) + verify(mockQuestionListObserver, atLeastOnce()).onChanged(questionListResultCaptor.capture()) + + assertThat(questionListResultCaptor.value.isSuccess()).isTrue() + val questionsList = questionListResultCaptor.value.getOrThrow() + assertThat(questionsList.size).isEqualTo(3) + val questionIds = questionsList.map { it.questionId } + assertThat(questionIds).containsExactlyElementsIn( + mutableListOf( + TEST_QUESTION_ID_0, TEST_QUESTION_ID_1, TEST_QUESTION_ID_3 + ) + ) + } + + @Test + @ExperimentalCoroutinesApi + fun testController_startsDifferentQuestionSessionForExistingSkillIds() = runBlockingTest(coroutineContext) { + val questionListLiveData = questionTrainingController.startQuestionTrainingSession( + listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1) + ) advanceUntilIdle() questionListLiveData.observeForever(mockQuestionListObserver) verify(mockQuestionListObserver, atLeastOnce()).onChanged(questionListResultCaptor.capture()) + assertThat(questionListResultCaptor.value.isSuccess()).isTrue() + val questionsList = questionListResultCaptor.value.getOrThrow() + assertThat(questionsList.size).isEqualTo(3) + val questionIds = questionsList.map { it.questionId } + assertThat(questionIds).containsExactlyElementsIn( + mutableListOf( + TEST_QUESTION_ID_2, TEST_QUESTION_ID_0, TEST_QUESTION_ID_3 + ) + ) } @Qualifier @@ -165,9 +199,24 @@ class QuestionTrainingControllerTest { fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE } + @Module + class TestQuestionModule { + companion object { + var questionSeed = 0L + } + + @Provides + @QuestionCountPerTrainingSession + fun provideQuestionCountPerTrainingSession(): Int = 3 + + @Provides + @QuestionTrainingSeed + fun provideQuestionTrainingSeed(): Long = questionSeed ++ + } + // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [TestModule::class, TestQuestionModule::class]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt b/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt index 5a14ea6b38f..a7019dffb2b 100644 --- a/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt @@ -2,6 +2,8 @@ package org.oppia.domain.topic import android.app.Application import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat @@ -9,18 +11,42 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain +import org.junit.After 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.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule import org.oppia.app.model.ChapterPlayState import org.oppia.app.model.ChapterSummary import org.oppia.app.model.LessonThumbnailGraphic +import org.oppia.app.model.Question import org.oppia.app.model.SkillSummary import org.oppia.app.model.StorySummary import org.oppia.app.model.Topic +import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProviders +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 /** Tests for [TopicController]. */ @RunWith(AndroidJUnit4::class) @@ -29,11 +55,49 @@ class TopicControllerTest { @Inject lateinit var topicController: TopicController + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Rule + @JvmField + val executorRule = InstantTaskExecutorRule() + + @Mock + lateinit var mockQuestionListObserver: Observer>> + + @Captor + lateinit var questionListResultCaptor: ArgumentCaptor>> + + @Inject + lateinit var dataProviders: DataProviders + + @Inject + @field:TestDispatcher + lateinit var testDispatcher: CoroutineDispatcher + + private val coroutineContext by lazy { + EmptyCoroutineContext + testDispatcher + } + + // https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/ + @ObsoleteCoroutinesApi + private val testThread = newSingleThreadContext("TestMain") + @Before fun setUp() { + Dispatchers.setMain(testThread) setUpTestApplicationComponent() } + @After + @ExperimentalCoroutinesApi + @ObsoleteCoroutinesApi + fun tearDown() { + Dispatchers.resetMain() + testThread.close() + } + @Test fun testRetrieveTopic_validTopic_isSuccessful() { val topicLiveData = topicController.getTopic(TEST_TOPIC_ID_0) @@ -414,6 +478,31 @@ class TopicControllerTest { assertThat(conceptCardLiveData.value!!.isFailure()).isTrue() } + @Test + fun testRetrieveQuestionsForSkillIds_returnsAllQuestions() = runBlockingTest(coroutineContext) { + val questionsListProvider = topicController.retrieveQuestionsForSkillIds( + listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1)) + dataProviders.convertToLiveData(questionsListProvider).observeForever(mockQuestionListObserver) + verify(mockQuestionListObserver).onChanged(questionListResultCaptor.capture()) + + assertThat(questionListResultCaptor.value.isSuccess()).isTrue() + val questionsList = questionListResultCaptor.value.getOrThrow() + assertThat(questionsList.size).isEqualTo(5) + val questionIds = questionsList.map { it -> it.questionId } + assertThat(questionIds).containsExactlyElementsIn(mutableListOf(TEST_QUESTION_ID_0, TEST_QUESTION_ID_1, + TEST_QUESTION_ID_2, TEST_QUESTION_ID_0, TEST_QUESTION_ID_3)) + } + + @Test + fun testRetrieveQuestionsForInvalidSkillIds_returnsFailure() = runBlockingTest(coroutineContext) { + val questionsListProvider = topicController.retrieveQuestionsForSkillIds( + listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1, "NON_EXISTENT_SKILL_ID")) + dataProviders.convertToLiveData(questionsListProvider).observeForever(mockQuestionListObserver) + verify(mockQuestionListObserver).onChanged(questionListResultCaptor.capture()) + + assertThat(questionListResultCaptor.value.isFailure()).isTrue() + } + private fun setUpTestApplicationComponent() { DaggerTopicControllerTest_TestApplicationComponent.builder() .setApplication(ApplicationProvider.getApplicationContext()) @@ -433,6 +522,9 @@ class TopicControllerTest { return story.chapterList.map(ChapterSummary::getExplorationId) } + @Qualifier + annotation class TestDispatcher + // TODO(#89): Move this to a common test application component. @Module class TestModule { @@ -441,6 +533,28 @@ class TopicControllerTest { fun provideContext(application: Application): Context { return application } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestDispatcher + fun provideTestDispatcher(): CoroutineDispatcher { + return TestCoroutineDispatcher() + } + + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @Singleton + @Provides + @BlockingDispatcher + fun provideBlockingDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } } // TODO(#89): Move this to a common test application component.