diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt index 4d0beef7eb5..458e07cb75b 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt @@ -149,21 +149,13 @@ class ExplorationProgressController @Inject constructor( } } - /** - * Navigates to the previous state in the stack. If the learner is currently on the initial state, this method will - * throw an exception. Calling code is responsible to make sure that this method is not called when it's not possible - * to navigate to a previous card. - * - * This method cannot be called until an exploration has started and [getCurrentState] returns a non-pending result or - * an exception will be thrown. - */ /** * Navigates to the previous state in the graph. If the learner is currently on the initial state, this method will * throw an exception. Calling code is responsible for ensuring this method is only called when it's possible to * navigate backward. * * @return a one-time [LiveData] indicating whether the movement to the previous state was successful, or a failure if - * state navigation was attempted at an invalid time in the state graph (e.g. if currently vieiwng the initial + * state navigation was attempted at an invalid time in the state graph (e.g. if currently viewing the initial * state of the exploration). It's recommended that calling code only listen to this result for failures, and * instead rely on [getCurrentState] for observing a successful transition to another state. */ @@ -228,9 +220,6 @@ class ExplorationProgressController @Inject constructor( * corresponds to a a terminal state, then the learner has completed the exploration. Note that [moveToPreviousState] * and [moveToNextState] will automatically update observers of this live data when the next state is navigated to. * - * Note that the returned [LiveData] is always the same object no matter when this method is called, except - * potentially when a new exploration is started. - * * This [LiveData] may initially be pending while the exploration object is loaded. It may also switch from a * completed to a pending result during transient operations like submitting an answer via [submitAnswer]. Calling * code should be made resilient to this by caching the current state object to display since it may disappear 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 1c91397dd0d..da88acb2166 100644 --- a/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt +++ b/domain/src/main/java/org/oppia/domain/question/QuestionAssessmentProgressController.kt @@ -1,25 +1,174 @@ package org.oppia.domain.question +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.oppia.app.model.AnsweredQuestionOutcome +import org.oppia.app.model.EphemeralQuestion +import org.oppia.app.model.EphemeralState +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.PendingState import org.oppia.app.model.Question +import org.oppia.app.model.SubtitledHtml +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 +private const val EPHEMERAL_QUESTION_DATA_PROVIDER_ID = "EphemeralQuestionDataProvider" +private const val EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID = "EmptyQuestionsListDataProvider" + /** - * Controller that tracks and reports the learner's ephemeral/non-persisted progress through a question training + * Controller that tracks and reports the learner's ephemeral/non-persisted progress through a practice 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. + * The current training 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: DataProvider>) { +class QuestionAssessmentProgressController @Inject constructor(private val dataProviders: DataProviders) { + private var inProgressQuestionsListDataProvider: DataProvider> = createEmptyQuestionsListDataProvider() + private var playing: Boolean = false + private val ephemeralQuestionDataSource: DataProvider by lazy { + dataProviders.transformAsync( + EPHEMERAL_QUESTION_DATA_PROVIDER_ID, inProgressQuestionsListDataProvider, this::computeEphemeralQuestionStateAsync + ) + } + + internal fun beginQuestionTrainingSession(questionsListDataProvider: DataProvider>) { + check(!playing) { "Cannot start a new training session until the previous one is completed" } + inProgressQuestionsListDataProvider = questionsListDataProvider + playing = true + } + + internal fun finishQuestionTrainingSession() { + check(playing) { "Cannot stop a new training session which wasn't started" } + playing = false + inProgressQuestionsListDataProvider = createEmptyQuestionsListDataProvider() + } + + /** + * Submits an answer to the current question and returns how the UI should respond to this answer. The returned + * [LiveData] will only have at most two results posted: a pending result, and then a completed success/failure + * result. Failures in this case represent a failure of the app (possibly due to networking conditions). The app + * should report this error in a consumable way to the user so that they may take action on it. No additional values + * will be reported to the [LiveData]. Each call to this method returns a new, distinct, [LiveData] object that must + * be observed. Note also that the returned [LiveData] is not guaranteed to begin with a pending state. + * + * If the app undergoes a configuration change, calling code should rely on the [LiveData] from [getCurrentQuestion] + * to know whether a current answer is pending. That [LiveData] will have its state changed to pending during answer + * submission and until answer resolution. + * + * Submitting an answer should result in the learner staying in the current question or moving to a new question in + * the training session. Note that once a correct answer is processed, the current state reported to + * [getCurrentQuestion] will change from a pending question to a completed question since the learner completed that + * question card. The learner can then proceed from the current completed question to the next pending question using + * [moveToNextQuestion]. + * + * This method cannot be called until a training session has started and [getCurrentQuestion] returns a non-pending + * result or the result will fail. Calling code must also take care not to allow users to submit an answer while a + * previous answer is pending. That scenario will also result in a failed answer submission. + * + * No assumptions should be made about the completion order of the returned [LiveData] vs. the [LiveData] from + * [getCurrentQuestion]. Also note that the returned [LiveData] will only have a single value and not be reused after + * that point. + */ + fun submitAnswer(answer: InteractionObject): LiveData> { + val outcome = AnsweredQuestionOutcome.newBuilder() + .setFeedback(SubtitledHtml.newBuilder().setHtml("Response to answer: $answer")) + .setIsCorrectAnswer(true) + .build() + return MutableLiveData(AsyncResult.success(outcome)) + } + + /** + * Navigates to the previous question already answered. If the learner is currently on the first question, this method + * will throw an exception. Calling code is responsible for ensuring this method is only called when it's possible to + * navigate backward. + * + * @return a one-time [LiveData] indicating whether the movement to the previous question was successful, or a failure + * if question navigation was attempted at an invalid time (such as when viewing the first question). It's + * recommended that calling code only listen to this result for failures, and instead rely on [getCurrentQuestion] + * for observing a successful transition to another state. + */ + fun moveToPreviousQuestion(): LiveData> { + check(playing) { "Cannot move to the previous question unless an active training session is ongoing" } + return MutableLiveData(AsyncResult.success(null)) + } + + /** + * Navigates to the next question in the assessment. This method is only valid if the current [EphemeralQuestion] + * reported by [getCurrentQuestion] is a completed question. Calling code is responsible for ensuring this method is + * only called when it's possible to navigate forward. + * + * Note that if the current question is pending, the user needs to submit a correct answer via [submitAnswer] before + * forward navigation can occur. + * + * @return a one-time [LiveData] indicating whether the movement to the next question was successful, or a failure if + * question navigation was attempted at an invalid time (such as if the current question is pending or terminal). + * It's recommended that calling code only listen to this result for failures, and instead rely on + * [getCurrentQuestion] for observing a successful transition to another question. + */ + fun moveToNextQuestion(): LiveData> { + check(playing) { "Cannot move to the next question unless an active training session is ongoing" } + return MutableLiveData(AsyncResult.success(null)) + } + + /** + * Returns a [LiveData] monitoring the current [EphemeralQuestion] the learner is currently viewing. If this state + * corresponds to a a terminal state, then the learner has completed the training session. Note that + * [moveToPreviousQuestion] and [moveToNextQuestion] will automatically update observers of this live data when the + * next question is navigated to. + * + * This [LiveData] may switch from a completed to a pending result during transient operations like submitting an + * answer via [submitAnswer]. Calling code should be made resilient to this by caching the current question object to + * display since it may disappear temporarily during answer submission. Calling code should persist this state object + * across configuration changes if needed since it cannot rely on this [LiveData] for immediate UI reconstitution + * after configuration changes. + * + * The underlying question returned by this function can only be changed by calls to [moveToNextQuestion] and + * [moveToPreviousQuestion], or the question training controller if another question session begins. UI code can be + * confident only calls from the UI layer will trigger changes here to ensure atomicity between receiving and making + * question state changes. + * + * This method is safe to be called before a training session has started. If there is no ongoing session, it should + * return a pending state. + */ + fun getCurrentQuestion(): LiveData> { + return dataProviders.convertToLiveData(ephemeralQuestionDataSource) + } + + @Suppress("RedundantSuspendModifier") // 'suspend' expected by DataProviders. + private suspend fun computeEphemeralQuestionStateAsync( + questionsList: List + ): AsyncResult { + if (!playing) { + return AsyncResult.pending() + } + return try { + AsyncResult.success(computeEphemeralQuestionState(questionsList)) + } catch (e: Exception) { + AsyncResult.failed(e) + } + } + + private fun computeEphemeralQuestionState(questionsList: List): EphemeralQuestion { + check(questionsList.isNotEmpty()) { "Cannot start a training session with zero questions." } + val currentQuestion = questionsList.first() + return EphemeralQuestion.newBuilder() + .setEphemeralState(EphemeralState.newBuilder() + .setState(currentQuestion.questionState) + .setPendingState(PendingState.getDefaultInstance())) + .setCurrentQuestionIndex(0) + .setTotalQuestionCount(questionsList.size) + .setInitialTotalQuestionCount(questionsList.size) + .build() } - fun finishQuestionTrainingSession() { + /** Returns a temporary [DataProvider] that always provides an empty list of [Question]s. */ + private fun createEmptyQuestionsListDataProvider(): DataProvider> { + return dataProviders.createInMemoryDataProvider(EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID) { listOf() } } } 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 ac775d4fc5b..f3bcdb97d3b 100644 --- a/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt +++ b/domain/src/main/java/org/oppia/domain/question/QuestionTrainingController.kt @@ -11,7 +11,8 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random -const val TRAINING_QUESTIONS_PROVIDER = "TrainingQuestionsProvider" +private const val TRAINING_QUESTIONS_PROVIDER = "TrainingQuestionsProvider" +private const val RETRIEVE_QUESTIONS_RESULT_DATA_PROVIDER = "RetrieveQuestionsResultsProvider" /** Controller for retrieving a set of questions. */ @Singleton @@ -35,13 +36,17 @@ 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 retrieveQuestionsDataProvider = retrieveQuestionsForSkillIds(skillIdsList) questionAssessmentProgressController.beginQuestionTrainingSession( retrieveQuestionsDataProvider ) - dataProviders.convertToLiveData(retrieveQuestionsDataProvider) + // Convert the data provider type to 'Any' via a transformation. + val erasedDataProvider: DataProvider = dataProviders.transform( + RETRIEVE_QUESTIONS_RESULT_DATA_PROVIDER, retrieveQuestionsDataProvider + ) { it } + dataProviders.convertToLiveData(erasedDataProvider) } catch (e: Exception) { MutableLiveData(AsyncResult.failed(e)) } @@ -50,10 +55,14 @@ class QuestionTrainingController @Inject constructor( 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 - ) + if (skillIdsList.isEmpty()) { + listOf() + } else { + getFilteredQuestionsForTraining( + skillIdsList, it.shuffled(random), + questionCountPerSession / skillIdsList.size + ) + } } } diff --git a/domain/src/test/java/org/oppia/domain/question/QuestionAssessmentProgressControllerTest.kt b/domain/src/test/java/org/oppia/domain/question/QuestionAssessmentProgressControllerTest.kt new file mode 100644 index 00000000000..d6c902cb5fd --- /dev/null +++ b/domain/src/test/java/org/oppia/domain/question/QuestionAssessmentProgressControllerTest.kt @@ -0,0 +1,253 @@ +package org.oppia.domain.question + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.app.model.EphemeralQuestion +import org.oppia.app.model.EphemeralState.StateTypeCase.PENDING_STATE +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.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 [QuestionAssessmentProgressController]. */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class QuestionAssessmentProgressControllerTest { + private val TEST_SKILL_ID_LIST_012 = listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1, TEST_SKILL_ID_2) + private val TEST_SKILL_ID_LIST_02 = listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_2) + + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + lateinit var questionTrainingController: QuestionTrainingController + + @Inject + lateinit var questionAssessmentProgressController: QuestionAssessmentProgressController + + @Inject + @field:TestDispatcher + lateinit var testDispatcher: CoroutineDispatcher + + @Mock + lateinit var mockCurrentQuestionLiveDataObserver: Observer> + + @Mock + lateinit var mockAsyncResultLiveDataObserver: Observer> + + @Captor + lateinit var currentQuestionResultCaptor: ArgumentCaptor> + + @Captor + lateinit var asyncResultCaptor: ArgumentCaptor> + + private val coroutineContext by lazy { + EmptyCoroutineContext + testDispatcher + } + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + // TODO(#111): Add tests for the controller once there's a real controller to test. + + @Test + @ExperimentalCoroutinesApi + fun testStartTrainingSession_succeeds() = runBlockingTest(coroutineContext) { + val resultLiveData = questionTrainingController.startQuestionTrainingSession(TEST_SKILL_ID_LIST_012) + resultLiveData.observeForever(mockAsyncResultLiveDataObserver) + advanceUntilIdle() + + verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture()) + assertThat(asyncResultCaptor.value.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testStopTrainingSession_afterStartingPreviousSession_succeeds() = runBlockingTest(coroutineContext) { + questionTrainingController.startQuestionTrainingSession(TEST_SKILL_ID_LIST_012) + + val resultLiveData = questionTrainingController.stopQuestionTrainingSession() + advanceUntilIdle() + + assertThat(resultLiveData.value).isNotNull() + assertThat(resultLiveData.value!!.isSuccess()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testStartTrainingSession_afterStartingPreviousSession_fails() = runBlockingTest(coroutineContext) { + questionTrainingController.startQuestionTrainingSession(TEST_SKILL_ID_LIST_012) + + val resultLiveData = questionTrainingController.startQuestionTrainingSession(TEST_SKILL_ID_LIST_02) + advanceUntilIdle() + + assertThat(resultLiveData.value).isNotNull() + assertThat(resultLiveData.value!!.isFailure()).isTrue() + assertThat(resultLiveData.value!!.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot start a new training session until the previous one is completed") + } + + @Test + @ExperimentalCoroutinesApi + fun testStopTrainingSession_withoutStartingSession_fails() = runBlockingTest(coroutineContext) { + val resultLiveData = questionTrainingController.stopQuestionTrainingSession() + advanceUntilIdle() + + assertThat(resultLiveData.value).isNotNull() + assertThat(resultLiveData.value!!.isFailure()).isTrue() + assertThat(resultLiveData.value!!.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot stop a new training session which wasn't started") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentQuestion_noSessionStarted_returnsPendingResult() = runBlockingTest(coroutineContext) { + val resultLiveData = questionAssessmentProgressController.getCurrentQuestion() + resultLiveData.observeForever(mockCurrentQuestionLiveDataObserver) + advanceUntilIdle() + + verify(mockCurrentQuestionLiveDataObserver).onChanged(currentQuestionResultCaptor.capture()) + assertThat(currentQuestionResultCaptor.value.isPending()).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentQuestion_sessionStarted_withEmptyQuestionList_fails() = runBlockingTest(coroutineContext) { + questionTrainingController.startQuestionTrainingSession(listOf()) + + val resultLiveData = questionAssessmentProgressController.getCurrentQuestion() + resultLiveData.observeForever(mockCurrentQuestionLiveDataObserver) + advanceUntilIdle() + + verify(mockCurrentQuestionLiveDataObserver, atLeastOnce()).onChanged(currentQuestionResultCaptor.capture()) + assertThat(currentQuestionResultCaptor.value.isFailure()).isTrue() + assertThat(currentQuestionResultCaptor.value.getErrorOrNull()) + .hasMessageThat() + .contains("Cannot start a training session with zero questions.") + } + + @Test + @ExperimentalCoroutinesApi + fun testGetCurrentQuestion_sessionStarted_returnsInitialQuestion() = runBlockingTest(coroutineContext) { + questionTrainingController.startQuestionTrainingSession(TEST_SKILL_ID_LIST_012) + + val resultLiveData = questionAssessmentProgressController.getCurrentQuestion() + resultLiveData.observeForever(mockCurrentQuestionLiveDataObserver) + advanceUntilIdle() + + verify(mockCurrentQuestionLiveDataObserver, atLeastOnce()).onChanged(currentQuestionResultCaptor.capture()) + assertThat(currentQuestionResultCaptor.value.isSuccess()).isTrue() + val ephemeralQuestion = currentQuestionResultCaptor.value.getOrThrow() + assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(0) + assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(3) + assertThat(ephemeralQuestion.ephemeralState.stateTypeCase).isEqualTo(PENDING_STATE) + assertThat(ephemeralQuestion.ephemeralState.state.content.html).contains("What is the numerator") + } + + private fun setUpTestApplicationComponent() { + DaggerQuestionAssessmentProgressControllerTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + @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 + } + } + + @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,TestQuestionModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(questionAssessmentProgressControllerTest: QuestionAssessmentProgressControllerTest) + } +} 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 2b41311cc37..2826944900b 100644 --- a/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/question/QuestionTrainingControllerTest.kt @@ -28,8 +28,10 @@ import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock +import org.mockito.Mockito import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.app.model.Question @@ -68,10 +70,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 @@ -119,7 +121,8 @@ class QuestionTrainingControllerTest { verify(mockQuestionListObserver, atLeastOnce()).onChanged(questionListResultCaptor.capture()) assertThat(questionListResultCaptor.value.isSuccess()).isTrue() - val questionsList = questionListResultCaptor.value.getOrThrow() + @Suppress("UNCHECKED_CAST") // TODO(#111): Observe this via the progress controller, instead. + val questionsList = questionListResultCaptor.value.getOrThrow() as List assertThat(questionsList.size).isEqualTo(3) val questionIds = questionsList.map { it.questionId } assertThat(questionIds).containsExactlyElementsIn( @@ -140,7 +143,8 @@ class QuestionTrainingControllerTest { verify(mockQuestionListObserver, atLeastOnce()).onChanged(questionListResultCaptor.capture()) assertThat(questionListResultCaptor.value.isSuccess()).isTrue() - val questionsList = questionListResultCaptor.value.getOrThrow() + @Suppress("UNCHECKED_CAST") // TODO(#111): Observe this via the progress controller, instead. + val questionsList = questionListResultCaptor.value.getOrThrow() as List assertThat(questionsList.size).isEqualTo(3) val questionIds = questionsList.map { it.questionId } assertThat(questionIds).containsExactlyElementsIn( @@ -211,7 +215,7 @@ class QuestionTrainingControllerTest { @Provides @QuestionTrainingSeed - fun provideQuestionTrainingSeed(): Long = questionSeed ++ + fun provideQuestionTrainingSeed(): Long = questionSeed++ } // TODO(#89): Move this to a common test application component. diff --git a/model/src/main/proto/question.proto b/model/src/main/proto/question.proto index f4aa01a3f2d..9a21ed3a5f5 100644 --- a/model/src/main/proto/question.proto +++ b/model/src/main/proto/question.proto @@ -3,17 +3,73 @@ syntax = "proto3"; package model; import "exploration.proto"; +import "subtitled_html.proto"; option java_package = "org.oppia.app.model"; option java_multiple_files = true; -// Structure for a single question. +// Represents a question that can be used to determine how well a learner understands specific skills. message Question { + // The ID of the question. string question_id = 1; + + // The [State] representing the Q&A structure of the question. State question_state = 2; + + // The language code to which this question is localized. string language_code = 3; + + // The version of the question. int32 version = 4; + + // The IDs of skills to which this question is associated. repeated string linked_skill_ids = 5; + + // Number of milliseconds since the Unix epoch corresponding to when this question was created. int64 created_on_timestamp_ms = 6; + + // Number of milliseconds since the Unix epoch corresponding to when this question was most recently updated. int64 updated_on_timestamp_ms = 7; } + +// Corresponds to a question that has been answered in an assessment, or is in the process of being answered, and will +// disappear once the user finishes the assessment or navigates away from the training session. This has strong +// correlation with the EphemeralState structure used when playing explorations, and contains an instance of that due to +// the internal structure of Question also relying on a State. This also contains additional information to help report +// the user's progress through the assessment. +message EphemeralQuestion { + // Corresponds to the current ephemeral state the question is in (including indicating whether the question has been + // completed, or if the learner has reached the end of the assessment). Note that a valid question can also be + // terminal (such as if it's the last question in the assessment). This differs from explorations which have a + // dedicated terminal state. + EphemeralState ephemeral_state = 1; + + // Corresponds the index of the current question in the assessment, starting at 0. This index is guaranteed to be + // unique for a specific question being assessed, even as different EphemeralQuestions are being dispatched to + // represent different states the question is going through. + int32 current_question_index = 2; + + // Corresponds to the number of questions in the assessment. This value will change across EphemeralQuestion instances + // for a single assessment if the controller decides to add/remove questions mid-assessment. + int32 total_question_count = 3; + + // Corresponds to the number of questions in the assessment when it started. This value will never change across + // EphemeralQuestion instances for the same assessment. It can be compared with total_question_count to detect if the + // learner required extra questions during the assessment, or completed it early. + int32 initial_total_question_count = 4; + + // Corresponds to skill IDs that the learner should review. This is expected only to be filled at the end of the + // assessment (for the terminal question), but this should not be used as an indicator of whether the assessment + // ended. If this has values outside the terminal state, they should be ignored. There's no guarantee any skill IDs + // will be suggested to review at the end of the assessment. + repeated string skill_ids_to_review = 5; +} + +// The outcome of submitting an answer to a question during a training session. +message AnsweredQuestionOutcome { + // Oppia's feedback to the learner's most recent answer. + SubtitledHtml feedback = 1; + + // Whether the answer the learner submitted is the correct answer. + bool is_correct_answer = 2; +}