diff --git a/.github/workflows/androidbuild.yml b/.github/workflows/androidbuild.yml index dc93f25..0b29f3d 100644 --- a/.github/workflows/androidbuild.yml +++ b/.github/workflows/androidbuild.yml @@ -35,14 +35,16 @@ jobs: - name: Run ktlintCheck on the codebase run: ./gradlew ktlintCheck + - name: Build With Gradle + run: ./gradlew build + - name: Run Unit Tests run: ./gradlew clean test - name: Run debug Build run: ./gradlew assembleDebug - - name : Build With Gradle - run: ./gradlew build + - name: Upload a Build Artifact uses: actions/upload-artifact@v4.3.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba03df1 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +## CalorieBytez App + +This is a simple Android app that uses the [Calorie Ninja](https://calorieninjas.com/api) to display +nutritional data for various food items. + + + +*Pre-requisites* +- Built on A.S JellyFish +- JDK 17 +- Access token from CalorieNinja. +- Once received place it in the local.properties file as follows: +``` properties +API_KEY = YourKey +``` +To Inject the Key when using CI/CD with github actions , add the key to your projects secrets and extract in to your build workflow: + +``` yaml + - name: Get local.properties from secrets + run: echo "${{secrets.LOCAL_PROPERTIES }}" > $GITHUB_WORKSPACE/local.properties +``` + + +## Architecture + +This project uses a modularized approach using MVVM with Clean architecture which has the following advantages + +- Loose coupling between the code - The code can easily be modified without affecting any or a large part of the app's codebase thus easier to scale the application later on. +- Easier to test code. +- Separation of Concern - Different modules have specific responsibilities making it easier for modification and maintenance. + +### Modularization Structure + +- `core` + - `data` + - aggregates the data from the network and local database + - `network` + - handles getting data from any server/remote source + - `database` + - handles getting cached device data +- `domain` + - defines the core business logic for reuse +- `app` + - handles Ui logic of the app i.e navigation and use of the bottom bar. +- `feature` + - `Search` + - handles displaying data of queried item combinations + - `Food Details` + - handles displaying all food nutritional details + - `Saved Food` + - handles displaying food saved locally +- `testing` + - Encompasses the core testing functionality of the project + + + +| Modularization Graph | +|--------------------------------------------| +| | + +### Testing + +The app includes unit tests for all modules, Instrumented tests are ran as unit tests with the use of Roboelectric +#### tests screenshots + +| Image | desc | +|---|-----------------------------------| +| | Unit tests for the ViewModels | +| | Unit tests for the data layer | +| | Unit tests for the network layer | +| | Unit tests for the database layer | + + + +## TechStack +### Libraries +* Tech-stack + * [Kotlin](https://kotlinlang.org/) - a modern, cross-platform, statically typed, general-purpose programming language with type inference. + * [Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) - lightweight threads to perform asynchronous tasks. + * [Flow](https://kotlinlang.org/docs/reference/coroutines/flow.html) - a stream of data that emits multiple values sequentially. + * [StateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow#:~:text=StateFlow%20is%20a%20state%2Dholder,property%20of%20the%20MutableStateFlow%20class.) - Flow APIs that enable flows to emit updated state and emit values to multiple consumers optimally. + * [Dagger Hilt](https://dagger.dev/hilt/) - a dependency injection library for Android built on top of [Dagger](https://dagger.dev/) that reduces the boilerplate of doing manual injection. + * [Jetpack](https://developer.android.com/jetpack) + * [Jetpack Compose](https://developer.android.com/jetpack/compose) - A modern toolkit for building native Android UI + * [Lifecycle](https://developer.android.com/topic/libraries/architecture/lifecycle) - perform actions in response to a change in the lifecycle state. + * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) - store and manage UI-related data lifecycle in a conscious manner and survive configuration change. + * [Room](https://developer.android.com/training/data-storage/room) - An ORM that provides an abstraction layer over SQLite to allow fluent database access. + * [Timber](https://github.com/JakeWharton/timber) - a highly extensible Android logger. + * [Ktor](https://ktor.io/) - A pure Create asynchronous http client + + +* Tests + * [JUnit](https://junit.org/junit4/) - a simple framework for writing repeatable tests. + * [MockK](https://github.com/mockk) - mocking library for Kotlin + * [Truth](https://github.com/agoda-com/Kakao) - A fluent assertions library for Android and Java. +* Gradle + * [Gradle Kotlin DSL](https://docs.gradle.org/current/userguide/kotlin_dsl.html) - An alternative syntax for writing Gradle build scripts using Koltin. + * [Version Catalogs](https://developer.android.com/build/migrate-to-catalogs) - A scalable way of maintaining dependencies and plugins in a multi-module project. + * [Convention Plugins](https://docs.gradle.org/current/samples/sample_convention_plugins.html) - A way to encapsulate and reuse common build configuration in Gradle, see [here](https://github.com/daniel-waiguru/WeatherApp/tree/main/build-logic%2Fconvention%2Fsrc%2Fmain%2Fjava) + * Plugins + * [Ktlint](https://github.com/JLLeitschuh/ktlint-gradle) - creates convenient tasks in your Gradle project that run ktlint checks or do code auto format. + * [Spotless](https://github.com/diffplug/spotless) - format Java, groovy, markdown, and license headers using gradle. +* CI/CD + * [GitHub Actions](https://github.com/features/actions) + +## ScreenShots +## Screenshots with Descriptions in Columns + +| Loading | Recent Searches | Food List | +|---|---|---| +| | | | + +| Idle | Details | Error Screen | +|---|---|--------------| +| | | + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3be1475..edf2ce9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,6 +29,8 @@ android { packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" + merges += "META-INF/LICENSE.md" + merges += "META-INF/LICENSE-notice.md" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 382cdc6..924cec2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + { - Text("Saved") + SavedItemsScreen { foodName -> + navController.navigate(AppDestinations.FoodDetails(name = foodName)) + } } } } diff --git a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt b/app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt similarity index 98% rename from app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt rename to app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt index 1765d15..504ed80 100644 --- a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt +++ b/app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt @@ -1,4 +1,4 @@ -package com.devmike.caloriebytez.ui.navigation +package com.devmike.caloriebytez.navigation import androidx.compose.animation.AnimatedVisibility import androidx.compose.material3.BottomAppBar diff --git a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt b/app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt similarity index 95% rename from app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt rename to app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt index febdff9..6e19a3c 100644 --- a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt +++ b/app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt @@ -1,4 +1,4 @@ -package com.devmike.caloriebytez.ui.navigation +package com.devmike.caloriebytez.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmarks diff --git a/build.gradle.kts b/build.gradle.kts index 311fec0..680a27f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.spotless) alias(libs.plugins.android.library) apply false + alias(libs.plugins.module.graph) apply true } subprojects { diff --git a/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt b/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt deleted file mode 100644 index 00add85..0000000 --- a/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.devmike.commonui - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.devmike.common_ui.test", appContext.packageName) - } -} diff --git a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt new file mode 100644 index 0000000..58d7254 --- /dev/null +++ b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt @@ -0,0 +1,69 @@ +package com.devmike.commonui.sharedui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.TimerOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.devmike.domain.models.AppErrors + +@Composable +fun ErrorScreen( + error: AppErrors, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = + when (error) { + is AppErrors.NoInternet -> Icons.Default.WifiOff + is AppErrors.Timeout -> Icons.Default.TimerOff + is AppErrors.Unknown -> Icons.Default.Error + is AppErrors.NotFound -> Icons.Default.SearchOff + is AppErrors.Empty -> Icons.Default.Warning + is AppErrors.Unauthorized -> Icons.Default.Lock + }, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color.Red, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = error.message, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(text = "Retry") + } + } +} diff --git a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt index e112407..c4f0e1d 100644 --- a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt +++ b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt @@ -29,12 +29,13 @@ import com.devmike.domain.models.CalorieModel @Composable fun FoodList( + modifier: Modifier = Modifier, ingredients: List, onFoodItemClicked: (name: String) -> Unit, ) { LazyColumn( modifier = - Modifier + modifier .fillMaxWidth() .padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), diff --git a/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt b/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt deleted file mode 100644 index 8be7c2d..0000000 --- a/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.devmike.commonui - -import org.junit.Assert.* -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 566c536..1acdd6d 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -27,4 +27,6 @@ dependencies { api(project(":domain")) implementation(project(":core:network")) implementation(project(":core:database")) + + testImplementation(project(":core:testing")) } diff --git a/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt b/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt deleted file mode 100644 index 01fc003..0000000 --- a/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.devmike.data - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.devmike.core.test", appContext.packageName) - } -} diff --git a/core/data/src/main/java/com/devmike/data/di/DataModule.kt b/core/data/src/main/java/com/devmike/data/di/DataModule.kt index 51148a8..a77e7b7 100644 --- a/core/data/src/main/java/com/devmike/data/di/DataModule.kt +++ b/core/data/src/main/java/com/devmike/data/di/DataModule.kt @@ -1,9 +1,9 @@ package com.devmike.data.di -import com.devmike.data.repository.CaloriesRepository import com.devmike.data.repository.CaloriesRepositoryImpl -import com.devmike.data.repository.RecentSearchesRepository import com.devmike.data.repository.RecentSearchesRepositoryImpl +import com.devmike.domain.repositories.CaloriesRepository +import com.devmike.domain.repositories.RecentSearchesRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt b/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt index b209157..0c151a8 100644 --- a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt +++ b/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt @@ -9,7 +9,10 @@ import com.devmike.database.entities.RecentSearchEntity import com.devmike.database.entities.SearchQueryCalorieCrossRef import com.devmike.domain.models.AppErrors import com.devmike.domain.models.CalorieModel +import com.devmike.domain.repositories.CaloriesRepository import com.devmike.network.datasource.CalorieNetworkSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import java.time.Instant import javax.inject.Inject @@ -55,7 +58,11 @@ class CaloriesRepositoryImpl return calorieDao.getCalorieById(name)?.toCalorieModel() } - private suspend fun saveNetworkCalorieDb( + override fun getAllCalories(): Flow> = calorieDao.getAllCalories().map { calorieEntities -> + calorieEntities.map { it.toCalorieModel() } + } + + private suspend fun saveNetworkCalorieDb( query: String, calories: List, ) { diff --git a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt b/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt index 14de4a2..48f35d4 100644 --- a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt +++ b/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt @@ -1,18 +1,21 @@ package com.devmike.data.repository import com.devmike.database.dao.SearchQueryDao +import com.devmike.domain.repositories.RecentSearchesRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject -class RecentSearchesRepositoryImpl @Inject constructor( - private val searchQueryDao: SearchQueryDao -) : RecentSearchesRepository { - override fun getRecentSearches(limit: Int): Flow> { - return searchQueryDao.getRecentQueries(limit = limit).map { - it.map { recentSearchEntity -> - recentSearchEntity.queryString +class RecentSearchesRepositoryImpl + @Inject + constructor( + private val searchQueryDao: SearchQueryDao, + ) : RecentSearchesRepository { + override fun getRecentSearches(limit: Int): Flow> { + return searchQueryDao.getRecentQueries(limit = limit).map { + it.map { recentSearchEntity -> + recentSearchEntity.queryString + } } } } -} diff --git a/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt b/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt new file mode 100644 index 0000000..a89942c --- /dev/null +++ b/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt @@ -0,0 +1,131 @@ +package com.devmike.data + +import com.devmike.data.mappers.toCalorieModel +import com.devmike.data.repository.CaloriesRepositoryImpl +import com.devmike.database.dao.CalorieDao +import com.devmike.database.dao.CrossRefDao +import com.devmike.database.dao.SearchQueryDao +import com.devmike.database.entities.SearchQueryWithCalories +import com.devmike.domain.models.AppErrors +import com.devmike.network.datasource.CalorieNetworkSource +import com.devmike.network.model.CalorieResponse +import com.devmike.sampleData.SampleModels +import com.devmike.testing.sampleData.SampleDto +import com.devmike.testing.sampleData.SampleEntities +import com.devmike.testing.sampleData.SampleSearchEntity +import com.devmike.testing.sampleData.SampleSearchWithCalorie +import com.google.common.truth.Truth +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CaloriesRepositoryImplTest { + private lateinit var repository: CaloriesRepositoryImpl + private val mockNetworkSource: CalorieNetworkSource = mockk() + private val mockCalorieDao: CalorieDao = mockk() + private val mockSearchQueryDao: SearchQueryDao = mockk() + private val mockCrossRefDao: CrossRefDao = mockk() + + @Before + fun setup() { + repository = + CaloriesRepositoryImpl( + mockNetworkSource, + mockCalorieDao, + mockSearchQueryDao, + mockCrossRefDao, + ) + } + + @Test + fun` getCalories cachedQueryExists returnsSuccessWithCachedData`() = + runTest { + val query = SampleSearchEntity.tomatowithpizza.queryString + val cachedCalories = + listOf( + SampleEntities.tomato, + SampleEntities.onion, + ) + val cachedSearchQuery = + SearchQueryWithCalories( + searchQuery = SampleSearchEntity.tomatowithpizza, + calories = cachedCalories, + ) + + coEvery { mockSearchQueryDao.getSearchQueryWithCalories(query) } returns listOf(cachedSearchQuery) + + coEvery { + mockSearchQueryDao.getSearchQueryWithCalories("mikke") + } returns emptyList() + val flowrst = mockSearchQueryDao.getSearchQueryWithCalories("mikke") + + print("the flow result is $flowrst") + coVerify { + mockNetworkSource wasNot called + } + coVerify { + mockSearchQueryDao.getSearchQueryWithCalories("mikke") + } + + val result = repository.getCalories(query) + + Truth.assertThat(result.isSuccess).isTrue() + + Truth.assertThat(result.getOrNull()).containsAnyIn(listOf(SampleModels.tomatomodel)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `getCalories networkRequestSuccessful returns with data`() = + runTest { + val query = SampleSearchEntity.pizzaonion.queryString + val networkCalories = + listOf( + SampleDto.onionDTO, + SampleDto.pizzaDTO, + ) + + coEvery { + mockNetworkSource.getCalorieInformation(query) + } returns Result.success(CalorieResponse(networkCalories)) + + coEvery { + mockCalorieDao.insertAll(any()) + } + + coEvery { + mockSearchQueryDao.getSearchQueryWithCalories(query) + } returns listOf(SampleSearchWithCalorie.pizzaonion) + coEvery { + mockSearchQueryDao.insert(SampleSearchEntity.pizzaonion) + } returns 9999L + + coEvery { + mockCrossRefDao.insert(any()) + } + + val result = repository.getCalories(query) + + Truth.assertThat(result.getOrNull()).containsAnyIn(networkCalories.map { it.toCalorieModel() }) + } + + @Test + fun ` getCalories networkRequestFailed returnsFailure`() = + runTest { + val query = "Invalid query" + val error = AppErrors.Unauthorized() + + coEvery { mockNetworkSource.getCalorieInformation(query) } returns Result.failure(error) + coEvery { mockSearchQueryDao.getSearchQueryWithCalories(query) } returns emptyList() + + val result = repository.getCalories(query) + + Truth.assertThat(result.isFailure).isTrue() + Truth.assertThat(result.exceptionOrNull()).isInstanceOf(AppErrors.Unauthorized::class.java) + } +} diff --git a/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt b/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt deleted file mode 100644 index 14a4e7e..0000000 --- a/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.devmike.data - -import org.junit.Assert.* -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/core/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt b/core/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt new file mode 100644 index 0000000..1f4b4f9 --- /dev/null +++ b/core/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt @@ -0,0 +1,55 @@ +package com.devmike.data + +import com.devmike.data.repository.RecentSearchesRepositoryImpl +import com.devmike.database.dao.SearchQueryDao +import com.devmike.database.entities.RecentSearchEntity +import com.google.common.truth.Truth +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.time.Instant + +@ExperimentalCoroutinesApi +class RecentSearchesRepositoryImplTest { + private lateinit var repository: RecentSearchesRepositoryImpl + private val mockSearchQueryDao: SearchQueryDao = mockk() + + @Before + fun setUp() { + repository = RecentSearchesRepositoryImpl(mockSearchQueryDao) + } + + @Test + fun `getRecentSearches should return recent search queries`() = + runTest { + val limit = 5 + val recentSearchEntities = + listOf( + RecentSearchEntity(queryString = "tomato and cabbage", queriedDate = Instant.now()), + RecentSearchEntity(queryString = "pineapple on pizza", queriedDate = Instant.now()), + ) + coEvery { mockSearchQueryDao.getRecentQueries(limit) } returns flowOf(recentSearchEntities) + + val result = repository.getRecentSearches(limit).toList() + + Truth.assertThat(result).hasSize(1) + Truth.assertThat(result.first()).containsExactly("tomato and cabbage", "pineapple on pizza") + } + + @Test + fun `getRecentSearches should return empty list when no recent searches`() = + runTest { + val limit = 5 + coEvery { mockSearchQueryDao.getRecentQueries(limit) } returns flowOf(emptyList()) + + val result = repository.getRecentSearches(limit).toList() + + Truth.assertThat(result).hasSize(1) + Truth.assertThat(result.first()).isEmpty() + } +} diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt b/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt new file mode 100644 index 0000000..c662bde --- /dev/null +++ b/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt @@ -0,0 +1,53 @@ +package com.devmike.testing.sampleData + +import com.devmike.network.model.CalorieDTO + +object SampleDto { + val onionDTO = + CalorieDTO( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeG = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomatoDTO = + CalorieDTO( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeG = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizzaDTO = + CalorieDTO( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeG = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt b/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt new file mode 100644 index 0000000..ee7a6bf --- /dev/null +++ b/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt @@ -0,0 +1,86 @@ +package com.devmike.testing.sampleData + +import com.devmike.database.entities.CalorieEntity +import com.devmike.database.entities.RecentSearchEntity +import com.devmike.database.entities.SearchQueryWithCalories +import java.time.Instant + +object SampleEntities { + val onion = + CalorieEntity( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeG = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomato = + CalorieEntity( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeG = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizza = + CalorieEntity( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeG = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} + +object SampleSearchEntity { + val fruits = RecentSearchEntity("Fruits", Instant.now()) + val vegetablesonion = RecentSearchEntity("Vegetables with onion", Instant.now()) + val tomatowithpizza = RecentSearchEntity("Tomato with pizza", Instant.now()) + val pizzaonion = RecentSearchEntity("Pizza on onion", Instant.now()) + val onion = RecentSearchEntity("Onion", Instant.now()) +} + +object SampleSearchWithCalorie { + val tomatopizza = + SearchQueryWithCalories( + searchQuery = SampleSearchEntity.tomatowithpizza, + calories = + listOf( + SampleEntities.tomato, + SampleEntities.pizza, + ), + ) + + val pizzaonion = + SearchQueryWithCalories( + searchQuery = SampleSearchEntity.pizzaonion, + calories = + listOf( + SampleEntities.onion, + SampleEntities.pizza, + ), + ) +} diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt b/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt new file mode 100644 index 0000000..621c980 --- /dev/null +++ b/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt @@ -0,0 +1,53 @@ +package com.devmike.sampleData + +import com.devmike.domain.models.CalorieModel + +object SampleModels { + val onionmodel = + CalorieModel( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeinGrams = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomatomodel = + CalorieModel( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeinGrams = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizzamodel = + CalorieModel( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeinGrams = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 73f67f3..737cbde 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -33,5 +33,8 @@ dependencies { implementation(libs.room.runtime) implementation(libs.room.ktx) implementation(libs.room.testing) + implementation(libs.androidx.test.ext) ksp(libs.room.compiler) + + testApi(project(":core:testing")) } diff --git a/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt b/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt deleted file mode 100644 index 39b6501..0000000 --- a/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.devmike.database - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.devmike.database.test", appContext.packageName) - } -} diff --git a/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt b/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt index 9c7d3c8..98d0e69 100644 --- a/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt +++ b/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.devmike.database.entities.CalorieEntity +import kotlinx.coroutines.flow.Flow @Dao interface CalorieDao { @@ -16,4 +17,10 @@ interface CalorieDao { @Query("SELECT * FROM calorie_table WHERE name = :name") suspend fun getCalorieById(name: String): CalorieEntity? + + @Query("Select * from calorie_table") + fun getAllCalories(): Flow> + + @Query("DELETE FROM calorie_table") + suspend fun deleteAll() } diff --git a/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt b/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt index 499b806..f8b6311 100644 --- a/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt +++ b/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt @@ -14,8 +14,8 @@ interface CrossRefDao { @Query("DELETE FROM search_query_calorie_cross_ref WHERE queryString = :searchId") suspend fun deleteBySearchId(searchId: String) - @Query("DELETE FROM search_query_calorie_cross_ref WHERE name = :calorieId") - suspend fun deleteByCalorieId(calorieId: Long) + @Query("DELETE FROM search_query_calorie_cross_ref WHERE name = :caloriename") + suspend fun deleteByCalorieId(caloriename: String) @Query("DELETE FROM search_query_calorie_cross_ref") suspend fun deleteAll() diff --git a/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt b/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt new file mode 100644 index 0000000..0422f38 --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt @@ -0,0 +1,73 @@ +package com.devmike.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.devmike.database.baseDb.BaseDbTest +import com.devmike.database.dao.CalorieDao +import com.devmike.database.util.SampleData.onion +import com.devmike.database.util.SampleData.tomato +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CalorieDaoTest : BaseDbTest() { + private lateinit var calorieDao: CalorieDao + + @Before + fun setUp() { + calorieDao = db.calorieDao() + } + + @Test + fun `insert calorie entity`() { + val calorieEntity = onion + runTest { + calorieDao.insert(calorieEntity) + val insertedCalorie = calorieDao.getCalorieById(calorieEntity.name) + assertNotNull(insertedCalorie) + assertEquals(calorieEntity, insertedCalorie) + } + } + + @Test + fun `insert all calorie entities`() { + val calories = listOf(onion, tomato) + runTest { + calorieDao.insertAll(calories) + val allCalories = calorieDao.getAllCalories().first() + assertEquals(calories.size, allCalories.size) + assertTrue(allCalories.contains(onion)) + assertTrue(allCalories.contains(tomato)) + } + } + + @Test + fun `get calorie entity by name - not found`() { + val unknownName = "unknown" + runTest { + val calorie = calorieDao.getCalorieById(unknownName) + assertNull(calorie) + } + } + + @Test + fun `delete all calorie entities`() { + val calories = listOf(onion, tomato) + runTest { + calorieDao.insertAll(calories) + + val insertedCalorie = calorieDao.getCalorieById(calories.first().name) + assertEquals(onion, insertedCalorie) + calorieDao.deleteAll() + + val allCalories = calorieDao.getAllCalories().first() + assertEquals(0, allCalories.size) + } + } +} diff --git a/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt b/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt new file mode 100644 index 0000000..cd5d171 --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt @@ -0,0 +1,89 @@ +package com.devmike.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.devmike.database.baseDb.BaseDbTest +import com.devmike.database.dao.CalorieDao +import com.devmike.database.dao.CrossRefDao +import com.devmike.database.dao.SearchQueryDao +import com.devmike.database.entities.RecentSearchEntity +import com.devmike.database.entities.SearchQueryCalorieCrossRef +import com.devmike.database.util.SampleData.onion +import com.devmike.database.util.SampleData.tomato +import com.google.common.truth.Truth +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant + +@RunWith(AndroidJUnit4::class) +class SearchEntityCrossrefDaoTest : BaseDbTest() { + private lateinit var calorieDao: CalorieDao + private lateinit var crossRefDao: CrossRefDao + private lateinit var searchQueryDao: SearchQueryDao + + private val searchQuery = "Onion with Tomatoes" + + @Before + fun setUp() { + calorieDao = db.calorieDao() + searchQueryDao = db.searchQueryDao() + crossRefDao = db.crossRefDao() + } + + @Test + fun `insertCrossRef works`() = + runTest { + calorieDao.insertAll(listOf(onion, tomato)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name)) + searchQueryDao.insert(RecentSearchEntity(searchQuery, Instant.now())) + + val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery) + val searchResults = searchQueryWithCalories.map { it.calories }.flatten() + + Truth.assertThat(searchResults).containsAnyOf(onion, tomato) + } + + @Test + fun `deleteBySearchId works`() = + runTest { + calorieDao.insertAll(listOf(onion, tomato)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name)) + + crossRefDao.deleteBySearchId(searchQuery) + + val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery) + + Truth.assertThat(searchQueryWithCalories).isEmpty() + } + + @Test + fun `deleteByCaloriename works`() = + runTest { + calorieDao.insertAll(listOf(onion, tomato)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name)) + + crossRefDao.deleteByCalorieId(onion.name) + + val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery) + + Truth.assertThat(searchQueryWithCalories).doesNotContain(tomato) + } + + @Test + fun ` deleteAll works`() = + runTest { + calorieDao.insertAll(listOf(onion, tomato)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name)) + crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name)) + + crossRefDao.deleteAll() + + val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery) + + Truth.assertThat(searchQueryWithCalories).isEmpty() + } +} diff --git a/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt b/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt new file mode 100644 index 0000000..40cfd27 --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt @@ -0,0 +1,54 @@ +package com.devmike.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.devmike.database.baseDb.BaseDbTest +import com.devmike.database.dao.SearchQueryDao +import com.devmike.database.entities.RecentSearchEntity +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant + +@RunWith(AndroidJUnit4::class) +class SearchQueryDaoTest : BaseDbTest() { + private lateinit var searchQueryDao: SearchQueryDao + + @Before + fun setUp() { + searchQueryDao = db.searchQueryDao() + } + + @Test + fun `insert recent search entity`() { + val query = "apple" + val searchEntity = RecentSearchEntity(query, Instant.now()) + runTest { + val insertedRowId = searchQueryDao.insert(searchEntity) + assertTrue(insertedRowId > 0L) + val retrievedQuery = searchQueryDao.getSearchQueryWithCalories(query) + assertEquals(1, retrievedQuery.size) + + val retrievedSearch = retrievedQuery.find { it.searchQuery.queryString == query }?.searchQuery?.queryString + assertEquals(query, retrievedSearch) + } + } + + @Test + fun `clear recent search queries`() { + val query = "pizza" + + runTest { + searchQueryDao.insert(RecentSearchEntity(query, Instant.now())) + val initialCount = searchQueryDao.getRecentQueries(Int.MAX_VALUE).first() + assertTrue(initialCount.isNotEmpty()) + + searchQueryDao.clearRecentSearchQueries() + val clearedCount = searchQueryDao.getRecentQueries(Int.MAX_VALUE).first() + assertTrue(clearedCount.isEmpty()) + } + } +} diff --git a/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt b/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt new file mode 100644 index 0000000..21812ee --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt @@ -0,0 +1,38 @@ +package com.devmike.database.baseDb + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.devmike.database.CalorieDatabase +import com.devmike.database.util.MainCoroutineRule +import org.junit.After +import org.junit.Before +import org.junit.Rule + +abstract class BaseDbTest { + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = MainCoroutineRule() + lateinit var db: CalorieDatabase + + @Before + open fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = + Room.inMemoryDatabaseBuilder( + context, + CalorieDatabase::class.java, + ) + .allowMainThreadQueries() + .build() + } + + @After + open fun tearDownDb() { + db.clearAllTables() + db.close() + } +} diff --git a/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt b/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt new file mode 100644 index 0000000..554df52 --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt @@ -0,0 +1,26 @@ +package com.devmike.database.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class MainCoroutineRule + @OptIn(ExperimentalCoroutinesApi::class) + constructor( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), + ) : TestWatcher() { + @OptIn(ExperimentalCoroutinesApi::class) + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun finished(description: Description) { + Dispatchers.resetMain() + } + } diff --git a/core/database/src/test/java/com/devmike/database/util/SampleData.kt b/core/database/src/test/java/com/devmike/database/util/SampleData.kt new file mode 100644 index 0000000..c3c83fc --- /dev/null +++ b/core/database/src/test/java/com/devmike/database/util/SampleData.kt @@ -0,0 +1,63 @@ +package com.devmike.database.util + +import com.devmike.database.entities.CalorieEntity +import com.devmike.database.entities.RecentSearchEntity +import java.time.Instant + +object SampleData { + val onion = + CalorieEntity( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeG = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomato = + CalorieEntity( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeG = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizza = + CalorieEntity( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeG = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} + +object SampleSearchEntity { + val fruits = RecentSearchEntity("Fruits", Instant.now()) + val vegetables = RecentSearchEntity("Vegetables", Instant.now()) + val tomato = RecentSearchEntity("Tomato", Instant.now()) + val pizza = RecentSearchEntity("Pizza", Instant.now()) + val onion = RecentSearchEntity("Onion", Instant.now()) +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index b196d25..280b0dc 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -44,5 +44,5 @@ dependencies { implementation(libs.bundles.ktor) implementation(libs.kotlinx.serialization.json) api(project(":domain")) - testImplementation(libs.ktor.mock) + testApi(project(":core:testing")) } diff --git a/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt b/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt deleted file mode 100644 index bd100f1..0000000 --- a/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.devmike.network - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.devmike.network.test", appContext.packageName) - } -} diff --git a/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt b/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt new file mode 100644 index 0000000..2beaece --- /dev/null +++ b/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt @@ -0,0 +1,107 @@ +package com.devmike.network + +import com.devmike.domain.models.AppErrors +import com.devmike.network.datasource.CalorieNetworkSourceImpl +import com.google.common.truth.Truth +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CalorieClientApiTest { + @Test + fun `getCalorie response code 200 deserializes data correctly `() = + runTest { + val mockEngine = + MockEngine { + respond( + mockValidJsonResponse, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val httpClient = + HttpClient(mockEngine) { + install(ContentNegotiation) { + json() + } + install(DefaultRequest) { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } + } + + val netWorkRepo = CalorieNetworkSourceImpl(httpClient) + val result = netWorkRepo.getCalorieInformation("pizza") + + Truth.assertThat(result.getOrNull()?.items).containsAnyOf(SampleDTO.tomatoDTO, SampleDTO.onionDTO) + } + + @Test + fun `get client returns failure for errors`() = + runTest { + val mockEngine = + MockEngine { + respond( + """ + invalid data + """.trimIndent(), + headers = headersOf(HttpHeaders.ContentType, "application/json"), + status = HttpStatusCode.BadRequest, + ) + } + + val httpClient = + HttpClient(mockEngine) { + install(ContentNegotiation) { + json() + } + install(DefaultRequest) { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } + } + + val netWorkRepo = CalorieNetworkSourceImpl(httpClient) + val result = netWorkRepo.getCalorieInformation("pizza") + + Truth.assertThat(result.getOrNull()).isNull() + } + + @Test + fun ` given a response 404 the client returns AppErrors not found`() = + runTest { + val mockEngine = + MockEngine { + respond( + """ + invalid data + """.trimIndent(), + headers = headersOf(HttpHeaders.ContentType, "application/json"), + status = HttpStatusCode.NotFound, + ) + } + + val httpClient = + HttpClient(mockEngine) { + install(ContentNegotiation) { + json() + } + install(DefaultRequest) { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } + } + + val netWorkRepo = CalorieNetworkSourceImpl(httpClient) + val result = netWorkRepo.getCalorieInformation("pizza") + Truth.assertThat(result.exceptionOrNull()).isInstanceOf(AppErrors.NotFound::class.java) + } +} diff --git a/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt b/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt deleted file mode 100644 index c235972..0000000 --- a/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.devmike.network - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt b/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt index a1cf146..3d26983 100644 --- a/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt +++ b/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt @@ -1,6 +1,8 @@ package com.devmike.network -internal val mockJsonResponse = +import com.devmike.network.model.CalorieDTO + +internal val mockValidJsonResponse = """ { "items": [ @@ -49,3 +51,53 @@ internal val mockJsonResponse = ] } """.trimIndent() + +object SampleDTO { + val onionDTO = + CalorieDTO( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeG = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomatoDTO = + CalorieDTO( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeG = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizzaDTO = + CalorieDTO( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeG = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} diff --git a/core/testing/.gitignore b/core/testing/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts new file mode 100644 index 0000000..ddcb9b6 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.caloriebytez.android.feature) + alias(libs.plugins.caloriebytez.library.compose) +} + +android { + namespace = "com.devmike.testing" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } +} + +dependencies { + api(libs.ktor.mock) + api(libs.androidx.arch.core.testing) + api(libs.kotlinx.coroutines.test) + api(libs.core.ktx) + api(libs.truth) + api(libs.robolectric) + api(libs.mockk) + implementation(project(":domain")) +} diff --git a/core/testing/consumer-rules.pro b/core/testing/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/testing/proguard-rules.pro b/core/testing/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core/testing/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/testing/src/main/AndroidManifest.xml b/core/testing/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/core/testing/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt b/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt new file mode 100644 index 0000000..e5c0f4a --- /dev/null +++ b/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt @@ -0,0 +1,52 @@ +package com.devmike.testing.fake + +import com.devmike.domain.models.AppErrors +import com.devmike.domain.models.CalorieModel +import com.devmike.domain.repositories.CaloriesRepository +import com.devmike.testing.sampleData.SampleModels +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeCaloriesRepository : CaloriesRepository { + private val calorieMap = mutableMapOf>() + private val foodItemMap = mutableMapOf() + + private var returnError = false + + fun setReturnError(value: Boolean) { + returnError = value + } + + fun setCalories( + query: String, + calories: List, + ) { + calorieMap[query] = calories + } + + fun setFoodItem( + name: String, + calorieModel: CalorieModel, + ) { + foodItemMap[name] = calorieModel + } + + override suspend fun getCalories(query: String): Result> { + if (returnError) return Result.failure(AppErrors.Unknown()) + return calorieMap[query]?.let { Result.success(it) } + ?: Result.failure(Exception("No data found for query: $query")) + } + + override suspend fun getFoodItem(name: String): CalorieModel? { + return foodItemMap[name] + } + + override fun getAllCalories(): Flow> { + return flowOf( + listOf( + SampleModels.tomatomodel, + SampleModels.pizzamodel, + ), + ) + } +} diff --git a/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt b/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt new file mode 100644 index 0000000..3cae3ff --- /dev/null +++ b/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt @@ -0,0 +1,17 @@ +package com.devmike.testing.fake + +import com.devmike.domain.repositories.RecentSearchesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeRecentSearchesRepository : RecentSearchesRepository { + private val recentSearchesFlow = MutableStateFlow(listOf()) + + fun setRecentSearches(searches: List) { + recentSearchesFlow.value = searches + } + + override fun getRecentSearches(limit: Int): Flow> { + return recentSearchesFlow + } +} diff --git a/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt b/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt new file mode 100644 index 0000000..f68f72a --- /dev/null +++ b/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt @@ -0,0 +1,26 @@ +package com.devmike.testing.helpers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class MainCoroutineRule + @OptIn(ExperimentalCoroutinesApi::class) + constructor( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), + ) : TestWatcher() { + @OptIn(ExperimentalCoroutinesApi::class) + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun finished(description: Description) { + Dispatchers.resetMain() + } + } diff --git a/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt b/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt new file mode 100644 index 0000000..72967da --- /dev/null +++ b/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt @@ -0,0 +1,53 @@ +package com.devmike.testing.sampleData + +import com.devmike.domain.models.CalorieModel + +object SampleModels { + val onionmodel = + CalorieModel( + calories = 126.7, + carbohydratesTotalG = 28.6, + cholesterolMg = 0.0, + fatSaturatedG = 0.1, + fatTotalG = 0.5, + fiberG = 4.0, + name = "onion", + potassiumMg = 99.0, + proteinG = 3.9, + servingSizeinGrams = 283.0, + sodiumMg = 8.0, + sugarG = 13.3, + ) + + val tomatomodel = + CalorieModel( + calories = 18.2, + carbohydratesTotalG = 3.9, + cholesterolMg = 0.0, + fatSaturatedG = 0.0, + fatTotalG = 0.2, + fiberG = 1.2, + name = "tomato", + potassiumMg = 23.0, + proteinG = 0.9, + servingSizeinGrams = 100.0, + sodiumMg = 4.0, + sugarG = 2.6, + ) + + val pizzamodel = + CalorieModel( + calories = 262.9, + carbohydratesTotalG = 32.9, + cholesterolMg = 16.0, + fatSaturatedG = 4.5, + fatTotalG = 9.8, + fiberG = 2.3, + name = "pizza", + potassiumMg = 217.0, + proteinG = 11.4, + servingSizeinGrams = 100.0, + sodiumMg = 587.0, + sugarG = 3.6, + ) +} diff --git a/docs/datalayertests.png b/docs/datalayertests.png new file mode 100644 index 0000000..c14a4c3 Binary files /dev/null and b/docs/datalayertests.png differ diff --git a/docs/dbtests.png b/docs/dbtests.png new file mode 100644 index 0000000..52d1247 Binary files /dev/null and b/docs/dbtests.png differ diff --git a/docs/details.jpg b/docs/details.jpg new file mode 100644 index 0000000..6f4bf93 Binary files /dev/null and b/docs/details.jpg differ diff --git a/docs/errorscreen.jpg b/docs/errorscreen.jpg new file mode 100644 index 0000000..1eb8aee Binary files /dev/null and b/docs/errorscreen.jpg differ diff --git a/docs/foodlist.jpg b/docs/foodlist.jpg new file mode 100644 index 0000000..c1e299e Binary files /dev/null and b/docs/foodlist.jpg differ diff --git a/docs/graphviz.svg b/docs/graphviz.svg new file mode 100644 index 0000000..386a367 --- /dev/null +++ b/docs/graphviz.svg @@ -0,0 +1,169 @@ + + +G + + + +:app + +:app + + + +:feature:search + +:feature:search + + + +:app->:feature:search + + + + + +:feature:foodDetails + +:feature:foodDetails + + + +:app->:feature:foodDetails + + + + + +:feature:savedItems + +:feature:savedItems + + + +:app->:feature:savedItems + + + + + +:domain + +:domain + + + +:app->:domain + + + + + +:core:common-ui + +:core:common-ui + + + +:feature:search->:core:common-ui + + + + + +:core:data + +:core:data + + + +:feature:search->:core:data + + + + + +:core:testing + +:core:testing + + + +:feature:search->:core:testing + + + + + +:feature:foodDetails->:core:common-ui + + + + + +:feature:foodDetails->:core:data + + + + + +:feature:savedItems->:core:common-ui + + + + + +:feature:savedItems->:core:data + + + + + +:core:common-ui->:domain + + + + + +:core:data->:domain + + + + + +:core:network + +:core:network + + + +:core:data->:core:network + + + + + +:core:database + +:core:database + + + +:core:data->:core:database + + + + + +:core:testing->:domain + + + + + +:core:network->:domain + + + + + \ No newline at end of file diff --git a/docs/idle.jpg b/docs/idle.jpg new file mode 100644 index 0000000..95f3e40 Binary files /dev/null and b/docs/idle.jpg differ diff --git a/docs/loading.jpg b/docs/loading.jpg new file mode 100644 index 0000000..f4c0c63 Binary files /dev/null and b/docs/loading.jpg differ diff --git a/docs/networktests.png b/docs/networktests.png new file mode 100644 index 0000000..4001ba6 Binary files /dev/null and b/docs/networktests.png differ diff --git a/docs/recentssearch.jpg b/docs/recentssearch.jpg new file mode 100644 index 0000000..7fadf56 Binary files /dev/null and b/docs/recentssearch.jpg differ diff --git a/docs/viewmodeltests.png b/docs/viewmodeltests.png new file mode 100644 index 0000000..93ce192 Binary files /dev/null and b/docs/viewmodeltests.png differ diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 6529428..cbc54d7 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -18,5 +18,5 @@ android { } dependencies { - implementation(libs.kotlinx.serialization.json) + api(libs.kotlinx.serialization.json) } diff --git a/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt b/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt deleted file mode 100644 index c149fdd..0000000 --- a/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.devmike.domain - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.devmike.domain.test", appContext.packageName) - } -} diff --git a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt b/domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt similarity index 62% rename from core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt rename to domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt index 24a1dec..c9a56cd 100644 --- a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt +++ b/domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt @@ -1,9 +1,12 @@ -package com.devmike.data.repository +package com.devmike.domain.repositories import com.devmike.domain.models.CalorieModel +import kotlinx.coroutines.flow.Flow interface CaloriesRepository { suspend fun getCalories(query: String): Result> suspend fun getFoodItem(name: String): CalorieModel? + + fun getAllCalories(): Flow> } diff --git a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt b/domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt similarity index 77% rename from core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt rename to domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt index a0d5d85..b33006b 100644 --- a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt +++ b/domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt @@ -1,4 +1,4 @@ -package com.devmike.data.repository +package com.devmike.domain.repositories import kotlinx.coroutines.flow.Flow diff --git a/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts b/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts new file mode 100644 index 0000000..ddcb9b6 --- /dev/null +++ b/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.caloriebytez.android.feature) + alias(libs.plugins.caloriebytez.library.compose) +} + +android { + namespace = "com.devmike.testing" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } +} + +dependencies { + api(libs.ktor.mock) + api(libs.androidx.arch.core.testing) + api(libs.kotlinx.coroutines.test) + api(libs.core.ktx) + api(libs.truth) + api(libs.robolectric) + api(libs.mockk) + implementation(project(":domain")) +} diff --git a/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt b/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt deleted file mode 100644 index 397f1f8..0000000 --- a/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.devmike.domain - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/feature/foodDetails/build.gradle.kts b/feature/foodDetails/build.gradle.kts index d43575c..9e1a046 100644 --- a/feature/foodDetails/build.gradle.kts +++ b/feature/foodDetails/build.gradle.kts @@ -20,4 +20,5 @@ android { dependencies { implementation(project(":core:data")) api(project(":core:common-ui")) + testImplementation(project(":core:testing")) } diff --git a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt index fdee369..4113817 100644 --- a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt +++ b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt @@ -4,9 +4,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute -import com.devmike.data.repository.CaloriesRepository import com.devmike.domain.models.AppDestinations import com.devmike.domain.models.CalorieModel +import com.devmike.domain.repositories.CaloriesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt index 1bfd8a9..aa1998e 100644 --- a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt +++ b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt @@ -1,5 +1,6 @@ package com.devmike.fooddetails +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,11 +13,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Dining +import androidx.compose.material.icons.filled.Earbuds import androidx.compose.material.icons.filled.EggAlt -import androidx.compose.material.icons.filled.Fastfood +import androidx.compose.material.icons.filled.EnergySavingsLeaf import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FitnessCenter -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.LineAxis +import androidx.compose.material.icons.filled.LocalParking import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -35,7 +38,9 @@ fun FoodDetails(calorieModel: CalorieModel) { Column( modifier = Modifier.padding(16.dp).fillMaxSize().verticalScroll(rememberScrollState()), ) { - CalorieItemScreen(calorieModel.calories) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + CalorieItemScreen(calorieModel.calories) + } NutritionDetailItem( modifier = Modifier, @@ -58,31 +63,31 @@ fun FoodDetails(calorieModel: CalorieModel) { ) NutritionDetailItem( modifier = Modifier, - icon = Icons.Default.Fastfood, + icon = Icons.Default.EggAlt, value = "${calorieModel.proteinG}g", label = "Protein", ) NutritionDetailItem( modifier = Modifier, - icon = Icons.Default.Search, + icon = Icons.Default.Earbuds, value = "${calorieModel.sodiumMg}mg", label = "Sodium", ) NutritionDetailItem( modifier = Modifier, - icon = Icons.Default.EggAlt, + icon = Icons.Default.EnergySavingsLeaf, value = "${calorieModel.sugarG}g", label = "Sugar", ) NutritionDetailItem( modifier = Modifier, - icon = Icons.Default.Search, + icon = Icons.Default.LocalParking, value = "${calorieModel.potassiumMg}mg", label = "Potassium", ) NutritionDetailItem( modifier = Modifier, - icon = Icons.Default.Search, + icon = Icons.Default.LineAxis, value = "${calorieModel.fiberG}g", label = "Fiber", ) diff --git a/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt new file mode 100644 index 0000000..fdc0b8f --- /dev/null +++ b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt @@ -0,0 +1,40 @@ +package com.devmike.saveditems + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.devmike.commonui.sharedui.FoodList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SavedItemsScreen( + viewmodel: SavedItemsViewmodel = hiltViewModel(), + onItemClicked: (name: String) -> Unit, +) { + val fooditems by viewmodel.savedCalorieItems.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text("Recently Viewed ") + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + FoodList(modifier = Modifier.padding(it), fooditems, onItemClicked) + } +} diff --git a/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt new file mode 100644 index 0000000..4516562 --- /dev/null +++ b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt @@ -0,0 +1,21 @@ +package com.devmike.saveditems + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devmike.domain.repositories.CaloriesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class SavedItemsViewmodel + @Inject + constructor(private val repository: CaloriesRepository) : ViewModel() { + val savedCalorieItems = + repository.getAllCalories().stateIn( + viewModelScope, + SharingStarted.Lazily, + emptyList(), + ) + } diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index a1a7c0b..e52f2dc 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -29,4 +29,5 @@ dependencies { implementation(project(":core:data")) api(project(":core:common-ui")) + implementation(project(":core:testing")) } diff --git a/feature/search/src/main/java/com/devmike/search/SearchScreen.kt b/feature/search/src/main/java/com/devmike/search/SearchScreen.kt index 8c6190f..6ada6ae 100644 --- a/feature/search/src/main/java/com/devmike/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/devmike/search/SearchScreen.kt @@ -25,6 +25,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition +import com.devmike.commonui.sharedui.ErrorScreen +import com.devmike.commonui.sharedui.FoodList import com.devmike.domain.models.CalorieModel import com.devmike.domain.models.FetchItemState @@ -72,7 +74,9 @@ fun SearchScreen( when (val state = calorieSearchState) { is FetchItemState.Error -> { - Text(state.err.message) + ErrorScreen(state.err) { + searchViewModel.searchCalories() + } } is FetchItemState.Idle -> { @@ -98,7 +102,7 @@ fun SearchScreen( } is FetchItemState.Success -> { - com.devmike.commonui.sharedui.FoodList(state.data, onFoodItemClicked) + FoodList(Modifier, state.data, onFoodItemClicked) } } } diff --git a/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt b/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt index 03b5de0..5ea191a 100644 --- a/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt +++ b/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt @@ -4,11 +4,11 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.devmike.data.repository.CaloriesRepository -import com.devmike.data.repository.RecentSearchesRepository import com.devmike.domain.models.AppErrors import com.devmike.domain.models.CalorieModel import com.devmike.domain.models.FetchItemState +import com.devmike.domain.repositories.CaloriesRepository +import com.devmike.domain.repositories.RecentSearchesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -21,43 +21,44 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel - @Inject - constructor( - private val repository: CaloriesRepository, - private val searchesRepository: RecentSearchesRepository, - ) : ViewModel() { - val recentSearchList = - searchesRepository.getRecentSearches(10) - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - private var searchJob: Job? = null - var searchQuery: MutableState = mutableStateOf("") - private set - - private val _caloriesSearchState: MutableStateFlow>> = - MutableStateFlow(FetchItemState.Idle) - val caloriesSearchState: StateFlow>> = - _caloriesSearchState.asStateFlow() - - fun modifyQuery(query: String) { - searchQuery.value = query - } - - fun searchCalories() { - searchJob?.cancel() - _caloriesSearchState.value = FetchItemState.Loading - if (searchQuery.value.length > 3) { - searchJob = - viewModelScope.launch { - repository.getCalories(searchQuery.value.trim()).onSuccess { calories -> - _caloriesSearchState.value = FetchItemState.Success(calories) - }.onFailure { throwable -> - - if (throwable is AppErrors) { - _caloriesSearchState.value = FetchItemState.Error(throwable) - } - } - } - } - } - } + @Inject + constructor( + private val repository: CaloriesRepository, + private val searchesRepository: RecentSearchesRepository, + ) : ViewModel() { + val recentSearchList = + searchesRepository.getRecentSearches(10) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private var searchJob: Job? = null + var searchQuery: MutableState = mutableStateOf("") + private set + + private val _caloriesSearchState: MutableStateFlow>> = + MutableStateFlow(FetchItemState.Idle) + val caloriesSearchState: StateFlow>> = + _caloriesSearchState.asStateFlow() + + fun modifyQuery(query: String) { + searchQuery.value = query + } + + fun searchCalories() { + searchJob?.cancel() + + if (searchQuery.value.length > 3) { + _caloriesSearchState.value = FetchItemState.Loading + searchJob = + viewModelScope.launch { + repository.getCalories(searchQuery.value.trim()).onSuccess { calories -> + _caloriesSearchState.value = FetchItemState.Success(calories) + }.onFailure { throwable -> + + if (throwable is AppErrors) { + _caloriesSearchState.value = FetchItemState.Error(throwable) + } + } + } + } + } + } diff --git a/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt b/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt deleted file mode 100644 index a068722..0000000 --- a/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.devmike.search - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt b/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt new file mode 100644 index 0000000..34d4ab0 --- /dev/null +++ b/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt @@ -0,0 +1,97 @@ +package com.devmike.search + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.devmike.domain.models.AppErrors +import com.devmike.domain.models.FetchItemState +import com.devmike.testing.fake.FakeCaloriesRepository +import com.devmike.testing.fake.FakeRecentSearchesRepository +import com.devmike.testing.helpers.MainCoroutineRule +import com.devmike.testing.sampleData.SampleModels +import com.google.common.truth.Truth +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class SearchViewmodelTest { + private lateinit var fakeRecentSearchesRepository: FakeRecentSearchesRepository + + private lateinit var fakeCaloriesRepository: FakeCaloriesRepository + + @get:Rule + val mainDispatcherRule = MainCoroutineRule() + + @get:Rule + val instantTaskExecutor: InstantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: SearchViewModel + + @Before + fun setup() { + fakeRecentSearchesRepository = FakeRecentSearchesRepository() + fakeCaloriesRepository = FakeCaloriesRepository() + viewModel = SearchViewModel(fakeCaloriesRepository, fakeRecentSearchesRepository) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `test getRecentSearches initializes with recent searches`() = + runTest { + val recentSearches = listOf("apple", "pizza") + fakeRecentSearchesRepository.setRecentSearches(recentSearches) + + val result = viewModel.recentSearchList.first() + + Truth.assertThat(result).containsExactly(*recentSearches.toTypedArray()) + } + + @Test + fun `test modifyQuery updates searchQuery`() { + viewModel.modifyQuery("onion") + + Truth.assertThat(viewModel.searchQuery.value).isEqualTo("onion") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `test searchCalories sets loading state and fetches calories successfully`() = + runTest { + val sampleCalories = listOf(SampleModels.tomatomodel) + fakeCaloriesRepository.setCalories("tomato", sampleCalories) + + viewModel.modifyQuery("tomato") + viewModel.searchCalories() + + advanceUntilIdle() + + Truth.assertThat(viewModel.caloriesSearchState.value).isInstanceOf(FetchItemState.Success::class.java) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `test searchCalories handles error state`() = + runTest { + fakeCaloriesRepository.setReturnError(true) + + viewModel.modifyQuery("apple") + viewModel.searchCalories() + + Truth.assertThat(viewModel.caloriesSearchState.value).isInstanceOf(FetchItemState.Error::class.java) + + val isErr = (viewModel.caloriesSearchState.value as FetchItemState.Error) + + Truth.assertThat(isErr.err).isInstanceOf(AppErrors.Unknown::class.java) + } + + @Test + fun `test searchCalories does not search for short queries`() = + runTest { + viewModel.modifyQuery("app") + viewModel.searchCalories() + + Truth.assertThat(viewModel.caloriesSearchState.value).isEqualTo(FetchItemState.Idle) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ba0c03..583dc7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ activityCompose = "1.9.0" androidxComposeAlpha = "1.7.0-beta02" androidxCoreSplashscreen = "1.0.1" androidxLifecycle = "2.8.1" +archCore = "2.2.0" composeNavigation = "2.8.0-beta02" androidxTestExt = "1.1.5" coreKtx = "1.13.1" @@ -23,12 +24,16 @@ ktlint = "11.6.1" ktor = "2.3.7" lifecycleRuntimeKtx = "2.8.1" lottie = "6.0.0" - +mockk = "1.13.11" +moduleGraph = "2.5.0" +robolectric = "4.12.2" room = "2.6.1" spotless = "6.4.2" timber = "5.0.1" appcompat = "1.6.1" material = "1.10.0" +coreKtxVersion = "1.5.0" +truth = "1.4.2" @@ -68,6 +73,7 @@ hilt-android= { module = "com.google.dagger:hilt-android",version.ref = "hilt"} hilt-testing= { module = "'com.google.dagger:hilt-android-testing",version.ref = "hilt"} #room +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-testing = {module = "androidx.room:room-testing",version.ref = "room"} @@ -88,6 +94,9 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "archCore" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } @@ -96,6 +105,8 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } [bundles] ktor = ["ktor-core", "ktor-android", "ktor-json", "ktor-content-negotiation", "ktor-logging", "ktor-okhttp"] @@ -118,6 +129,7 @@ hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } android-library = { id = "com.android.library", version.ref = "agp" } kotlinX-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } room-compiler = { id = "androidx.room", version.ref = "room" } +module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" } # project plugins diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a1cfed..a969c26 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":feature:search") include(":feature:foodDetails") include(":feature:savedItems") include(":core:common-ui") +include(":core:testing")