diff --git a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt index abda9c898a2..d16fcb3da5e 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt @@ -35,6 +35,7 @@ const val RATIOS_EXPLORATION_ID_0 = "2mzzFVDLuAj8" const val RATIOS_EXPLORATION_ID_1 = "5NWuolNcwH6e" const val RATIOS_EXPLORATION_ID_2 = "k2bQ7z5XHNbK" const val RATIOS_EXPLORATION_ID_3 = "tIoSb3HZFN6e" +const val UPCOMING_TOPIC_ID_1 = "test_topic_id_2" private const val CACHE_NAME = "topic_progress_database" private const val RETRIEVE_TOPIC_PROGRESS_LIST_DATA_PROVIDER_ID = diff --git a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressTestHelper.kt b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressTestHelper.kt index d21248d56e6..a8d44a04102 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressTestHelper.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressTestHelper.kt @@ -41,13 +41,58 @@ class StoryProgressTestHelper @Inject constructor( ) } + /** + * Creates a partial story progress for a particular profile. + * + * @param profileId the profile we are setting partial progress of the fraction story for + * @param timestampOlderThanOneWeek if the timestamp for this topic progress is more than one week ago + */ + fun markChapDoneFrac0Story0Exp0(profileId: ProfileId, timestampOlderThanOneWeek: Boolean) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + timestamp + ) + } + + /** + * Mark a partial story progress for a particular profile. + * + * @param profileId the profile we are setting partial progress of the fraction story for + * @param timestampOlderThanOneWeek if the timestamp for this topic progress is more than one week ago + */ + fun markChapDoneFrac0Story0Expl(profileId: ProfileId, timestampOlderThanOneWeek: Boolean) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_1, + timestamp + ) + } + /** * Creates a partial topic progress for a particular profile. * * @param profileId the profile we are setting partial progress of the fraction topic for - * @param timestampOlderThanOneWeek if the timestamp for this topic progress is more than one week ago + * @param timestampOlderThanAWeek if the timestamp for this topic progress is more than one week ago */ - fun markPartialTopicProgressForFractions(profileId: ProfileId, timestampOlderThanAWeek: Boolean) { + fun markPartialTopicProgressForFractions( + profileId: ProfileId, + timestampOlderThanAWeek: Boolean + ) { val timestamp = if (!timestampOlderThanAWeek) { getCurrentTimestamp() } else { @@ -66,7 +111,7 @@ class StoryProgressTestHelper @Inject constructor( * Marks full story progress for a particular profile. * * @param profileId the profile we are setting full on the fraction story progress for - * @param timestampOlderThanOneWeek if the timestamp for completing the story is more than one week ago + * @param timestampOlderThanAWeek if the timestamp for completing the story is more than one week ago */ fun markFullStoryProgressForFractions(profileId: ProfileId, timestampOlderThanAWeek: Boolean) { val timestamp = if (!timestampOlderThanAWeek) { @@ -97,8 +142,8 @@ class StoryProgressTestHelper @Inject constructor( * @param profileId the profile we are setting fraction topic progress for * @param timestampOlderThanOneWeek if the timestamp for completing the topic is more than one week ago */ - fun markFullTopicProgressForFractions(profileId: ProfileId, timestampOlderThanAWeek: Boolean) { - val timestamp = if (!timestampOlderThanAWeek) { + fun markFullTopicProgressForFractions(profileId: ProfileId, timestampOlderThanOneWeek: Boolean) { + val timestamp = if (!timestampOlderThanOneWeek) { getCurrentTimestamp() } else { getOldTimestamp() @@ -232,10 +277,55 @@ class StoryProgressTestHelper @Inject constructor( } /** - * Marks one story progress full in ratios exploration for a particular profile. + * Marks full topic progress on the second test topic for a particular profile. + * + * @param profileId the profile we are setting topic progress for + * @param timestampOlderThanOneWeek if the timestamp for completing the topic is from more than one week ago + */ + fun markFullProgressForSecondTestTopic(profileId: ProfileId, timestampOlderThanOneWeek: Boolean) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + timestamp + ) + } + + /** + * Marks recently played on the second test topic for a particular profile. + * + * @param profileId the profile we are setting topic progress for + * @param timestampOlderThanOneWeek if the timestamp for completing the topic is from more than one week ago + */ + fun markRecentlyPlayedForSecondTestTopic( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordRecentlyPlayedChapter( + profileId, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + timestamp + ) + } + + /** + * Marks one story progress fully complete in the ratios topic for a particular profile. * * @param profileId the profile we are setting topic progress on ratios for - * @param timestampOlderThanOneWeek if the timestamp for this progress is from more than one week ago + * @param timestampOlderThanAWeek if the timestamp for this progress is from more than one week ago */ fun markFullStoryPartialTopicProgressForRatios( profileId: ProfileId, @@ -253,7 +343,54 @@ class StoryProgressTestHelper @Inject constructor( RATIOS_EXPLORATION_ID_0, timestamp ) + storyProgressController.recordCompletedChapter( + profileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_1, + timestamp + ) + } + /** + * Marks one story progress full in ratios exploration for a particular profile. + * + * @param profileId the profile we are setting topic progress on ratios for + * @param timestampOlderThanOneWeek if the timestamp for this progress is from more than one week ago + */ + fun markChapDoneOfRatiosStory0Exp0( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + timestamp + ) + } + + /** + * Marks one story progress full in ratios exploration for a particular profile. + * + * @param profileId the profile we are setting topic progress on ratios for + * @param timestampOlderThanOneWeek if the timestamp for this progress is from more than one week ago + */ + fun markChapDoneOfRatiosStory0Exp1( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } storyProgressController.recordCompletedChapter( profileId, RATIOS_TOPIC_ID, @@ -267,10 +404,13 @@ class StoryProgressTestHelper @Inject constructor( * Marks two partial story progress in ratios exploration for a particular profile. * * @param profileId the profile we are setting topic progress on ratios for - * @param timestampOlderThanOneWeek if the timestamp for the progress on the two stories is from more than one week + * @param timestampOlderThanAWeek if the timestamp for the progress on the two stories is from more than one week * ago. */ - fun markTwoPartialStoryProgressForRatios(profileId: ProfileId, timestampOlderThanAWeek: Boolean) { + fun markTwoPartialStoryProgressForRatios( + profileId: ProfileId, + timestampOlderThanAWeek: Boolean + ) { val timestamp = if (!timestampOlderThanAWeek) { getCurrentTimestamp() } else { @@ -297,7 +437,7 @@ class StoryProgressTestHelper @Inject constructor( * Marks exploration [FRACTIONS_EXPLORATION_ID_0] as recently played for a particular profile. * * @param profileId the profile we are setting recently played for - * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + * @param timestampOlderThanAWeek if the timestamp for the recently played story is more than a week ago */ fun markRecentlyPlayedForFractionsStory0Exploration0( profileId: ProfileId, @@ -321,7 +461,7 @@ class StoryProgressTestHelper @Inject constructor( * Marks exploration [RATIOS_EXPLORATION_ID_0] as recently played for a particular profile. * * @param profileId the profile we are setting recently played for - * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + * @param timestampOlderThanAWeek if the timestamp for the recently played story is more than a week ago */ fun markRecentlyPlayedForRatiosStory0Exploration0( profileId: ProfileId, @@ -350,9 +490,9 @@ class StoryProgressTestHelper @Inject constructor( */ fun markRecentlyPlayedForRatiosStory0Exploration0AndStory1Exploration2( profileId: ProfileId, - timestampOlderThanAWeek: Boolean + timestampOlderThanOneWeek: Boolean ) { - val timestamp = if (!timestampOlderThanAWeek) { + val timestamp = if (!timestampOlderThanOneWeek) { getCurrentTimestamp() } else { getOldTimestamp() @@ -379,7 +519,7 @@ class StoryProgressTestHelper @Inject constructor( * Marks first exploration in all stories of Ratios & Fractions as recently played for a particular profile. * * @param profileId the profile we are setting recently played for. - * @param timestampOlderThanOneWeek the timestamp for the recently played explorations is more than a week ago. + * @param timestampOlderThanAWeek the timestamp for the recently played explorations is more than a week ago. */ fun markRecentlyPlayedForFirstExplorationInAllStoriesInFractionsAndRatios( profileId: ProfileId, @@ -420,7 +560,7 @@ class StoryProgressTestHelper @Inject constructor( * Marks one explorations in each of the two two test topics as recently played for a particular profile. * * @param profileId the profile we are setting recently played for - * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + * @param timestampOlderThanAWeek if the timestamp for the recently played story is more than a week ago */ fun markRecentlyPlayedForOneExplorationInTestTopics1And2( profileId: ProfileId, @@ -446,4 +586,76 @@ class StoryProgressTestHelper @Inject constructor( timestamp ) } + + /** + * Marks one exploration in first test topic as completed played for a particular profile. + * + * @param profileId the profile we are setting recently played for + * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + */ + fun markChapterDoneFirstTestTopicStory0Exploration0( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + timestamp + ) + } + + /** + * Marks one exploration in first test topic as completed played for a particular profile. + * + * @param profileId the profile we are setting recently played for + * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + */ + fun markRecentlyPlayedFirstTestTopicStory1Exploration1( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordRecentlyPlayedChapter( + profileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_1, + TEST_EXPLORATION_ID_1, + timestamp + ) + } + + /** + * Marks one explorations in first test topic as completed for a particular profile. + * + * @param profileId the profile we are setting recently played for + * @param timestampOlderThanOneWeek if the timestamp for the recently played story is more than a week ago + */ + fun markChapterDoneFirstTestTopicStory0Exploration1( + profileId: ProfileId, + timestampOlderThanOneWeek: Boolean + ) { + val timestamp = if (!timestampOlderThanOneWeek) { + getCurrentTimestamp() + } else { + getOldTimestamp() + } + storyProgressController.recordCompletedChapter( + profileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + timestamp + ) + } } diff --git a/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt b/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt index 5cff912eb01..0ef8cc76fcb 100755 --- a/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt @@ -248,11 +248,6 @@ class TopicController @Inject constructor( ) } - // If there is no completed chapter, it cannot be an ongoing-topic. - if (completedChapterProgressList.isEmpty()) { - return false - } - // If there is at least 1 completed chapter and 1 not-completed chapter, it is definitely an // ongoing-topic. if (startedChapterProgressList.isNotEmpty()) { 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 9163c518ffa..1d6bcbc39ec 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 @@ -5,27 +5,34 @@ import org.json.JSONObject import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.ChapterProgress import org.oppia.android.app.model.ChapterSummary +import org.oppia.android.app.model.ComingSoonTopicList import org.oppia.android.app.model.LessonThumbnail import org.oppia.android.app.model.LessonThumbnailGraphic import org.oppia.android.app.model.OngoingStoryList import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.PromotedStory +import org.oppia.android.app.model.PromotedStoryList +import org.oppia.android.app.model.StoryProgress +import org.oppia.android.app.model.StorySummary import org.oppia.android.app.model.Topic 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_IN_FUTURE import org.oppia.android.app.model.TopicPlayAvailability.AvailabilityCase.AVAILABLE_TO_PLAY_NOW import org.oppia.android.app.model.TopicProgress import org.oppia.android.app.model.TopicSummary +import org.oppia.android.app.model.UpcomingTopic import org.oppia.android.domain.util.JsonAssetRetriever import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders import org.oppia.android.util.data.DataProviders.Companion.transformAsync +import org.oppia.android.util.system.OppiaClock import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -import kotlin.collections.ArrayList private const val ONE_WEEK_IN_DAYS = 7 private const val ONE_DAY_IN_MS = 24 * 60 * 60 * 1000 @@ -77,6 +84,8 @@ val EXPLORATION_THUMBNAILS = mapOf( private const val GET_TOPIC_LIST_PROVIDER_ID = "get_topic_list_provider_id" private const val GET_ONGOING_STORY_LIST_PROVIDER_ID = "get_ongoing_story_list_provider_id" +private const val GET_PROMOTED_ACTIVITY_LIST_PROVIDER_ID = + "get_recommended_actvity_list_provider_id" private val EVICTION_TIME_MILLIS = TimeUnit.DAYS.toMillis(1) @@ -86,8 +95,10 @@ class TopicListController @Inject constructor( private val jsonAssetRetriever: JsonAssetRetriever, private val topicController: TopicController, private val storyProgressController: StoryProgressController, - private val dataProviders: DataProviders + private val dataProviders: DataProviders, + private val oppiaClock: OppiaClock ) { + /** * Returns the list of [TopicSummary]s currently tracked by the app, possibly up to * [EVICTION_TIME_MILLIS] old. @@ -116,6 +127,23 @@ class TopicListController @Inject constructor( } } + /** + * Returns the list of ongoing [PromotedStory]s that can be viewed via a link on the homescreen. + * The total number of promoted stories should correspond to the ongoing story count within the + * [TopicList] returned by [getTopicList]. + * + * @param profileId the ID corresponding to the profile for which [PromotedStory] needs to be + * fetched. + * @return a [DataProvider] for an [PromotedActivityList]. + */ + fun getPromotedActivityList(profileId: ProfileId): DataProvider { + return storyProgressController.retrieveTopicProgressListDataProvider(profileId) + .transformAsync(GET_PROMOTED_ACTIVITY_LIST_PROVIDER_ID) { + val promotedActivityList = computePromotedActivityList(it) + AsyncResult.success(promotedActivityList) + } + } + private fun createTopicList(): TopicList { val topicIdJsonArray = jsonAssetRetriever .loadJsonFromAsset("topics.json")!! @@ -131,12 +159,35 @@ class TopicListController @Inject constructor( return topicListBuilder.build() } + private fun computeComingSoonTopicList(): ComingSoonTopicList { + val topicIdJsonArray = jsonAssetRetriever + .loadJsonFromAsset("topics.json")!! + .getJSONArray("topic_id_list") + val comingSoonTopicListBuilder = ComingSoonTopicList.newBuilder() + for (i in 0 until topicIdJsonArray.length()) { + val upcomingTopicSummary = createUpcomingTopicSummary(topicIdJsonArray.optString(i)!!) + // Only include topics currently not playable in the upcoming topic list. + if (upcomingTopicSummary.topicPlayAvailability.availabilityCase + == AVAILABLE_TO_PLAY_IN_FUTURE + ) { + comingSoonTopicListBuilder.addUpcomingTopic(upcomingTopicSummary) + } + } + return comingSoonTopicListBuilder.build() + } + private fun createTopicSummary(topicId: String): TopicSummary { val topicJson = jsonAssetRetriever.loadJsonFromAsset("$topicId.json")!! return createTopicSummaryFromJson(topicId, topicJson) } + private fun createUpcomingTopicSummary(topicId: String): UpcomingTopic { + val topicJson = + jsonAssetRetriever.loadJsonFromAsset("$topicId.json")!! + return createUpcomingTopicSummaryFromJson(topicId, topicJson) + } + private fun createTopicSummaryFromJson(topicId: String, jsonObject: JSONObject): TopicSummary { var totalChapterCount = 0 val storyData = jsonObject.getJSONArray("canonical_story_dicts") @@ -161,6 +212,32 @@ class TopicListController @Inject constructor( .build() } + private fun createUpcomingTopicSummaryFromJson( + topicId: String, + jsonObject: JSONObject + ): UpcomingTopic { + 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 topicPlayAvailability = if (jsonObject.getBoolean("published")) { + TopicPlayAvailability.newBuilder().setAvailableToPlayNow(true).build() + } else { + TopicPlayAvailability.newBuilder().setAvailableToPlayInFuture(true).build() + } + + return UpcomingTopic.newBuilder().setTopicId(topicId) + .setName(jsonObject.getString("topic_name")) + .setVersion(jsonObject.optInt("version")) + .setTopicPlayAvailability(topicPlayAvailability) + .setLessonThumbnail(createTopicThumbnail(jsonObject)) + .build() + } + private fun createOngoingStoryListFromProgress( topicProgressList: List ): OngoingStoryList { @@ -206,7 +283,8 @@ class TopicListController @Inject constructor( completedChapterProgressList.size, story.chapterCount, recentlyPlayerChapterSummary.name, - recentlyPlayerChapterSummary.explorationId + recentlyPlayerChapterSummary.explorationId, + isTopicConsideredCompleted = false ) if (numberOfDaysPassed < ONE_WEEK_IN_DAYS) { ongoingStoryListBuilder.addRecentStory(promotedStory) @@ -231,7 +309,8 @@ class TopicListController @Inject constructor( completedChapterProgressList.size, story.chapterCount, nextChapterSummary.name, - nextChapterSummary.explorationId + nextChapterSummary.explorationId, + isTopicConsideredCompleted = true ) if (numberOfDaysPassed < ONE_WEEK_IN_DAYS) { ongoingStoryListBuilder.addRecentStory(promotedStory) @@ -261,37 +340,344 @@ class TopicListController @Inject constructor( return recommendedStories } - private fun createRecommendedStoryFromAssets(topicId: String): PromotedStory? { - val topicJson = jsonAssetRetriever.loadJsonFromAsset("$topicId.json")!! - if (!topicJson.getBoolean("published")) { - // Do not recommend unpublished topics. - return null + private fun computePromotedActivityList( + topicProgressList: List + ): PromotedActivityList { + val promotedActivityListBuilder = PromotedActivityList.newBuilder() + if (topicProgressList.isNotEmpty()) { + promotedActivityListBuilder.promotedStoryList = computePromotedStoryList(topicProgressList) + if (promotedActivityListBuilder.promotedStoryList.getTotalPromotedStoryCount() == 0) { + promotedActivityListBuilder.comingSoonTopicList = computeComingSoonTopicList() + } + } + return promotedActivityListBuilder.build() + } + + private fun computePromotedStoryList(topicProgressList: List): PromotedStoryList { + return PromotedStoryList.newBuilder() + .addAllRecentlyPlayedStory(computePlayedStories(topicProgressList) { it < ONE_WEEK_IN_DAYS }) + .addAllOlderPlayedStory(computePlayedStories(topicProgressList) { it > ONE_WEEK_IN_DAYS }) + .addAllSuggestedStory(computeSuggestedStories(topicProgressList)) + .build() + } + + private fun PromotedStoryList.getTotalPromotedStoryCount(): Int { + return recentlyPlayedStoryList.size + olderPlayedStoryList.size + suggestedStoryList.size + } + + private fun computePlayedStories( + topicProgressList: List, + completionTimeFilter: (Long) -> Boolean + ): List { + + val playedPromotedStoryList = mutableListOf() + val sortedTopicProgressList = + topicProgressList.sortedByDescending { + val topicProgressStories = it.storyProgressMap.values + val topicProgressChapters = topicProgressStories.flatMap { it.chapterProgressMap.values } + val topicProgressLastPlayedTimes = + topicProgressChapters.map(ChapterProgress::getLastPlayedTimestamp) + topicProgressLastPlayedTimes.maxOrNull() + } + + sortedTopicProgressList.forEach { topicProgress -> + val topic = topicController.retrieveTopic(topicProgress.topicId) + + val isTopicConsideredCompleted = topicHasAtLeastOneStoryCompleted(topicProgress) + + topicProgress.storyProgressMap.values.forEach { storyProgress -> + val storyId = storyProgress.storyId + val story = topicController.retrieveStory(topic.topicId, storyId) + + val completedChapterProgressList = getCompletedChapterProgressList(storyProgress) + val latestCompletedChapterProgress: ChapterProgress? = + completedChapterProgressList.firstOrNull() + + val startedChapterProgressList = getStartedChapterProgressList(storyProgress) + val latestStartedChapterProgress: ChapterProgress? = + startedChapterProgressList.firstOrNull() + + when { + latestStartedChapterProgress != null -> { + val numberOfDaysPassed = latestStartedChapterProgress.getNumberOfDaysPassed() + if (completionTimeFilter(numberOfDaysPassed)) { + createOngoingStoryListBasedOnRecentlyPlayed( + storyId, + story, + latestStartedChapterProgress, + completedChapterProgressList, + topic, + isTopicConsideredCompleted + )?.let { promotedStory -> + playedPromotedStoryList.add(promotedStory) + } + } + } + // Compute the ongoing story list for stories that are not fully completed yet. + latestCompletedChapterProgress != null && + latestCompletedChapterProgress.explorationId != + story.chapterList.last().explorationId -> { + val numberOfDaysPassed = latestCompletedChapterProgress.getNumberOfDaysPassed() + if (completionTimeFilter(numberOfDaysPassed)) { + createOngoingStoryListBasedOnMostRecentlyCompleted( + storyId, + story, + latestCompletedChapterProgress, + completedChapterProgressList, + topic, + isTopicConsideredCompleted + )?.let { promotedStory -> + playedPromotedStoryList.add(promotedStory) + } + } + } + } + } } + return playedPromotedStoryList + } + + private fun checkIfStoryIsCompleted( + storyProgress: StoryProgress, + storySummary: StorySummary + ): Boolean { + val completedChapterProgressList = getCompletedChapterProgressList(storyProgress) + val lastChapterSummary = storySummary.chapterList.lastOrNull() + return completedChapterProgressList.find { chapterProgress -> + chapterProgress.explorationId == lastChapterSummary?.explorationId + } != null + } + + private fun getStartedChapterProgressList(storyProgress: StoryProgress): List = + getSortedChapterProgressListByPlayState( + storyProgress, playState = ChapterPlayState.STARTED_NOT_COMPLETED + ) + + private fun getCompletedChapterProgressList(storyProgress: StoryProgress): List = + getSortedChapterProgressListByPlayState( + storyProgress, playState = ChapterPlayState.COMPLETED + ) + + private fun getSortedChapterProgressListByPlayState( + storyProgress: StoryProgress, + playState: ChapterPlayState + ): List { + return storyProgress.chapterProgressMap.values + .filter { chapterProgress -> chapterProgress.chapterPlayState == playState } + .sortedByDescending { chapterProgress -> chapterProgress.lastPlayedTimestamp } + } - val storyData = topicJson.getJSONArray("canonical_story_dicts") - if (storyData.length() == 0) { - return PromotedStory.getDefaultInstance() + private fun createOngoingStoryListBasedOnRecentlyPlayed( + storyId: String, + story: StorySummary, + latestStartedChapterProgress: ChapterProgress, + completedChapterProgressList: List, + topic: Topic, + isTopicConsideredCompleted: Boolean + ): PromotedStory? { + val recentlyPlayerChapterSummary: ChapterSummary? = + story.chapterList.find { chapterSummary -> + latestStartedChapterProgress.explorationId == chapterSummary.explorationId + } + if (recentlyPlayerChapterSummary != null) { + return createPromotedStory( + storyId, + topic, + completedChapterProgressList.size, + story.chapterCount, + recentlyPlayerChapterSummary.name, + recentlyPlayerChapterSummary.explorationId, + isTopicConsideredCompleted + ) } - val totalChapterCount = storyData - .getJSONObject(0) - .getJSONArray("node_titles") - .length() - val storyId = storyData.optJSONObject(0).optString("id") - val storySummary = topicController.retrieveStory(topicId, storyId) + return null + } - val promotedStoryBuilder = PromotedStory.newBuilder() - .setStoryId(storyId) - .setStoryName(storySummary.storyName) - .setLessonThumbnail(storySummary.storyThumbnail) - .setTopicId(topicId) - .setTopicName(topicJson.optString("topic_name")) - .setCompletedChapterCount(0) - .setTotalChapterCount(totalChapterCount) - if (storySummary.chapterList.isNotEmpty()) { - promotedStoryBuilder.nextChapterName = storySummary.chapterList[0].name - promotedStoryBuilder.explorationId = storySummary.chapterList[0].explorationId + private fun createOngoingStoryListBasedOnMostRecentlyCompleted( + storyId: String, + story: StorySummary, + latestCompletedChapterProgress: ChapterProgress, + completedChapterProgressList: List, + topic: Topic, + isTopicConsideredCompleted: Boolean + ): PromotedStory? { + val lastChapterSummary: ChapterSummary? = + story.chapterList.find { chapterSummary -> + latestCompletedChapterProgress.explorationId == chapterSummary.explorationId + } + val nextChapterIndex = story.chapterList.indexOf(lastChapterSummary) + 1 + if (story.chapterList.size > nextChapterIndex) { + val nextChapterSummary: ChapterSummary? = story.chapterList[nextChapterIndex] + if (nextChapterSummary != null) { + return createPromotedStory( + storyId, + topic, + completedChapterProgressList.size, + story.chapterCount, + nextChapterSummary.name, + nextChapterSummary.explorationId, + isTopicConsideredCompleted + ) + } + } + return null + } + + private fun ChapterProgress.getNumberOfDaysPassed(): Long { + return TimeUnit.MILLISECONDS.toDays( + oppiaClock.getCurrentCalendar().timeInMillis - this.lastPlayedTimestamp + ) + } + + // TODO(#2550): Remove hardcoded order of topics. Compute list of suggested stories from backend structures + /** + * Returns a list of topic IDs for which the specified topic ID expects to be completed before + * being suggested. + */ + private fun retrieveTopicDependencies(topicId: String): List { + // The comments describe the correct dependencies, but those might not be available until the + // topic is introduced into the app. + return when (topicId) { + // TEST_TOPIC_ID_0 (depends on Fractions) + TEST_TOPIC_ID_0 -> listOf(FRACTIONS_TOPIC_ID) + // TEST_TOPIC_ID_1 (depends on TEST_TOPIC_ID_0,Ratios) + TEST_TOPIC_ID_1 -> listOf(TEST_TOPIC_ID_0, RATIOS_TOPIC_ID) + // Fractions (depends on A+S, Multiplication, Division) + FRACTIONS_TOPIC_ID -> listOf() + // Ratios (depends on A+S, Multiplication, Division) + RATIOS_TOPIC_ID -> listOf() + // Addition and Subtraction (depends on Place Values) + // Multiplication (depends on Addition and Subtraction) + // Division (depends on Multiplication) + // Expressions and Equations (depends on A+S, Multiplication, Division) + // Decimals (depends on A+S, Multiplication, Division) + else -> listOf() + } + } + + private fun computeSuggestedStories( + topicProgressList: List + ): List { + val recommendedStories = mutableListOf() + val topicIdJsonArray = jsonAssetRetriever + .loadJsonFromAsset("topics.json")!! + .getJSONArray("topic_id_list") + + // The list of started or completed topic IDs. + val startedTopicIds = topicProgressList.map(TopicProgress::getTopicId) + // All topics that could potentially be recommended. + val topicIdList = + (0 until topicIdJsonArray.length()).map { topicIdJsonArray[it].toString() } + // The list of topic IDs that qualify for being recommended. + val unstartedTopicIdList = topicIdList.filterNot { startedTopicIds.contains(it) } + + // A map of topic IDs to their dependencies. + val topicDependencyMap = topicIdList.associateWith { + retrieveTopicDependencies(it).toSet() + }.withDefault { setOf() } + // The list of topic IDs that are considered "finished" from a recommendation perspective. + val fullyCompletedTopicIds = topicProgressList.filter { + topicHasAtLeastOneStoryCompleted(it) + }.map(TopicProgress::getTopicId) + // A set of topic IDs that can be considered topics that should not be recommended. + val impliedFinishedTopicIds = computeImpliedCompletedDependencies( + fullyCompletedTopicIds, topicDependencyMap + ) + // Suggest prerequisite topic user needs to learn after completing any of the topics. + // The order in which the topic IDs are enumerated matters, and that it should be in the order + // of the list itself. + for (topicId in unstartedTopicIdList) { + // All of the topic's prerequisites can be suggested if the topic is ongoing. + val dependentTopicIds = topicDependencyMap[topicId] ?: listOf() + if (topicId !in impliedFinishedTopicIds && + impliedFinishedTopicIds.containsAll(dependentTopicIds) + ) { + createRecommendedStoryFromAssets(topicId)?.let { + recommendedStories.add(it) + } + } + } + return recommendedStories + } + + private fun topicHasAtLeastOneStoryCompleted(it: TopicProgress): Boolean { + val topic = topicController.retrieveTopic(it.topicId) + return it.storyProgressMap.values.any { storyProgress -> + val story = topicController.retrieveStory(topic.topicId, storyProgress.storyId) + return@any checkIfStoryIsCompleted(storyProgress, story) + } + } + + /** + * Return the list of topic IDs that are completed or can be implied completed based on actually + * completed topics. + */ + private fun computeImpliedCompletedDependencies( + fullyCompletedTopicIds: List, + topicDependencyMap: Map> + ): Set { + // For each completed topic ID, compute the transitive closure of all of its dependencies & + // then combine them into a single list with the actual completed topic IDs. The returned list + // is a list of either completed or assumed completed topics which will eliminate potential + // recommendations. + val completedTopicIds = + fullyCompletedTopicIds.flatMap { topicId -> + computeTransitiveDependencyClosure(topicId, topicDependencyMap) + } + fullyCompletedTopicIds + return completedTopicIds.toSet() + } + + private fun computeTransitiveDependencyClosure( + topicId: String, + topicDependencyMap: Map> + ): Set { + // Compute the total list of dependent topics that must be completed before the specified topic + // can be recommended. Note that this will cause a stack overflow if the graph has cycles. + val directDependencies = topicDependencyMap[topicId] ?: listOf() + val transitiveDependencies = directDependencies.flatMap { dependentId -> + computeTransitiveDependencyClosure( + dependentId, + topicDependencyMap + ) + } + return (transitiveDependencies + directDependencies).toSet() + } + + private fun createRecommendedStoryFromAssets(topicId: String): PromotedStory? { + val topicJson = jsonAssetRetriever.loadJsonFromAsset("$topicId.json") + if (topicJson!!.optString("topic_name").isNullOrEmpty()) { + return null + } else { + if (!topicJson.getBoolean("published")) { + // Do not recommend unpublished topics. + return null + } + + val storyData = topicJson.getJSONArray("canonical_story_dicts") + if (storyData.length() == 0) { + return PromotedStory.getDefaultInstance() + } + val totalChapterCount = storyData + .getJSONObject(0) + .getJSONArray("node_titles") + .length() + val storyId = storyData.optJSONObject(0).optString("id") + val storySummary = topicController.retrieveStory(topicId, storyId) + + val promotedStoryBuilder = PromotedStory.newBuilder() + .setStoryId(storyId) + .setStoryName(storySummary.storyName) + .setLessonThumbnail(storySummary.storyThumbnail) + .setTopicId(topicId) + .setTopicName(topicJson.optString("topic_name")) + .setCompletedChapterCount(0) + .setTotalChapterCount(totalChapterCount) + if (storySummary.chapterList.isNotEmpty()) { + promotedStoryBuilder.nextChapterName = storySummary.chapterList[0].name + promotedStoryBuilder.explorationId = storySummary.chapterList[0].explorationId + } + return promotedStoryBuilder.build() } - return promotedStoryBuilder.build() } private fun createPromotedStory( @@ -300,7 +686,8 @@ class TopicListController @Inject constructor( completedChapterCount: Int, totalChapterCount: Int, nextChapterName: String?, - explorationId: String? + explorationId: String?, + isTopicConsideredCompleted: Boolean ): PromotedStory { val storySummary = topic.storyList.find { summary -> summary.storyId == storyId }!! val promotedStoryBuilder = PromotedStory.newBuilder() @@ -311,6 +698,7 @@ class TopicListController @Inject constructor( .setTopicName(topic.name) .setCompletedChapterCount(completedChapterCount) .setTotalChapterCount(totalChapterCount) + .setIsTopicLearned(isTopicConsideredCompleted) if (nextChapterName != null && explorationId != null) { promotedStoryBuilder.nextChapterName = nextChapterName promotedStoryBuilder.explorationId = explorationId diff --git a/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressTestHelperTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressTestHelperTest.kt index b26f029b99b..5f869adbc83 100644 --- a/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressTestHelperTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/StoryProgressTestHelperTest.kt @@ -23,9 +23,9 @@ import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.CompletedStoryList -import org.oppia.android.app.model.OngoingStoryList import org.oppia.android.app.model.OngoingTopicList import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.StorySummary import org.oppia.android.app.model.Topic import org.oppia.android.app.model.TopicList @@ -79,10 +79,11 @@ class StoryProgressTestHelperTest { lateinit var completedStoryListResultCaptor: ArgumentCaptor> @Mock - lateinit var mockOngoingStoryListObserver: Observer> + lateinit var mockPromotedActivityListObserver: Observer> @Captor - lateinit var ongoingStoryListResultCaptor: ArgumentCaptor> + lateinit var promotedActivityListResultCaptor: + ArgumentCaptor> @Mock lateinit var mockOngoingTopicListObserver: Observer> @@ -125,9 +126,9 @@ class StoryProgressTestHelperTest { @Test fun testProgressTestHelper_markPartialStoryProgressForFractions_getTopicIsCorrect() { - storyProgressTestHelper.markPartialStoryProgressForFractions( + storyProgressTestHelper.markChapDoneFrac0Story0Exp0( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -148,9 +149,9 @@ class StoryProgressTestHelperTest { @Test fun testProgressTestHelper_markPartialStoryProgressForFractions_getStoryIsCorrect() { - storyProgressTestHelper.markPartialStoryProgressForFractions( + storyProgressTestHelper.markChapDoneFrac0Story0Exp0( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -170,9 +171,9 @@ class StoryProgressTestHelperTest { @Test fun testProgressTestHelper_markPartialStoryProgressForFractions_getOngoingTopicListIsCorrect() { - storyProgressTestHelper.markPartialStoryProgressForFractions( + storyProgressTestHelper.markChapDoneFrac0Story0Exp0( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -190,9 +191,9 @@ class StoryProgressTestHelperTest { @Test fun testProgressTestHelper_markPartialStoryProgressForFractions_getCompletedStoryListIsCorrect() { - storyProgressTestHelper.markPartialStoryProgressForFractions( + storyProgressTestHelper.markChapDoneFrac0Story0Exp0( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -374,7 +375,7 @@ class StoryProgressTestHelperTest { fun testProgressTestHelper_markFullTopicProgressForFractions_getTopicIsCorrect() { storyProgressTestHelper.markFullTopicProgressForFractions( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -397,7 +398,7 @@ class StoryProgressTestHelperTest { fun testProgressTestHelper_markFullTopicProgressForFractions_getStoryIsCorrect() { storyProgressTestHelper.markFullTopicProgressForFractions( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -417,7 +418,7 @@ class StoryProgressTestHelperTest { fun testProgressTestHelper_markFullTopicProgressForFractions_getOngoingTopicListIsCorrect() { storyProgressTestHelper.markFullTopicProgressForFractions( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -436,7 +437,7 @@ class StoryProgressTestHelperTest { fun testProgressTestHelper_markFullTopicProgressForFractions_getCompletedStoryListIsCorrect() { storyProgressTestHelper.markFullTopicProgressForFractions( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() @@ -737,139 +738,190 @@ class StoryProgressTestHelperTest { } @Test - fun testProgressTestHelper_markRecentlyPlayed_fractionsStory0Exp0_getOngoingStoryListIsCorrect() { + fun testProgressTestHelper_markRecentlyPlayed_fractionsStory0Exp0_promotedStoryListIsCorrect() { storyProgressTestHelper.markRecentlyPlayedForFractionsStory0Exploration0( profileId = profileId, timestampOlderThanAWeek = false ) testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() + verifyGetPromotedActivityListSucceeded() - val ongoingStoryList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingStoryList.recentStoryCount).isEqualTo(1) - assertThat(ongoingStoryList.olderStoryCount).isEqualTo(0) - assertThat(ongoingStoryList.recentStoryList[0].explorationId).isEqualTo( - FRACTIONS_EXPLORATION_ID_0 - ) - assertThat(ongoingStoryList.recentStoryList[0].completedChapterCount).isEqualTo(0) + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].explorationId) + .isEqualTo( + FRACTIONS_EXPLORATION_ID_0 + ) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].completedChapterCount + ).isEqualTo(0) } @Test - fun testProgressTestHelper_markRecentlyPlayed_ratiosStory0Exp0_getOngoingStoryListIsCorrect() { + fun testProgressTestHelper_markRecentlyPlayed_ratiosStory0Exp0_promotedStoryListIsCorrect() { storyProgressTestHelper.markRecentlyPlayedForRatiosStory0Exploration0( profileId = profileId, timestampOlderThanAWeek = false ) testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() + verifyGetPromotedActivityListSucceeded() - val ongoingStoryList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingStoryList.recentStoryCount).isEqualTo(1) - assertThat(ongoingStoryList.olderStoryCount).isEqualTo(0) - assertThat(ongoingStoryList.recentStoryList[0].explorationId).isEqualTo( - RATIOS_EXPLORATION_ID_0 + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].explorationId) + .isEqualTo(RATIOS_EXPLORATION_ID_0) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].completedChapterCount ) - assertThat(ongoingStoryList.recentStoryList[0].completedChapterCount).isEqualTo(0) + .isEqualTo(0) } @Test fun testProgressTestHelper_markRecentlyPlayed_ratiosStory0Exp0AndStory1Exp2_storyListIsCorrect() { storyProgressTestHelper.markRecentlyPlayedForRatiosStory0Exploration0AndStory1Exploration2( profileId = profileId, - timestampOlderThanAWeek = false + timestampOlderThanOneWeek = false ) testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() + verifyGetPromotedActivityListSucceeded() - val ongoingStoryList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingStoryList.recentStoryCount).isEqualTo(2) - assertThat(ongoingStoryList.olderStoryCount).isEqualTo(0) - assertThat(ongoingStoryList.recentStoryList[0].explorationId).isEqualTo( - RATIOS_EXPLORATION_ID_0 + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(2) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount).isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].explorationId) + .isEqualTo(RATIOS_EXPLORATION_ID_0) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].completedChapterCount ) - assertThat(ongoingStoryList.recentStoryList[0].completedChapterCount).isEqualTo(0) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1].explorationId) + .isEqualTo(RATIOS_EXPLORATION_ID_2) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1].completedChapterCount + ) + .isEqualTo(0) + } - assertThat(ongoingStoryList.recentStoryList[1].explorationId).isEqualTo( - RATIOS_EXPLORATION_ID_2 + @Test + fun testProgressTestHelper_markFullProgressForSecondTestTopic_suggestedStoryListIsCorrect() { + storyProgressTestHelper.markFullProgressForSecondTestTopic( + profileId = profileId, + timestampOlderThanOneWeek = false ) - assertThat(ongoingStoryList.recentStoryList[1].completedChapterCount).isEqualTo(0) + testCoroutineDispatchers.runCurrent() + + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) + testCoroutineDispatchers.runCurrent() + + verifyGetPromotedActivityListSucceeded() + + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount).isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount).isEqualTo(0) + assertThat(promotedActivityList.comingSoonTopicList.upcomingTopicCount).isEqualTo(1) } @Test - fun testProgressTestHelper_markRecentlyPlayed_firstStoryInTestTopic1And2_storyListIsCorrect() { + fun testProgressTestHelper_markRecentlyPlayed_firstStoryInTestTopic1And2_promotedListIsCorrect() { storyProgressTestHelper.markRecentlyPlayedForOneExplorationInTestTopics1And2( profileId = profileId, timestampOlderThanAWeek = false ) testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() + verifyGetPromotedActivityListSucceeded() - val ongoingStoryList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingStoryList.recentStoryCount).isEqualTo(2) - assertThat(ongoingStoryList.olderStoryCount).isEqualTo(0) - assertThat(ongoingStoryList.recentStoryList[0].explorationId).isEqualTo( - TEST_EXPLORATION_ID_2 - ) - assertThat(ongoingStoryList.recentStoryList[0].completedChapterCount).isEqualTo(0) - - assertThat(ongoingStoryList.recentStoryList[1].explorationId).isEqualTo( - TEST_EXPLORATION_ID_4 - ) - assertThat(ongoingStoryList.recentStoryList[1].completedChapterCount).isEqualTo(0) + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(2) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].explorationId) + .isEqualTo( + TEST_EXPLORATION_ID_2 + ) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].completedChapterCount + ).isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1].explorationId) + .isEqualTo( + TEST_EXPLORATION_ID_4 + ) + assertThat( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1].completedChapterCount + ).isEqualTo(0) } @Test - fun testHelper_recentlyPlayed_firstExpInAllFracRatio_asOldStories_ongoingStoryListCorrect() { + fun testHelper_recentlyPlayed_firstExpInAllFracRatio_asOldStories_promotedActivityListCorrect() { storyProgressTestHelper.markRecentlyPlayedForFirstExplorationInAllStoriesInFractionsAndRatios( profileId = profileId, timestampOlderThanAWeek = true ) testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() - - val ongoingStoryList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingStoryList.recentStoryCount).isEqualTo(0) - assertThat(ongoingStoryList.olderStoryCount).isEqualTo(3) + verifyGetPromotedActivityListSucceeded() - assertThat(ongoingStoryList.olderStoryList[0].explorationId).isEqualTo( + val promotedActivityList = promotedActivityListResultCaptor.value.getOrThrow() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(3) + assertThat( + promotedActivityList.promotedStoryList.olderPlayedStoryList[0].explorationId + ).isEqualTo( FRACTIONS_EXPLORATION_ID_0 ) - assertThat(ongoingStoryList.olderStoryList[0].completedChapterCount) + assertThat( + promotedActivityList.promotedStoryList.olderPlayedStoryList[0].completedChapterCount + ) .isEqualTo(0) - - assertThat(ongoingStoryList.olderStoryList[1].explorationId) + assertThat( + promotedActivityList.promotedStoryList.olderPlayedStoryList[1].explorationId + ) .isEqualTo(RATIOS_EXPLORATION_ID_0) - assertThat(ongoingStoryList.olderStoryList[1].completedChapterCount) + assertThat( + promotedActivityList.promotedStoryList.olderPlayedStoryList[1].completedChapterCount + ) .isEqualTo(0) - - assertThat(ongoingStoryList.olderStoryList[2].explorationId) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryList[2].explorationId) .isEqualTo(RATIOS_EXPLORATION_ID_2) - assertThat(ongoingStoryList.olderStoryList[2].completedChapterCount) + assertThat( + promotedActivityList.promotedStoryList.olderPlayedStoryList[2].completedChapterCount + ) .isEqualTo(0) } @@ -899,12 +951,12 @@ class StoryProgressTestHelperTest { assertThat(completedStoryListResultCaptor.value.isSuccess()).isTrue() } - private fun verifyGetOngoingStoryListSucceeded() { + private fun verifyGetPromotedActivityListSucceeded() { verify( - mockOngoingStoryListObserver, + mockPromotedActivityListObserver, atLeastOnce() - ).onChanged(ongoingStoryListResultCaptor.capture()) - assertThat(ongoingStoryListResultCaptor.value.isSuccess()).isTrue() + ).onChanged(promotedActivityListResultCaptor.capture()) + assertThat(promotedActivityListResultCaptor.value.isSuccess()).isTrue() } private fun verifyGetTopicListSucceeded() { 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 6bc4a850d37..7cc82eb0e8e 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 @@ -22,11 +22,12 @@ import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.android.app.model.LessonThumbnailGraphic -import org.oppia.android.app.model.OngoingStoryList import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.PromotedStory import org.oppia.android.app.model.TopicList import org.oppia.android.app.model.TopicSummary +import org.oppia.android.app.model.UpcomingTopic import org.oppia.android.domain.oppialogger.LogStorageModule import org.oppia.android.testing.RobolectricModule import org.oppia.android.testing.TestCoroutineDispatchers @@ -78,13 +79,14 @@ class TopicListControllerTest { lateinit var mockTopicListObserver: Observer> @Mock - lateinit var mockOngoingStoryListObserver: Observer> + lateinit var mockPromotedActivityListObserver: Observer> @Captor lateinit var topicListResultCaptor: ArgumentCaptor> @Captor - lateinit var ongoingStoryListResultCaptor: ArgumentCaptor> + lateinit var promotedActivityListResultCaptor: + ArgumentCaptor> private lateinit var profileId0: ProfileId @@ -100,7 +102,7 @@ class TopicListControllerTest { // TODO(#15): Add tests for recommended lessons rather than promoted, and tests for the 'continue playing' LiveData // not providing any data for cases when there are no ongoing lessons. Also, add tests for other uncovered cases - // (such as having and not having lessons in either of the OngoingStoryList section, or AsyncResult errors). + // (such as having and not having lessons in either of the PromotedActivityList section, or AsyncResult errors). @Test fun testRetrieveTopicList_isSuccessful() { @@ -228,17 +230,16 @@ class TopicListControllerTest { } @Test - fun testRetrieveOngoingStoryList_defaultLesson_hasCorrectInfo() { - topicListController.getOngoingStoryList(profileId0).toLiveData() - .observeForever(mockOngoingStoryListObserver) + fun testRetrievePromotedActivityList_defaultLesson_hasCorrectInfo() { + topicListController.getPromotedActivityList(profileId0).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() - verifyDefaultOngoingStoryListSucceeded() + verifyGetPromotedActivityListSucceeded() } @Test - fun testRetrieveOngoingStoryList_markRecentlyPlayedFracStory0Exp0_ongoingStoryListIsCorrect() { + fun testGetPromotedActivityList_markRecentlyPlayedFracStory0Exp0_ongoingStoryListIsCorrect() { storyProgressController.recordRecentlyPlayedChapter( profileId0, FRACTIONS_TOPIC_ID, @@ -246,14 +247,18 @@ class TopicListControllerTest { FRACTIONS_EXPLORATION_ID_0, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(1) - verifyOngoingStoryAsFractionStory0Exploration0(ongoingTopicList.recentStoryList[0]) + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + verifyOngoingStoryAsFractionStory0Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) } @Test - fun testRetrieveOngoingStoryList_markChapterCompletedFracStory0Exp0_ongoingStoryListIsCorrect() { + fun testGetPromotedStoryList_markChapDoneFracStory0Exp0_ongoingStoryListIsCorrect() { storyProgressController.recordCompletedChapter( profileId0, FRACTIONS_TOPIC_ID, @@ -261,14 +266,18 @@ class TopicListControllerTest { FRACTIONS_EXPLORATION_ID_0, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(1) - verifyOngoingStoryAsFractionStory0Exploration1(ongoingTopicList.recentStoryList[0]) + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + verifyOngoingStoryAsFractionStory0Exploration1( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) } @Test - fun testRetrieveStoryList_markChapDoneFracStory0Exp0_playedFracStory0Exp1_ongoingListCorrect() { + fun testGetStoryList_markChapDoneFracStory0Exp0_playedFracStory0Exp1_ongoingStoryListCorrect() { storyProgressController.recordCompletedChapter( profileId0, FRACTIONS_TOPIC_ID, @@ -285,14 +294,18 @@ class TopicListControllerTest { FRACTIONS_EXPLORATION_ID_1, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(1) - verifyOngoingStoryAsFractionStory0Exploration1(ongoingTopicList.recentStoryList[0]) + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + verifyOngoingStoryAsFractionStory0Exploration1( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) } @Test - fun testRetrieveOngoingStoryList_markAllChaptersCompletedInFractions_ongoingStoryListIsCorrect() { + fun testGetPromotedStoryList_markAllChapsDoneInFractions_suggestedStoryListIsCorrect() { storyProgressController.recordCompletedChapter( profileId0, FRACTIONS_TOPIC_ID, @@ -309,14 +322,23 @@ class TopicListControllerTest { FRACTIONS_EXPLORATION_ID_1, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(4) - verifyDefaultOngoingStoryListSucceeded() + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(2) + verifyPromotedStoryAsFirstTestTopicStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[0] + ) + verifyPromotedStoryAsRatioStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[1] + ) } @Test - fun testRetrieveStoryList_markRecentPlayedFirstChapInAllStoriesInRatios_ongoingListIsCorrect() { + fun testGetStoryList_markRecentPlayedFirstChapInAllStoriesInRatios_ongoingStoryListIsCorrect() { storyProgressController.recordRecentlyPlayedChapter( profileId0, RATIOS_TOPIC_ID, @@ -333,15 +355,67 @@ class TopicListControllerTest { RATIOS_EXPLORATION_ID_2, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(2) - verifyOngoingStoryAsRatioStory0Exploration0(ongoingTopicList.recentStoryList[0]) - verifyOngoingStoryAsRatioStory1Exploration2(ongoingTopicList.recentStoryList[1]) + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(2) + verifyOngoingStoryAsRatioStory0Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + verifyOngoingStoryAsRatioStory1Exploration2( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1] + ) } @Test - fun testRetrieveStoryList_markExp0DoneAndExp2AsPlayedInRatios_ongoingStoryListIsCorrect() { + fun testGetStoryList_markExp0DoneAndExp2InRatios_promotedStoryListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_1, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(1) + verifyPromotedStoryAsFractionStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[0] + ) + } + + @Test + fun testGetStoryList_markStoryDoneOfRatiosAndFirstTestTopic_suggestedStoryListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( profileId0, RATIOS_TOPIC_ID, @@ -351,22 +425,163 @@ class TopicListControllerTest { ) testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_1, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(1) + verifyPromotedStoryAsSecondTestTopicStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[0] + ) + } + + @Test + fun testGetStoryList_markRecentlyPlayedFirstTestTopic_defaultSuggestedStoryListIsCorrect() { storyProgressController.recordRecentlyPlayedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(2) + verifyOngoingStoryAsFirstTopicStory0Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + verifyPromotedStoryAsFractionStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[0] + ) + verifyPromotedStoryAsRatioStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[1] + ) + } + + @Test + fun testRetrievePromotedActivityList_markAllChapDoneInAllTopics_comingSoonTopicListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( + profileId0, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_1, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + storyProgressController.recordCompletedChapter( profileId0, RATIOS_TOPIC_ID, - RATIOS_STORY_ID_1, - RATIOS_EXPLORATION_ID_2, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_1, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(2) - verifyOngoingStoryAsRatioStory0Exploration1(ongoingTopicList.recentStoryList[0]) - verifyOngoingStoryAsRatioStory1Exploration2(ongoingTopicList.recentStoryList[1]) + assertThat(promotedActivityList.comingSoonTopicList.upcomingTopicCount) + .isEqualTo(1) } @Test - fun testRetrieveStoryList_markFirstExpOfEveryStoryDoneWithinLastSevenDays_ongoingListIsCorrect() { + fun testGetStoryList_markAllChapDoneInSecondTestTopic_doesNotPromoteAnyStories() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(0) + } + + @Test + fun testGetStoryList_markAllChapDoneInSecondTestTopic_comingSoonTopicListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_1, + TEST_STORY_ID_2, + TEST_EXPLORATION_ID_4, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(0) + assertThat(promotedActivityList.comingSoonTopicList.upcomingTopicCount) + .isEqualTo(1) + verifyUpcomingTopic1(promotedActivityList.comingSoonTopicList.upcomingTopicList[0]) + } + + @Test + fun testGetStoryList_markFirstExpOfEveryStoryDoneWithinLastSevenDays_ongoingListIsCorrect() { storyProgressController.recordCompletedChapter( profileId0, FRACTIONS_TOPIC_ID, @@ -392,12 +607,203 @@ class TopicListControllerTest { RATIOS_EXPLORATION_ID_2, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(3) + verifyOngoingStoryAsRatioStory0Exploration1( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + verifyOngoingStoryAsRatioStory1Exploration3( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1] + ) + verifyOngoingStoryAsFractionStory0Exploration1( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[2] + ) + } + + @Test + fun testGetStoryList_markFirstExpOfEveryStoryDoneWithinLastMonth_ongoingOlderListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + FRACTIONS_TOPIC_ID, + FRACTIONS_STORY_ID_0, + FRACTIONS_EXPLORATION_ID_0, + getOldTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + getOldTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_1, + RATIOS_EXPLORATION_ID_2, + getOldTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(3) + verifyOngoingStoryAsRatioStory0Exploration1( + promotedActivityList.promotedStoryList.olderPlayedStoryList[0] + ) + verifyOngoingStoryAsRatioStory1Exploration3( + promotedActivityList.promotedStoryList.olderPlayedStoryList[1] + ) + verifyOngoingStoryAsFractionStory0Exploration1( + promotedActivityList.promotedStoryList.olderPlayedStoryList[2] + ) + } + + @Test + fun testGetStoryList_markRecentlyPlayedForFirstTestTopic_ongoingStoryListIsCorrect() { + storyProgressController.recordRecentlyPlayedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + verifyOngoingStoryAsFirstTopicStory0Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + } + + @Test + fun testGetStoryList_markOneStoryDoneForFirstTestTopic_suggestedStoryListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + + assertThat(promotedActivityList.promotedStoryList.suggestedStoryCount) + .isEqualTo(1) + verifyPromotedStoryAsRatioStory0Exploration0( + promotedActivityList.promotedStoryList.suggestedStoryList[0] + ) + } + + @Test + fun testGetStoryList_markOneStoryDoneAndPlayNextStoryOfFirstTestTopic_ongoingListIsCorrect() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordRecentlyPlayedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_1, + TEST_EXPLORATION_ID_1, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].isTopicLearned) + .isTrue() + verifyOngoingStoryAsFirstTopicStory1Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + } + + @Test + fun testGetStoryList_story0DonePlayStory1FirstTestTopic_playRatios_firstTestTopicisLearned() { + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordCompletedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_5, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + storyProgressController.recordRecentlyPlayedChapter( + profileId0, + TEST_TOPIC_ID_0, + TEST_STORY_ID_1, + TEST_EXPLORATION_ID_1, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(3) - verifyOngoingStoryAsFractionStory0Exploration1(ongoingTopicList.recentStoryList[0]) - verifyOngoingStoryAsRatioStory0Exploration1(ongoingTopicList.recentStoryList[1]) - verifyOngoingStoryAsRatioStory1Exploration3(ongoingTopicList.recentStoryList[2]) + storyProgressController.recordRecentlyPlayedChapter( + profileId0, + RATIOS_TOPIC_ID, + RATIOS_STORY_ID_0, + RATIOS_EXPLORATION_ID_0, + getCurrentTimestamp() + ) + testCoroutineDispatchers.runCurrent() + + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(2) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0].isTopicLearned) + .isFalse() + verifyOngoingStoryAsRatioStory0Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1].isTopicLearned) + .isTrue() + verifyOngoingStoryAsFirstTopicStory1Exploration0( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[1] + ) } @Test @@ -427,30 +833,56 @@ class TopicListControllerTest { RATIOS_EXPLORATION_ID_2, getCurrentTimestamp() ) + testCoroutineDispatchers.runCurrent() - val ongoingTopicList = retrieveOngoingStoryList() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(1) - assertThat(ongoingTopicList.olderStoryCount).isEqualTo(2) - verifyOngoingStoryAsFractionStory0Exploration1(ongoingTopicList.olderStoryList[0]) - verifyOngoingStoryAsRatioStory0Exploration1(ongoingTopicList.olderStoryList[1]) - verifyOngoingStoryAsRatioStory1Exploration3(ongoingTopicList.recentStoryList[0]) + val promotedActivityList = retrievePromotedActivityList() + assertThat(promotedActivityList.promotedStoryList.recentlyPlayedStoryCount) + .isEqualTo(1) + assertThat(promotedActivityList.promotedStoryList.olderPlayedStoryCount) + .isEqualTo(2) + verifyOngoingStoryAsRatioStory0Exploration1( + promotedActivityList.promotedStoryList.olderPlayedStoryList[0] + ) + verifyOngoingStoryAsFractionStory0Exploration1( + promotedActivityList.promotedStoryList.olderPlayedStoryList[1] + ) + verifyOngoingStoryAsRatioStory1Exploration3( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList[0] + ) } - private fun verifyGetOngoingStoryListSucceeded() { + private fun verifyGetPromotedActivityListSucceeded() { verify( - mockOngoingStoryListObserver, + mockPromotedActivityListObserver, atLeastOnce() - ).onChanged(ongoingStoryListResultCaptor.capture()) - assertThat(ongoingStoryListResultCaptor.value.isSuccess()).isTrue() + ).onChanged(promotedActivityListResultCaptor.capture()) + assertThat(promotedActivityListResultCaptor.value.isSuccess()).isTrue() + } + + private fun verifyPromotedStoryAsFirstTestTopicStory0Exploration0(promotedStory: PromotedStory) { + assertThat(promotedStory.explorationId).isEqualTo(TEST_EXPLORATION_ID_2) + assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_0) + assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(promotedStory.topicName).isEqualTo("First Test Topic") + assertThat(promotedStory.nextChapterName).isEqualTo("Prototype Exploration") + assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) + .isEqualTo(LessonThumbnailGraphic.BAKER) + assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() + assertThat(promotedStory.totalChapterCount).isEqualTo(2) } - private fun verifyDefaultOngoingStoryListSucceeded() { - val ongoingTopicList = ongoingStoryListResultCaptor.value.getOrThrow() - assertThat(ongoingTopicList.recentStoryCount).isEqualTo(4) - verifyOngoingStoryAsFirstTopicStory0Exploration0(ongoingTopicList.recentStoryList[0]) - verifyOngoingStoryAsSecondTopicStory0Exploration0(ongoingTopicList.recentStoryList[1]) - verifyOngoingStoryAsFractionStory0Exploration0(ongoingTopicList.recentStoryList[2]) - verifyOngoingStoryAsRatioStory0Exploration0(ongoingTopicList.recentStoryList[3]) + private fun verifyOngoingStoryAsFirstTopicStory1Exploration0(promotedStory: PromotedStory) { + assertThat(promotedStory.explorationId).isEqualTo(TEST_EXPLORATION_ID_1) + assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_1) + assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(promotedStory.topicName).isEqualTo("First Test Topic") + assertThat(promotedStory.nextChapterName).isEqualTo("Second Exploration") + assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) + .isEqualTo(LessonThumbnailGraphic.COMPARING_FRACTIONS) + assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isTrue() + assertThat(promotedStory.totalChapterCount).isEqualTo(3) } private fun verifyOngoingStoryAsFirstTopicStory0Exploration0(promotedStory: PromotedStory) { @@ -462,10 +894,11 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.BAKER) assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } - private fun verifyOngoingStoryAsSecondTopicStory0Exploration0(promotedStory: PromotedStory) { + private fun verifyPromotedStoryAsSecondTestTopicStory0Exploration0(promotedStory: PromotedStory) { assertThat(promotedStory.explorationId).isEqualTo(TEST_EXPLORATION_ID_4) assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_2) assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_1) @@ -474,6 +907,7 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.DERIVE_A_RATIO) assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(1) } @@ -486,6 +920,20 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.DUCK_AND_CHICKEN) assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() + assertThat(promotedStory.totalChapterCount).isEqualTo(2) + } + + private fun verifyPromotedStoryAsFractionStory0Exploration0(promotedStory: PromotedStory) { + assertThat(promotedStory.explorationId).isEqualTo(FRACTIONS_EXPLORATION_ID_0) + assertThat(promotedStory.storyId).isEqualTo(FRACTIONS_STORY_ID_0) + assertThat(promotedStory.topicId).isEqualTo(FRACTIONS_TOPIC_ID) + assertThat(promotedStory.topicName).isEqualTo("Fractions") + assertThat(promotedStory.nextChapterName).isEqualTo("What is a Fraction?") + assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) + .isEqualTo(LessonThumbnailGraphic.DUCK_AND_CHICKEN) + assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } @@ -510,6 +958,20 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_FRACTIONS_HOMEWORK) assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() + assertThat(promotedStory.totalChapterCount).isEqualTo(2) + } + + private fun verifyPromotedStoryAsRatioStory0Exploration0(promotedStory: PromotedStory) { + assertThat(promotedStory.explorationId).isEqualTo(RATIOS_EXPLORATION_ID_0) + assertThat(promotedStory.storyId).isEqualTo(RATIOS_STORY_ID_0) + assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) + assertThat(promotedStory.nextChapterName).isEqualTo("What is a Ratio?") + assertThat(promotedStory.topicName).isEqualTo("Ratios and Proportional Reasoning") + assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) + .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_FRACTIONS_HOMEWORK) + assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } @@ -522,9 +984,17 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_FRACTIONS_HOMEWORK) assertThat(promotedStory.completedChapterCount).isEqualTo(1) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } + private fun verifyUpcomingTopic1(upcomingTopic: UpcomingTopic) { + assertThat(upcomingTopic.topicId).isEqualTo(UPCOMING_TOPIC_ID_1) + assertThat(upcomingTopic.name).isEqualTo("Third Test Topic") + assertThat(upcomingTopic.lessonThumbnail.thumbnailGraphic) + .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_FRACTIONS_HOMEWORK) + } + private fun verifyOngoingStoryAsRatioStory1Exploration2(promotedStory: PromotedStory) { assertThat(promotedStory.explorationId).isEqualTo(RATIOS_EXPLORATION_ID_2) assertThat(promotedStory.storyId).isEqualTo(RATIOS_STORY_ID_1) @@ -534,6 +1004,7 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_CUPCAKES) assertThat(promotedStory.completedChapterCount).isEqualTo(0) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } @@ -546,6 +1017,7 @@ class TopicListControllerTest { assertThat(promotedStory.lessonThumbnail.thumbnailGraphic) .isEqualTo(LessonThumbnailGraphic.CHILD_WITH_CUPCAKES) assertThat(promotedStory.completedChapterCount).isEqualTo(1) + assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) } @@ -566,13 +1038,13 @@ class TopicListControllerTest { return topicListResultCaptor.value.getOrThrow() } - private fun retrieveOngoingStoryList(): OngoingStoryList { + private fun retrievePromotedActivityList(): PromotedActivityList { testCoroutineDispatchers.runCurrent() - topicListController.getOngoingStoryList(profileId0).toLiveData() - .observeForever(mockOngoingStoryListObserver) + topicListController.getPromotedActivityList(profileId0).toLiveData() + .observeForever(mockPromotedActivityListObserver) testCoroutineDispatchers.runCurrent() - verifyGetOngoingStoryListSucceeded() - return ongoingStoryListResultCaptor.value.getOrThrow() + verifyGetPromotedActivityListSucceeded() + return promotedActivityListResultCaptor.value.getOrThrow() } // TODO(#89): Move this to a common test application component. diff --git a/model/src/main/proto/topic.proto b/model/src/main/proto/topic.proto index fe5058b3c65..9106276857f 100755 --- a/model/src/main/proto/topic.proto +++ b/model/src/main/proto/topic.proto @@ -203,6 +203,10 @@ message PromotedStory { // The thumbnail that should be displayed for this promoted story. LessonThumbnail lesson_thumbnail = 9; + + // Indicates whether the topic containing this story is 'learned' + // (meaning at least one story was completed). + bool is_topic_learned = 10; } // A homescreen summary of a topic. @@ -259,6 +263,52 @@ message TopicProgress { map story_progress = 2; } +// A structure corresponding to the promoted stories. This structure is set up +// to properly account for recently played stories, recommended stories +// and coming soon topics. +message PromotedActivityList { + oneof recommendation_type { + PromotedStoryList promoted_story_list = 1; + ComingSoonTopicList coming_soon_topic_list = 2; + } +} + +// Corresponds to the list of stories the player is currently playing across all topics and recommended stories. +message PromotedStoryList { + // Ongoing stories from within the last 7 days. + repeated PromotedStory recently_played_story = 1; + + // Other ongoing stories from longer than 7 days ago. + repeated PromotedStory older_played_story = 2; + + // Stories specifically recommended for the learner to complete next. + repeated PromotedStory suggested_story = 3; +} + +// Corresponds to the list of coming soon topics that can be shown on the home screen. +message ComingSoonTopicList { + // Upcoming topics for the learner. + repeated UpcomingTopic upcoming_topic = 1; +} + +// Represents topics not yet ready to be played by the learner. +message UpcomingTopic { + // The ID of the topic. + string topic_id = 1; + + // The name of the topic. + string name = 2; + + // The structural version of the topic. + int32 version = 3; + + // The associated thumbnail that should be displayed with this topic. + LessonThumbnail lesson_thumbnail = 4; + + // Specifics about whether this topic is playable. + TopicPlayAvailability topic_play_availability = 5; +} + // Represents the story progress. message StoryProgress { // The ID corresponding to the story.