diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3529cc0dd09..704b5f48861 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,11 +10,12 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/OppiaTheme">
+
+
-
diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
index 84e163ab1bf..c6ff61ea262 100644
--- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
+++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
@@ -6,6 +6,7 @@ import dagger.Subcomponent
import org.oppia.app.fragment.FragmentComponent
import org.oppia.app.home.HomeActivity
import org.oppia.app.player.exploration.ExplorationActivity
+import org.oppia.app.topic.conceptcard.testing.ConceptCardFragmentTestActivity
import org.oppia.app.player.state.testing.StateFragmentTestActivity
import org.oppia.app.testing.BindableAdapterTestActivity
import org.oppia.app.topic.TopicActivity
@@ -28,6 +29,7 @@ interface ActivityComponent {
fun inject(bindableAdapterTestActivity: BindableAdapterTestActivity)
fun inject(explorationActivity: ExplorationActivity)
fun inject(homeActivity: HomeActivity)
+ fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity)
fun inject(questionPlayerActivity: QuestionPlayerActivity)
fun inject(reviewActivity: ReviewActivity)
fun inject(topicActivity: TopicActivity)
diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt
index f48309853fd..a9c7b5f4c41 100644
--- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt
+++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt
@@ -9,8 +9,26 @@ import org.oppia.app.R
import org.oppia.app.fragment.InjectableDialogFragment
import javax.inject.Inject
+private const val KEY_SKILL_ID = "SKILL_ID"
+
/* Fragment that displays a fullscreen dialog for concept cards */
class ConceptCardFragment : InjectableDialogFragment() {
+
+ companion object {
+ /**
+ * Creates a new instance of a DialogFragment to display content
+ * @param skillId Used in TopicController to get correct concept card data.
+ * @return [ConceptCardFragment]: DialogFragment
+ */
+ fun newInstance(skillId: String): ConceptCardFragment {
+ val conceptCardFrag = ConceptCardFragment()
+ val args = Bundle()
+ args.putString(KEY_SKILL_ID, skillId)
+ conceptCardFrag.arguments = args
+ return conceptCardFrag
+ }
+ }
+
@Inject lateinit var conceptCardPresenter: ConceptCardPresenter
override fun onAttach(context: Context?) {
@@ -25,7 +43,9 @@ class ConceptCardFragment : InjectableDialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
super.onCreateView(inflater, container, savedInstanceState)
- return conceptCardPresenter.handleCreateView(inflater, container)
+ val args = checkNotNull(arguments) { "Expected arguments to be passed to ConceptCardFragment" }
+ val skillId = checkNotNull(args.getString(KEY_SKILL_ID)) { "Expected skillId to be passed to ConceptCardFragment" }
+ return conceptCardPresenter.handleCreateView(inflater, container, skillId)
}
override fun onStart() {
diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt
index a3487a857a0..7b70abf9c6c 100644
--- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt
+++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt
@@ -6,8 +6,11 @@ import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import org.oppia.app.R
-import org.oppia.app.databinding.ConceptcardFragmentBinding
+import org.oppia.app.databinding.ConceptCardExampleViewBinding
+import org.oppia.app.databinding.ConceptCardFragmentBinding
import org.oppia.app.fragment.FragmentScope
+import org.oppia.app.model.SubtitledHtml
+import org.oppia.app.recyclerview.BindableAdapter
import org.oppia.app.viewmodel.ViewModelProvider
import javax.inject.Inject
@@ -17,21 +20,37 @@ class ConceptCardPresenter @Inject constructor(
private val fragment: Fragment,
private val viewModelProvider: ViewModelProvider
){
- fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? {
- val binding = ConceptcardFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ /** Sets up data binding and adapter for RecyclerView */
+ fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?, skillId: String): View? {
+ val viewModel = getConceptCardViewModel()
+ viewModel.setSkillId(skillId)
+ val binding = ConceptCardFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
binding.conceptCardToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp)
binding.conceptCardToolbar.setNavigationOnClickListener {
(fragment as? DialogFragment)?.dismiss()
}
+ binding.workedExamples.apply {
+ adapter = createRecyclerViewAdapter()
+ }
+
binding.let {
- it.viewModel = getConceptCardViewModel()
+ it.viewModel = viewModel
it.lifecycleOwner = fragment
}
-
return binding.root
}
private fun getConceptCardViewModel(): ConceptCardViewModel {
return viewModelProvider.getForFragment(fragment, ConceptCardViewModel::class.java)
}
+
+ private fun createRecyclerViewAdapter(): BindableAdapter {
+ return BindableAdapter.Builder
+ .newBuilder()
+ .registerViewDataBinder(
+ inflateDataBinding = ConceptCardExampleViewBinding::inflate,
+ setViewModel = ConceptCardExampleViewBinding::setSubtitledHtml)
+ .build()
+ }
}
diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt
index 8362ea37b6e..73a3822f1d3 100644
--- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt
+++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt
@@ -1,11 +1,63 @@
package org.oppia.app.topic.conceptcard
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import org.oppia.app.fragment.FragmentScope
+import org.oppia.app.model.ConceptCard
+import org.oppia.app.model.SubtitledHtml
+import org.oppia.domain.topic.TopicController
+import org.oppia.util.data.AsyncResult
+import org.oppia.util.logging.Logger
import javax.inject.Inject
/** [ViewModel] for concept card, providing rich text and worked examples */
@FragmentScope
-class ConceptCardViewModel @Inject constructor() : ViewModel() {
- fun getDummyText() = "hello world"
-}
\ No newline at end of file
+class ConceptCardViewModel @Inject constructor(
+ private val topicController: TopicController,
+ private val logger: Logger
+) : ViewModel() {
+
+ private lateinit var skillId: String
+
+ /** Live Data for concept card explanation */
+ val conceptCardLiveData: LiveData by lazy {
+ processConceptCardLiveData()
+ }
+
+ /** Live Data for concept card worked examples. */
+ val workedExamplesLiveData: LiveData> by lazy {
+ processWorkedExamplesLiveData()
+ }
+
+ /** Sets the value of skillId. Must be called before setting ViewModel to binding. */
+ fun setSkillId(id: String) {
+ skillId = id
+ }
+
+ private val conceptCardResultLiveData: LiveData> by lazy {
+ topicController.getConceptCard(skillId)
+ }
+
+ private fun processConceptCardLiveData(): LiveData {
+ return Transformations.map(conceptCardResultLiveData, ::processConceptCardResult)
+ }
+
+ private fun processWorkedExamplesLiveData(): LiveData> {
+ return Transformations.map(conceptCardResultLiveData, ::processConceptCardWorkExamples)
+ }
+
+ private fun processConceptCardResult(conceptCardResult: AsyncResult): ConceptCard {
+ if (conceptCardResult.isFailure()) {
+ logger.e("ConceptCardFragment", "Failed to retrieve Concept Card: " + conceptCardResult.getErrorOrNull())
+ }
+ return conceptCardResult.getOrDefault(ConceptCard.getDefaultInstance())
+ }
+
+ private fun processConceptCardWorkExamples(conceptCardResult: AsyncResult): List {
+ if (conceptCardResult.isFailure()) {
+ logger.e("ConceptCardFragment", "Failed to retrieve Concept Card: " + conceptCardResult.getErrorOrNull())
+ }
+ return conceptCardResult.getOrDefault(ConceptCard.getDefaultInstance()).workedExampleList
+ }
+}
diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt
new file mode 100644
index 00000000000..24b6055970c
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt
@@ -0,0 +1,17 @@
+package org.oppia.app.topic.conceptcard.testing
+
+import android.os.Bundle
+import org.oppia.app.activity.InjectableAppCompatActivity
+import javax.inject.Inject
+
+/** Test Activity used for testing ConceptCardFragment */
+class ConceptCardFragmentTestActivity : InjectableAppCompatActivity() {
+
+ @Inject lateinit var conceptCardFragmentTestActivityController: ConceptCardFragmentTestActivityPresenter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityComponent.inject(this)
+ conceptCardFragmentTestActivityController.handleOnCreate()
+ }
+}
diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityPresenter.kt
new file mode 100644
index 00000000000..68d16f32bf4
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityPresenter.kt
@@ -0,0 +1,26 @@
+package org.oppia.app.topic.conceptcard.testing
+
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.android.synthetic.main.concept_card_fragment_test_activity.*
+import org.oppia.app.R
+import org.oppia.app.topic.conceptcard.ConceptCardFragment
+import org.oppia.domain.topic.TEST_SKILL_ID_1
+import org.oppia.domain.topic.TEST_SKILL_ID_2
+import javax.inject.Inject
+
+private const val TAG_CONCEPT_CARD_DIALOG = "CONCEPT_CARD_DIALOG"
+
+/** The presenter for [ConceptCardFragmentTestActivity] */
+class ConceptCardFragmentTestActivityPresenter @Inject constructor(private val activity: AppCompatActivity) {
+ fun handleOnCreate() {
+ activity.setContentView(R.layout.concept_card_fragment_test_activity)
+ activity.open_dialog_1.setOnClickListener {
+ val frag = ConceptCardFragment.newInstance(TEST_SKILL_ID_1)
+ frag.showNow(activity.supportFragmentManager, TAG_CONCEPT_CARD_DIALOG)
+ }
+ activity.open_dialog_2.setOnClickListener {
+ val frag = ConceptCardFragment.newInstance(TEST_SKILL_ID_2)
+ frag.showNow(activity.supportFragmentManager, TAG_CONCEPT_CARD_DIALOG)
+ }
+ }
+}
diff --git a/app/src/main/res/layout/concept_card_example_view.xml b/app/src/main/res/layout/concept_card_example_view.xml
new file mode 100644
index 00000000000..a903c7e0c76
--- /dev/null
+++ b/app/src/main/res/layout/concept_card_example_view.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/conceptcard_fragment.xml b/app/src/main/res/layout/concept_card_fragment.xml
similarity index 58%
rename from app/src/main/res/layout/conceptcard_fragment.xml
rename to app/src/main/res/layout/concept_card_fragment.xml
index aefaca6f1c2..fe81eacd92b 100644
--- a/app/src/main/res/layout/conceptcard_fragment.xml
+++ b/app/src/main/res/layout/concept_card_fragment.xml
@@ -1,17 +1,15 @@
-
+
-
-
+ android:orientation="vertical"
+ android:paddingTop="10dp">
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/concept_card_fragment_test_activity.xml b/app/src/main/res/layout/concept_card_fragment_test_activity.xml
new file mode 100644
index 00000000000..8505adfd9d1
--- /dev/null
+++ b/app/src/main/res/layout/concept_card_fragment_test_activity.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/sharedTest/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentTest.kt
index 7a02c220535..33678f58ab7 100644
--- a/app/src/sharedTest/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentTest.kt
@@ -2,21 +2,25 @@ package org.oppia.app.topic.conceptcard
import android.app.Application
import android.content.Context
+import android.content.res.Configuration
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import dagger.BindsInstance
import dagger.Component
import dagger.Module
import dagger.Provides
import kotlinx.coroutines.CoroutineDispatcher
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.oppia.app.R
-import org.oppia.app.home.HomeActivity
+import org.oppia.app.topic.conceptcard.testing.ConceptCardFragmentTestActivity
import org.oppia.util.threading.BackgroundDispatcher
import org.oppia.util.threading.BlockingDispatcher
import javax.inject.Singleton
@@ -25,12 +29,47 @@ import javax.inject.Singleton
@RunWith(AndroidJUnit4::class)
class ConceptCardFragmentTest {
+ private lateinit var activityScenario: ActivityScenario
+
+ @Before
+ fun setUp() {
+ activityScenario = ActivityScenario.launch(ConceptCardFragmentTestActivity::class.java)
+ }
+
+ @Test
+ fun testConceptCardFragment_openDialogFragmentWithSkill1_explanationIsDisplayed() {
+ onView(withId(R.id.open_dialog_1)).perform(click())
+ onView(withId(R.id.explanation)).check(matches(withText("Explanation with rich text.")))
+ }
+
+ @Test
+ fun testConceptCardFragment_openDialogFragmentWithSkill1_workedExamplesAreDisplayed() {
+ onView(withId(R.id.open_dialog_1)).perform(click())
+ onView(withText("Worked example with rich text.")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun testConceptCardFragment_openDialogFragmentWithSkill2_explanationIsDisplayed() {
+ onView(withId(R.id.open_dialog_2)).perform(click())
+ onView(withId(R.id.explanation)).check(matches(withText("Explanation without rich text.")))
+ }
+
+ @Test
+ fun testConceptCardFragment_openDialogFragmentWithSkill2_workedExamplesAreDisplayed() {
+ onView(withId(R.id.open_dialog_2)).perform(click())
+ onView(withText("Worked example without rich text.")).check(matches(isDisplayed()))
+ onView(withText("Second worked example.")).check(matches(isDisplayed()))
+ }
+
@Test
- fun testConceptCardFragment_loadFragment_textIsDisplayed() {
- // I'm not sure how to launch just the fragment because it doesn't have its own activity
- ActivityScenario.launch(HomeActivity::class.java).use {
- onView(withId(R.id.rich_text_card)).check(matches(withText("Hello World")))
+ fun testConceptCardFragment_openDialogFragmentWithSkill2_afterConfigurationChange_workedExamplesAreDisplayed() {
+ onView(withId(R.id.open_dialog_2)).perform(click())
+ activityScenario.onActivity { activity ->
+ activity.requestedOrientation = Configuration.ORIENTATION_LANDSCAPE
}
+ activityScenario.recreate()
+ onView(withText("Worked example without rich text.")).check(matches(isDisplayed()))
+ onView(withText("Second worked example.")).check(matches(isDisplayed()))
}
@Module
diff --git a/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt b/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt
new file mode 100644
index 00000000000..509913b03ac
--- /dev/null
+++ b/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt
@@ -0,0 +1,27 @@
+package org.oppia.domain.question
+
+import androidx.lifecycle.LiveData
+import org.oppia.app.model.Question
+import org.oppia.util.data.AsyncResult
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Controller that tracks and reports the learner's ephemeral/non-persisted progress through a question training
+ * session. Note that this controller only supports one active training session at a time.
+ *
+ * The current training session session is started via the question training controller.
+ *
+ * This class is thread-safe, but the order of applied operations is arbitrary. Calling code should take care to ensure
+ * that uses of this class do not specifically depend on ordering.
+ */
+@Singleton
+class QuestionAssessmentProgressController @Inject constructor(
+) {
+ fun beginQuestionTrainingSession(questionsList: LiveData>>) {
+ }
+
+ fun finishQuestionTrainingSession() {
+
+ }
+}
diff --git a/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt b/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt
new file mode 100644
index 00000000000..b65ddb693d1
--- /dev/null
+++ b/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt
@@ -0,0 +1,101 @@
+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.util.data.AsyncResult
+import org.oppia.util.data.DataProviders
+import javax.inject.Inject
+import javax.inject.Singleton
+
+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"
+
+/** Controller for retrieving a set of questions. */
+@Singleton
+class QuestionTrainingController @Inject constructor(
+ private val questionAssessmentProgressController: QuestionAssessmentProgressController,
+ private val dataProviders: DataProviders
+) {
+ /**
+ * Begins a question training session given a list of skill Ids and a total number of questions.
+ *
+ * This method is not expected to fail. [QuestionAssessmentProgressController] should be used to manage the
+ * play state, and monitor the load success/failure of the training session.
+ *
+ * Questions will be shuffled and then the training session will begin.
+ *
+ * @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> {
+ return try {
+ val questionsList = retrieveQuestionsForSkillIds(skillIdsList)
+ questionAssessmentProgressController.beginQuestionTrainingSession(questionsList)
+ MutableLiveData(AsyncResult.success(null))
+ } catch (e: Exception) {
+ MutableLiveData(AsyncResult.failed(e))
+ }
+ }
+
+ /**
+ * Finishes the most recent training session started by [startQuestionTrainingSession].
+ * This method should only be called if there is a training session is being played,
+ * otherwise an exception will be thrown.
+ */
+ fun stopQuestionTrainingSession(): LiveData> {
+ return try {
+ questionAssessmentProgressController.finishQuestionTrainingSession()
+ MutableLiveData(AsyncResult.success(null))
+ } catch (e: Exception) {
+ 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/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt b/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt
new file mode 100644
index 00000000000..3eb95a2fbd0
--- /dev/null
+++ b/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt
@@ -0,0 +1,182 @@
+package org.oppia.domain.question
+
+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
+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.atLeastOnce
+import org.mockito.Mockito.verify
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.oppia.domain.topic.TEST_SKILL_ID_0
+import org.oppia.domain.topic.TEST_SKILL_ID_1
+import org.oppia.util.data.AsyncResult
+import org.oppia.util.logging.EnableConsoleLog
+import org.oppia.util.logging.EnableFileLog
+import org.oppia.util.logging.GlobalLogLevel
+import org.oppia.util.logging.LogLevel
+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
+
+const val TEST_TOPIC_ID_0 = "test_topic_id_0"
+
+/** Tests for [QuestionTrainingController]. */
+@RunWith(AndroidJUnit4::class)
+@Config(manifest = Config.NONE)
+class QuestionTrainingControllerTest {
+ @Rule
+ @JvmField
+ val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Rule
+ @JvmField
+ val executorRule = InstantTaskExecutorRule()
+
+ @Inject
+ lateinit var questionTrainingController: QuestionTrainingController
+
+ @Mock
+ lateinit var mockQuestionListObserver: Observer>
+
+ @Captor
+ lateinit var questionListResultCaptor: ArgumentCaptor>
+
+ @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
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun setUp() {
+ Dispatchers.setMain(testThread)
+ setUpTestApplicationComponent()
+ }
+
+ @After
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun tearDown() {
+ Dispatchers.resetMain()
+ testThread.close()
+ }
+
+ private fun setUpTestApplicationComponent() {
+ DaggerQuestionTrainingControllerTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ .inject(this)
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_successfullyStartsQuestionSessionForExistingSkillIds() = 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()
+ }
+
+ @Qualifier
+ annotation class TestDispatcher
+
+ // TODO(#89): Move this to a common test application component.
+ @Module
+ class TestModule {
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ @ExperimentalCoroutinesApi
+ @Singleton
+ @Provides
+ @TestDispatcher
+ fun provideTestDispatcher(): 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(#59): Either isolate these to their own shared test module, or use the real logging
+ // module in tests to avoid needing to specify these settings for tests.
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(modules = [TestModule::class])
+ interface TestApplicationComponent {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(questionTrainingControllerTest: QuestionTrainingControllerTest)
+ }
+}
diff --git a/model/src/main/proto/question.proto b/model/src/main/proto/question.proto
new file mode 100644
index 00000000000..f4aa01a3f2d
--- /dev/null
+++ b/model/src/main/proto/question.proto
@@ -0,0 +1,19 @@
+syntax = "proto3";
+
+package model;
+
+import "exploration.proto";
+
+option java_package = "org.oppia.app.model";
+option java_multiple_files = true;
+
+// Structure for a single question.
+message Question {
+ string question_id = 1;
+ State question_state = 2;
+ string language_code = 3;
+ int32 version = 4;
+ repeated string linked_skill_ids = 5;
+ int64 created_on_timestamp_ms = 6;
+ int64 updated_on_timestamp_ms = 7;
+}