From c63ab379390bc620c903253f4e6125cbfc107ea1 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Mon, 28 Oct 2024 02:13:47 +0400 Subject: [PATCH] inject noteId to NoteViewModel constructor --- iosApp/Podfile.lock | 2 +- iosApp/Pods/Manifest.lock | 2 +- iosApp/Pods/Pods.xcodeproj/project.pbxproj | 4 +- .../Pods-iosApp/Pods-iosApp-frameworks.sh | 2 +- .../kotlin/com/softartdev/notedelight/App.kt | 12 +- .../softartdev/notedelight/ui/NoteDetail.kt | 13 +- shared/build.gradle.kts | 3 + .../presentation/note/NoteViewModelTest.kt | 146 +++++++++++------- .../notedelight/shared/di/sharedModules.kt | 3 +- .../shared/presentation/note/NoteViewModel.kt | 22 +-- .../shared/di/KoinModulesJvmTest.kt | 24 ++- 11 files changed, 132 insertions(+), 101 deletions(-) diff --git a/iosApp/Podfile.lock b/iosApp/Podfile.lock index 641ccb94..bf773ca3 100644 --- a/iosApp/Podfile.lock +++ b/iosApp/Podfile.lock @@ -24,4 +24,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0dc93a6f6109335ea8cd3f91d2c87cc8c99f04a3 -COCOAPODS: 1.15.2 +COCOAPODS: 1.12.1 diff --git a/iosApp/Pods/Manifest.lock b/iosApp/Pods/Manifest.lock index 641ccb94..bf773ca3 100644 --- a/iosApp/Pods/Manifest.lock +++ b/iosApp/Pods/Manifest.lock @@ -24,4 +24,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0dc93a6f6109335ea8cd3f91d2c87cc8c99f04a3 -COCOAPODS: 1.15.2 +COCOAPODS: 1.12.1 diff --git a/iosApp/Pods/Pods.xcodeproj/project.pbxproj b/iosApp/Pods/Pods.xcodeproj/project.pbxproj index 816688e6..4269bd41 100644 --- a/iosApp/Pods/Pods.xcodeproj/project.pbxproj +++ b/iosApp/Pods/Pods.xcodeproj/project.pbxproj @@ -328,8 +328,8 @@ BFDFE7DC352907FC980B868725387E98 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastSwiftUpdateCheck = 1300; + LastUpgradeCheck = 1300; }; buildConfigurationList = 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */; compatibilityVersion = "Xcode 12.0"; diff --git a/iosApp/Pods/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh b/iosApp/Pods/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh index 79bf656a..94102de6 100755 --- a/iosApp/Pods/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh +++ b/iosApp/Pods/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh @@ -18,7 +18,7 @@ echo "mkdir -p ${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" mkdir -p "${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}" -SWIFT_STDLIB_PATH="${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +SWIFT_STDLIB_PATH="${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" BCSYMBOLMAP_DIR="BCSymbolMaps" diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt index 7916bf27..7c12b18c 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt @@ -1,3 +1,5 @@ +@file:OptIn(KoinExperimentalAPI::class) + package com.softartdev.notedelight import androidx.compose.runtime.Composable @@ -26,7 +28,10 @@ import com.softartdev.notedelight.ui.dialog.security.EnterPasswordDialog import com.softartdev.theme.material3.PreferableMaterialTheme import com.softartdev.theme.material3.ThemeDialog import com.softartdev.theme.pref.PreferenceHelper +import org.koin.compose.viewmodel.koinNavViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.parameter.parametersOf @Composable fun App( @@ -51,10 +56,9 @@ fun App( MainScreen(mainViewModel = koinViewModel()) } composable { backStackEntry: NavBackStackEntry -> - NoteDetail( - noteViewModel = koinViewModel(), - noteId = backStackEntry.toRoute().noteId, - ) + NoteDetail(noteViewModel = koinNavViewModel { + parametersOf(backStackEntry.toRoute().noteId) + }) } composable { SettingsScreen(settingsViewModel = koinViewModel()) diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/NoteDetail.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/NoteDetail.kt index f4612052..6ef38d2f 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/NoteDetail.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/NoteDetail.kt @@ -46,21 +46,14 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @Composable -fun NoteDetail(noteViewModel: NoteViewModel, noteId: Long) { - LaunchedEffect(key1 = noteId, key2 = noteViewModel) { - when (noteId) { - 0L -> noteViewModel.createNote() - else -> noteViewModel.loadNote(noteId) - } - } +fun NoteDetail(noteViewModel: NoteViewModel) { val noteResultState: State = noteViewModel.stateFlow.collectAsState() val titleState: MutableState = remember { mutableStateOf("") } val textState: MutableState = remember { mutableStateOf("") } val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } LaunchedEffect( - key1 = noteId, - key2 = noteViewModel, - key3 = noteResultState.value + key1 = noteViewModel, + key2 = noteResultState.value ) { when (val noteResult: NoteResult = noteResultState.value) { is NoteResult.Loading, diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a05d51fa..d67cb7c1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,6 +109,9 @@ kotlin { jvmTest.dependencies { implementation(kotlin("test")) implementation(kotlin("test-junit")) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.test) + implementation(libs.coroutines.swing) } all { languageSettings.optIn("kotlin.RequiresOptIn") diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModelTest.kt index 8a81a1db..300e00be 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModelTest.kt @@ -26,6 +26,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions class NoteViewModelTest { @@ -41,7 +43,6 @@ class NoteViewModelTest { private val mockDeleteNoteUseCase = Mockito.mock(DeleteNoteUseCase::class.java) private val mockRouter = Mockito.mock(Router::class.java) private val coroutineDispatchers = CoroutineDispatchersStub(testDispatcher = mainDispatcherRule.testDispatcher) - private val noteViewModel = NoteViewModel(mockNoteDAO, mockCreateNoteUseCase, saveNoteUseCase, mockDeleteNoteUseCase, mockRouter, coroutineDispatchers) private val id = 1L private val title: String = "title" @@ -58,16 +59,38 @@ class NoteViewModelTest { @After fun tearDown() = runTest { - noteViewModel.onCleared() Napier.takeLogarithm() + Mockito.reset(mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockRouter) } @Test - fun createNote() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + fun `init with noteId 0 creates new note`() = runTest { + val viewModel = createViewModel(noteId = 0) + viewModel.stateFlow.test { + assertEquals(NoteResult.Created(id), awaitItem()) + verify(mockCreateNoteUseCase).invoke() + verifyNoMoreInteractions(mockNoteDAO) - noteViewModel.createNote() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `init with existing noteId loads note`() = runTest { + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) + verify(mockNoteDAO).load(id) + verifyNoMoreInteractions(mockCreateNoteUseCase) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun createNote() = runTest { + val viewModel = createViewModel(noteId = 0) + viewModel.stateFlow.test { assertEquals(NoteResult.Created(id), awaitItem()) cancelAndIgnoreRemainingEvents() @@ -76,10 +99,8 @@ class NoteViewModelTest { @Test fun loadNote() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) - - noteViewModel.loadNote(id) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { assertEquals(NoteResult.Loaded(note), awaitItem()) cancelAndIgnoreRemainingEvents() @@ -88,10 +109,11 @@ class NoteViewModelTest { @Test fun saveNoteEmpty() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.saveNote("", "") + viewModel.saveNote("", "") assertEquals(NoteResult.Empty, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -100,11 +122,11 @@ class NoteViewModelTest { @Test fun saveNote() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.setIdForTest(id) - noteViewModel.saveNote(title, text) + viewModel.saveNote(title, text) assertEquals(NoteResult.Saved(title), awaitItem()) cancelAndIgnoreRemainingEvents() @@ -113,14 +135,15 @@ class NoteViewModelTest { @Test fun editTitle() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.setIdForTest(id) - noteViewModel.editTitle() - Mockito.verify(mockRouter).navigate(route = AppNavGraph.EditTitleDialog(noteId = id)) + viewModel.editTitle() + verify(mockRouter).navigate(route = AppNavGraph.EditTitleDialog(noteId = id)) UpdateTitleUseCase.titleChannel.send(title) + assertEquals(NoteResult.Loading, awaitItem()) assertEquals(NoteResult.TitleUpdated(title), awaitItem()) cancelAndIgnoreRemainingEvents() @@ -129,11 +152,11 @@ class NoteViewModelTest { @Test fun deleteNote() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.setIdForTest(id) - noteViewModel.deleteNote() + viewModel.deleteNote() assertEquals(NoteResult.Deleted, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -142,30 +165,27 @@ class NoteViewModelTest { @Test fun checkSaveChange() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) - - noteViewModel.setIdForTest(id) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "new text")) - noteViewModel.checkSaveChange(title, text) - Mockito.verify(mockRouter).navigate(route = AppNavGraph.SaveChangesDialog) + + viewModel.checkSaveChange(title, text) + verify(mockRouter).navigate(route = AppNavGraph.SaveChangesDialog) SaveNoteUseCase.dialogChannel.send(true) - Mockito.verify(mockRouter).popBackStack() + verify(mockRouter).popBackStack() - Mockito.verifyNoMoreInteractions(mockRouter) + verifyNoMoreInteractions(mockRouter) cancelAndIgnoreRemainingEvents() } } @Test fun checkSaveChangeNavBack() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) - - noteViewModel.setIdForTest(id) - noteViewModel.checkSaveChange(title, text) - Mockito.verify(mockRouter).popBackStack() + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + viewModel.checkSaveChange(title, text) + verify(mockRouter).popBackStack() cancelAndIgnoreRemainingEvents() } @@ -173,12 +193,12 @@ class NoteViewModelTest { @Test fun checkSaveChangeDeleted() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.setIdForTest(id) Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "", title = "")) - noteViewModel.checkSaveChange("", "") + viewModel.checkSaveChange("", "") assertEquals(NoteResult.Deleted, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -187,12 +207,10 @@ class NoteViewModelTest { @Test fun saveNoteAndNavBack() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) - - noteViewModel.setIdForTest(id) - noteViewModel.saveNoteAndNavBack(title, text) - Mockito.verify(mockRouter).popBackStack() + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + viewModel.saveNoteAndNavBack(title, text) + verify(mockRouter).popBackStack() cancelAndIgnoreRemainingEvents() } @@ -200,12 +218,10 @@ class NoteViewModelTest { @Test fun doNotSaveAndNavBack() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) - - noteViewModel.setIdForTest(id) - noteViewModel.doNotSaveAndNavBack() - Mockito.verify(mockRouter).popBackStack() + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + viewModel.doNotSaveAndNavBack() + verify(mockRouter).popBackStack() cancelAndIgnoreRemainingEvents() } @@ -213,15 +229,25 @@ class NoteViewModelTest { @Test fun doNotSaveAndNavBackDeleted() = runTest { - noteViewModel.stateFlow.test { - assertEquals(NoteResult.Loading, awaitItem()) + val viewModel = createViewModel(noteId = id) + viewModel.stateFlow.test { + assertEquals(NoteResult.Loaded(note), awaitItem()) - noteViewModel.setIdForTest(id) Mockito.`when`(mockNoteDAO.load(id)).thenReturn(note.copy(text = "", title = "")) - noteViewModel.doNotSaveAndNavBack() + viewModel.doNotSaveAndNavBack() assertEquals(NoteResult.Deleted, awaitItem()) cancelAndIgnoreRemainingEvents() } } + + private fun createViewModel(noteId: Long): NoteViewModel = NoteViewModel( + noteId = noteId, + noteDAO = mockNoteDAO, + createNoteUseCase = mockCreateNoteUseCase, + saveNoteUseCase = saveNoteUseCase, + deleteNoteUseCase = mockDeleteNoteUseCase, + router = mockRouter, + coroutineDispatchers = coroutineDispatchers + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/di/sharedModules.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/di/sharedModules.kt index 55de3f0c..73151d35 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/di/sharedModules.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/di/sharedModules.kt @@ -1,5 +1,6 @@ package com.softartdev.notedelight.shared.di +import com.softartdev.notedelight.shared.db.NoteDAO import com.softartdev.notedelight.shared.db.SafeRepo import com.softartdev.notedelight.shared.presentation.main.MainViewModel import com.softartdev.notedelight.shared.presentation.note.DeleteViewModel @@ -33,7 +34,7 @@ Provide the [SafeRepo] expect val repoModule: Module val daoModule: Module = module { - factory { get().noteDAO } + factory { get().noteDAO } } val useCaseModule: Module = module { diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModel.kt index 4ad76a01..edd3da7e 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/note/NoteViewModel.kt @@ -1,6 +1,5 @@ package com.softartdev.notedelight.shared.presentation.note -import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.db.NoteDAO @@ -18,6 +17,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class NoteViewModel( + private var noteId: Long, private val noteDAO: NoteDAO, private val createNoteUseCase: CreateNoteUseCase, private val saveNoteUseCase: SaveNoteUseCase, @@ -30,11 +30,12 @@ class NoteViewModel( ) val stateFlow: StateFlow = mutableStateFlow - private var noteId: Long = 0 - get() = when (field) { - 0L -> throw IllegalStateException("Note doesn't loaded") - else -> field + init { + when (noteId) { + 0L -> createNote() + else -> loadNote(noteId) } + } fun createNote() = viewModelScope.launch { noteId = withContext(coroutineDispatchers.io) { @@ -154,17 +155,6 @@ class NoteViewModel( } } - public override fun onCleared() { - super.onCleared() - // workaround due to koin uses remember function of compose - mutableStateFlow.value = NoteResult.Loading - } - - @VisibleForTesting - fun setIdForTest(id: Long) { - noteId = id - } - private fun createTitleIfNeed(title: String?, text: String): String = if (title.isNullOrEmpty()) createTitle(text) else title diff --git a/shared/src/jvmTest/kotlin/com/softartdev/notedelight/shared/di/KoinModulesJvmTest.kt b/shared/src/jvmTest/kotlin/com/softartdev/notedelight/shared/di/KoinModulesJvmTest.kt index 534adaf9..ff553516 100644 --- a/shared/src/jvmTest/kotlin/com/softartdev/notedelight/shared/di/KoinModulesJvmTest.kt +++ b/shared/src/jvmTest/kotlin/com/softartdev/notedelight/shared/di/KoinModulesJvmTest.kt @@ -1,15 +1,29 @@ package com.softartdev.notedelight.shared.di +import com.softartdev.notedelight.shared.db.NoteDAO +import com.softartdev.notedelight.shared.db.NoteQueries import org.junit.Test -import org.koin.core.logger.Level +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.module import org.koin.test.KoinTest -import org.koin.test.check.checkKoinModules +import org.koin.test.verify.definition +import org.koin.test.verify.injectedParameters +import org.koin.test.verify.verify +@KoinExperimentalAPI class KoinModulesJvmTest : KoinTest { + private val allModules: Array = (sharedModules + uiModules).toTypedArray() + + private val appModule: Module = module { + includes(*allModules) + } + @Test - fun checkModulesTest() = checkKoinModules( - modules = sharedModules + uiModules, - appDeclaration = { printLogger(level = Level.DEBUG) } + fun verifyKoinModules() = appModule.verify( + injections = injectedParameters( + definition(NoteQueries::class), + ) ) } \ No newline at end of file