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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user