From 04391efa3b7abbfb25eb62dc364c5f4dc41367f1 Mon Sep 17 00:00:00 2001 From: James Xu Date: Thu, 5 Dec 2019 13:57:31 -0500 Subject: [PATCH] Fixes part of #16 and #44: Implementation of ProfileChooserFragment (#326) * implemented get and delete * Finished test cases * added observing cachestore * Fixed test cases * Finished first draft of implementation * Added additional checks to setCurrentProfileId * Added more test cases * Finished test stubs * Finished test cases * Added query string to gravatar * primed cache on init * Fixed typo * Fixed addProfile test case * added create method to PersistentCacheStore * added setTimestamp and setAdmin * changed isAdmin to default to false * Added update last logged in * Started working on chooser * debugging * Added UI for chooser * added click functionality * Minor fixes * Updated to use deferred value from persistentCacheStore * Updated to use login * Ensured result is error to post error value * name can only be letters and unique check now case insensitive * Added profile sorting by last accessed * Fixed margins and added gradient * allow names to have spaces * Added image rotation and compression * fixed api requires for exifinterface * Changed gradle dependency * Added new asset and changed colors * Sorted alphabetically * added local default to tolowercase * started profile test helper * Removed broken test, couldn't get mockito to work * Added ProfileTestHelperTest * added test case for profilechooserfragment * using string.xml values in tests * Minor fixes and added default profile avatar * minor fixes * added endline * Minor fixes. * Started updating to DataProviders * Fixed loginToProfile * Minor fixes * Added comments and new test. * minor fixes * Fixed bug * Added vector version of avatar * Minor fixes. * Converted livedata to dataproviders * Minor fixes * Removed coroutines from test. * Removed ExperimentalCoroutinesApi * Removed import * Removed ExperimentalCouroutinesApi * Fixed failing test cases * Updated xml to multiples of 4 * Added annotations. * Fixed test * Fixed splash activity test * Fixed ProfileTestHelper --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 1 + .../databinding/ImageViewBindingAdapters.kt | 8 + .../org/oppia/app/profile/ProfileActivity.kt | 3 +- .../app/profile/ProfileActivityPresenter.kt | 16 +- .../app/profile/ProfileChooserFragment.kt | 10 +- .../ProfileChooserFragmentPresenter.kt | 72 +++++- .../app/profile/ProfileChooserViewModel.kt | 48 +++- .../org/oppia/app/splash/SplashActivity.kt | 8 +- app/src/main/res/drawable/ic_add_profile.xml | 14 ++ .../main/res/drawable/ic_default_avatar.xml | 5 + .../main/res/layout/admin_auth_fragment.xml | 10 +- .../res/layout/profile_chooser_add_view.xml | 27 +++ .../res/layout/profile_chooser_fragment.xml | 38 ++- .../layout/profile_chooser_profile_view.xml | 42 ++++ app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 4 + .../app/player/audio/AudioFragmentTest.kt | 4 - .../app/profile/ProfileChooserFragmentTest.kt | 149 ++++++++++++ .../oppia/app/splash/SplashActivityTest.kt | 4 +- .../oppia/domain/profile/ProfileTestHelper.kt | 49 ++++ .../domain/profile/ProfileTestHelperTest.kt | 229 ++++++++++++++++++ .../oppia/domain/topic/TopicControllerTest.kt | 10 +- model/src/main/proto/profile.proto | 10 + 24 files changed, 733 insertions(+), 32 deletions(-) create mode 100644 app/src/main/res/drawable/ic_add_profile.xml create mode 100644 app/src/main/res/drawable/ic_default_avatar.xml create mode 100644 app/src/main/res/layout/profile_chooser_add_view.xml create mode 100644 app/src/main/res/layout/profile_chooser_profile_view.xml create mode 100644 app/src/sharedTest/java/org/oppia/app/profile/ProfileChooserFragmentTest.kt create mode 100644 domain/src/main/java/org/oppia/domain/profile/ProfileTestHelper.kt create mode 100644 domain/src/test/java/org/oppia/domain/profile/ProfileTestHelperTest.kt diff --git a/app/build.gradle b/app/build.gradle index 20658430d11..00afa974d23 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -71,6 +71,7 @@ dependencies { 'com.google.android.material:material:1.0.0', 'com.google.dagger:dagger:2.24', 'com.google.guava:guava:28.1-android', + 'de.hdodenhof:circleimageview:3.0.1', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1', 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b25ddeeb2ea..88d592793fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,7 @@ + private val viewModelProvider: ViewModelProvider, + private val profileManagementController: ProfileManagementController ) { + /** Binds ViewModel and sets up RecyclerView Adapter. */ fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { - val binding = ProfileChooserFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + val binding = + ProfileChooserFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.apply { viewModel = getProfileChooserViewModel() + lifecycleOwner = fragment + } + binding.profileRecyclerView.apply { + adapter = createRecyclerViewAdapter() } return binding.root } @@ -26,4 +42,56 @@ class ProfileChooserFragmentPresenter @Inject constructor( private fun getProfileChooserViewModel(): ProfileChooserViewModel { return viewModelProvider.getForFragment(fragment, ProfileChooserViewModel::class.java) } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return BindableAdapter.MultiTypeBuilder + .newBuilder(ProfileChooserModel::getModelTypeCase) + .registerViewDataBinderWithSameModelType( + viewType = ProfileChooserModel.ModelTypeCase.PROFILE, + inflateDataBinding = ProfileChooserProfileViewBinding::inflate, + setViewModel = this::bindProfileView + ) + .registerViewDataBinderWithSameModelType( + viewType = ProfileChooserModel.ModelTypeCase.ADDPROFILE, + inflateDataBinding = ProfileChooserAddViewBinding::inflate, + setViewModel = this::bindAddView + ) + .build() + } + + private fun bindProfileView( + binding: ProfileChooserProfileViewBinding, + data: ProfileChooserModel + ) { + binding.viewModel = data + binding.root.setOnClickListener { + profileManagementController.loginToProfile(data.profile.id).observe(fragment, Observer { + if (it.isSuccess()) { + fragment.requireActivity() + .startActivity(Intent(fragment.context, HomeActivity::class.java)) + } + }) + } + } + + private fun bindAddView(binding: ProfileChooserAddViewBinding, data: ProfileChooserModel) { + binding.root.setOnClickListener { + if (getAdminAuthFragment() == null) { + fragment.requireActivity().supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_up, + R.anim.slide_down, + R.anim.slide_up, + R.anim.slide_down + ).add( + R.id.profile_chooser_fragment_placeholder, + AdminAuthFragment() + ).addToBackStack(null).commit() + } + } + } + + private fun getAdminAuthFragment(): AdminAuthFragment? { + return fragment.requireActivity().supportFragmentManager.findFragmentById(R.id.profile_chooser_fragment_placeholder) as? AdminAuthFragment? + } } diff --git a/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt b/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt index c80c6ae4e17..1ccc3aabb49 100644 --- a/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt +++ b/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt @@ -1,10 +1,56 @@ package org.oppia.app.profile +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.Profile +import org.oppia.app.model.ProfileChooserModel import org.oppia.app.viewmodel.ObservableViewModel +import org.oppia.domain.profile.ProfileManagementController +import org.oppia.util.data.AsyncResult +import org.oppia.util.logging.Logger +import java.util.Locale import javax.inject.Inject /** The ViewModel for [ProfileChooserFragment]. */ @FragmentScope -class ProfileChooserViewModel @Inject constructor(): ObservableViewModel() { +class ProfileChooserViewModel @Inject constructor( + private val profileManagementController: ProfileManagementController, private val logger: Logger +) : ObservableViewModel() { + val profiles: LiveData> by lazy { + Transformations.map(profileManagementController.getProfiles(), ::processGetProfilesResult) + } + + lateinit var adminPin: String + + /** Sorts profiles alphabetically by name and put Admin in front. */ + private fun processGetProfilesResult(profilesResult: AsyncResult>): List { + if (profilesResult.isFailure()) { + logger.e( + "ProfileChooserViewModel", + "Failed to retrieve the list of profiles: ", + profilesResult.getErrorOrNull()!! + ) + } + val profileList = profilesResult.getOrDefault(emptyList()).map { + ProfileChooserModel.newBuilder().setProfile(it).build() + }.toMutableList() + + val sortedProfileList = profileList.sortedBy { + it.profile.name.toLowerCase(Locale.getDefault()) + }.toMutableList() + + val adminProfile = sortedProfileList.find { it.profile.isAdmin } + adminProfile?.let { + sortedProfileList.remove(adminProfile) + adminPin = it.profile.pin + sortedProfileList.add(0, it) + } + + if (sortedProfileList.size < 10) { + sortedProfileList.add(ProfileChooserModel.newBuilder().setAddProfile(true).build()) + } + + return sortedProfileList + } } diff --git a/app/src/main/java/org/oppia/app/splash/SplashActivity.kt b/app/src/main/java/org/oppia/app/splash/SplashActivity.kt index cdd6be10f7e..e2e45056e3c 100644 --- a/app/src/main/java/org/oppia/app/splash/SplashActivity.kt +++ b/app/src/main/java/org/oppia/app/splash/SplashActivity.kt @@ -5,17 +5,17 @@ import android.os.Bundle import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import org.oppia.app.R -import org.oppia.app.home.HomeActivity +import org.oppia.app.profile.ProfileActivity -/** An activity that shows a temporary loading page until the app is fully loaded then navigates to [HomeActivity]. */ +/** An activity that shows a temporary loading page until the app is fully loaded then navigates to [ProfileActivity]. */ class SplashActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.splash_activity) - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - val intent = Intent(this@SplashActivity, HomeActivity::class.java) + window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + val intent = Intent(this@SplashActivity, ProfileActivity::class.java) startActivity(intent) finish() } diff --git a/app/src/main/res/drawable/ic_add_profile.xml b/app/src/main/res/drawable/ic_add_profile.xml new file mode 100644 index 00000000000..f75b5abff92 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_profile.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_default_avatar.xml b/app/src/main/res/drawable/ic_default_avatar.xml new file mode 100644 index 00000000000..f56b9135908 --- /dev/null +++ b/app/src/main/res/drawable/ic_default_avatar.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/admin_auth_fragment.xml b/app/src/main/res/layout/admin_auth_fragment.xml index 7e42279a97a..a4ffe4965ec 100644 --- a/app/src/main/res/layout/admin_auth_fragment.xml +++ b/app/src/main/res/layout/admin_auth_fragment.xml @@ -1,7 +1,13 @@ - + android:layout_height="match_parent" + android:background="@color/white"> + + diff --git a/app/src/main/res/layout/profile_chooser_add_view.xml b/app/src/main/res/layout/profile_chooser_add_view.xml new file mode 100644 index 00000000000..096a53e7b4e --- /dev/null +++ b/app/src/main/res/layout/profile_chooser_add_view.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/layout/profile_chooser_fragment.xml b/app/src/main/res/layout/profile_chooser_fragment.xml index e519564d32b..f90b7cde0c9 100644 --- a/app/src/main/res/layout/profile_chooser_fragment.xml +++ b/app/src/main/res/layout/profile_chooser_fragment.xml @@ -1,14 +1,42 @@ - - + - - + android:layout_height="match_parent" + android:background="@color/profileChooserBackground"> + + + diff --git a/app/src/main/res/layout/profile_chooser_profile_view.xml b/app/src/main/res/layout/profile_chooser_profile_view.xml new file mode 100644 index 00000000000..6acd7131542 --- /dev/null +++ b/app/src/main/res/layout/profile_chooser_profile_view.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b2ecbf2086f..c7d54437ffe 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,6 +38,9 @@ #F9F9F9 @color/oppiaDarkBlue + + #4E4E50 + #212121 #26A69A #C55F45 #264653 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2593d8a795f..2146a71a534 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,8 @@ %d Lessons + + Administrator + Select your profile + Add Profile diff --git a/app/src/sharedTest/java/org/oppia/app/player/audio/AudioFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/audio/AudioFragmentTest.kt index cbc42eafd62..31662a9fcf0 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/audio/AudioFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/audio/AudioFragmentTest.kt @@ -264,10 +264,6 @@ class AudioFragmentTest { return TestCoroutineDispatcher() } - @CacheAssetsLocally - @Provides - fun provideCacheAssetsLocally(): Boolean = false - @Singleton @Provides @BackgroundDispatcher diff --git a/app/src/sharedTest/java/org/oppia/app/profile/ProfileChooserFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/profile/ProfileChooserFragmentTest.kt new file mode 100644 index 00000000000..e7f4367993b --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/app/profile/ProfileChooserFragmentTest.kt @@ -0,0 +1,149 @@ +package org.oppia.app.profile + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineDispatcher +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.app.R +import org.oppia.domain.profile.ProfileTestHelper +import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.util.logging.EnableConsoleLog +import org.oppia.util.logging.EnableFileLog +import org.oppia.util.logging.GlobalLogLevel +import org.oppia.util.logging.LogLevel +import org.oppia.util.threading.BackgroundDispatcher +import org.oppia.util.threading.BlockingDispatcher +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Singleton + +@RunWith(AndroidJUnit4::class) +class ProfileChooserFragmentTest { + + @Inject lateinit var profileTestHelper: ProfileTestHelper + @Inject lateinit var context: Context + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + setUpTestApplicationComponent() + profileTestHelper.initializeProfiles() + } + + private fun setUpTestApplicationComponent() { + DaggerProfileChooserFragmentTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + @Test + fun testProfileChooserFragment_initializeProfiles_checkProfilesAreShown() { + ActivityScenario.launch(ProfileActivity::class.java).use { + onView(atPositionOnView(R.id.profile_recycler_view, 0, R.id.profile_name_text)).check(matches(withText("Sean"))) + onView(atPositionOnView(R.id.profile_recycler_view, 0, R.id.profile_is_admin_text)).check(matches(withText(context.getString(R.string.profile_chooser_admin)))) + onView(atPositionOnView(R.id.profile_recycler_view, 1, R.id.profile_name_text)).check(matches(withText("Ben"))) + onView(atPositionOnView(R.id.profile_recycler_view, 1, R.id.profile_is_admin_text)).check(matches(not(isDisplayed()))) + onView(atPositionOnView(R.id.profile_recycler_view, 2, R.id.add_profile_text)).check(matches(withText(context.getString(R.string.profile_chooser_add)))) + } + } + + @Test + @ExperimentalCoroutinesApi + fun testProfileChooserFragment_addManyProfiles_checkProfilesSortedAndNoAddProfile() { + profileTestHelper.addMoreProfiles(8) + ActivityScenario.launch(ProfileActivity::class.java).use { + onView(atPositionOnView(R.id.profile_recycler_view, 0, R.id.profile_name_text)).check(matches(withText("Sean"))) + onView(atPositionOnView(R.id.profile_recycler_view, 1, R.id.profile_name_text)).check(matches(withText("A"))) + onView(atPositionOnView(R.id.profile_recycler_view, 2, R.id.profile_name_text)).check(matches(withText("B"))) + onView(atPositionOnView(R.id.profile_recycler_view, 3, R.id.profile_name_text)).check(matches(withText("Ben"))) + onView(atPositionOnView(R.id.profile_recycler_view, 4, R.id.profile_name_text)).check(matches(withText("C"))) + onView(atPositionOnView(R.id.profile_recycler_view, 5, R.id.profile_name_text)).check(matches(withText("D"))) + onView(atPositionOnView(R.id.profile_recycler_view, 6, R.id.profile_name_text)).check(matches(withText("E"))) + onView(atPositionOnView(R.id.profile_recycler_view, 7, R.id.profile_name_text)).check(matches(withText("F"))) + onView(atPositionOnView(R.id.profile_recycler_view, 8, R.id.profile_name_text)).check(matches(withText("G"))) + onView(atPositionOnView(R.id.profile_recycler_view, 9, R.id.profile_name_text)).check(matches(withText("H"))) + } + } + + @Qualifier + annotation class TestDispatcher + + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestDispatcher + fun provideTestDispatcher(): CoroutineDispatcher { + return TestCoroutineDispatcher() + } + + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @Singleton + @Provides + @BlockingDispatcher + fun provideBlockingDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(profileChooserFragmentTest: ProfileChooserFragmentTest) + } +} diff --git a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt index 2b7c721d5b5..43d573e5c5b 100644 --- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt @@ -10,7 +10,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.oppia.app.home.HomeActivity +import org.oppia.app.profile.ProfileActivity /** * Tests for [SplashActivity]. For context on the activity test rule setup see: @@ -33,7 +33,7 @@ class SplashActivityTest { @Test fun testSplashActivity_initialOpen_routesToHomeActivity() { activityTestRule.launchActivity(null) - intended(hasComponent(HomeActivity::class.java.getName())) + intended(hasComponent(ProfileActivity::class.java.getName())) } @After diff --git a/domain/src/main/java/org/oppia/domain/profile/ProfileTestHelper.kt b/domain/src/main/java/org/oppia/domain/profile/ProfileTestHelper.kt new file mode 100644 index 00000000000..70a88c12c45 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/profile/ProfileTestHelper.kt @@ -0,0 +1,49 @@ +package org.oppia.domain.profile + +import org.oppia.app.model.ProfileId +import javax.inject.Inject + +/** This helper allows tests to easily create new profiles and switch between them. */ +class ProfileTestHelper @Inject constructor( + private val profileManagementController: ProfileManagementController +) { + /** Creates one admin profile and one user profile. Logs in to admin profile. */ + fun initializeProfiles() { + profileManagementController.addProfile( + "Sean", + "12345", + null, + allowDownloadAccess = true, + isAdmin = true + ) + profileManagementController.addProfile( + "Ben", + "123", + null, + allowDownloadAccess = false, + isAdmin = false + ) + profileManagementController.loginToProfile(ProfileId.newBuilder().setInternalId(0).build()).observeForever {} + } + + /** Create [numProfiles] number of user profiles. */ + fun addMoreProfiles(numProfiles: Int) { + for (x in 0 until numProfiles) { + profileManagementController.addProfile( + (x + 65).toChar().toString(), + "123", + null, + allowDownloadAccess = false, + isAdmin = false + ) + } + } + + /** Login to Admin profile. */ + fun loginToAdmin() = + profileManagementController.loginToProfile(ProfileId.newBuilder().setInternalId(0).build()) + + /* Login to user profile. */ + fun loginToUser() = + profileManagementController.loginToProfile(ProfileId.newBuilder().setInternalId(1).build()) +} diff --git a/domain/src/test/java/org/oppia/domain/profile/ProfileTestHelperTest.kt b/domain/src/test/java/org/oppia/domain/profile/ProfileTestHelperTest.kt new file mode 100644 index 00000000000..454fe5cd350 --- /dev/null +++ b/domain/src/test/java/org/oppia/domain/profile/ProfileTestHelperTest.kt @@ -0,0 +1,229 @@ +package org.oppia.domain.profile + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.app.model.Profile +import org.oppia.util.data.AsyncResult +import org.oppia.util.logging.EnableConsoleLog +import org.oppia.util.logging.EnableFileLog +import org.oppia.util.logging.GlobalLogLevel +import org.oppia.util.logging.LogLevel +import org.oppia.util.threading.BackgroundDispatcher +import org.oppia.util.threading.BlockingDispatcher +import org.robolectric.annotation.Config +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.EmptyCoroutineContext + +/** Tests for [ProfileManagementControllerTest]. */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ProfileTestHelperTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + lateinit var context: Context + + @Inject + lateinit var profileTestHelper: ProfileTestHelper + + @Inject + lateinit var profileManagementController: ProfileManagementController + + @Mock + lateinit var mockProfilesObserver: Observer>> + @Captor + lateinit var profilesResultCaptor: ArgumentCaptor>> + + @Mock + lateinit var mockUpdateResultObserver: Observer> + @Captor + lateinit var updateResultCaptor: ArgumentCaptor> + + @Inject + @field:TestDispatcher + lateinit var testDispatcher: CoroutineDispatcher + + private val coroutineContext by lazy { + EmptyCoroutineContext + testDispatcher + } + + // https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/ + @ObsoleteCoroutinesApi + private val testThread = newSingleThreadContext("TestMain") + + @Before + @ExperimentalCoroutinesApi + @ObsoleteCoroutinesApi + fun setUp() { + Dispatchers.setMain(testThread) + setUpTestApplicationComponent() + } + + @After + @ExperimentalCoroutinesApi + @ObsoleteCoroutinesApi + fun tearDown() { + Dispatchers.resetMain() + testThread.close() + } + + private fun setUpTestApplicationComponent() { + DaggerProfileTestHelperTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + @Test + @ExperimentalCoroutinesApi + fun testInitializeProfiles_initializeProfiles_checkProfilesAreAddedAndCurrentIsSet() = runBlockingTest(coroutineContext) { + profileTestHelper.initializeProfiles() + profileManagementController.getProfiles().observeForever(mockProfilesObserver) + advanceUntilIdle() + + verify(mockProfilesObserver, atLeastOnce()).onChanged(profilesResultCaptor.capture()) + assertThat(profilesResultCaptor.value.isSuccess()).isTrue() + val profiles = profilesResultCaptor.value.getOrThrow() + assertThat(profiles[0].name).isEqualTo("Sean") + assertThat(profiles[0].isAdmin).isTrue() + assertThat(profiles[1].name).isEqualTo("Ben") + assertThat(profiles[1].isAdmin).isFalse() + assertThat(profileManagementController.getCurrentProfileId().internalId).isEqualTo(0) + } + + @Test + @ExperimentalCoroutinesApi + fun testAddMoreProfiles_addMoreProfiles_checkProfilesAreAdded() = runBlockingTest(coroutineContext) { + profileTestHelper.addMoreProfiles(10) + advanceUntilIdle() + profileManagementController.getProfiles().observeForever(mockProfilesObserver) + advanceUntilIdle() + + verify(mockProfilesObserver, atLeastOnce()).onChanged(profilesResultCaptor.capture()) + assertThat(profilesResultCaptor.value.isSuccess()).isTrue() + assertThat(profilesResultCaptor.value.getOrThrow().size).isEqualTo(10) + } + + @Test + @ExperimentalCoroutinesApi + fun testLoginToAdmin_initializeProfiles_loginToAdmin_checkIsSuccessful() = runBlockingTest(coroutineContext) { + profileTestHelper.initializeProfiles() + + profileTestHelper.loginToAdmin().observeForever(mockUpdateResultObserver) + advanceUntilIdle() + + verify(mockUpdateResultObserver, atLeastOnce()).onChanged(updateResultCaptor.capture()) + assertThat(updateResultCaptor.value.isSuccess()).isTrue() + assertThat(profileManagementController.getCurrentProfileId().internalId).isEqualTo(0) + } + + @Test + @ExperimentalCoroutinesApi + fun testLoginToUser_initializeProfiles_loginToUser_checkIsSuccessful() = runBlockingTest(coroutineContext) { + profileTestHelper.initializeProfiles() + + profileTestHelper.loginToUser().observeForever(mockUpdateResultObserver) + advanceUntilIdle() + + verify(mockUpdateResultObserver, atLeastOnce()).onChanged(updateResultCaptor.capture()) + assertThat(updateResultCaptor.value.isSuccess()).isTrue() + assertThat(profileManagementController.getCurrentProfileId().internalId).isEqualTo(1) + } + + @Qualifier + annotation class TestDispatcher + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestDispatcher + fun provideTestDispatcher(): CoroutineDispatcher { + return TestCoroutineDispatcher() + } + + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @Singleton + @Provides + @BlockingDispatcher + fun provideBlockingDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(profileTestHelperTest: ProfileTestHelperTest) + } +} diff --git a/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt b/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt index a9d1b4aaf5f..47774cb3bab 100644 --- a/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/topic/TopicControllerTest.kt @@ -91,6 +91,8 @@ class TopicControllerTest { private val testThread = newSingleThreadContext("TestMain") @Before + @ExperimentalCoroutinesApi + @ObsoleteCoroutinesApi fun setUp() { Dispatchers.setMain(testThread) setUpTestApplicationComponent() @@ -624,6 +626,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForSkillIds_returnsAllQuestions() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1) @@ -634,7 +637,7 @@ class TopicControllerTest { assertThat(questionListResultCaptor.value.isSuccess()).isTrue() val questionsList = questionListResultCaptor.value.getOrThrow() assertThat(questionsList.size).isEqualTo(5) - val questionIds = questionsList.map { it -> it.questionId } + val questionIds = questionsList.map { it.questionId } assertThat(questionIds).containsExactlyElementsIn( mutableListOf( TEST_QUESTION_ID_0, TEST_QUESTION_ID_1, @@ -644,6 +647,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForFractionsSkillId0_returnsAllQuestions() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(FRACTIONS_SKILL_ID_0) @@ -664,6 +668,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForFractionsSkillId1_returnsAllQuestions() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(FRACTIONS_SKILL_ID_1) @@ -684,6 +689,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForFractionsSkillId2_returnsAllQuestions() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(FRACTIONS_SKILL_ID_2) @@ -704,6 +710,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForRatiosSkillId0_returnsAllQuestions() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(RATIOS_SKILL_ID_0) @@ -723,6 +730,7 @@ class TopicControllerTest { } @Test + @ExperimentalCoroutinesApi fun testRetrieveQuestionsForInvalidSkillIds_returnsFailure() = runBlockingTest(coroutineContext) { val questionsListProvider = topicController.retrieveQuestionsForSkillIds( listOf(TEST_SKILL_ID_0, TEST_SKILL_ID_1, "NON_EXISTENT_SKILL_ID") diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto index d129946f2d2..3fde965f03a 100644 --- a/model/src/main/proto/profile.proto +++ b/model/src/main/proto/profile.proto @@ -52,3 +52,13 @@ message ProfileId { */ int32 internal_id = 1; } + +// Used in BindableAdapter for ProfileChooserFragment. +message ProfileChooserModel { + oneof model_type { + // Indicates an individual Profile. + Profile profile = 1; + //Indicates the add profile button. + bool addProfile = 2; + } +}