diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4edbc7b..f74096e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ coil = "3.4.0" composeMaterial3Adaptive = "1.2.0-alpha04" detekt = "2.0.0-alpha.2" sqldelight = "2.0.2" +turbine = "1.2.1" +mockk = "1.14.9" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -62,6 +64,8 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "composeMaterial3Adaptive" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index aa41734..0bb392f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -16,6 +16,9 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } + + withHostTestBuilder { + } } listOf( @@ -44,6 +47,10 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + getByName("androidHostTest").dependencies { + implementation(libs.mockk) } androidMain.dependencies { implementation(libs.kotlinx.coroutines.android) diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/repository/WardrobeRepositoryImplTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/repository/WardrobeRepositoryImplTest.kt new file mode 100644 index 0000000..3c235d3 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/github/worn/repository/WardrobeRepositoryImplTest.kt @@ -0,0 +1,314 @@ +package com.github.worn.repository + +import app.cash.sqldelight.Query +import com.github.worn.data.repository.WardrobeRepositoryImpl +import com.github.worn.data.source.local.PhotoFileStorage +import com.github.worn.data.source.local.db.ClothingItemQueries +import com.github.worn.data.source.local.db.WardrobeDatabase +import com.github.worn.data.source.remote.ClaudeApiClient +import com.github.worn.domain.model.AiAnalysisResult +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.TryItResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import com.github.worn.data.source.local.db.ClothingItem as DbClothingItem + +@OptIn(ExperimentalCoroutinesApi::class) +class WardrobeRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val db = mockk() + private val queries = mockk(relaxed = true) + private val fileStorage = mockk() + private val aiClient = mockk() + + private lateinit var repository: WardrobeRepositoryImpl + + private val dbItem = DbClothingItem( + id = "item-1", + name = "Blue T-Shirt", + category = "TOP", + colors = listOf("blue"), + seasons = listOf("SUMMER"), + tags = listOf("casual"), + description = "A blue t-shirt", + photoPath = "/photos/item-1.jpg", + createdAt = 1_000_000L, + ) + + @BeforeTest + fun setup() { + every { db.clothingItemQueries } returns queries + repository = WardrobeRepositoryImpl(db, fileStorage, aiClient, testDispatcher) + } + + // region getAll + + @Test + fun `getAll returns mapped domain items`() = runTest { + val query = mockk>() + every { queries.getAll() } returns query + every { query.executeAsList() } returns listOf(dbItem) + + val result = repository.getAll() + + assertTrue(result.isSuccess) + val items = result.getOrThrow() + assertEquals(1, items.size) + assertEquals("item-1", items[0].id) + assertEquals(Category.TOP, items[0].category) + assertEquals(listOf(Season.SUMMER), items[0].seasons) + } + + // endregion + + // region getById + + @Test + fun `getById returns item when exists`() = runTest { + val query = mockk>() + every { queries.getById("item-1") } returns query + every { query.executeAsOneOrNull() } returns dbItem + + val result = repository.getById("item-1") + + assertTrue(result.isSuccess) + val item = result.getOrThrow() + assertEquals("item-1", item?.id) + assertEquals(Category.TOP, item?.category) + } + + @Test + fun `getById returns null when not found`() = runTest { + val query = mockk>() + every { queries.getById("missing") } returns query + every { query.executeAsOneOrNull() } returns null + + val result = repository.getById("missing") + + assertTrue(result.isSuccess) + assertNull(result.getOrThrow()) + } + + // endregion + + // region getByCategory + + @Test + fun `getByCategory calls queries with category name`() = runTest { + val query = mockk>() + every { queries.getByCategory("TOP") } returns query + every { query.executeAsList() } returns listOf(dbItem) + + val result = repository.getByCategory(Category.TOP) + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrThrow().size) + verify { queries.getByCategory("TOP") } + } + + // endregion + + // region search + + @Test + fun `search wraps query in wildcards`() = runTest { + val query = mockk>() + every { queries.search("%blue%") } returns query + every { query.executeAsList() } returns listOf(dbItem) + + val result = repository.search("blue") + + assertTrue(result.isSuccess) + verify { queries.search("%blue%") } + } + + // endregion + + // region addItem + + @Test + fun `addItem writes photo inserts DB and returns item`() = runTest { + coEvery { fileStorage.write(any(), any()) } returns "/photos/new.jpg" + + val result = repository.addItem( + imageBytes = byteArrayOf(1, 2, 3), + name = "Red Jacket", + category = Category.OUTERWEAR, + colors = listOf("red"), + seasons = listOf(Season.WINTER), + ) + + assertTrue(result.isSuccess) + val item = result.getOrThrow() + assertEquals("Red Jacket", item.name) + assertEquals(Category.OUTERWEAR, item.category) + assertEquals("/photos/new.jpg", item.photoPath) + + coVerify { fileStorage.write(any(), byteArrayOf(1, 2, 3)) } + verify { + queries.insert( + id = any(), + name = "Red Jacket", + category = "OUTERWEAR", + colors = listOf("red"), + seasons = listOf("WINTER"), + tags = emptyList(), + description = null, + photoPath = "/photos/new.jpg", + createdAt = any(), + ) + } + } + + @Test + fun `addItem wraps file write failure in Result failure`() = runTest { + coEvery { fileStorage.write(any(), any()) } throws RuntimeException("Disk full") + + val result = repository.addItem( + imageBytes = byteArrayOf(1), + name = "Shirt", + category = Category.TOP, + colors = emptyList(), + seasons = emptyList(), + ) + + assertTrue(result.isFailure) + assertEquals("Disk full", result.exceptionOrNull()?.message) + } + + // endregion + + // region analyzeAndTag + + @Test + fun `analyzeAndTag reads photo calls AI and updates DB`() = runTest { + val query = mockk>() + every { queries.getById("item-1") } returns query + every { query.executeAsOneOrNull() } returns dbItem + coEvery { fileStorage.read("/photos/item-1.jpg") } returns byteArrayOf(10, 20) + coEvery { aiClient.analyzeImage(byteArrayOf(10, 20)) } returns AiAnalysisResult( + description = "Analyzed shirt", + suggestedCategory = Category.TOP, + colors = listOf("navy"), + seasons = listOf(Season.SPRING, Season.FALL), + tags = listOf("smart-casual"), + ) + + val result = repository.analyzeAndTag("item-1") + + assertTrue(result.isSuccess) + val updated = result.getOrThrow() + assertEquals("Analyzed shirt", updated.description) + assertEquals(listOf("navy"), updated.colors) + assertEquals(listOf(Season.SPRING, Season.FALL), updated.seasons) + assertEquals(listOf("smart-casual"), updated.tags) + + coVerify { fileStorage.read("/photos/item-1.jpg") } + coVerify { aiClient.analyzeImage(byteArrayOf(10, 20)) } + verify { + queries.update( + name = "Blue T-Shirt", + category = "TOP", + colors = listOf("navy"), + seasons = listOf("SPRING", "FALL"), + tags = listOf("smart-casual"), + description = "Analyzed shirt", + photoPath = "/photos/item-1.jpg", + id = "item-1", + ) + } + } + + @Test + fun `analyzeAndTag fails when item not found`() = runTest { + val query = mockk>() + every { queries.getById("missing") } returns query + every { query.executeAsOneOrNull() } returns null + + val result = repository.analyzeAndTag("missing") + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Item not found") == true) + } + + // endregion + + // region deleteItem + + @Test + fun `deleteItem removes photo and DB record`() = runTest { + val query = mockk>() + every { queries.getById("item-1") } returns query + every { query.executeAsOneOrNull() } returns dbItem + coEvery { fileStorage.delete("/photos/item-1.jpg") } returns Unit + + val result = repository.deleteItem("item-1") + + assertTrue(result.isSuccess) + coVerify { fileStorage.delete("/photos/item-1.jpg") } + verify { queries.delete("item-1") } + } + + @Test + fun `deleteItem succeeds when item not found`() = runTest { + val query = mockk>() + every { queries.getById("missing") } returns query + every { query.executeAsOneOrNull() } returns null + + val result = repository.deleteItem("missing") + + assertTrue(result.isSuccess) + } + + // endregion + + // region updateItem + + @Test + fun `updateItem updates DB record`() = runTest { + val item = com.github.worn.domain.model.ClothingItem( + id = "item-1", + name = "Updated Shirt", + category = Category.TOP, + colors = listOf("green"), + seasons = listOf(Season.SPRING), + tags = listOf("updated"), + description = "Updated description", + photoPath = "/photos/item-1.jpg", + createdAt = 1_000_000L, + ) + + val result = repository.updateItem(item) + + assertTrue(result.isSuccess) + assertEquals(item, result.getOrThrow()) + verify { + queries.update( + name = "Updated Shirt", + category = "TOP", + colors = listOf("green"), + seasons = listOf("SPRING"), + tags = listOf("updated"), + description = "Updated description", + photoPath = "/photos/item-1.jpg", + id = "item-1", + ) + } + } + + // endregion +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSecretStore.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSecretStore.kt new file mode 100644 index 0000000..ee4226d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSecretStore.kt @@ -0,0 +1,11 @@ +package com.github.worn.fake + +import com.github.worn.util.secret.SecretStore + +class FakeSecretStore( + var storedKey: String? = null, +) : SecretStore { + override fun getApiKey(): String? = storedKey + override fun saveApiKey(key: String) { storedKey = key } + override fun clearApiKey() { storedKey = null } +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt new file mode 100644 index 0000000..8c9c234 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt @@ -0,0 +1,82 @@ +package com.github.worn.fake + +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.TryItResult +import com.github.worn.domain.repository.WardrobeRepository +import kotlinx.datetime.Clock + +class FakeWardrobeRepository : WardrobeRepository { + + val items = mutableListOf() + + var getAllError: Throwable? = null + var getByCategoryError: Throwable? = null + var addItemError: Throwable? = null + var deleteItemError: Throwable? = null + var updateItemError: Throwable? = null + + val deletedIds = mutableListOf() + + override suspend fun getAll(): Result> = + getAllError?.let { Result.failure(it) } ?: Result.success(items.toList()) + + override suspend fun getById(id: String): Result = + Result.success(items.find { it.id == id }) + + override suspend fun getByCategory(category: Category): Result> = + getByCategoryError?.let { Result.failure(it) } + ?: Result.success(items.filter { it.category == category }) + + override suspend fun search(query: String): Result> = + Result.success(items.filter { it.name.contains(query, ignoreCase = true) }) + + override suspend fun addItem( + imageBytes: ByteArray, + name: String, + category: Category, + colors: List, + seasons: List, + ): Result { + addItemError?.let { return Result.failure(it) } + val item = ClothingItem( + id = "fake-${items.size + 1}", + name = name, + category = category, + colors = colors, + seasons = seasons, + photoPath = "/photos/fake.jpg", + createdAt = Clock.System.now().toEpochMilliseconds(), + ) + items.add(item) + return Result.success(item) + } + + override suspend fun analyzeAndTag(itemId: String): Result = + Result.success(items.first { it.id == itemId }) + + override suspend fun updateItem(item: ClothingItem): Result = + updateItemError?.let { Result.failure(it) } ?: Result.success(item) + + override suspend fun deleteItem(id: String): Result { + deleteItemError?.let { return Result.failure(it) } + deletedIds.add(id) + items.removeAll { it.id == id } + return Result.success(Unit) + } + + override suspend fun getGapRecommendations(): Result> = + Result.success(emptyList()) + + override suspend fun analyzeProspectiveItem(imageBytes: ByteArray): Result = + Result.success( + TryItResult( + matchingItems = emptyList(), + combinationsUnlocked = 0, + gapsFilled = emptyList(), + worthAdding = false, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt new file mode 100644 index 0000000..02d28cc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt @@ -0,0 +1,27 @@ +package com.github.worn.fake + +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.Season + +fun clothingItem( + id: String = "item-1", + name: String = "Blue T-Shirt", + category: Category = Category.TOP, + colors: List = listOf("blue"), + seasons: List = listOf(Season.SUMMER), + tags: List = emptyList(), + description: String? = null, + photoPath: String = "/photos/$id.jpg", + createdAt: Long = 1_000_000L, +): ClothingItem = ClothingItem( + id = id, + name = name, + category = category, + colors = colors, + seasons = seasons, + tags = tags, + description = description, + photoPath = photoPath, + createdAt = createdAt, +) diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt new file mode 100644 index 0000000..8098c22 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt @@ -0,0 +1,274 @@ +package com.github.worn.viewmodel + +import app.cash.turbine.test +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.Season +import com.github.worn.fake.FakeSecretStore +import com.github.worn.fake.FakeWardrobeRepository +import com.github.worn.fake.clothingItem +import com.github.worn.presentation.viewmodel.WardrobeEffect +import com.github.worn.presentation.viewmodel.WardrobeIntent +import com.github.worn.presentation.viewmodel.WardrobeViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WardrobeViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var repository: FakeWardrobeRepository + private lateinit var secretStore: FakeSecretStore + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = FakeWardrobeRepository() + secretStore = FakeSecretStore() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): WardrobeViewModel = + WardrobeViewModel(repository, secretStore) + + // region init + + @Test + fun `init sets hasApiKey true when key exists`() { + secretStore.storedKey = "test-key" + val vm = createViewModel() + assertTrue(vm.state.value.hasApiKey) + } + + @Test + fun `init sets hasApiKey false when key is null`() { + secretStore.storedKey = null + val vm = createViewModel() + assertFalse(vm.state.value.hasApiKey) + } + + @Test + fun `init loads items on creation`() { + val item = clothingItem(id = "1") + repository.items.add(item) + + val vm = createViewModel() + + assertEquals(listOf(item), vm.state.value.items) + assertFalse(vm.state.value.isLoading) + } + + // endregion + + // region LoadItems + + @Test + fun `LoadItems success updates state with items`() { + val vm = createViewModel() + + val item = clothingItem(id = "2") + repository.items.add(item) + vm.onIntent(WardrobeIntent.LoadItems) + + assertEquals(listOf(item), vm.state.value.items) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `LoadItems failure sets error and sends ShowError effect`() = runTest { + val vm = createViewModel() + + repository.getAllError = RuntimeException("DB error") + vm.effects.test { + vm.onIntent(WardrobeIntent.LoadItems) + + val effect = awaitItem() + assertIs(effect) + assertEquals("DB error", effect.message) + } + assertEquals("DB error", vm.state.value.error) + assertFalse(vm.state.value.isLoading) + } + + // endregion + + // region FilterByCategory + + @Test + fun `FilterByCategory updates activeCategory and filters items`() { + val top = clothingItem(id = "1", category = Category.TOP) + val bottom = clothingItem(id = "2", category = Category.BOTTOM) + repository.items.addAll(listOf(top, bottom)) + + val vm = createViewModel() + vm.onIntent(WardrobeIntent.FilterByCategory(Category.TOP)) + + assertEquals(Category.TOP, vm.state.value.activeCategory) + assertEquals(listOf(top), vm.state.value.items) + } + + @Test + fun `FilterByCategory with null shows all items`() { + val top = clothingItem(id = "1", category = Category.TOP) + val bottom = clothingItem(id = "2", category = Category.BOTTOM) + repository.items.addAll(listOf(top, bottom)) + + val vm = createViewModel() + vm.onIntent(WardrobeIntent.FilterByCategory(Category.TOP)) + vm.onIntent(WardrobeIntent.FilterByCategory(null)) + + assertNull(vm.state.value.activeCategory) + assertEquals(listOf(top, bottom), vm.state.value.items) + } + + // endregion + + // region AddItem + + @Test + fun `AddItem success sends ItemAdded effect and reloads`() = runTest { + val vm = createViewModel() + + vm.effects.test { + vm.onIntent( + WardrobeIntent.AddItem( + imageBytes = byteArrayOf(1, 2, 3), + name = "New Shirt", + category = Category.TOP, + colors = listOf("red"), + seasons = listOf(Season.SUMMER), + ), + ) + + val effect = awaitItem() + assertIs(effect) + } + assertFalse(vm.state.value.isSaving) + assertEquals(1, vm.state.value.items.size) + } + + @Test + fun `AddItem failure sends ShowError effect`() = runTest { + val vm = createViewModel() + repository.addItemError = RuntimeException("Storage full") + + vm.effects.test { + vm.onIntent( + WardrobeIntent.AddItem( + imageBytes = byteArrayOf(1, 2, 3), + name = "New Shirt", + category = Category.TOP, + colors = listOf("red"), + seasons = listOf(Season.SUMMER), + ), + ) + + val effect = awaitItem() + assertIs(effect) + assertEquals("Storage full", effect.message) + } + assertFalse(vm.state.value.isSaving) + } + + // endregion + + // region ToggleSelection / ClearSelection + + @Test + fun `ToggleSelection adds item to selectedIds`() { + val item = clothingItem(id = "1") + repository.items.add(item) + val vm = createViewModel() + + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + + assertTrue("1" in vm.state.value.selectedIds) + } + + @Test + fun `ToggleSelection removes already-selected item`() { + val item = clothingItem(id = "1") + repository.items.add(item) + val vm = createViewModel() + + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + + assertTrue(vm.state.value.selectedIds.isEmpty()) + } + + @Test + fun `ClearSelection empties selectedIds`() { + val item = clothingItem(id = "1") + repository.items.add(item) + val vm = createViewModel() + + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + vm.onIntent(WardrobeIntent.ClearSelection) + + assertTrue(vm.state.value.selectedIds.isEmpty()) + } + + // endregion + + // region DeleteSelected + + @Test + fun `DeleteSelected removes items and sends ItemsDeleted`() = runTest { + val item1 = clothingItem(id = "1") + val item2 = clothingItem(id = "2") + repository.items.addAll(listOf(item1, item2)) + val vm = createViewModel() + + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + + vm.effects.test { + vm.onIntent(WardrobeIntent.DeleteSelected) + + val effect = awaitItem() + assertIs(effect) + } + assertTrue(vm.state.value.selectedIds.isEmpty()) + assertFalse(vm.state.value.isDeleting) + assertEquals(listOf("1"), repository.deletedIds) + } + + @Test + fun `DeleteSelected partial failure sends ShowError`() = runTest { + val item1 = clothingItem(id = "1") + val item2 = clothingItem(id = "2") + repository.items.addAll(listOf(item1, item2)) + val vm = createViewModel() + + vm.onIntent(WardrobeIntent.ToggleSelection("1")) + vm.onIntent(WardrobeIntent.ToggleSelection("2")) + repository.deleteItemError = RuntimeException("Failed") + + vm.effects.test { + vm.onIntent(WardrobeIntent.DeleteSelected) + + val effect = awaitItem() + assertIs(effect) + assertEquals("Some items could not be deleted", effect.message) + } + assertFalse(vm.state.value.isDeleting) + } + + // endregion +}