From 4867dd9f4d92167992c13580be087e1242cc7072 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 24 Sep 2019 22:15:08 -0700 Subject: [PATCH] Fix #117: Introduce topic list controller interface [Blocked: #175] (#176) * Introduce initially viable TopicListController interface with tests, and no real implementation. This commit includes thumbnail structures that should be split into its own branch/PR since other controllers will depend on it. * Introduce 6 sample thumbnail graphics locally and a proto data structure for thumbnails that can be used for lessons. * Update topic list controller to use new-and-improved thumbnail options. * Fix post-merge breakages. * Address review comment. --- .../oppia/domain/topic/TopicListController.kt | 117 +++++++++ .../domain/topic/TopicListControllerTest.kt | 242 ++++++++++++++++++ model/src/main/proto/topic.proto | 92 +++++++ 3 files changed, 451 insertions(+) create mode 100644 domain/src/main/java/org/oppia/domain/topic/TopicListController.kt create mode 100644 domain/src/test/java/org/oppia/domain/topic/TopicListControllerTest.kt diff --git a/domain/src/main/java/org/oppia/domain/topic/TopicListController.kt b/domain/src/main/java/org/oppia/domain/topic/TopicListController.kt new file mode 100644 index 00000000000..7d3065b16e7 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/topic/TopicListController.kt @@ -0,0 +1,117 @@ +package org.oppia.domain.topic + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import org.oppia.app.model.LessonThumbnail +import org.oppia.app.model.LessonThumbnailGraphic +import org.oppia.app.model.OngoingStoryList +import org.oppia.app.model.PromotedStory +import org.oppia.app.model.TopicList +import org.oppia.app.model.TopicSummary +import org.oppia.util.data.AsyncResult + +const val TEST_TOPIC_ID_0 = "test_topic_id_0" +const val TEST_TOPIC_ID_1 = "test_topic_id_1" + +private val EVICTION_TIME_MILLIS = TimeUnit.DAYS.toMillis(1) + +/** Controller for retrieving the list of topics available to the learner to play. */ +@Singleton +class TopicListController @Inject constructor() { + /** + * Returns the list of [TopicSummary]s currently tracked by the app, possibly up to + * [EVICTION_TIME_MILLIS] old. + */ + fun getTopicList(): LiveData> { + return MutableLiveData(AsyncResult.success(createTopicList())) + } + + /** + * 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]. + */ + fun getOngoingStoryList(): LiveData> { + return MutableLiveData(AsyncResult.success(createOngoingStoryList())) + } + + private fun createTopicList(): TopicList { + return TopicList.newBuilder() + .setPromotedStory(createPromotedStory1()) + .addTopicSummary(createTopicSummary0()) + .addTopicSummary(createTopicSummary1()) + .setOngoingStoryCount(2) + .build() + } + + private fun createTopicSummary0(): TopicSummary { + return TopicSummary.newBuilder() + .setTopicId(TEST_TOPIC_ID_0) + .setName("First Topic") + .setVersion(1) + .setSubtopicCount(0) + .setCanonicalStoryCount(2) + .setUncategorizedSkillCount(0) + .setAdditionalStoryCount(0) + .setTotalSkillCount(2) + .setTotalChapterCount(4) + .setTopicThumbnail(createTopicThumbnail0()) + .build() + } + + private fun createTopicSummary1(): TopicSummary { + return TopicSummary.newBuilder() + .setTopicId(TEST_TOPIC_ID_1) + .setName("Second Topic") + .setVersion(3) + .setSubtopicCount(0) + .setCanonicalStoryCount(1) + .setUncategorizedSkillCount(0) + .setAdditionalStoryCount(0) + .setTotalSkillCount(1) + .setTotalChapterCount(1) + .setTopicThumbnail(createTopicThumbnail1()) + .build() + } + + private fun createOngoingStoryList(): OngoingStoryList { + return OngoingStoryList.newBuilder() + .addRecentStory(createPromotedStory1()) + .build() + } + + private fun createPromotedStory1(): PromotedStory { + return PromotedStory.newBuilder() + .setStoryId(TEST_STORY_ID_1) + .setStoryName("Second Story") + .setTopicId(TEST_TOPIC_ID_0) + .setTopicName("First Topic") + .setCompletedChapterCount(1) + .setTotalChapterCount(3) + .setLessonThumbnail(createStoryThumbnail()) + .build() + } + + private fun createTopicThumbnail0(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.CHILD_WITH_BOOK) + .setBackgroundColorRgb(0xd5836f) + .build() + } + + private fun createTopicThumbnail1(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.CHILD_WITH_CUPCAKES) + .setBackgroundColorRgb(0xf7bf73) + .build() + } + + private fun createStoryThumbnail(): LessonThumbnail { + return LessonThumbnail.newBuilder() + .setThumbnailGraphic(LessonThumbnailGraphic.DUCK_AND_CHICKEN) + .setBackgroundColorRgb(0xa5d3ec) + .build() + } +} diff --git a/domain/src/test/java/org/oppia/domain/topic/TopicListControllerTest.kt b/domain/src/test/java/org/oppia/domain/topic/TopicListControllerTest.kt new file mode 100644 index 00000000000..2527c421e83 --- /dev/null +++ b/domain/src/test/java/org/oppia/domain/topic/TopicListControllerTest.kt @@ -0,0 +1,242 @@ +package org.oppia.domain.topic + +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.Test +import org.junit.runner.RunWith +import org.oppia.app.model.LessonThumbnailGraphic +import org.robolectric.annotation.Config +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TopicListController]. */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class TopicListControllerTest { + @Inject + lateinit var topicListController: TopicListController + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + // 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). + + @Test + fun testRetrieveTopicList_isSuccessful() { + val topicListLiveData = topicListController.getTopicList() + + val topicListResult = topicListLiveData.value + assertThat(topicListResult).isNotNull() + assertThat(topicListResult!!.isSuccess()).isTrue() + } + + @Test + fun testRetrieveTopicList_providesListOfMultipleTopics() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + assertThat(topicList.topicSummaryCount).isGreaterThan(1) + } + + @Test + fun testRetrieveTopicList_firstTopic_hasCorrectTopicInfo() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(firstTopic.name).isEqualTo("First Topic") + } + + @Test + fun testRetrieveTopicList_firstTopic_hasCorrectThumbnail() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.topicThumbnail.thumbnailGraphic).isEqualTo(LessonThumbnailGraphic.CHILD_WITH_BOOK) + } + + @Test + fun testRetrieveTopicList_firstTopic_hasCorrectLessonCount() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.totalChapterCount).isEqualTo(4) + } + + @Test + fun testRetrieveTopicList_secondTopic_hasCorrectTopicInfo() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val secondTopic = topicList.getTopicSummary(1) + assertThat(secondTopic.topicId).isEqualTo(TEST_TOPIC_ID_1) + assertThat(secondTopic.name).isEqualTo("Second Topic") + } + + @Test + fun testRetrieveTopicList_secondTopic_hasCorrectThumbnail() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val secondTopic = topicList.getTopicSummary(1) + assertThat(secondTopic.topicThumbnail.thumbnailGraphic).isEqualTo(LessonThumbnailGraphic.CHILD_WITH_CUPCAKES) + } + + @Test + fun testRetrieveTopicList_secondTopic_hasCorrectLessonCount() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val secondTopic = topicList.getTopicSummary(1) + assertThat(secondTopic.totalChapterCount).isEqualTo(1) + } + + @Test + fun testRetrieveTopicList_promotedLesson_hasCorrectLessonInfo() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val promotedStory = topicList.promotedStory + assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_1) + assertThat(promotedStory.storyName).isEqualTo("Second Story") + } + + @Test + fun testRetrieveTopicList_promotedLesson_hasCorrectTopicInfo() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val promotedStory = topicList.promotedStory + assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(promotedStory.topicName).isEqualTo("First Topic") + } + + @Test + fun testRetrieveTopicList_promotedLesson_hasCorrectCompletionStats() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + val promotedStory = topicList.promotedStory + assertThat(promotedStory.completedChapterCount).isEqualTo(1) + assertThat(promotedStory.totalChapterCount).isEqualTo(3) + } + + @Test + fun testRetrieveTopicList_hasMultipleOngoingLessons() { + val topicListLiveData = topicListController.getTopicList() + + val topicList = topicListLiveData.value!!.getOrThrow() + assertThat(topicList.ongoingStoryCount).isEqualTo(2) + } + + @Test + fun testRetrieveOngoingStoryList_isSuccessful() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryListResult = ongoingStoryListLiveData.value + assertThat(ongoingStoryListResult).isNotNull() + assertThat(ongoingStoryListResult!!.isSuccess()).isTrue() + } + + @Test + fun testRetrieveOngoingStoryList_withinSevenDays_hasOngoingLesson() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + assertThat(ongoingStoryList.recentStoryCount).isEqualTo(1) + } + + @Test + fun testRetrieveOngoingStoryList_recentLesson_hasCorrectStoryInfo() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + val recentLesson = ongoingStoryList.getRecentStory(0) + assertThat(recentLesson.storyId).isEqualTo(TEST_STORY_ID_1) + assertThat(recentLesson.storyName).isEqualTo("Second Story") + } + + @Test + fun testRetrieveOngoingStoryList_recentLesson_hasCorrectTopicInfo() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + val recentLesson = ongoingStoryList.getRecentStory(0) + assertThat(recentLesson.topicId).isEqualTo(TEST_TOPIC_ID_0) + assertThat(recentLesson.topicName).isEqualTo("First Topic") + } + + @Test + fun testRetrieveOngoingStoryList_recentLesson_hasCorrectCompletionStats() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + val recentLesson = ongoingStoryList.getRecentStory(0) + assertThat(recentLesson.completedChapterCount).isEqualTo(1) + assertThat(recentLesson.totalChapterCount).isEqualTo(3) + } + + @Test + fun testRetrieveOngoingStoryList_recentLesson_hasCorrectThumbnail() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + val recentLesson = ongoingStoryList.getRecentStory(0) + assertThat(recentLesson.lessonThumbnail.thumbnailGraphic).isEqualTo(LessonThumbnailGraphic.DUCK_AND_CHICKEN) + } + + @Test + fun testRetrieveOngoingStoryList_earlierThanSevenDays_doesNotHaveOngoingLesson() { + val ongoingStoryListLiveData = topicListController.getOngoingStoryList() + + val ongoingStoryList = ongoingStoryListLiveData.value!!.getOrThrow() + assertThat(ongoingStoryList.olderStoryCount).isEqualTo(0) + } + + private fun setUpTestApplicationComponent() { + DaggerTopicListControllerTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(topicListControllerTest: TopicListControllerTest) + } +} diff --git a/model/src/main/proto/topic.proto b/model/src/main/proto/topic.proto index d1e1d626136..adab5c19f40 100644 --- a/model/src/main/proto/topic.proto +++ b/model/src/main/proto/topic.proto @@ -2,9 +2,101 @@ syntax = "proto3"; package model; +import "thumbnail.proto"; + option java_package = "org.oppia.app.model"; option java_multiple_files = true; +// Corresponds to the on-disk storage representing all available topics for play. +message Classroom { + // Known topics that the player can play. + TopicSummary topic_summary = 1; + + // The last time this classroom was updated. + int64 last_update_time_ms = 2; +} + +// Corresponds to the list of topics that can be shown on the homescreen. +message TopicList { + // Corresponds to the story promoted at the top of the homescreen. Either the story is in-progress, or it's a + // recommended story if no other stories are in progress. + PromotedStory promoted_story = 1; + + // All topics that are available to the player. + repeated TopicSummary topic_summary = 2; + + // The total number of ongoing stories by the player. + int32 ongoing_story_count = 3; +} + +// Corresponds to the list of stories the player is currently playing. +message OngoingStoryList { + // Ongoing stories from within the last 7 days. + repeated PromotedStory recent_story = 1; + + // Other ongoing stories from longer than 7 days ago. + repeated PromotedStory older_story = 2; +} + +// The summary of a story that should be promoted, either because it's been started and not yet completed by the player, +// or because they have completed all other lessons and may find this one interesting. +message PromotedStory { + // The ID of the story being promoted. + string story_id = 1; + + // The name of the story being promoted. + string story_name = 2; + + // The ID of the topic this story is part of. + string topic_id = 3; + + // The name of the topic this story is part of. + string topic_name = 4; + + // The number of lessons the player has completed in this story. This may be 0 if the promoted story is promoted for + // reasons other than to complete it (e.g. it's recommended). + int32 completed_chapter_count = 5; + + // The total number of lessons this story contains. + int32 total_chapter_count = 6; + + // The thumbnail that should be displayed for this promoted story. + LessonThumbnail lesson_thumbnail = 7; +} + +// A homescreen summary of a topic. +message TopicSummary { + // 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 number of subtopics. + int32 subtopic_count = 4; + + // The number of canonical stories. + int32 canonical_story_count = 5; + + // The number of skills that have yet to be categorized. + int32 uncategorized_skill_count = 6; + + // The number of additional, non-canonical stories. + int32 additional_story_count = 7; + + // The total number of skills associated with this topic. + int32 total_skill_count = 8; + + // The total number of lessons associated with this topic. + int32 total_chapter_count = 9; + + // The associated thumbnail that should be displayed with this topic summary. + LessonThumbnail topic_thumbnail = 10; +} + // Represents the play state of a single chapter. enum ChapterPlayState { // The completion status is unknown.