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.