From dee27c04d72730c73c7dacea1d9f63608347a93f Mon Sep 17 00:00:00 2001 From: "Mr. 17" Date: Wed, 26 Jun 2024 17:35:57 +0530 Subject: [PATCH] Fix part of #5344: Introduce & modify controllers to support multiple classrooms (#5419) ## Explanation Fixes part of #5344 - Introduces `ClassroomController` to surface `getClassroomList` function and adds relevant tests. - Adds `getTopicList` function in the `ClassroomController` and its tests. - Modifies `ProfileManagementController` to store the `last_selected_classroom_id` per profile and adds its tests. ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only If your PR includes UI-related changes, then: - Add screenshots for portrait/landscape for both a tablet & phone of the before & after UI changes - For the screenshots above, include both English and pseudo-localized (RTL) screenshots (see [RTL guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines)) - Add a video showing the full UX flow with a screen reader enabled (see [accessibility guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide)) - For PRs introducing new UI elements or color changes, both light and dark mode screenshots must be included - Add a screenshot demonstrating that you ran affected Espresso tests locally & that they're passing --- .../domain/classroom/ClassroomController.kt | 385 ++++++++++++++++++ .../profile/ProfileManagementController.kt | 45 ++ .../domain/topic/TopicListController.kt | 6 +- .../classroom/ClassroomControllerTest.kt | 354 ++++++++++++++++ .../ProfileManagementControllerTest.kt | 110 +++++ .../domain/topic/TopicListControllerTest.kt | 3 + model/src/main/proto/profile.proto | 3 + model/src/main/proto/thumbnail.proto | 9 + 8 files changed, 910 insertions(+), 5 deletions(-) create mode 100644 domain/src/main/java/org/oppia/android/domain/classroom/ClassroomController.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/classroom/ClassroomControllerTest.kt diff --git a/domain/src/main/java/org/oppia/android/domain/classroom/ClassroomController.kt b/domain/src/main/java/org/oppia/android/domain/classroom/ClassroomController.kt new file mode 100644 index 00000000000..69c2acffa78 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/classroom/ClassroomController.kt @@ -0,0 +1,385 @@ +package org.oppia.android.domain.classroom + +import android.graphics.Color +import org.json.JSONObject +import org.oppia.android.app.model.ClassroomIdList +import org.oppia.android.app.model.ClassroomList +import org.oppia.android.app.model.ClassroomRecord +import org.oppia.android.app.model.ClassroomSummary +import org.oppia.android.app.model.EphemeralClassroomSummary +import org.oppia.android.app.model.EphemeralTopicSummary +import org.oppia.android.app.model.LessonThumbnail +import org.oppia.android.app.model.LessonThumbnailGraphic +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.StoryRecord +import org.oppia.android.app.model.SubtitledHtml +import org.oppia.android.app.model.TopicList +import org.oppia.android.app.model.TopicPlayAvailability +import org.oppia.android.app.model.TopicPlayAvailability.AvailabilityCase.AVAILABLE_TO_PLAY_NOW +import org.oppia.android.app.model.TopicRecord +import org.oppia.android.app.model.TopicSummary +import org.oppia.android.domain.topic.createTopicThumbnailFromJson +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.domain.util.JsonAssetRetriever +import org.oppia.android.domain.util.getStringFromObject +import org.oppia.android.util.caching.AssetRepository +import org.oppia.android.util.caching.LoadLessonProtosFromAssets +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders.Companion.transform +import org.oppia.android.util.locale.OppiaLocale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** ID of test classroom 0. */ +const val TEST_CLASSROOM_ID_0 = "test_classroom_id_0" + +/** ID of test classroom 1. */ +const val TEST_CLASSROOM_ID_1 = "test_classroom_id_1" + +/** ID of test classroom 2. */ +const val TEST_CLASSROOM_ID_2 = "test_classroom_id_2" + +/** Map of classroom ID to its thumbnail. */ +val CLASSROOM_THUMBNAILS = mapOf( + TEST_CLASSROOM_ID_0 to createClassroomThumbnail0(), + TEST_CLASSROOM_ID_1 to createClassroomThumbnail1(), + TEST_CLASSROOM_ID_2 to createClassroomThumbnail2(), +) + +private const val CLASSROOM_BG_COLOR = "#C6DCDA" + +private const val GET_CLASSROOM_LIST_PROVIDER_ID = "get_classroom_list_provider_id" +private const val GET_TOPIC_LIST_PROVIDER_ID = "get_topic_list_provider_id" + +private val EVICTION_TIME_MILLIS = TimeUnit.DAYS.toMillis(1) + +/** Controller for retrieving the list of classrooms & topics available to the learner. */ +@Singleton +class ClassroomController @Inject constructor( + private val jsonAssetRetriever: JsonAssetRetriever, + private val assetRepository: AssetRepository, + private val translationController: TranslationController, + @LoadLessonProtosFromAssets private val loadLessonProtosFromAssets: Boolean, +) { + /** Returns the list of [ClassroomSummary]s currently tracked by the app. */ + fun getClassroomList(profileId: ProfileId): DataProvider { + val translationLocaleProvider = + translationController.getWrittenTranslationContentLocale(profileId) + return translationLocaleProvider.transform( + GET_CLASSROOM_LIST_PROVIDER_ID, + ::createClassroomList + ) + } + + /** + * Returns the list of [TopicSummary]s currently tracked by the app, possibly up to + * [EVICTION_TIME_MILLIS] old. + */ + fun getTopicList(profileId: ProfileId, classroomId: String): DataProvider { + val translationLocaleProvider = + translationController.getWrittenTranslationContentLocale(profileId) + return translationLocaleProvider.transform(GET_TOPIC_LIST_PROVIDER_ID) { contentLocale -> + createTopicList(classroomId, contentLocale) + } + } + + private fun createClassroomList( + contentLocale: OppiaLocale.ContentLocale + ): ClassroomList { + return if (loadLessonProtosFromAssets) + loadClassroomListFromProto(contentLocale) + else + loadClassroomListFromJson(contentLocale) + } + + private fun loadClassroomListFromProto(contentLocale: OppiaLocale.ContentLocale): ClassroomList { + val classroomIdList = assetRepository.loadProtoFromLocalAssets( + assetName = "classrooms", + baseMessage = ClassroomIdList.getDefaultInstance() + ) + return ClassroomList.newBuilder().apply { + addAllClassroomSummary( + classroomIdList.classroomIdsList.map { classroomId -> + createEphemeralClassroomSummary(classroomId, contentLocale) + }.filter { ephemeralClassroomSummary -> + ephemeralClassroomSummary.classroomSummary.topicSummaryList.any { topicSummary -> + topicSummary.topicPlayAvailability.availabilityCase == AVAILABLE_TO_PLAY_NOW + } + } + ) + }.build() + } + + private fun loadClassroomListFromJson(contentLocale: OppiaLocale.ContentLocale): ClassroomList { + val classroomIdJsonArray = jsonAssetRetriever + .loadJsonFromAsset("classrooms.json") + ?.getJSONArray("classroom_id_list") + ?: return ClassroomList.getDefaultInstance() + val classroomListBuilder = ClassroomList.newBuilder() + for (i in 0 until classroomIdJsonArray.length()) { + val classroomId = classroomIdJsonArray.optString(i) + val ephemeralClassroomSummary = createEphemeralClassroomSummary(classroomId, contentLocale) + val hasPublishedTopics = + ephemeralClassroomSummary.classroomSummary.topicSummaryList.any { topicSummary -> + topicSummary.topicPlayAvailability.availabilityCase == AVAILABLE_TO_PLAY_NOW + } + if (hasPublishedTopics) classroomListBuilder.addClassroomSummary(ephemeralClassroomSummary) + } + return classroomListBuilder.build() + } + + private fun createEphemeralClassroomSummary( + classroomId: String, + contentLocale: OppiaLocale.ContentLocale + ): EphemeralClassroomSummary { + return EphemeralClassroomSummary.newBuilder().apply { + classroomSummary = createClassroomSummary(classroomId) + writtenTranslationContext = + translationController.computeWrittenTranslationContext( + classroomSummary.writtenTranslationsMap, contentLocale + ) + }.build() + } + + private fun createClassroomSummary(classroomId: String): ClassroomSummary { + return if (loadLessonProtosFromAssets) { + val classroomRecord = assetRepository.loadProtoFromLocalAssets( + assetName = classroomId, + baseMessage = ClassroomRecord.getDefaultInstance() + ) + return ClassroomSummary.newBuilder().apply { + this.classroomId = classroomId + putAllWrittenTranslations(classroomRecord.writtenTranslationsMap) + classroomTitle = classroomRecord.translatableTitle + classroomThumbnail = createClassroomThumbnailFromProto( + classroomId, + classroomRecord.classroomThumbnail + ) + addAllTopicSummary( + classroomRecord.topicPrerequisitesMap.keys.toList().map { topicId -> + createTopicSummary(topicId, classroomId) + } + ) + }.build() + } else createClassroomSummaryFromJson(classroomId) + } + + private fun createClassroomSummaryFromJson(classroomId: String): ClassroomSummary { + val classroomJsonObject = jsonAssetRetriever + .loadJsonFromAsset("$classroomId.json") + ?: return ClassroomSummary.getDefaultInstance() + return ClassroomSummary.newBuilder().apply { + setClassroomId(classroomJsonObject.getStringFromObject("classroom_id")) + classroomTitle = SubtitledHtml.newBuilder().apply { + val classroomTitleObj = classroomJsonObject.getJSONObject("classroom_title") + contentId = classroomTitleObj.getStringFromObject("content_id") + html = classroomTitleObj.getStringFromObject("html") + }.build() + classroomThumbnail = createClassroomThumbnailFromJson(classroomJsonObject) + val topicIdArray = classroomJsonObject + .getJSONObject("topic_prerequisites").keys().asSequence().toList() + val topicSummaryList = mutableListOf() + topicIdArray.forEach { topicId -> + topicSummaryList.add(createTopicSummary(topicId, classroomId)) + } + addAllTopicSummary(topicSummaryList) + }.build() + } + + private fun createTopicList( + classroomId: String, + contentLocale: OppiaLocale.ContentLocale + ): TopicList { + return TopicList.newBuilder().apply { + addAllTopicSummary( + getTopicIdListFromClassroomRecord(classroomId).topicIdsList.map { topicId -> + createEphemeralTopicSummary(topicId, classroomId, contentLocale) + }.filter { + it.topicSummary.topicPlayAvailability.availabilityCase == AVAILABLE_TO_PLAY_NOW + } + ) + }.build() + } + + private fun getTopicIdListFromClassroomRecord(classroomId: String): ClassroomRecord.TopicIdList { + return if (loadLessonProtosFromAssets) { + val classroomRecord = assetRepository.loadProtoFromLocalAssets( + assetName = classroomId, + baseMessage = ClassroomRecord.getDefaultInstance() + ) + ClassroomRecord.TopicIdList.newBuilder().apply { + addAllTopicIds(classroomRecord.topicPrerequisitesMap.keys.toList()) + }.build() + } else { + val classroomJsonObject = jsonAssetRetriever + .loadJsonFromAsset("$classroomId.json") + ?: return ClassroomRecord.TopicIdList.getDefaultInstance() + val topicIdArray = classroomJsonObject + .getJSONObject("topic_prerequisites").keys().asSequence().toList() + ClassroomRecord.TopicIdList.newBuilder().apply { + topicIdArray.forEach { topicId -> + addTopicIds(topicId) + } + }.build() + } + } + + private fun createEphemeralTopicSummary( + topicId: String, + classroomId: String, + contentLocale: OppiaLocale.ContentLocale + ): EphemeralTopicSummary { + val topicSummary = createTopicSummary(topicId, classroomId) + val classroomSummary = createClassroomSummary(classroomId) + return EphemeralTopicSummary.newBuilder().apply { + this.topicSummary = topicSummary + writtenTranslationContext = + translationController.computeWrittenTranslationContext( + topicSummary.writtenTranslationsMap, contentLocale + ) + classroomWrittenTranslationContext = + translationController.computeWrittenTranslationContext( + classroomSummary.writtenTranslationsMap, contentLocale + ) + classroomTitle = classroomSummary.classroomTitle + }.build() + } + + private fun createTopicSummary(topicId: String, classroomId: String): TopicSummary { + return if (loadLessonProtosFromAssets) { + val topicRecord = + assetRepository.loadProtoFromLocalAssets( + assetName = topicId, + baseMessage = TopicRecord.getDefaultInstance() + ) + val storyRecords = topicRecord.canonicalStoryIdsList.map { + assetRepository.loadProtoFromLocalAssets( + assetName = it, + baseMessage = StoryRecord.getDefaultInstance() + ) + } + TopicSummary.newBuilder().apply { + this.topicId = topicId + putAllWrittenTranslations(topicRecord.writtenTranslationsMap) + title = topicRecord.translatableTitle + this.classroomId = classroomId + totalChapterCount = storyRecords.map { it.chaptersList.size }.sum() + topicThumbnail = topicRecord.topicThumbnail + topicPlayAvailability = if (topicRecord.isPublished) { + TopicPlayAvailability.newBuilder().setAvailableToPlayNow(true).build() + } else { + TopicPlayAvailability.newBuilder().setAvailableToPlayInFuture(true).build() + } + storyRecords.firstOrNull()?.storyId?.let { this.firstStoryId = it } + }.build() + } else { + val topicJsonObject = jsonAssetRetriever + .loadJsonFromAsset("$topicId.json") + ?: return TopicSummary.getDefaultInstance() + createTopicSummaryFromJson(topicId, classroomId, topicJsonObject) + } + } + + private fun createTopicSummaryFromJson( + topicId: String, + classroomId: String, + jsonObject: JSONObject + ): TopicSummary { + var totalChapterCount = 0 + val storyData = jsonObject.getJSONArray("canonical_story_dicts") + for (i in 0 until storyData.length()) { + totalChapterCount += storyData + .getJSONObject(i) + .getJSONArray("node_titles") + .length() + } + val firstStoryId = + if (storyData.length() == 0) "" else storyData.getJSONObject(0).getStringFromObject("id") + + val topicPlayAvailability = if (jsonObject.getBoolean("published")) { + TopicPlayAvailability.newBuilder().setAvailableToPlayNow(true).build() + } else { + TopicPlayAvailability.newBuilder().setAvailableToPlayInFuture(true).build() + } + val topicTitle = SubtitledHtml.newBuilder().apply { + contentId = "title" + html = jsonObject.getStringFromObject("topic_name") + }.build() + // No written translations are included since none are retrieved from JSON. + return TopicSummary.newBuilder() + .setTopicId(topicId) + .setTitle(topicTitle) + .setClassroomId(classroomId) + .setVersion(jsonObject.optInt("version")) + .setTotalChapterCount(totalChapterCount) + .setTopicThumbnail(createTopicThumbnailFromJson(jsonObject)) + .setTopicPlayAvailability(topicPlayAvailability) + .setFirstStoryId(firstStoryId) + .build() + } +} + +/** Creates a [LessonThumbnail] from a classroomJsonObject. */ +internal fun createClassroomThumbnailFromJson(classroomJsonObject: JSONObject): LessonThumbnail { + val classroomId = classroomJsonObject.optString("classroom_id") + val thumbnailBgColor = classroomJsonObject.optString("thumbnail_bg_color") + val thumbnailFilename = classroomJsonObject.optString("thumbnail_filename") + return if (thumbnailFilename.isNotNullOrEmpty() && thumbnailBgColor.isNotNullOrEmpty()) { + LessonThumbnail.newBuilder() + .setThumbnailFilename(thumbnailFilename) + .setBackgroundColorRgb(Color.parseColor(thumbnailBgColor)) + .build() + } else if (CLASSROOM_THUMBNAILS.containsKey(classroomId)) { + CLASSROOM_THUMBNAILS.getValue(classroomId) + } else { + createDefaultClassroomThumbnail() + } +} + +/** Creates a [LessonThumbnail] from a classroom proto. */ +internal fun createClassroomThumbnailFromProto( + classroomId: String, + lessonThumbnail: LessonThumbnail +): LessonThumbnail { + val thumbnailFilename = lessonThumbnail.thumbnailFilename + return when { + thumbnailFilename.isNotNullOrEmpty() -> lessonThumbnail + CLASSROOM_THUMBNAILS.containsKey(classroomId) -> CLASSROOM_THUMBNAILS.getValue(classroomId) + else -> createDefaultClassroomThumbnail() + } +} + +/** Creates a default [LessonThumbnail]. */ +internal fun createDefaultClassroomThumbnail(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.MATHS_CLASSROOM) + .setBackgroundColorRgb(Color.parseColor(CLASSROOM_BG_COLOR)) + .build() +} + +/** Creates a [LessonThumbnail] for [TEST_CLASSROOM_ID_0]. */ +internal fun createClassroomThumbnail0(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.SCIENCE_CLASSROOM) + .setBackgroundColorRgb(Color.parseColor(CLASSROOM_BG_COLOR)) + .build() +} + +/** Creates a [LessonThumbnail] for [TEST_CLASSROOM_ID_1]. */ +internal fun createClassroomThumbnail1(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.MATHS_CLASSROOM) + .setBackgroundColorRgb(Color.parseColor(CLASSROOM_BG_COLOR)) + .build() +} + +/** Creates a [LessonThumbnail] for [TEST_CLASSROOM_ID_2]. */ +internal fun createClassroomThumbnail2(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.ENGLISH_CLASSROOM) + .setBackgroundColorRgb(Color.parseColor(CLASSROOM_BG_COLOR)) + .build() +} + +private fun String?.isNotNullOrEmpty(): Boolean = !this.isNullOrBlank() || this != "null" diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index 8e3da7fb459..51db3f941bb 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -71,6 +71,10 @@ private const val SET_SURVEY_LAST_SHOWN_TIMESTAMP_PROVIDER_ID = "record_survey_last_shown_timestamp_provider_id" private const val RETRIEVE_SURVEY_LAST_SHOWN_TIMESTAMP_PROVIDER_ID = "retrieve_survey_last_shown_timestamp_provider_id" +private const val SET_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID = + "set_last_selected_classroom_id_provider_id" +private const val RETRIEVE_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID = + "retrieve_last_selected_classroom_id_provider_id" /** Controller for retrieving, adding, updating, and deleting profiles. */ @Singleton @@ -851,6 +855,47 @@ class ProfileManagementController @Inject constructor( } } + /** + * Sets the last selected [classroomId] for the specified [profileId]. Returns a [DataProvider] + * indicating whether the save was a success. + */ + fun updateLastSelectedClassroomId( + profileId: ProfileId, + classroomId: String + ): DataProvider { + val deferred = profileDataStore.storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { profileDatabase -> + val profile = profileDatabase.profilesMap[profileId.internalId] + val updatedProfile = profile?.toBuilder()?.setLastSelectedClassroomId( + classroomId + )?.build() + val profileDatabaseBuilder = profileDatabase.toBuilder().putProfiles( + profileId.internalId, + updatedProfile + ) + Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) + } + return dataProviders.createInMemoryDataProviderAsync( + SET_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID + ) { + return@createInMemoryDataProviderAsync getDeferredResult(profileId, null, deferred) + } + } + + /** + * Returns a [DataProvider] containing a nullable last selected classroom ID for the specified + * [profileId]. + */ + fun retrieveLastSelectedClassroomId( + profileId: ProfileId + ): DataProvider { + return profileDataStore.transformAsync(RETRIEVE_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID) { + val lastSelectedClassroomId = it.profilesMap[profileId.internalId]?.lastSelectedClassroomId + AsyncResult.Success(lastSelectedClassroomId) + } + } + private suspend fun getDeferredResult( profileId: ProfileId?, name: String?, diff --git a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt index 067763a9963..4fa2ed9edef 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt @@ -29,6 +29,7 @@ import org.oppia.android.app.model.TopicProgress import org.oppia.android.app.model.TopicRecord import org.oppia.android.app.model.TopicSummary import org.oppia.android.app.model.UpcomingTopic +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_0 import org.oppia.android.domain.translation.TranslationController import org.oppia.android.domain.util.JsonAssetRetriever import org.oppia.android.domain.util.getStringFromObject @@ -60,11 +61,6 @@ const val SUBTOPIC_TOPIC_ID = 1 const val SUBTOPIC_TOPIC_ID_2 = 2 const val RATIOS_TOPIC_ID = "omzF4oqgeTXd" -// TODO(#5344): Move these classroom ids to [ClassroomController]. -const val TEST_CLASSROOM_ID_0 = "test_classroom_id_0" -const val TEST_CLASSROOM_ID_1 = "test_classroom_id_1" -const val TEST_CLASSROOM_ID_2 = "test_classroom_id_2" - val TOPIC_THUMBNAILS = mapOf( FRACTIONS_TOPIC_ID to createTopicThumbnail0(), RATIOS_TOPIC_ID to createTopicThumbnail1(), diff --git a/domain/src/test/java/org/oppia/android/domain/classroom/ClassroomControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/classroom/ClassroomControllerTest.kt new file mode 100644 index 00000000000..9ff4b0f29fc --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/classroom/ClassroomControllerTest.kt @@ -0,0 +1,354 @@ +package org.oppia.android.domain.classroom + +import android.app.Application +import android.content.Context +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 org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.ProfileId +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.topic.FRACTIONS_TOPIC_ID +import org.oppia.android.domain.topic.RATIOS_TOPIC_ID +import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_1 +import org.oppia.android.domain.topic.TEST_TOPIC_ID_2 +import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.environment.TestEnvironmentConfig +import org.oppia.android.testing.platformparameter.TestPlatformParameterModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetRepository +import org.oppia.android.util.caching.LoadLessonProtosFromAssets +import org.oppia.android.util.caching.testing.FakeAssetRepository +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.gcsresource.DefaultResourceBucketName +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.EnableConsoleLog +import org.oppia.android.util.logging.EnableFileLog +import org.oppia.android.util.logging.GlobalLogLevel +import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.parser.image.DefaultGcsPrefix +import org.oppia.android.util.parser.image.ImageDownloadUrlTemplate +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [ClassroomController]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = ClassroomControllerTest.TestApplication::class) +class ClassroomControllerTest { + @get:Rule + val oppiaTestRule = OppiaTestRule() + + @Inject + lateinit var classroomController: ClassroomController + + @Inject + lateinit var monitorFactory: DataProviderTestMonitor.Factory + + private lateinit var profileId0: ProfileId + + @Before + fun setUp() { + profileId0 = ProfileId.newBuilder().setInternalId(0).build() + TestPlatformParameterModule.forceEnableMultipleClassrooms(true) + setUpTestApplicationComponent() + } + + @Test + fun testGetClassroomList_isSuccessful() { + val classroomListProvider = classroomController.getClassroomList(profileId0) + + monitorFactory.waitForNextSuccessfulResult(classroomListProvider) + } + + @Test + fun testGetClassroomList_providesListOfMultipleClassrooms() { + val classroomList = getClassroomList(profileId0) + + assertThat(classroomList.classroomSummaryList.size).isGreaterThan(1) + } + + @Test + fun testGetClassroomList_firstClassroom_hasCorrectClassroomInfo() { + val classroomList = getClassroomList(profileId0) + + val firstClassroom = classroomList.classroomSummaryList[0] + assertThat(firstClassroom.classroomSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(firstClassroom.classroomSummary.classroomTitle.html).isEqualTo("Science") + } + + @Test + fun testGetClassroomList_firstClassroom_hasCorrectTopicCount() { + val classroomList = getClassroomList(profileId0) + + val firstClassroom = classroomList.classroomSummaryList[0] + assertThat(firstClassroom.classroomSummary.topicSummaryCount).isEqualTo(2) + } + + @Test + fun testGetClassroomList_secondClassroom_hasCorrectClassroomInfo() { + val classroomList = getClassroomList(profileId0) + + val secondClassroom = classroomList.classroomSummaryList[1] + assertThat(secondClassroom.classroomSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(secondClassroom.classroomSummary.classroomTitle.html).isEqualTo("Maths") + } + + @Test + fun testGetClassroomList_secondClassroom_hasCorrectTopicCount() { + val classroomList = getClassroomList(profileId0) + + val secondClassroom = classroomList.classroomSummaryList[1] + assertThat(secondClassroom.classroomSummary.topicSummaryCount).isEqualTo(2) + } + + @Test + fun testGetClassroomList_noPublishedTopicsInThirdClassroom_checkListExcludesThirdClassroom() { + val classroomList = getClassroomList(profileId0) + + assertThat(classroomList.classroomSummaryList.size).isEqualTo(2) + } + + @Test + fun testRetrieveTopicList_isSuccessful() { + val topicListProvider = classroomController.getTopicList(profileId0, TEST_CLASSROOM_ID_0) + + monitorFactory.waitForNextSuccessfulResult(topicListProvider) + } + + @Test + fun testRetrieveTopicList_testTopic0_hasCorrectTopicInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val firstTopic = topicList.getTopicSummary(0).topicSummary + assertThat(firstTopic.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(firstTopic.title.html).isEqualTo("First Test Topic") + } + + @Test + fun testRetrieveTopicList_testTopic0_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Science") + } + + @Test + fun testRetrieveTopicList_testTopic0_hasCorrectLessonCount() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val firstTopic = topicList.getTopicSummary(0).topicSummary + assertThat(firstTopic.totalChapterCount).isEqualTo(3) + } + + @Test + fun testRetrieveTopicList_testTopic1_hasCorrectTopicInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val secondTopic = topicList.getTopicSummary(1).topicSummary + assertThat(secondTopic.topicId).isEqualTo(TEST_TOPIC_ID_1) + assertThat(secondTopic.title.html).isEqualTo("Second Test Topic") + } + + @Test + fun testRetrieveTopicList_testTopic1_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val firstTopic = topicList.getTopicSummary(1) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Science") + } + + @Test + fun testRetrieveTopicList_testTopic1_hasCorrectLessonCount() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_0) + + val secondTopic = topicList.getTopicSummary(1).topicSummary + assertThat(secondTopic.totalChapterCount).isEqualTo(1) + } + + @Test + fun testRetrieveTopicList_fractionsTopic_hasCorrectTopicInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val fractionsTopic = topicList.getTopicSummary(0).topicSummary + assertThat(fractionsTopic.topicId).isEqualTo(FRACTIONS_TOPIC_ID) + assertThat(fractionsTopic.title.html).isEqualTo("Fractions") + } + + @Test + fun testRetrieveTopicList_fractionsTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Maths") + } + + @Test + fun testRetrieveTopicList_fractionsTopic_hasCorrectLessonCount() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val fractionsTopic = topicList.getTopicSummary(0).topicSummary + assertThat(fractionsTopic.totalChapterCount).isEqualTo(2) + } + + @Test + fun testRetrieveTopicList_ratiosTopic_hasCorrectTopicInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val ratiosTopic = topicList.getTopicSummary(1).topicSummary + assertThat(ratiosTopic.topicId).isEqualTo(RATIOS_TOPIC_ID) + assertThat(ratiosTopic.title.html).isEqualTo("Ratios and Proportional Reasoning") + } + + @Test + fun testRetrieveTopicList_ratiosTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val firstTopic = topicList.getTopicSummary(1) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Maths") + } + + @Test + fun testRetrieveTopicList_ratiosTopic_hasCorrectLessonCount() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_1) + + val ratiosTopic = topicList.getTopicSummary(1).topicSummary + assertThat(ratiosTopic.totalChapterCount).isEqualTo(4) + } + + @Test + fun testRetrieveTopicList_doesNotContainUnavailableTopic() { + val topicList = retrieveTopicList(TEST_CLASSROOM_ID_2) + + // Verify that the topic list does not contain a not-yet published topic (since it can't be + // played by the user). + val topicIds = topicList.topicSummaryList.map { it.topicSummary }.map { it.topicId } + assertThat(topicIds).doesNotContain(TEST_TOPIC_ID_2) + } + + private fun getClassroomList(profileId: ProfileId) = + monitorFactory.waitForNextSuccessfulResult(classroomController.getClassroomList(profileId)) + + private fun retrieveTopicList(classroomId: String) = monitorFactory.waitForNextSuccessfulResult( + classroomController.getTopicList(profileId0, classroomId) + ) + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @Provides + @DefaultGcsPrefix + @Singleton + fun provideDefaultGcsPrefix(): String { + return "https://storage.googleapis.com/" + } + + @Provides + @DefaultResourceBucketName + @Singleton + fun provideDefaultGcsResource(): String { + return "oppiaserver-resources/" + } + + @Provides + @ImageDownloadUrlTemplate + @Singleton + fun provideImageDownloadUrlTemplate(): String { + return "%s/%s/assets/image/%s" + } + + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + + @Provides + @LoadLessonProtosFromAssets + fun provideLoadLessonProtosFromAssets(testEnvironmentConfig: TestEnvironmentConfig): Boolean = + testEnvironmentConfig.isUsingBazel() + + @Provides + fun provideFakeAssetRepository(fakeImpl: FakeAssetRepository): AssetRepository = fakeImpl + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, LogStorageModule::class, + TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + LoggingIdentifierModule::class, ApplicationLifecycleModule::class, + SyncStatusModule::class, TestPlatformParameterModule::class, + PlatformParameterSingletonModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(classroomControllerTest: ClassroomControllerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerClassroomControllerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(classroomControllerTest: ClassroomControllerTest) { + component.inject(classroomControllerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt index ae98ccd5ae1..ba6082fa1c5 100644 --- a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt @@ -25,6 +25,8 @@ import org.oppia.android.app.model.Profile import org.oppia.android.app.model.ProfileDatabase import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ReadingTextSize.MEDIUM_TEXT_SIZE +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_1 +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_2 import org.oppia.android.domain.oppialogger.ApplicationIdSeed import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.domain.oppialogger.LoggingIdentifierController @@ -132,6 +134,7 @@ class ProfileManagementControllerTest { assertThat(profile.isContinueButtonAnimationSeen).isEqualTo(false) assertThat(File(getAbsoluteDirPath("0")).isDirectory).isTrue() assertThat(profile.surveyLastShownTimestampMs).isEqualTo(0L) + assertThat(profile.lastSelectedClassroomId).isEqualTo("") } @Test @@ -1168,6 +1171,113 @@ class ProfileManagementControllerTest { assertThat(lastShownTimeMs).isEqualTo(DEFAULT_SURVEY_LAST_SHOWN_TIMESTAMP_MILLIS) } + @Test + fun testFetchLastSelectedClassroomId_updateClassroomId_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() + addTestProfiles() + + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_0) + ) + + monitorFactory.waitForNextSuccessfulResult( + profileManagementController.updateLastSelectedClassroomId( + PROFILE_ID_0, + TEST_CLASSROOM_ID_1 + ) + ) + + val lastSelectedClassroomId = monitorFactory.waitForNextSuccessfulResult( + profileManagementController.retrieveLastSelectedClassroomId(PROFILE_ID_0) + ) + + assertThat(lastSelectedClassroomId).isEqualTo(TEST_CLASSROOM_ID_1) + } + + @Test + fun testFetchLastSelectedClassroomId_updateClassroomIdTwice_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() + addTestProfiles() + + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_0) + ) + + monitorFactory.waitForNextSuccessfulResult( + profileManagementController.updateLastSelectedClassroomId( + PROFILE_ID_0, + TEST_CLASSROOM_ID_1 + ) + ) + + monitorFactory.waitForNextSuccessfulResult( + profileManagementController.updateLastSelectedClassroomId( + PROFILE_ID_0, + TEST_CLASSROOM_ID_2 + ) + ) + + val lastSelectedClassroomId = monitorFactory.waitForNextSuccessfulResult( + profileManagementController.retrieveLastSelectedClassroomId(PROFILE_ID_0) + ) + + assertThat(lastSelectedClassroomId).isEqualTo(TEST_CLASSROOM_ID_2) + } + + @Test + fun testFetchLastSelectedClassroomId_updateClassroomIds_checkUpdateIsSuccessfulPerProfile() { + setUpTestApplicationComponent() + addTestProfiles() + + // Login to profile 0 and update the last selected classroom to classroom 1. + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_0) + ) + monitorFactory.waitForNextSuccessfulResult( + profileManagementController.updateLastSelectedClassroomId( + PROFILE_ID_0, + TEST_CLASSROOM_ID_1 + ) + ) + + // Login to profile 1 and update the last selected classroom to classroom 2. + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_1) + ) + monitorFactory.waitForNextSuccessfulResult( + profileManagementController.updateLastSelectedClassroomId( + PROFILE_ID_1, + TEST_CLASSROOM_ID_2 + ) + ) + + // Verify that last selected classroom of profile 0 is classroom 1. + val profile0SelectedClassroomId = monitorFactory.waitForNextSuccessfulResult( + profileManagementController.retrieveLastSelectedClassroomId(PROFILE_ID_0) + ) + assertThat(profile0SelectedClassroomId).isEqualTo(TEST_CLASSROOM_ID_1) + + // Verify that last selected classroom of profile 1 is classroom 2. + val classroomIdProfile1 = monitorFactory.waitForNextSuccessfulResult( + profileManagementController.retrieveLastSelectedClassroomId(PROFILE_ID_1) + ) + assertThat(classroomIdProfile1).isEqualTo(TEST_CLASSROOM_ID_2) + } + + @Test + fun testFetchLastSelectedClassroomId_withoutUpdatingClassroomId_returnEmptyClassroomId() { + setUpTestApplicationComponent() + addTestProfiles() + + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_0) + ) + val lastSelectedClassroomId = monitorFactory.waitForNextSuccessfulResult( + profileManagementController.retrieveLastSelectedClassroomId(PROFILE_ID_0) + ) + assertThat(lastSelectedClassroomId).isEmpty() + } + private fun addTestProfiles() { val profileAdditionProviders = PROFILES_LIST.map { addNonAdminProfile(it.name, pin = it.pin, allowDownloadAccess = it.allowDownloadAccess) diff --git a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt index 29006f7e528..052b684c8e6 100644 --- a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt @@ -18,6 +18,9 @@ import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.PromotedStory import org.oppia.android.app.model.TopicRecord import org.oppia.android.app.model.UpcomingTopic +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_0 +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_1 +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_2 import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.domain.oppialogger.LoggingIdentifierModule import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto index aadd1f34881..cc395949209 100644 --- a/model/src/main/proto/profile.proto +++ b/model/src/main/proto/profile.proto @@ -87,6 +87,9 @@ message Profile { // Represents the epoch timestamp in milliseconds when the nps survey was previously shown in // this profile. int64 survey_last_shown_timestamp_ms = 18; + + // Represents the ID of the classroom that the user selected during their last login. + string last_selected_classroom_id = 19; } // Represents a profile avatar image. diff --git a/model/src/main/proto/thumbnail.proto b/model/src/main/proto/thumbnail.proto index 607860ba30a..c13fcdbaa95 100644 --- a/model/src/main/proto/thumbnail.proto +++ b/model/src/main/proto/thumbnail.proto @@ -82,4 +82,13 @@ enum LessonThumbnailGraphic { // Corresponds to Number line Fractions subtopic. THE_NUMBER_LINE = 20; + + // Corresponds to Science classroom. + SCIENCE_CLASSROOM = 21; + + // Corresponds to Maths classroom. + MATHS_CLASSROOM = 22; + + // Corresponds to English classroom. + ENGLISH_CLASSROOM = 23; }