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; +}