diff --git a/Makefile b/Makefile index df28dd2..1cbe359 100644 --- a/Makefile +++ b/Makefile @@ -41,16 +41,66 @@ buildAndroid: cleanBuild $(GRADLEW) androidApp:app:installDebug @echo "✅ Done!" +# Run IOS build +buildIOS: cleanBuild + @echo "IOS build" + chmod +x ./scripts/ios_script.sh + ./scripts/ios_script.sh + @echo "✅ Done!" + +# Run Android Ui test +testUiAndroid: cleanBuild + @echo "Android UI test" + $(GRADLEW) androidApp:app:connectedDebugAndroidTest + @echo "✅ Done!" + # Run Desktop build buildDesktop: cleanBuild @echo "Desktop build" $(GRADLEW) :desktopapp:run @echo "✅ Done!" -# Run web build -buildWeb: cleanBuild - @echo "Web build" - $(GRADLEW) webApp:js:wasmJsBrowserDevelopmentRun +# Run Desktop Ui test +testUiDesktop: cleanBuild + @echo "Desktop UI test" + $(GRADLEW) :desktopapp:jvmTest + @echo "✅ Done!" + +# Run Desktop hot reload build +buildHotDesktop: clear + @echo "Desktop Hot reload build" + $(GRADLEW) :desktopapp:hotRunJvm --auto + @echo "✅ Done!" + +# Run wasm build +buildWasmWeb: cleanBuild + @echo "Web Wasm build" + $(GRADLEW) webApp:wasm:wasmJsBrowserDevelopmentRun + @echo "✅ Done!" + +# Run wasm UI test +testUiWasmWeb: cleanBuild + @echo "Web wasm UI test" + $(GRADLEW) webApp:wasm:wasmJsBrowserTest + @echo "✅ Done!" + +# Run js build +buildJsWeb: cleanBuild + @echo "Web JS build" + $(GRADLEW) webApp:js:jsBrowserDevelopmentRun + @echo "✅ Done!" + +# Run JS UI test +testUiJsWeb: cleanBuild + @echo "Web Js UI test" + $(GRADLEW) webApp:wasm:jsBrowserTest + @echo "✅ Done!" + + +# Run wasm UI test +testUiJsWeb: cleanBuild + @echo "Web Js UI test" + $(GRADLEW) iosApp:iosSimulatorArm64Test @echo "✅ Done!" # Run All test diff --git a/README.md b/README.md index cd962e3..41d9fc8 100644 --- a/README.md +++ b/README.md @@ -30,22 +30,4 @@ This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop. -* `/composeApp` is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - `commonMain` is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - `iosMain` would be the right folder for such calls. - -* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. - - -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), -[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), -[Kotlin/Wasm](https://kotl.in/wasm/)… - -We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web). -If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP). - -You can open the web application by running the `:composeApp:wasmJsBrowserDevelopmentRun` Gradle task. \ No newline at end of file +Run the command from Makefile to run the respective Platform \ No newline at end of file diff --git a/androidApp/app/build.gradle.kts b/androidApp/app/build.gradle.kts index 4dd25b3..9f089f5 100644 --- a/androidApp/app/build.gradle.kts +++ b/androidApp/app/build.gradle.kts @@ -49,4 +49,7 @@ composeCompiler { dependencies { implementation(projects.sharedCode) implementation(libs.androidx.activity.compose) + + androidTestImplementation(libs.androidx.uitest.junit4) + debugImplementation(libs.androidx.uitest.testManifest) } diff --git a/build.gradle.kts b/build.gradle.kts index b00238d..cd3fc9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,5 +5,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.hotReload) alias(libs.plugins.ksp) apply false } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 746e7ef..8a4bcbf 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,10 +1,12 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.compose.reload.gradle.ComposeHotRun plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.hotReload) } kotlin { @@ -22,6 +24,10 @@ kotlin { } } +tasks.withType().configureEach { + mainClass = "dev.reprator.github.MainKt" +} + compose.desktop { application { mainClass = "dev.reprator.github.MainKt" @@ -31,7 +37,19 @@ compose.desktop { packageName = "dev.reprator.github" packageVersion = "1.0.0" includeAllModules = true + + linux { + iconFile.set(project.file("resources/LinuxIcon.png")) + } + windows { + iconFile.set(project.file("resources/WindowsIcon.ico")) + } + macOS { + iconFile.set(project.file("resources/MacosIcon.icns")) + bundleID = "org.company.app.desktopApp" + } } + buildTypes.release.proguard { configurationFiles.from("compose-desktop.pro") } diff --git a/desktopApp/src/jvmMain/resources/LinuxIcon.png b/desktopApp/src/jvmMain/resources/LinuxIcon.png new file mode 100644 index 0000000..a2a4517 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/LinuxIcon.png differ diff --git a/desktopApp/src/jvmMain/resources/MacosIcon.icns b/desktopApp/src/jvmMain/resources/MacosIcon.icns new file mode 100644 index 0000000..083def5 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/MacosIcon.icns differ diff --git a/desktopApp/src/jvmMain/resources/WindowsIcon.ico b/desktopApp/src/jvmMain/resources/WindowsIcon.ico new file mode 100644 index 0000000..7e9ab75 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/WindowsIcon.ico differ diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..d5ec703 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,19 @@ -#Kotlin -kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx3072M - #Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx4G org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.daemon=true +org.gradle.parallel=true + +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx4G +kotlin.native.binary.gc=cms +kotlin.incremental.wasm=true #Android +android.useAndroidX=true android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file + +#Compose +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61de9dd..72bcee3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ androidx-constraintlayout = "2.2.1" androidx-core = "1.16.0" androidx-espresso = "3.6.1" androidx-lifecycle = "2.9.1" +androidx-uiTest = "1.8.3" androidx-navigation = "2.9.0-beta03" androidx-testExt = "1.2.1" composeHotReload = "1.0.0-beta04" @@ -17,7 +18,7 @@ junit = "4.13.2" kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" ktor = "3.2.2" -coil = "3.2.0" +coil = "3.3.0" kotlininject = "0.8.0" turbine = "1.2.1" ksp = "2.2.0-2.0.2" @@ -29,6 +30,8 @@ spotless = "7.1.0" kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { module = "junit:junit", version.ref = "junit" } +androidx-uitest-testManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-uiTest" } +androidx-uitest-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-uiTest" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } @@ -69,10 +72,12 @@ ktor-client-serialization-json = { group = "io.ktor", name = "ktor-serialization logging-napier = { module = "io.github.aakira:napier", version = "2.7.1" } +test-turbine = { module="app.cash.turbine:turbine", version = "1.2.1" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } +compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/scripts/ios_script.sh b/scripts/ios_script.sh new file mode 100755 index 0000000..28a4b85 --- /dev/null +++ b/scripts/ios_script.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +echo "🚀 Building Compose Multiplatform App..." + +# Build the shared Compose framework +echo "📦 Building shared Compose Multiplatform framework..." +./gradlew :shared:compileKotlinIosSimulatorArm64 +./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 + +if [ $? -eq 0 ]; then + echo "✅ Compose Multiplatform framework built successfully!" + + echo "📱 Starting iOS Simulator..." + xcrun simctl boot "iPhone 16 Pro" 2>/dev/null || echo "Simulator already running" + open -a Simulator + + echo "🍎 Opening iOS project in Xcode..." + open iosApp/iosApp.xcodeproj + + echo "" + echo "🎯 NEXT STEPS IN XCODE:" + echo "1. Wait for Xcode to open" + echo "2. Click on 'Any iOS Device' dropdown (top-left)" + echo "3. Select 'iPhone 16 Pro' from the list" + echo "4. Press Cmd+R or click ▶️ to run" + echo "" +else + echo "❌ Framework build failed!" + exit 1 +fi \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index dd85752..4328739 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,3 +36,4 @@ include(":sharedCode") include(":androidApp:app") include(":desktopApp") include(":webapp:js") +include(":webapp:wasm") diff --git a/sharedCode/build.gradle.kts b/sharedCode/build.gradle.kts index b3cd37e..64e3ddd 100644 --- a/sharedCode/build.gradle.kts +++ b/sharedCode/build.gradle.kts @@ -1,5 +1,7 @@ -@file:OptIn(ExperimentalWasmDsl::class) +@file:OptIn(ExperimentalWasmDsl::class, ExperimentalComposeLibrary::class) import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.invoke +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -13,6 +15,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) + alias(libs.plugins.mokkery) } kotlin { @@ -42,7 +45,12 @@ kotlin { wasmJs { browser() } - + + js { + browser() + binaries.executable() + } + sourceSets { val desktopMain by getting @@ -89,6 +97,9 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.test.turbine) + implementation(compose.uiTest) } desktopMain.dependencies { @@ -100,6 +111,10 @@ kotlin { wasmJsMain.dependencies { implementation(libs.ktor.client.js) } + + jsMain.dependencies { + implementation(libs.ktor.client.js) + } } } @@ -124,6 +139,10 @@ ksp { arg("me.tatarka.inject.generateCompanionExtensions", "true") } +mokkery { + ignoreFinalMembers.set(true) +} + fun Project.addKspDependencyForAllTargets(dependencyNotation: Any) = addKspDependencyForAllTargets("", dependencyNotation) fun Project.addKspTestDependencyForAllTargets(dependencyNotation: Any) = addKspDependencyForAllTargets("Test", dependencyNotation) diff --git a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userDetail/presentation/ui/UserDetailScreen.kt b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userDetail/presentation/ui/UserDetailScreen.kt index 7809581..97169fa 100644 --- a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userDetail/presentation/ui/UserDetailScreen.kt +++ b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userDetail/presentation/ui/UserDetailScreen.kt @@ -126,7 +126,7 @@ private fun UserDetailScreen( onAction: OnAction, modifier: Modifier = Modifier ) { - Column(modifier = modifier.fillMaxSize()) { + Column(modifier = modifier.padding(12.dp).fillMaxSize()) { UserToolbar( state.userInfo.userName, state.userInfo.profilePic, diff --git a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListScreenReducer.kt b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListScreenReducer.kt index 283f12f..0245480 100644 --- a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListScreenReducer.kt +++ b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListScreenReducer.kt @@ -5,8 +5,7 @@ import io.github.aakira.napier.Napier import me.tatarka.inject.annotations.Inject @Inject -class UserListScreenReducer : - Reducer { +class UserListScreenReducer : Reducer { override fun reduce( previousState: UserListState, @@ -74,7 +73,9 @@ class UserListScreenReducer : //Search query is empty, so fetch user list return previousState.copy( - userLoading = true + userLoading = true, + errorMessage = "", + isError = false, ) to null } } diff --git a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModel.kt b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModel.kt index 232b773..8819ab1 100644 --- a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModel.kt +++ b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModel.kt @@ -1,9 +1,7 @@ package dev.reprator.github.features.userList.presentation -import androidx.lifecycle.Lifecycle import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.viewModelScope import dev.reprator.github.util.AppCoroutineDispatchers import dev.reprator.github.util.base.mvi.MVI @@ -22,7 +20,7 @@ import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject -private const val SEARCH_QUERY_KEY = "searchQuery" +const val SEARCH_QUERY_KEY = "searchQuery" private const val MINIMUM_SEARCH_DEBOUNCE = 100L private const val MINIMUM_SEARCH_LENGTH = 3 diff --git a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/ui/UserListScreen.kt b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/ui/UserListScreen.kt index caebb82..d514401 100644 --- a/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/ui/UserListScreen.kt +++ b/sharedCode/src/commonMain/kotlin/dev/reprator/github/features/userList/presentation/ui/UserListScreen.kt @@ -145,7 +145,7 @@ private fun UserListScreen( onSearchQueryChanged: (String) -> Unit = {}, lazyListState: LazyListState = rememberLazyListState() ) { - Column(modifier = modifier) { + Column(modifier = modifier.padding(12.dp)) { SearchTextField(currentSearchQuery, onSearchQueryChanged, { query -> onAction(UserListAction.SearchUsers(query, state.userList.isEmpty())) diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/ComposeAppCommonTest.kt b/sharedCode/src/commonTest/kotlin/dev/reprator/github/ComposeAppCommonTest.kt deleted file mode 100644 index c220688..0000000 --- a/sharedCode/src/commonTest/kotlin/dev/reprator/github/ComposeAppCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.reprator.github - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModelTest.kt b/sharedCode/src/commonTest/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModelTest.kt new file mode 100644 index 0000000..adba8f1 --- /dev/null +++ b/sharedCode/src/commonTest/kotlin/dev/reprator/github/features/userList/presentation/UserListViewModelTest.kt @@ -0,0 +1,345 @@ +package dev.reprator.github.features.userList.presentation + +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.spy +import dev.mokkery.verify.VerifyMode.Companion.atMost +import dev.mokkery.verifySuspend +import dev.reprator.github.features.userList.domain.UserModel +import dev.reprator.github.features.userList.domain.usecase.UserListUseCase +import dev.reprator.github.fixtures.SEARCH_QUERY +import dev.reprator.github.fixtures.TOTAL_ITEM +import dev.reprator.github.fixtures.errorMessage +import dev.reprator.github.fixtures.uiUserListModel +import dev.reprator.github.fixtures.uiUserSearchListModel +import dev.reprator.github.util.AppCoroutineDispatchers +import dev.reprator.github.util.AppError +import dev.reprator.github.util.AppSuccess +import dev.reprator.github.util.MainDispatcherRule +import dev.reprator.github.util.base.mvi.Middleware +import dev.reprator.github.util.base.mvi.Reducer +import dev.reprator.github.util.runViewModelTest +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class UserListViewModelTest: MainDispatcherRule() { + + private lateinit var savedStateHandle: SavedStateHandle + + private val fetchUseCase = mock() + + private lateinit var dispatchers: AppCoroutineDispatchers + + private lateinit var userListViewModel: UserListViewModel + + private val reducer = spy>(UserListScreenReducer()) + private lateinit var middleware: Middleware + + @BeforeTest + override fun start() { + super.start() + + savedStateHandle = SavedStateHandle(mapOf(SEARCH_QUERY_KEY to "")) + dispatchers = AppCoroutineDispatchers( + testDispatcher, testDispatcher, testDispatcher, + testDispatcher) + + middleware = UserListMiddleware(fetchUseCase, dispatchers) + + userListViewModel = UserListViewModel( + savedStateHandle = savedStateHandle, + dispatchers = dispatchers, + reducer = reducer, + middleWareList = setOf(middleware) + ) + } + + @Test + fun fetchUsersListSuccessFullyWhenUserOpenTheApp() = runTest { + + everySuspend { + fetchUseCase() + } returns AppSuccess(uiUserListModel) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + + effect.expectNoEvents() + + assertEquals(UserListState.initial(), state.awaitItem()) + + val nextItemLoading = state.awaitItem() + + with(nextItemLoading) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(false , this.isError) + assertEquals(true , this.errorMessage.isEmpty()) + assertEquals(true , this.userLoading) + } + + val itemFetched = state.awaitItem() + + with(itemFetched) { + assertEquals(TOTAL_ITEM , this.userListSearch.size) + assertEquals(TOTAL_ITEM , this.userList.size) + assertEquals(false , this.isError) + assertEquals(true , this.errorMessage.isEmpty()) + assertEquals(false , this.userLoading) + } + + state.expectNoEvents() + } + + verifySuspend(atMost(1)) { + fetchUseCase() + reducer.reduce(any(), UserListAction.SearchUsers("", true)) + } + } + + @Test + fun fetchUsersListFetchFailedWhenUserOpenTheApp() = runTest { + + everySuspend { + fetchUseCase() + } returns AppError(message = errorMessage) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + + effect.expectNoEvents() + + assertEquals(UserListState.initial(), state.awaitItem()) + + val nextItemLoading = state.awaitItem() + + with(nextItemLoading) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(false , this.isError) + assertEquals(true , this.errorMessage.isEmpty()) + assertEquals(true , this.userLoading) + } + + val itemFetched = state.awaitItem() + + with(itemFetched) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(true , this.isError) + assertEquals(errorMessage , this.errorMessage) + assertEquals(false , this.userLoading) + } + + state.expectNoEvents() + } + + verifySuspend(atMost(1)) { + fetchUseCase() + reducer.reduce(any(), UserListAction.SearchUsers("", true)) + } + } + + @Test + fun retryToFetchUserListOnFailedWhenUserOpenTheApp() = runTest { + everySuspend { + fetchUseCase() + } returns AppError(message = errorMessage) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + effect.expectNoEvents() + + assertEquals(UserListState.initial(), state.awaitItem()) + + val nextItemLoading = state.awaitItem() + + with(nextItemLoading) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(false , this.isError) + assertEquals(true , this.errorMessage.isEmpty()) + assertEquals(true , this.userLoading) + } + + val itemFetched = state.awaitItem() + + with(itemFetched) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(true , this.isError) + assertEquals(errorMessage , this.errorMessage) + assertEquals(false , this.userLoading) + } + + everySuspend { + fetchUseCase() + } returns AppSuccess(uiUserListModel) + + userListViewModel.onAction(UserListAction.RetryFetchUser("", true)) + + with(state.awaitItem()) { + assertEquals(true , this.userListSearch.isEmpty()) + assertEquals(true , this.userList.isEmpty()) + assertEquals(false , this.isError) + assertEquals(true , this.errorMessage.isEmpty()) + assertEquals(true , this.userLoading) + } + + with(state.awaitItem()) { + assertEquals(TOTAL_ITEM , this.userListSearch.size) + assertEquals(TOTAL_ITEM , this.userList.size) + assertFalse { this.isError} + assertTrue { this.errorMessage.isEmpty() } + assertFalse(this.userLoading) + } + + state.expectNoEvents() + } + } + + @Test + fun `Navigate to detail on Action NavigateToDetail via side effect`() = runTest { + val clickedItem = uiUserListModel.first() + + everySuspend { + fetchUseCase() + } returns AppSuccess(uiUserListModel) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + + state.cancelAndIgnoreRemainingEvents() + /* + * Some how, + testScheduler.advanceUntilIdle(), advanceUntilIdle() is not working that's why i have to + ignore the state changes + * */ + + userListViewModel.onAction(UserListAction.UserClicked(clickedItem)) + + val nextItem = effect.awaitItem() + assertIs(nextItem) + assertEquals(clickedItem , nextItem.userModel) + + effect.expectNoEvents() + } + } + + @Test + fun searchForUserOnTypeWhenPreviousListAlreadyHaveDefaultUsers() = runTest { + + everySuspend { + fetchUseCase() + } returns AppSuccess(uiUserListModel) + + everySuspend { + fetchUseCase.searchUser(any()) + } returns AppSuccess(uiUserSearchListModel) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + effect.expectNoEvents() + + state.awaitItem() + state.awaitItem() + + /* + * Some how, + testScheduler.advanceUntilIdle(), advanceUntilIdle() is not working that's why i have to + consume the initial 2 events + * */ + + userListViewModel.onSearchQueryChanged(SEARCH_QUERY) + + testDispatcher.scheduler.advanceUntilIdle() + testDispatcher.scheduler.runCurrent() + + assertEquals(SEARCH_QUERY, savedStateHandle.getStateFlow(SEARCH_QUERY_KEY,"").value) + state.awaitItem() //Need to check, why 3 events are there, only 2 should be there + state.awaitItem() + with(state.awaitItem()) { + assertEquals(uiUserSearchListModel , this.userListSearch) + assertEquals(uiUserListModel , this.userList) + assertFalse(this.isError) + assertFalse(this.userLoading) + assertTrue( this.errorMessage.isEmpty()) + } + state.expectNoEvents() + } + } + + @Test + fun searchForUserOnTypeWhenPreviousListAlreadyHaveDefaultUsersForJetbrains() =runTest { + + everySuspend { + fetchUseCase() + } returns AppSuccess(uiUserListModel) + + everySuspend { + fetchUseCase.searchUser(any()) + } returns AppSuccess(uiUserSearchListModel) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + effect.expectNoEvents() + + advanceUntilIdle() + + userListViewModel.onSearchQueryChanged(SEARCH_QUERY) + + advanceUntilIdle() + + assertEquals(SEARCH_QUERY, savedStateHandle.getStateFlow(SEARCH_QUERY_KEY,"").value) + state.awaitItem() //Need to check, why 3 events are there, only 2 should be there + state.awaitItem() + with(state.awaitItem()) { + assertEquals(uiUserSearchListModel , this.userListSearch) + assertEquals(uiUserListModel , this.userList) + assertFalse(this.isError) + assertFalse(this.userLoading) + assertTrue( this.errorMessage.isEmpty()) + } + state.expectNoEvents() + } + } + + + @Test + fun searchForUserOnTypeWhenDefaultUserListIsEmpty() = runTest { + everySuspend { + fetchUseCase() + } returns AppSuccess>(emptyList()) + + everySuspend { + fetchUseCase.searchUser(any()) + } returns AppSuccess(uiUserSearchListModel) + + runViewModelTest(userListViewModel.uiState, userListViewModel.sideEffect) { state, effect -> + effect.expectNoEvents() + + state.awaitItem() + state.awaitItem() + + userListViewModel.onSearchQueryChanged(SEARCH_QUERY) + + testDispatcher.scheduler.advanceUntilIdle() + testDispatcher.scheduler.runCurrent() + + assertEquals(SEARCH_QUERY, savedStateHandle.getStateFlow(SEARCH_QUERY_KEY,"").value) + state.awaitItem() //Need to check, why 3 events are there, only 2 should be there + state.awaitItem() + with(state.awaitItem()) { + assertEquals(uiUserSearchListModel , this.userListSearch) + assertEquals(uiUserSearchListModel , this.userList) + assertFalse(this.isError) + assertFalse(this.userLoading) + assertTrue( this.errorMessage.isEmpty()) + } + state.expectNoEvents() + } + } +} \ No newline at end of file diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/fixtures/ResultContainer.kt b/sharedCode/src/commonTest/kotlin/dev/reprator/github/fixtures/ResultContainer.kt new file mode 100644 index 0000000..93ceae6 --- /dev/null +++ b/sharedCode/src/commonTest/kotlin/dev/reprator/github/fixtures/ResultContainer.kt @@ -0,0 +1,29 @@ +package dev.reprator.github.fixtures + +import dev.reprator.github.features.userList.domain.UserModel + +val TOTAL_ITEM = 10 +val TOTAL_ITEM_SEARCH = 5 + +val uiUserListModel = buildList { + repeat(TOTAL_ITEM) { + add( + UserModel( + "User $it", it, + "https://avatars.githubusercontent.com/u/$it?v=$it") + ) + } +} + +val uiUserSearchListModel = buildList { + repeat(TOTAL_ITEM_SEARCH) { + add( + UserModel( + "SearchUser $it", it, + "https://searchUser.avatars.githubusercontent.com/u/$it?v=$it") + ) + } +} + +val errorMessage = "Failed to fetch Users" +val SEARCH_QUERY = "Reprator" diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/un_authenticted_error.json b/sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/un_authenticted_error.json similarity index 100% rename from sharedCode/src/commonTest/kotlin/dev/reprator/github/un_authenticted_error.json rename to sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/un_authenticted_error.json diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/user_info.json b/sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_info.json similarity index 100% rename from sharedCode/src/commonTest/kotlin/dev/reprator/github/user_info.json rename to sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_info.json diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/user_respos.json b/sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_respos.json similarity index 100% rename from sharedCode/src/commonTest/kotlin/dev/reprator/github/user_respos.json rename to sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_respos.json diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/user_search_result.json b/sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_search_result.json similarity index 100% rename from sharedCode/src/commonTest/kotlin/dev/reprator/github/user_search_result.json rename to sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/user_search_result.json diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/userlist_response.json b/sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/userlist_response.json similarity index 100% rename from sharedCode/src/commonTest/kotlin/dev/reprator/github/userlist_response.json rename to sharedCode/src/commonTest/kotlin/dev/reprator/github/jsonResponse/userlist_response.json diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/MainDispatcherRule.kt b/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/MainDispatcherRule.kt new file mode 100644 index 0000000..0209c58 --- /dev/null +++ b/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/MainDispatcherRule.kt @@ -0,0 +1,26 @@ +package dev.reprator.github.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 kotlin.test.AfterTest +import kotlin.test.BeforeTest + +@OptIn(ExperimentalCoroutinesApi::class) +open class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) { + + @BeforeTest + open fun start() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun finished() { + Dispatchers.resetMain() + } +} diff --git a/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/TestUtil.kt b/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/TestUtil.kt new file mode 100644 index 0000000..1c7ff1c --- /dev/null +++ b/sharedCode/src/commonTest/kotlin/dev/reprator/github/util/TestUtil.kt @@ -0,0 +1,23 @@ +package dev.reprator.github.util + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.turbineScope +import dev.reprator.github.util.base.mvi.SideEffect +import dev.reprator.github.util.base.mvi.UiState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest + +fun runViewModelTest( + state: Flow, + effect: Flow, + block: suspend TestScope.(state: ReceiveTurbine, effect: ReceiveTurbine) -> Unit +) = runTest { + turbineScope { + val turbineState = state.testIn(this) + val turbineEffect = effect.testIn(this) + block(turbineState, turbineEffect) + turbineState.cancel() + turbineEffect.cancel() + } +} \ No newline at end of file diff --git a/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/application/SharedApplicationComponent.kt b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/application/SharedApplicationComponent.kt new file mode 100644 index 0000000..f8e9877 --- /dev/null +++ b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/application/SharedApplicationComponent.kt @@ -0,0 +1,25 @@ +package dev.reprator.github.di.inject.application + +import dev.reprator.github.di.inject.ApplicationScope + +import dev.reprator.github.util.AppCoroutineDispatchers +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.js.Js +import kotlinx.coroutines.Dispatchers +import me.tatarka.inject.annotations.Provides + +actual interface SharedPlatformApplicationComponent { + + @Provides + @ApplicationScope + fun provideHttpClientEngine(): HttpClientEngine = Js.create() + + @ApplicationScope + @Provides + fun provideCoroutineDispatchers(): AppCoroutineDispatchers = AppCoroutineDispatchers( + io = Dispatchers.Default, + singleThread = Dispatchers.Default, + computation = Dispatchers.Default, + main = Dispatchers.Main, + ) +} diff --git a/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsApplicationComponent.kt b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsApplicationComponent.kt new file mode 100644 index 0000000..014875b --- /dev/null +++ b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsApplicationComponent.kt @@ -0,0 +1,14 @@ +package dev.reprator.github.di.inject.component + + +import dev.reprator.github.di.inject.ApplicationScope +import dev.reprator.github.di.inject.application.SharedApplicationComponent +import me.tatarka.inject.annotations.Component + +@Component +@ApplicationScope +abstract class JsApplicationComponent( +) : SharedApplicationComponent { + + companion object +} diff --git a/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsWindowComponent.kt b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsWindowComponent.kt new file mode 100644 index 0000000..7177685 --- /dev/null +++ b/sharedCode/src/jsMain/kotlin/dev/reprator/github/di/inject/component/JsWindowComponent.kt @@ -0,0 +1,14 @@ +package dev.reprator.github.di.inject.component + +import dev.reprator.github.di.inject.ActivityScope +import dev.reprator.github.di.inject.activity.SharedModuleComponent +import me.tatarka.inject.annotations.Component + +@ActivityScope +@Component +abstract class JsWindowComponent( + @Component val applicationComponent: JsApplicationComponent, +) : SharedModuleComponent { + + companion object +} \ No newline at end of file diff --git a/webApp/js/build.gradle.kts b/webApp/js/build.gradle.kts index a3fee08..2b64d89 100644 --- a/webApp/js/build.gradle.kts +++ b/webApp/js/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } kotlin { - wasmJs { + js { outputModuleName.set("github") browser { val rootDirPath = project.rootDir.path diff --git a/webApp/js/src/jsMain/kotlin/dev/reprator/github/Main.kt b/webApp/js/src/jsMain/kotlin/dev/reprator/github/Main.kt new file mode 100644 index 0000000..6a0a2d0 --- /dev/null +++ b/webApp/js/src/jsMain/kotlin/dev/reprator/github/Main.kt @@ -0,0 +1,30 @@ +package dev.reprator.github + +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import dev.reprator.github.di.inject.component.JsApplicationComponent +import dev.reprator.github.di.inject.component.JsWindowComponent +import dev.reprator.github.di.inject.component.create +import dev.reprator.github.root.App +import kotlinx.browser.document +import org.jetbrains.skiko.wasm.onWasmReady + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + onWasmReady { + val body = document.body ?: return@onWasmReady + ComposeViewport(body) { + + val applicationComponent = remember { + JsApplicationComponent.create() + } + + val component = remember(applicationComponent) { + JsWindowComponent.create(applicationComponent) + } + + App(component.routeFactories) + } + } +} \ No newline at end of file diff --git a/webApp/js/src/jsMain/resources/index.html b/webApp/js/src/jsMain/resources/index.html new file mode 100644 index 0000000..ce536a4 --- /dev/null +++ b/webApp/js/src/jsMain/resources/index.html @@ -0,0 +1,13 @@ + + + + + + Github + + + + + + + \ No newline at end of file diff --git a/webApp/js/src/wasmJsMain/resources/styles.css b/webApp/js/src/jsMain/resources/styles.css similarity index 100% rename from webApp/js/src/wasmJsMain/resources/styles.css rename to webApp/js/src/jsMain/resources/styles.css diff --git a/webApp/wasm/build.gradle.kts b/webApp/wasm/build.gradle.kts new file mode 100644 index 0000000..a3fee08 --- /dev/null +++ b/webApp/wasm/build.gradle.kts @@ -0,0 +1,40 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.multiplatform) +} + +kotlin { + wasmJs { + outputModuleName.set("github") + browser { + val rootDirPath = project.rootDir.path + val projectDirPath = project.projectDir.path + commonWebpackConfig { + outputFileName = "github.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + static = (static ?: mutableListOf()).apply { + // Serve sources to debug inside browser + add(rootDirPath) + add(projectDirPath) + } + } + } + } + binaries.executable() + } + + sourceSets { + commonMain { + dependencies { + implementation(compose.ui) + implementation(projects.sharedCode) + } + } + } +} \ No newline at end of file diff --git a/webApp/js/src/wasmJsMain/kotlin/dev/reprator/github/Main.kt b/webApp/wasm/src/wasmJsMain/kotlin/dev/reprator/github/Main.kt similarity index 100% rename from webApp/js/src/wasmJsMain/kotlin/dev/reprator/github/Main.kt rename to webApp/wasm/src/wasmJsMain/kotlin/dev/reprator/github/Main.kt diff --git a/webApp/js/src/wasmJsMain/resources/index.html b/webApp/wasm/src/wasmJsMain/resources/index.html similarity index 100% rename from webApp/js/src/wasmJsMain/resources/index.html rename to webApp/wasm/src/wasmJsMain/resources/index.html diff --git a/webApp/wasm/src/wasmJsMain/resources/styles.css b/webApp/wasm/src/wasmJsMain/resources/styles.css new file mode 100644 index 0000000..0549b10 --- /dev/null +++ b/webApp/wasm/src/wasmJsMain/resources/styles.css @@ -0,0 +1,7 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} \ No newline at end of file