diff --git a/build-logic/convention/src/main/java/SeeDocsFeatureConventionPlugin.kt b/build-logic/convention/src/main/java/SeeDocsFeatureConventionPlugin.kt index d98a9e1..7fb2b82 100644 --- a/build-logic/convention/src/main/java/SeeDocsFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/java/SeeDocsFeatureConventionPlugin.kt @@ -1,5 +1,6 @@ import kr.co.convention.implementations import kr.co.convention.libs +import kr.co.convention.testImplementations import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies @@ -21,6 +22,10 @@ class SeeDocsFeatureConventionPlugin : Plugin<Project> { project(":core:model"), libs.koin.compose ) + + testImplementations( + project(":core:testing") + ) } } } diff --git a/build-logic/convention/src/main/java/SeeDocsLibraryConventionPlugin.kt b/build-logic/convention/src/main/java/SeeDocsLibraryConventionPlugin.kt index 35eb190..b80be3d 100644 --- a/build-logic/convention/src/main/java/SeeDocsLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/java/SeeDocsLibraryConventionPlugin.kt @@ -37,10 +37,10 @@ class SeeDocsLibraryConventionPlugin : Plugin<Project> { libs.kotlinx.coroutines.android ) testImplementations( - kotlin("test") + kotlin("test"), ) androidTestImplementations( - kotlin("test") + kotlin("test"), ) } } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index f7f2090..2b0e81a 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -9,7 +9,7 @@ android { } dependencies { + api(projects.core.model) implementation(projects.core.common) - implementation(projects.core.model) implementation(projects.core.database) } \ No newline at end of file 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..8d27bc6 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,17 @@ +import kr.co.convention.setNamespace + +plugins { + alias(libs.plugins.seedocs.library) +} + +setNamespace("core.testing") + +dependencies { + api(libs.mockk) + api(libs.kotlinx.coroutines.test) + api(libs.mockk.android) + api(libs.koin.test) + api(libs.koin.junit) + api(projects.core.data) + api(libs.turbine) +} \ No newline at end of file diff --git a/core/testing/src/main/java/kr/co/testing/repository/TestRecentRepository.kt b/core/testing/src/main/java/kr/co/testing/repository/TestRecentRepository.kt new file mode 100644 index 0000000..72f04c0 --- /dev/null +++ b/core/testing/src/main/java/kr/co/testing/repository/TestRecentRepository.kt @@ -0,0 +1,24 @@ +package kr.co.testing.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kr.co.data.repository.RecentRepository +import kr.co.model.FileInfo + +class TestRecentRepository: RecentRepository { + + private val recentFilesFlow: MutableStateFlow<List<FileInfo>> = + MutableStateFlow(emptyList()) + + override suspend fun insert(recentFile: FileInfo) { + recentFilesFlow.update { it + recentFile } + } + + override fun get(): Flow<List<FileInfo>> = + recentFilesFlow + + override suspend fun delete(recentFile: FileInfo) { + recentFilesFlow.update { it - recentFile } + } +} \ No newline at end of file diff --git a/core/testing/src/main/java/kr/co/testing/rule/CoroutineTestRule.kt b/core/testing/src/main/java/kr/co/testing/rule/CoroutineTestRule.kt new file mode 100644 index 0000000..c995564 --- /dev/null +++ b/core/testing/src/main/java/kr/co/testing/rule/CoroutineTestRule.kt @@ -0,0 +1,19 @@ +package kr.co.testing.rule + +import kotlinx.coroutines.Dispatchers +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 CoroutineTestRule : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + override fun finished(description: Description?) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/feature/explore/build.gradle.kts b/feature/explore/build.gradle.kts index 8befd5f..02c4b72 100644 --- a/feature/explore/build.gradle.kts +++ b/feature/explore/build.gradle.kts @@ -5,4 +5,7 @@ plugins { alias(libs.plugins.seedocs.library.compose) } -setNamespace("feature.explore") \ No newline at end of file +setNamespace("feature.explore") +dependencies { + implementation(libs.firebase.crashlytics.buildtools) +} diff --git a/feature/explore/src/main/java/kr/co/di/ExploreModule.kt b/feature/explore/src/main/java/kr/co/di/ExploreModule.kt index 6327709..6254928 100644 --- a/feature/explore/src/main/java/kr/co/di/ExploreModule.kt +++ b/feature/explore/src/main/java/kr/co/di/ExploreModule.kt @@ -1,6 +1,8 @@ package kr.co.di import kr.co.explore.ExploreViewModel +import kr.co.util.FileManagerImpl +import kr.co.util.FileManager import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -8,7 +10,12 @@ val exploreModule = module { viewModel { ExploreViewModel( - get() + get(), + get<FileManager>(), ) } + + single<FileManager> { + FileManagerImpl() + } } diff --git a/feature/explore/src/main/java/kr/co/explore/ExploreScreen.kt b/feature/explore/src/main/java/kr/co/explore/ExploreScreen.kt index f659708..ee8f5a4 100644 --- a/feature/explore/src/main/java/kr/co/explore/ExploreScreen.kt +++ b/feature/explore/src/main/java/kr/co/explore/ExploreScreen.kt @@ -25,14 +25,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kr.co.model.ExploreSideEffect import kr.co.model.ExploreUiIntent import kr.co.model.ExploreUiState -import kr.co.model.FileInfo import kr.co.seedocs.feature.explore.R import kr.co.ui.theme.SeeDocsTheme import kr.co.ui.theme.Theme import kr.co.ui.util.LaunchIntentHandler import kr.co.ui.util.LaunchSideEffect import kr.co.ui.widget.FileBox -import kr.co.util.DEFAULT_STORAGE +import kr.co.util.FileManagerImpl.Companion.DEFAULT_STORAGE import kr.co.widget.FolderBox import org.koin.androidx.compose.koinViewModel diff --git a/feature/explore/src/main/java/kr/co/explore/ExploreViewModel.kt b/feature/explore/src/main/java/kr/co/explore/ExploreViewModel.kt index 7e63f70..bd1cac0 100644 --- a/feature/explore/src/main/java/kr/co/explore/ExploreViewModel.kt +++ b/feature/explore/src/main/java/kr/co/explore/ExploreViewModel.kt @@ -6,10 +6,11 @@ import kr.co.model.ExploreUiIntent import kr.co.model.ExploreUiState import kr.co.model.FileInfo import kr.co.ui.base.BaseMviViewModel -import kr.co.util.readPDFOrDirectory +import kr.co.util.FileManager internal class ExploreViewModel( private val recentRepository: RecentRepository, + private val fileManager: FileManager, ) : BaseMviViewModel<ExploreUiState, ExploreUiIntent, ExploreSideEffect>(ExploreUiState.INIT) { @@ -26,7 +27,7 @@ internal class ExploreViewModel( copy(path = path) } - readPDFOrDirectory(path).partition { it.isDirectory }.let { (folders, files) -> + fileManager.readPDFOrDirectory(path).partition { it.isDirectory }.let { (folders, files) -> reduce { copy( folders = folders, diff --git a/feature/explore/src/main/java/kr/co/util/FileManager.kt b/feature/explore/src/main/java/kr/co/util/FileManager.kt new file mode 100644 index 0000000..1509207 --- /dev/null +++ b/feature/explore/src/main/java/kr/co/util/FileManager.kt @@ -0,0 +1,7 @@ +package kr.co.util + +import kr.co.model.FileInfo + +internal fun interface FileManager { + suspend fun readPDFOrDirectory(path: String): List<FileInfo> +} diff --git a/feature/explore/src/main/java/kr/co/util/FileManagerImpl.kt b/feature/explore/src/main/java/kr/co/util/FileManagerImpl.kt new file mode 100644 index 0000000..78de762 --- /dev/null +++ b/feature/explore/src/main/java/kr/co/util/FileManagerImpl.kt @@ -0,0 +1,44 @@ +package kr.co.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kr.co.model.FileInfo +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime +import java.time.LocalDateTime +import java.time.ZoneId + +internal class FileManagerImpl: FileManager { + override suspend fun readPDFOrDirectory( + path: String, + ): List<FileInfo> = withContext(Dispatchers.IO) { + File(path).listFiles()?.filter { !it.isHidden && (it.isDirectory || it.extension == PDF) } + ?.map { + val attributes = getFileAttributes(it) + FileInfo( + name = it.name, + path = it.path, + type = FileInfo.Type.from(it.extension), + isDirectory = it.isDirectory, + size = it.length(), + isHidden = it.isHidden, + createdAt = attributes.creationTime().toLocalDateTime(), + lastModified = attributes.lastModifiedTime().toLocalDateTime(), + ) + } ?: emptyList() + } + + private fun getFileAttributes(file: File): BasicFileAttributes = + Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) + + private fun FileTime.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) + + companion object { + internal const val DEFAULT_STORAGE = "/storage/emulated/0" + + private const val PDF = "pdf" + } +} \ No newline at end of file diff --git a/feature/explore/src/main/java/kr/co/util/FileUtil.kt b/feature/explore/src/main/java/kr/co/util/FileUtil.kt deleted file mode 100644 index 747568b..0000000 --- a/feature/explore/src/main/java/kr/co/util/FileUtil.kt +++ /dev/null @@ -1,40 +0,0 @@ -package kr.co.util - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kr.co.model.FileInfo -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.BasicFileAttributes -import java.nio.file.attribute.FileTime -import java.time.LocalDateTime -import java.time.ZoneId - -internal suspend fun readPDFOrDirectory( - path: String, -): List<FileInfo> = withContext(Dispatchers.IO) { - File(path).listFiles()?.filter { !it.isHidden && (it.isDirectory || it.extension == PDF) } - ?.map { - val attributes = getFileAttributes(it) - FileInfo( - name = it.name, - path = it.path, - type = FileInfo.Type.from(it.extension), - isDirectory = it.isDirectory, - size = it.length(), - isHidden = it.isHidden, - createdAt = attributes.creationTime().toLocalDateTime(), - lastModified = attributes.lastModifiedTime().toLocalDateTime(), - ) - } ?: emptyList() -} - -private fun getFileAttributes(file: File) : BasicFileAttributes = - Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) - -private fun FileTime.toLocalDateTime(): LocalDateTime = - LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) - -internal const val DEFAULT_STORAGE = "/storage/emulated/0" - -private const val PDF = "pdf" \ No newline at end of file diff --git a/feature/explore/src/test/kotlin/kr/co/explore/ExploreViewModelTest.kt b/feature/explore/src/test/kotlin/kr/co/explore/ExploreViewModelTest.kt new file mode 100644 index 0000000..8b44da1 --- /dev/null +++ b/feature/explore/src/test/kotlin/kr/co/explore/ExploreViewModelTest.kt @@ -0,0 +1,120 @@ +package kr.co.explore + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import kr.co.model.ExploreSideEffect +import kr.co.model.ExploreUiIntent +import kr.co.model.FileInfo +import kr.co.model.FileInfo.Type.PDF +import kr.co.testing.repository.TestRecentRepository +import kr.co.testing.rule.CoroutineTestRule +import kr.co.util.FileManager +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDateTime +import kotlin.test.assertEquals + + +class ExploreViewModelTest { + + @get: Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var viewModel: ExploreViewModel + + private val recentRepository = TestRecentRepository() + + @MockK + private lateinit var fileManager: FileManager + + @Before + fun setup() { + MockKAnnotations.init(this) + viewModel = ExploreViewModel(recentRepository, fileManager) + } + + @Test + fun `Given a path when Init intent is handled then state is updated`() = runTest { + val path = "/path" + val folders = listOf( + FOLDER_DUMMY + ) + val files = listOf( + PDF_DUMMY, + PDF_DUMMY + ) + + coEvery { fileManager.readPDFOrDirectory(path) } returns folders + files + + viewModel.handleIntent(ExploreUiIntent.Init(path)) + + viewModel.uiState.test { + val state = awaitItem() + assert(state.path == path) + assertEquals(state.files.size, files.size) + assertEquals(state.folders.size, folders.size) + assert(state.folders == folders) + assert(state.files == files) + } + } + + @Test + fun `Given a file when ClickFile intent is handled then navigate to pdf`() = runTest { + val file = PDF_DUMMY + + recentRepository.insert(file) + + viewModel.handleIntent(ExploreUiIntent.ClickFile(file)) + + recentRepository.insert(file) + + viewModel.sideEffect.test { + awaitItem().also { + assert(it is ExploreSideEffect.NavigateToPdf) + assert((it as ExploreSideEffect.NavigateToPdf).path == file.path) + } + } + } + + @Test + fun `Given a folder when ClickFolder intent is handled then navigate to folder`() = runTest { + val folder = FOLDER_DUMMY + + viewModel.handleIntent(ExploreUiIntent.ClickFolder(folder)) + + viewModel.sideEffect.test { + awaitItem().also { + assert(it is ExploreSideEffect.NavigateToFolder) + assert((it as ExploreSideEffect.NavigateToFolder).path == folder.path) + } + } + } + + companion object { + val PDF_DUMMY = FileInfo( + name = "DUMMY.pdf", + path = "", + type = PDF, + isDirectory = false, + isHidden = false, + size = 0, + createdAt = LocalDateTime.now(), + lastModified = LocalDateTime.now() + ) + + val FOLDER_DUMMY = FileInfo( + name = "DUMMY", + path = "", + type = PDF, + isDirectory = true, + isHidden = false, + size = 0, + createdAt = LocalDateTime.now(), + lastModified = LocalDateTime.now() + ) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 301f082..faf21c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,9 @@ lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.9.3" composeBom = "2024.11.00" jetbrainsKotlinJvm = "1.9.0" +mockk = "1.13.14" +firebaseCrashlyticsBuildtools = "3.0.2" +turbine = "1.2.0" [libraries] android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } @@ -49,8 +52,12 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } + kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } @@ -109,6 +116,8 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycleService" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [bundles] androidx = [ diff --git a/settings.gradle.kts b/settings.gradle.kts index b792150..5d0c267 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,3 +38,4 @@ include(":feature:explore") include(":feature:recent") include(":feature:bookmark") include(":feature:pdf") +include(":core:testing")