Complete migration to Compose (#194)

* Migrate disclaimer screen to Compose

* Migrate base URL screen to Compose

* Migrate base URL screen to Compose

* Migrate authentication screen to Compose

* Initialize add recipe screen

* Remove unused resources

* Display add recipe operation result

* Add delete icon to ingredients and instructions

* Allow navigating between fields on add recipe

* Allow navigating between fields on authentication screen

* Allow to proceed from keyboard on base url screen

* Use material icons for recipe item

* Expose base URL as flow

* Initialize Compose navigation

* Allow sending logs again

* Allow to override navigation and top bar per screen

* Add additional logs

* Migrate share recipe screen to Compose

* Fix unit tests

* Restore recipe list tests

* Ensure authentication is shown after URL input

* Add autofill to authentication

* Complete first set up test

* Use image vector from Icons instead of drawable

* Add transition animations

* Fix logging host in Host header

* Do not fail test if login token is used
This commit is contained in:
Kirill Kamakin
2024-01-13 11:28:10 +01:00
committed by GitHub
parent 94f12820bc
commit de4df95a0e
107 changed files with 3294 additions and 2321 deletions

View File

@@ -1,70 +0,0 @@
package gq.kirmanak.mealient.ui.activity
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.ActivityUiStateController
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Before
import org.junit.Test
class MainActivityViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var disclaimerStorage: DisclaimerStorage
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var recipeRepo: RecipeRepo
@MockK(relaxUnitFun = true)
lateinit var activityUiStateController: ActivityUiStateController
private lateinit var subject: MainActivityViewModel
@Before
override fun setUp() {
super.setUp()
every { authRepo.isAuthorizedFlow } returns emptyFlow()
coEvery { disclaimerStorage.isDisclaimerAccepted() } returns true
coEvery { serverInfoRepo.getUrl() } returns TEST_BASE_URL
every { activityUiStateController.getUiStateFlow() } returns MutableStateFlow(
ActivityUiState()
)
subject = MainActivityViewModel(
authRepo = authRepo,
logger = logger,
disclaimerStorage = disclaimerStorage,
serverInfoRepo = serverInfoRepo,
recipeRepo = recipeRepo,
activityUiStateController = activityUiStateController,
)
}
@Test
fun `when onSearchQuery with query expect call to recipe repo`() {
subject.onSearchQuery("query")
verify { recipeRepo.updateNameQuery("query") }
}
@Test
fun `when onSearchQuery with null expect call to recipe repo`() {
subject.onSearchQuery("query")
subject.onSearchQuery(null)
verify { recipeRepo.updateNameQuery(null) }
}
}

View File

@@ -2,20 +2,20 @@ package gq.kirmanak.mealient.ui.add
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before
import org.junit.Test
class AddRecipeViewModelTest : BaseUnitTest() {
internal class AddRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var addRecipeRepo: AddRecipeRepo
@@ -25,50 +25,79 @@ class AddRecipeViewModelTest : BaseUnitTest() {
@Before
override fun setUp() {
super.setUp()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(EMPTY_ADD_RECIPE_INFO)
subject = AddRecipeViewModel(addRecipeRepo, logger)
}
@Test
fun `when saveRecipe fails then addRecipeResult is false`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } throws IllegalStateException()
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isFalse()
subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Error)
}
@Test
fun `when saveRecipe succeeds then addRecipeResult is true`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } returns "recipe-slug"
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isTrue()
subject.onEvent(AddRecipeScreenEvent.SaveRecipeClick)
assertThat(subject.screenState.value.snackbarMessage)
.isEqualTo(AddRecipeSnackbarMessage.Success)
}
@Test
fun `when preserve then doesn't update UI`() {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
subject.preserve(PORRIDGE_ADD_RECIPE_INFO)
coVerify(inverse = true) { addRecipeRepo.addRecipeRequestFlow }
fun `when UI is updated then preserves`() {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
val infoSlot = slot<AddRecipeInfo>()
coVerify { addRecipeRepo.preserve(capture(infoSlot)) }
assertThat(infoSlot.captured.name).isEqualTo("Porridge")
}
@Test
fun `when preservedAddRecipeRequest without loadPreservedRequest then empty`() = runTest {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
val actual = withTimeoutOrNull(10) { subject.preservedAddRecipeRequest.firstOrNull() }
assertThat(actual).isNull()
}
@Test
fun `when loadPreservedRequest then updates preservedAddRecipeRequest`() = runTest {
fun `when loadPreservedRequest then updates screenState`() = runTest {
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.loadPreservedRequest()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
subject.doLoadPreservedRequest()
val screenState = subject.screenState.value
assertThat(screenState.recipeNameInput).isSameInstanceAs("Porridge")
assertThat(screenState.recipeDescriptionInput).isSameInstanceAs("A tasty porridge")
assertThat(screenState.recipeYieldInput).isSameInstanceAs("3 servings")
assertThat(screenState.isPublicRecipe).isSameInstanceAs(true)
assertThat(screenState.disableComments).isSameInstanceAs(false)
assertThat(screenState.ingredients).isEqualTo(
listOf("2 oz of white milk", "2 oz of white sugar")
)
assertThat(screenState.instructions).isEqualTo(
listOf("Mix the ingredients", "Boil the ingredients")
)
}
@Test
fun `when clear then updates preservedAddRecipeRequest`() = runTest {
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.clear()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
fun `when initialized then name is empty`() {
assertThat(subject.screenState.value.recipeNameInput).isEmpty()
}
@Test
fun `when recipe name entered then screen state is updated`() {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
assertThat(subject.screenState.value.recipeNameInput).isEqualTo("Porridge")
}
@Test
fun `when clear then updates screen state`() = runTest {
subject.onEvent(AddRecipeScreenEvent.RecipeNameInput("Porridge"))
subject.onEvent(AddRecipeScreenEvent.ClearInputClick)
assertThat(subject.screenState.value.recipeNameInput).isEmpty()
}
companion object {
private val EMPTY_ADD_RECIPE_INFO = AddRecipeInfo(
name = "",
description = "",
recipeYield = "",
recipeInstructions = emptyList(),
recipeIngredient = emptyList(),
settings = AddRecipeSettingsInfo(public = false, disableComments = false)
)
}
}

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.ui.baseurl
import android.app.Application
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
@@ -9,10 +10,10 @@ import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.OperationUiState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -25,7 +26,7 @@ import java.io.IOException
import javax.net.ssl.SSLHandshakeException
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : BaseUnitTest() {
internal class BaseURLViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@@ -42,12 +43,19 @@ class BaseURLViewModelTest : BaseUnitTest() {
@RelaxedMockK
lateinit var baseUrlLogRedactor: BaseUrlLogRedactor
@MockK(relaxUnitFun = true)
lateinit var application: Application
lateinit var subject: BaseURLViewModel
@Before
override fun setUp() {
super.setUp()
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
coEvery { serverInfoRepo.getUrl() } returns null
subject = BaseURLViewModel(
application = application,
serverInfoRepo = serverInfoRepo,
authRepo = authRepo,
recipeRepo = recipeRepo,
@@ -119,7 +127,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
coEvery { serverInfoRepo.tryBaseURL(any()) } returns Result.failure(IOException())
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.uiState.value).isInstanceOf(OperationUiState.Failure::class.java)
assertThat(subject.screenState.value.errorText).isNotNull()
}
@Test

View File

@@ -13,7 +13,7 @@ import org.junit.Test
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class DisclaimerViewModelTest : BaseUnitTest() {
internal class DisclaimerViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var storage: DisclaimerStorage

View File

@@ -1,32 +1,27 @@
package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.asFlow
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.recipes.list.RecipeListEvent
import gq.kirmanak.mealient.ui.recipes.list.RecipeListItemState
import gq.kirmanak.mealient.ui.recipes.list.RecipeListSnackbar
import gq.kirmanak.mealient.ui.recipes.list.RecipesListViewModel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipesListViewModelTest : BaseUnitTest() {
internal class RecipesListViewModelTest : BaseUnitTest() {
@MockK
lateinit var authRepo: AuthRepo
@@ -64,61 +59,63 @@ class RecipesListViewModelTest : BaseUnitTest() {
}
@Test
fun `when refreshRecipeInfo succeeds expect successful result`() = runTest {
val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
assertThat(actual).isEqualTo(Result.success(Unit))
fun `when SearchQueryChanged happens with query expect call to recipe repo`() {
val subject = createSubject()
subject.onEvent(RecipeListEvent.SearchQueryChanged("query"))
verify { recipeRepo.updateNameQuery("query") }
}
@Test
fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest {
val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
createSubject().refreshRecipeInfo(slug).asFlow().first()
coVerify { recipeRepo.refreshRecipeInfo(slug) }
fun `when recipe is clicked expect call to repo`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.success(Unit)
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
coVerify { recipeRepo.refreshRecipeInfo("cake") }
}
@Test
fun `when refreshRecipeInfo fails expect result with error`() = runTest {
val slug = "cake"
val result = Result.failure<Unit>(RuntimeException())
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
assertThat(actual).isEqualTo(result)
fun `when recipe is clicked and refresh succeeds expect id to open`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.success(Unit)
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
assertThat(subject.screenState.value.recipeIdToOpen).isEqualTo("1")
}
@Test
fun `when recipe is clicked and refresh fails expect id to open`() = runTest {
coEvery { recipeRepo.refreshRecipeInfo(eq("cake")) } returns Result.failure(IOException())
val subject = createSubject()
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.RecipeClick(recipe))
assertThat(subject.screenState.value.recipeIdToOpen).isEqualTo("1")
}
@Test
fun `when delete recipe expect successful result in flow`() = runTest {
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.success(Unit)
val subject = createSubject()
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
}
assertThat(results.single().isSuccess).isTrue()
}
@Test
fun `when delete recipe expect failed result in flow`() = runTest {
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.failure(IOException())
val subject = createSubject()
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
}
assertThat(results.single().isFailure).isTrue()
}
private inline fun <T> TestScope.runTestAndCollectFlow(
flow: Flow<T>,
block: () -> Unit,
): List<T> {
val results = mutableListOf<T>()
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.toList(results)
}
block()
collectJob.cancel()
return results
val recipe = RecipeListItemState(
imageUrl = null,
showFavoriteIcon = true,
entity = CAKE_RECIPE_SUMMARY_ENTITY,
)
subject.onEvent(RecipeListEvent.DeleteConfirmed(recipe))
assertThat(subject.screenState.value.snackbarState).isEqualTo(RecipeListSnackbar.DeleteFailed)
}
private fun createSubject() = RecipesListViewModel(

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.ui.recipes.info
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
@@ -58,8 +59,11 @@ class RecipeInfoViewModelTest : BaseUnitTest() {
}
private fun createSubject(): RecipeInfoViewModel {
val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle()
return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, argument)
val savedStateHandle = SavedStateHandle(
mapOf("recipeId" to RECIPE_ID)
)
return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, savedStateHandle)
}
companion object {