diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3145669 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## What's new ๐Ÿ‘€ + +Please include a summary of the changes and the related issue. + +## Jira Ticket + +https:// + +## How to test + +- Step 1: +- Step 2: + +## Proof Of Work ๐Ÿ“น + +TODO: Screenshots, GIFs, etc. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..3e0c3df --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,66 @@ +name: Android CI + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + - 'release/*' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +env: + MIN_COVERAGE_ALL: ${{ vars.MIN_COVERAGE_ALL || 60 }} + MIN_COVERAGE_CHANGED_FILE: ${{ vars.MIN_COVERAGE_CHANGED_FILE || 60 }} + +jobs: + android-ci: + name: Lint & Coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Make gradlew executable + run: chmod +x gradlew + + # Run detekt + - name: Run Lint + run: ./gradlew lint + + # Run assembleUat +# - name: Run AssembleUat +# run: ./gradlew assembleDebug +# + # Kover ONLY on PRs + - name: Run Kover Coverage + if: github.event_name == 'pull_request' + run: ./gradlew composeApp:koverXmlReportDebug + + # Post Coverage Comment only for PRs + - name: Post Coverage Comment + if: github.event_name == 'pull_request' + uses: mi-kas/kover-report@v1 + with: + path: composeApp/build/reports/kover/reportDebug.xml + token: ${{ secrets.GITHUB_TOKEN }} + title: Code Coverage + update-comment: true + min-coverage-overall: ${{ env.MIN_COVERAGE_ALL }} + min-coverage-changed-files: ${{ env.MIN_COVERAGE_CHANGED_FILE }} + coverage-counter-type: LINE \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/leegroup/module/buildlogic/KmpIosExt.kt b/build-logic/convention/src/main/kotlin/leegroup/module/buildlogic/KmpIosExt.kt index d3b3456..fc71f7c 100644 --- a/build-logic/convention/src/main/kotlin/leegroup/module/buildlogic/KmpIosExt.kt +++ b/build-logic/convention/src/main/kotlin/leegroup/module/buildlogic/KmpIosExt.kt @@ -22,7 +22,6 @@ fun Project.configureIosFramework( val kmp = extensions.getByType() kmp.apply { listOf( - iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget -> diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0de006a..0a9a796 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.kotlinCocoapods) alias(libs.plugins.buildkonfig) + alias(libs.plugins.kotlinx.kover) } // ----- iOS: read Xcode configuration (Debug/UAT/Staging/Release) ----- @@ -112,4 +113,43 @@ android { dependencies { debugImplementation(compose.uiTooling) -} \ No newline at end of file +} + +dependencies { + kover(projects.core.coreKtx) + kover(projects.core.data) + kover(projects.core.designsystem) + kover(projects.gituser) +} + +kover { + reports { + filters { + includes { + classes("*ViewModel") + classes("*UseCase") + classes("*UseCase") + classes("*Mapper") + classes("*MapperImpl") + classes("*Mapping") + classes("*Repository") + classes("*RepositoryImpl") + classes("*Util") + classes("*Helper") + classes("*HelperImpl") + classes("*Formatter") + classes("*FormatterImpl") + classes("*Converter") + classes("*ConverterImpl") + classes("*UiState") + classes("*Event") + classes("*Builder") + classes("*Controller") + classes("*Data") + classes("*Analytic") + classes("*Analytics") + classes("*AnalyticManager") + } + } + } +} diff --git a/core/core-ktx/build.gradle.kts b/core/core-ktx/build.gradle.kts index 26462f0..9e9dbd2 100644 --- a/core/core-ktx/build.gradle.kts +++ b/core/core-ktx/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.nowinandroid.kmp.library) alias(libs.plugins.nowinandroid.kmp.library.compose) alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.kotlinx.kover) } kotlin { diff --git a/core/core-ktx/src/androidHostTest/kotlin/leegroup/module/core/JsonUtilTest.kt b/core/core-ktx/src/androidHostTest/kotlin/leegroup/module/core/JsonUtilTest.kt deleted file mode 100644 index 53bcecf..0000000 --- a/core/core-ktx/src/androidHostTest/kotlin/leegroup/module/core/JsonUtilTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package leegroup.module.core - -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertNull -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import leegroup.module.core.util.JsonUtil -import org.junit.Test - -class JsonUtilTest { - - @Test - fun `decodeFromString should return User for valid JSON`() { - // Arrange - val json = """{"id":1,"firstName":"John","lastName":"Doe"}""" - - // Act - val result = JsonUtil.decodeFromString(json) - - // Assert - assertNotNull(result) - assertEquals(1, result?.id) - assertEquals("John", result?.firstName) - assertEquals("Doe", result?.lastName) - } - - @Test - fun `decodeFromString should return null for invalid JSON`() { - // Arrange - val invalidJson = """{"id":1,"firstName":"John","lastName":}""" - - // Act - val result = JsonUtil.decodeFromString(invalidJson) - - // Assert - assertNull(result) - } - - @Test - fun `decodeFromString should return null for mismatched JSON structure`() { - // Arrange - val json = """{"name":"John","age":30}""" - - // Act - val result = JsonUtil.decodeFromString(json) - - // Assert - assertNull(result) - } - - @Test - fun `encodeToString should return valid JSON for User`() { - // Arrange - val user = TestUser(1, "Jane", "Doe") - - // Act - val result = JsonUtil.encodeToString(user) - - // Assert - assertEquals("""{"id":1,"firstName":"Jane","lastName":"Doe"}""", result) - } - - @Serializable - data class TestUser( - @SerialName("id") - val id: Int, - - @SerialName("firstName") - val firstName: String, - - @SerialName("lastName") - val lastName: String - ) -} - diff --git a/core/core-ktx/src/commonMain/kotlin/leegroup/module/core/util/JsonUtil.kt b/core/core-ktx/src/commonMain/kotlin/leegroup/module/core/util/JsonUtil.kt index b6b386b..c81c921 100644 --- a/core/core-ktx/src/commonMain/kotlin/leegroup/module/core/util/JsonUtil.kt +++ b/core/core-ktx/src/commonMain/kotlin/leegroup/module/core/util/JsonUtil.kt @@ -2,6 +2,9 @@ package leegroup.module.core.util import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -34,4 +37,19 @@ object JsonUtil { val jsonObject = json.encodeToJsonElement(value).jsonObject return jsonObject.mapValues { it.value.jsonPrimitive.content } } + + inline fun decodeFromMap(value: Map): T? { + return try { + val jsonObject = buildJsonObject { + value.forEach { (key, v) -> + put(key, JsonPrimitive(v.toString())) + } + } + json.decodeFromJsonElement(jsonObject) + } catch (ex: SerializationException) { + null + } catch (ex: IllegalArgumentException) { + null + } + } } diff --git a/core/core-ktx/src/commonTest/kotlin/leegroup/module/test/JsonUtilTest.kt b/core/core-ktx/src/commonTest/kotlin/leegroup/module/test/JsonUtilTest.kt new file mode 100644 index 0000000..905d109 --- /dev/null +++ b/core/core-ktx/src/commonTest/kotlin/leegroup/module/test/JsonUtilTest.kt @@ -0,0 +1,140 @@ +package leegroup.module.test + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import leegroup.module.core.util.JsonUtil +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class JsonUtilTest { + + @Test + fun `decodeFromString should return User for valid JSON`() { + // Arrange + val json = """{"id":1,"firstName":"John","lastName":"Doe"}""" + + // Act + val result = JsonUtil.decodeFromString(json) + + // Assert + assertNotNull(result) + assertEquals(1, result?.id) + assertEquals("John", result?.firstName) + assertEquals("Doe", result?.lastName) + } + + @Test + fun `decodeFromString should return null for invalid JSON`() { + // Arrange + val invalidJson = """{"id":1,"firstName":"John","lastName":}""" + + // Act + val result = JsonUtil.decodeFromString(invalidJson) + + // Assert + assertNull(result) + } + + @Test + fun `decodeFromString should return null for mismatched JSON structure`() { + // Arrange + val json = """{"name":"John","age":30}""" + + // Act + val result = JsonUtil.decodeFromString(json) + + // Assert + assertNull(result) + } + + @Test + fun `encodeToString should return valid JSON for User`() { + // Arrange + val user = TestUser(1, "Jane", "Doe") + + // Act + val result = JsonUtil.encodeToString(user) + + // Assert + assertEquals("""{"id":1,"firstName":"Jane","lastName":"Doe"}""", result) + } + + @Test + fun `encodeToMap should return valid Map for User`() { + // Arrange + val user = TestUser(1, "Jane", "Doe") + + // Act + val result = JsonUtil.encodeToMap(user) + + // Assert + assertEquals(3, result.size) + assertEquals("1", result["id"]) + assertEquals("Jane", result["firstName"]) + assertEquals("Doe", result["lastName"]) + } + + @Test + fun `decodeFromMap should return User for valid Map`() { + // Arrange + val map = mapOf( + "id" to 1, + "firstName" to "John", + "lastName" to "Doe" + ) + + // Act + val result = JsonUtil.decodeFromMap(map) + + // Assert + assertNotNull(result) + assertEquals(1, result?.id) + assertEquals("John", result?.firstName) + assertEquals("Doe", result?.lastName) + } + + @Test + fun `decodeFromMap should return null for invalid Map structure`() { + // Arrange + val map = mapOf( + "name" to "John", + "age" to 30 + ) + + // Act + val result = JsonUtil.decodeFromMap(map) + + // Assert + assertNull(result) + } + + @Test + fun `encodeToMap and decodeFromMap should be reversible`() { + // Arrange + val originalUser = TestUser(42, "Alice", "Smith") + + // Act + val map = JsonUtil.encodeToMap(originalUser) + val decodedUser = JsonUtil.decodeFromMap(map) + + // Assert + assertNotNull(decodedUser) + assertEquals(originalUser.id, decodedUser?.id) + assertEquals(originalUser.firstName, decodedUser?.firstName) + assertEquals(originalUser.lastName, decodedUser?.lastName) + } + + @Serializable + data class TestUser( + @SerialName("id") + val id: Int, + + @SerialName("firstName") + val firstName: String, + + @SerialName("lastName") + val lastName: String + ) +} \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index fcf55da..dff7f34 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.nowinandroid.kmp.library) alias(libs.plugins.nowinandroid.kmp.network) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.kotlinx.kover) } kotlin { diff --git a/core/data/src/commonMain/kotlin/leegroup/module/data/network/model/error/ErrorModel.kt b/core/data/src/commonMain/kotlin/leegroup/module/data/network/model/error/ErrorModel.kt index e32c0f0..ccbe34d 100644 --- a/core/data/src/commonMain/kotlin/leegroup/module/data/network/model/error/ErrorModel.kt +++ b/core/data/src/commonMain/kotlin/leegroup/module/data/network/model/error/ErrorModel.kt @@ -5,6 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class ErrorModel( - @SerialName("code") val code: Int? = null, - @SerialName("message") override val message: String? = null, + @SerialName("code") val code: Int = 0, + @SerialName("message") override val message: String = "", ) : Throwable(message) diff --git a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ApiMockUtil.kt b/core/data/src/commonTest/kotlin/leegroup/module/data/ApiMockUtil.kt similarity index 67% rename from core/data/src/androidHostTest/kotlin/leegroup/module/data/ApiMockUtil.kt rename to core/data/src/commonTest/kotlin/leegroup/module/data/ApiMockUtil.kt index 5154a0f..1ee1260 100644 --- a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ApiMockUtil.kt +++ b/core/data/src/commonTest/kotlin/leegroup/module/data/ApiMockUtil.kt @@ -1,7 +1,6 @@ package leegroup.module.data import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -10,11 +9,9 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonBuilder import leegroup.module.core.util.JsonUtil import leegroup.module.data.network.model.error.ErrorModel import leegroup.module.data.network.model.error.SampleCustomErrorModel -import kotlin.text.trimIndent internal object ApiMockUtil { @@ -40,20 +37,18 @@ internal object ApiMockUtil { ) } - return HttpClient(mockEngine) -// { -// HttpClientConfig.expectSuccess = true -// HttpClientConfig.(ContentNegotiation) { -// json( -// Json { -// JsonBuilder.ignoreUnknownKeys = true -// JsonBuilder.explicitNulls = false -// JsonBuilder.isLenient = true -// JsonBuilder.encodeDefaults = true -// } -// ) -// } -// } + return HttpClient(mockEngine) { + expectSuccess = true + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + } + ) + } + } } fun mockCustomApiError() = mockApiError(JsonUtil.encodeToString(sampleCustomErrorModel)) diff --git a/core/data/src/commonTest/kotlin/leegroup/module/data/EncryptionManagerUnitTest.kt b/core/data/src/commonTest/kotlin/leegroup/module/data/EncryptionManagerUnitTest.kt new file mode 100644 index 0000000..bd3bdd7 --- /dev/null +++ b/core/data/src/commonTest/kotlin/leegroup/module/data/EncryptionManagerUnitTest.kt @@ -0,0 +1,88 @@ +package leegroup.module.data + +import kotlinx.coroutines.test.runTest +import leegroup.module.data.encrypt.EncryptionManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class EncryptionManagerTest { + + @Test + fun generateKey_isDeterministic_forSameSeed() = runTest { + val k1 = EncryptionManager.generateKey("seed123") + val k2 = EncryptionManager.generateKey("seed123") + assertEquals(k1, k2) + assertTrue(k1.isNotBlank()) + } + + @Test + fun generateKey_differs_forDifferentSeeds() = runTest { + val k1 = EncryptionManager.generateKey("seedA") + val k2 = EncryptionManager.generateKey("seedB") + assertNotEquals(k1, k2) + } + + @Test + fun generateRandomKey_returnsNonEmptyBase64() = runTest { + val k = EncryptionManager.generateRandomKey() + assertTrue(k.isNotBlank()) + // 32 bytes raw -> Base64 length typically 44 with padding, but donโ€™t hard-fail on variant + assertTrue(k.length >= 40) + } + + @Test + fun encrypt_decrypt_roundTrip_withExplicitKey() = runTest { + val key = EncryptionManager.generateKey("my-seed") + val plain = "Hello KMP AES-GCM!" + + val encrypted = EncryptionManager.encrypt(plain, key) + val decrypted = EncryptionManager.decrypt(encrypted, key) + + assertEquals(plain, decrypted) + } + + @Test + fun encrypt_decrypt_roundTrip_withDefaultKey() = runTest { + val plain = "Default key roundtrip" + val encrypted = EncryptionManager.encrypt(plain) // uses defaultKey + val decrypted = EncryptionManager.decrypt(encrypted) + + assertEquals(plain, decrypted) + } + + @Test + fun encrypt_samePlaintext_twice_producesDifferentCiphertext() = runTest { + val key = EncryptionManager.generateKey("seed123") + val plain = "same text" + + val c1 = EncryptionManager.encrypt(plain, key) + val c2 = EncryptionManager.encrypt(plain, key) + + // AES-GCM should use a random nonce => ciphertext should differ + assertNotEquals(c1, c2) + } + + @Test + fun decrypt_withWrongKey_throws() = runTest { + val rightKey = EncryptionManager.generateKey("right") + val wrongKey = EncryptionManager.generateKey("wrong") + + val encrypted = EncryptionManager.encrypt("secret", rightKey) + + assertFailsWith { + EncryptionManager.decrypt(encrypted, wrongKey) + } + } + + @Test + fun decrypt_invalidCiphertext_throws() = runTest { + val key = EncryptionManager.generateKey("seed123") + + assertFailsWith { + EncryptionManager.decrypt("not-base64!!!", key) + } + } +} \ No newline at end of file diff --git a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ErrorMappingTest.kt b/core/data/src/commonTest/kotlin/leegroup/module/data/ErrorMappingTest.kt similarity index 96% rename from core/data/src/androidHostTest/kotlin/leegroup/module/data/ErrorMappingTest.kt rename to core/data/src/commonTest/kotlin/leegroup/module/data/ErrorMappingTest.kt index 73856a8..a8ca549 100644 --- a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ErrorMappingTest.kt +++ b/core/data/src/commonTest/kotlin/leegroup/module/data/ErrorMappingTest.kt @@ -8,8 +8,8 @@ import leegroup.module.data.network.ErrorMapper.mapApiCustomError import leegroup.module.data.network.ErrorMapper.mapApiError import leegroup.module.data.network.model.error.ErrorModel import leegroup.module.data.network.model.error.SampleCustomErrorModel -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class ErrorMappingTest { diff --git a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ResponseMappingTest.kt b/core/data/src/commonTest/kotlin/leegroup/module/data/ResponseMappingTest.kt similarity index 57% rename from core/data/src/androidHostTest/kotlin/leegroup/module/data/ResponseMappingTest.kt rename to core/data/src/commonTest/kotlin/leegroup/module/data/ResponseMappingTest.kt index 2876a0d..6232e74 100644 --- a/core/data/src/androidHostTest/kotlin/leegroup/module/data/ResponseMappingTest.kt +++ b/core/data/src/commonTest/kotlin/leegroup/module/data/ResponseMappingTest.kt @@ -11,19 +11,46 @@ import leegroup.module.data.network.ResponseMapper.asResult import leegroup.module.data.network.ResponseMapper.flowTransform import leegroup.module.data.network.model.error.ErrorModel import leegroup.module.data.network.model.error.SampleCustomErrorModel -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ResponseMappingTest { + @Test + fun `safeApiCall emits success data`() = runTest { + val response = "Hello World" + val flow = flowTransform { response } + + flow.test { + val item = awaitItem() + assertEquals("Hello World", item) + awaitComplete() + } + } + + @Test + fun `safeApiCall emits error when exception thrown`() = runTest { + val error = RuntimeException("Boom") + val flow = flowTransform { throw error } + .asResult() + + flow.test { + val item = expectMostRecentItem() + assertTrue(item.isFailure) + assertEquals(error, item.exceptionOrNull()) + awaitComplete() + } + } + @Test fun `asResult emits Success on normal flow`() = runTest { val flow = flowOf("Data").asResult() flow.test { val item = awaitItem() - assert(item.isSuccess) + assertTrue(item.isSuccess) assertEquals("Data", item.getOrThrow()) awaitComplete() } @@ -36,7 +63,7 @@ class ResponseMappingTest { flow.test { val item = expectMostRecentItem() - assert(item.isFailure) + assertTrue(item.isFailure) assertEquals(error, item.exceptionOrNull()) awaitComplete() } @@ -56,7 +83,7 @@ class ResponseMappingTest { client.get("https://fake.api/test") }.asResult().test { val item = awaitItem() - assert(item.isFailure) + assertTrue(item.isFailure) assertEquals(expectedError, item.exceptionOrNull()) awaitComplete() } @@ -77,9 +104,45 @@ class ResponseMappingTest { client.get("https://fake.api/test") }.asCustomResult().test { val item = awaitItem() - assert(item.isFailure) + assertTrue(item.isFailure) assertEquals(expectedError, item.exceptionOrNull()) awaitComplete() } } + + @Test + fun `flowTransform emits success value`() = runTest { + val flow = flowTransform { "OK" } + + flow.test { + val item = awaitItem() + assertEquals("OK", item) + awaitComplete() + } + } + + @Test + fun `safeApiCall emits Unit when data is null`() = runTest { + val response = Unit + + val flow = flowTransform { response } + + flow.test { + val item = awaitItem() + assertEquals(Unit, item) + awaitComplete() + } + } + + @Test + fun `asCustomResult emits Success on normal flow`() = runTest { + val flow = flowOf("Data").asCustomResult() + + flow.test { + val item = awaitItem() + assertTrue(item.isSuccess) + assertEquals("Data", item.getOrThrow()) + awaitComplete() + } + } } \ No newline at end of file diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index e557260..574de53 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.nowinandroid.kmp.library.compose) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.ksp) + alias(libs.plugins.kotlinx.kover) } kotlin { @@ -35,6 +36,7 @@ kotlin { commonTest { dependencies { implementation(libs.bundles.kmp.test) + implementation(projects.core.test) } } @@ -59,6 +61,13 @@ kotlin { } +dependencies { + + + testImplementation(libs.bundles.test) + testImplementation(projects.core.test) +} + compose.resources { // Can public resources to use in parent module but is not available in preview UI so far. // Currently apply actual/expect data type to get the resource. diff --git a/core/designsystem/src/androidDeviceTest/kotlin/leegroup/module/designsystem/ExampleInstrumentedTest.kt b/core/designsystem/src/androidDeviceTest/kotlin/leegroup/module/designsystem/ExampleInstrumentedTest.kt deleted file mode 100644 index 074db11..0000000 --- a/core/designsystem/src/androidDeviceTest/kotlin/leegroup/module/designsystem/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package leegroup.module.designsystem - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("leegroup.module.designsystem.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt b/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt deleted file mode 100644 index ac6b3dc..0000000 --- a/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package leegroup.module.designsystem - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.test.runTest -import leegroup.module.designsystem.ui.models.ErrorState -import leegroup.module.designsystem.ui.models.LoadingState -import leegroup.module.designsystem.ui.viewmodel.BaseViewModel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -@ExperimentalCoroutinesApi -class MockBaseViewModelTest { - - private lateinit var mockBaseViewModel: MockBaseViewModel - - @Before - fun setUp() { - mockBaseViewModel = MockBaseViewModel() - } - - @Test - fun `test handleAction ShowLoading triggers showLoading`() = runTest { - // Call the ShowLoading action - mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) - - // Verify that loading state is set to Loading - assertTrue(mockBaseViewModel.assertIsLoading()) - } - - @Test - fun `test inject loading`() = runTest { - // Call the ShowLoading action - mockBaseViewModel.testInjectLoading() - .onCompletion { - assertEquals(LoadingState.None, mockBaseViewModel.loading.value) - } - .collect { - assertTrue(mockBaseViewModel.loading.value is LoadingState.Loading) - assertEquals(1, it) - } - - } - - @Test - fun `test handleAction HideLoading triggers hideLoading`() = runTest { - // First, set loading state to show - mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) - assertTrue(mockBaseViewModel.loading.value is LoadingState.Loading) - - // Now, hide loading - mockBaseViewModel.handleAction(MockBaseViewModel.Action.HideLoading) - - // Verify that the loading state is reset to None - assertEquals(LoadingState.None, mockBaseViewModel.loading.value) - } - -// @Test -// fun `test handleAction HandleError triggers correct error state`() = runTest { -// val exception = MockUtil.noConnectivityException -// -// // Trigger HandleError action -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.HandleError(exception)) -// -// // Assert that error state is set to Network error -// assertTrue(mockBaseViewModel.error.value is ErrorState.Network) -// } -// -// @Test -// fun `test handleAction HideError triggers hideError`() = runTest { -// // Trigger an error to set an error state -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.HandleError(MockUtil.serverException)) -// -// // Assert that an error state is present -// assertTrue(mockBaseViewModel.error.value is ErrorState.Server) -// -// // Now, hide the error -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.HideError) -// -// // Assert that error state is reset to None -// assertEquals(ErrorState.None, mockBaseViewModel.error.value) -// } - -// @Test -// fun `test handle api error`() = runTest { -// // Trigger an error state -// mockBaseViewModel.handleAction( -// MockBaseViewModel.Action.HandleError(MockUtil.apiError) -// ) -// -// // Assert that an error state is set -// assertTrue(mockBaseViewModel.error.value is ErrorState.Api) -// } - -// @Test -// fun `test handleAction OnErrorDismissClick triggers hideError`() = runTest { -// // Trigger an error state -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.HandleError(MockUtil.apiError)) -// -// // Assert that an error state is set -// assertTrue(mockBaseViewModel.error.value is ErrorState.Api) -// -// // Now, simulate error dismissal -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.OnErrorDismissClick) -// -// // Assert that error state is reset to None -// assertEquals(ErrorState.None, mockBaseViewModel.error.value) -// } - -// @Test -// fun `test handleAction OnErrorConfirmation triggers hideError`() = runTest { -// // Trigger an error state -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.HandleError(MockUtil.serverException)) -// -// // Assert that an error state is set -// assertTrue(mockBaseViewModel.error.value is ErrorState.Server) -// -// // Now, simulate error confirmation -// mockBaseViewModel.handleAction(MockBaseViewModel.Action.OnErrorConfirmation) -// -// // Assert that error state is reset to None -// assertEquals(ErrorState.None, mockBaseViewModel.error.value) -// } -} - -private class MockBaseViewModel : BaseViewModel() { - - suspend fun handleAction(action: Action) { - when (action) { - is Action.ShowLoading -> showLoading() - is Action.HideLoading -> hideLoading() - is Action.HandleError -> handleError(action.throwable) - is Action.HideError -> hideError() - is Action.OnErrorDismissClick -> onErrorDismissClick(ErrorState.None) - is Action.OnErrorConfirmation -> onErrorConfirmation(ErrorState.None) - } - } - - fun testInjectLoading() = flow { - delay(100) - emit(1) - }.injectLoading() - - fun assertIsLoading() = isLoading() - - sealed interface Action { - data object ShowLoading : Action - data object HideLoading : Action - data class HandleError(val throwable: Throwable) : Action - data object HideError : Action - data object OnErrorDismissClick : Action - data object OnErrorConfirmation : Action - } -} diff --git a/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/MockUtil.kt b/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/MockUtil.kt deleted file mode 100644 index 68ba648..0000000 --- a/core/designsystem/src/androidHostTest/kotlin/leegroup/module/designsystem/MockUtil.kt +++ /dev/null @@ -1,6 +0,0 @@ -package leegroup.module.designsystem - -object MockUtil { - val serverException: Throwable = java.net.ConnectException() - val noConnectivityException: Throwable = java.net.UnknownHostException() -} diff --git a/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/screen/BaseActivity.kt b/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/screen/BaseActivity.kt new file mode 100644 index 0000000..8f6f0a4 --- /dev/null +++ b/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/screen/BaseActivity.kt @@ -0,0 +1,24 @@ +package leegroup.module.designsystem.ui.screen + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowInsetsControllerCompat + +open class BaseActivity : ComponentActivity() { + + private val insetsController: WindowInsetsControllerCompat? by lazy { + window?.let { window -> WindowInsetsControllerCompat(window, window.decorView) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + setLightStatusBar() + super.onCreate(savedInstanceState) + } + + private fun setLightStatusBar() { + insetsController?.isAppearanceLightStatusBars = true + } + +} \ No newline at end of file diff --git a/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/viewmodel/SavedStateViewModel.kt b/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/viewmodel/SavedStateViewModel.kt new file mode 100644 index 0000000..7fe548d --- /dev/null +++ b/core/designsystem/src/androidMain/kotlin/leegroup/module/designsystem/ui/viewmodel/SavedStateViewModel.kt @@ -0,0 +1,31 @@ +package leegroup.module.designsystem.ui.viewmodel + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.MutableStateFlow + +abstract class SavedStateViewModel( + private val savedStateHandle: SavedStateHandle, + defaultState: T +) : StateViewModel(defaultState) { + + private val uiStateKey = "${KEY_UI_STATE}_${defaultState::class.simpleName.orEmpty()}" + + // Retrieve the persisted UI state from SavedStateHandle + override val _uiState: MutableStateFlow = + MutableStateFlow(savedStateHandle.get(uiStateKey) ?: defaultState) + + protected open fun updateAndSave(function: (T) -> T) { + val currentValue = getUiState() + val newValue = function(currentValue) + + if (currentValue != newValue) { + _uiState.value = newValue + savedStateHandle[uiStateKey] = newValue + } + } + + companion object Companion { + private const val KEY_UI_STATE = "uiState" + } +} diff --git a/core/designsystem/src/commonMain/composeResources/drawable/ic_check_green_18dp.xml b/core/designsystem/src/commonMain/composeResources/drawable/ic_check_green_18dp.xml deleted file mode 100644 index 9e67de9..0000000 --- a/core/designsystem/src/commonMain/composeResources/drawable/ic_check_green_18dp.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/core/designsystem/src/commonMain/composeResources/drawable/ic_error_red_18dp.xml b/core/designsystem/src/commonMain/composeResources/drawable/ic_error_red_18dp.xml deleted file mode 100644 index ca2ea49..0000000 --- a/core/designsystem/src/commonMain/composeResources/drawable/ic_error_red_18dp.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/core/designsystem/src/commonMain/composeResources/drawable/ic_warning_yellow_18dp.xml b/core/designsystem/src/commonMain/composeResources/drawable/ic_warning_yellow_18dp.xml deleted file mode 100644 index dd17cff..0000000 --- a/core/designsystem/src/commonMain/composeResources/drawable/ic_warning_yellow_18dp.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/core/designsystem/src/commonMain/composeResources/drawable/im_avatar_placeholder.xml b/core/designsystem/src/commonMain/composeResources/drawable/im_avatar_placeholder.xml deleted file mode 100644 index ef4d64c..0000000 --- a/core/designsystem/src/commonMain/composeResources/drawable/im_avatar_placeholder.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/CheckGreen18Dp.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/CheckGreen18Dp.kt new file mode 100644 index 0000000..0283f75 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/CheckGreen18Dp.kt @@ -0,0 +1,70 @@ +package leegroup.module.designsystem.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val DesignSystemIcons.CheckGreen18Dp: ImageVector + get() { + if (_CheckGreen18Dp != null) { + return _CheckGreen18Dp!! + } + _CheckGreen18Dp = ImageVector.Builder( + name = "CheckGreen18Dp", + defaultWidth = 18.dp, + defaultHeight = 18.dp, + viewportWidth = 18f, + viewportHeight = 18f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(18f) + verticalLineToRelative(18f) + horizontalLineToRelative(-18f) + close() + } + ) { + path(fill = SolidColor(Color(0xFF0EB033))) { + moveTo(12.023f, 7.523f) + curveTo(12.242f, 7.303f, 12.242f, 6.947f, 12.023f, 6.727f) + curveTo(11.803f, 6.508f, 11.447f, 6.508f, 11.227f, 6.727f) + lineTo(7.875f, 10.08f) + lineTo(6.773f, 8.977f) + curveTo(6.553f, 8.758f, 6.197f, 8.758f, 5.977f, 8.977f) + curveTo(5.758f, 9.197f, 5.758f, 9.553f, 5.977f, 9.773f) + lineTo(7.477f, 11.273f) + curveTo(7.697f, 11.492f, 8.053f, 11.492f, 8.273f, 11.273f) + lineTo(12.023f, 7.523f) + close() + } + path( + fill = SolidColor(Color(0xFF0EB033)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(9f, 0.938f) + curveTo(4.547f, 0.938f, 0.938f, 4.547f, 0.938f, 9f) + curveTo(0.938f, 13.453f, 4.547f, 17.063f, 9f, 17.063f) + curveTo(13.453f, 17.063f, 17.063f, 13.453f, 17.063f, 9f) + curveTo(17.063f, 4.547f, 13.453f, 0.938f, 9f, 0.938f) + close() + moveTo(2.063f, 9f) + curveTo(2.063f, 5.169f, 5.169f, 2.063f, 9f, 2.063f) + curveTo(12.832f, 2.063f, 15.938f, 5.169f, 15.938f, 9f) + curveTo(15.938f, 12.832f, 12.832f, 15.938f, 9f, 15.938f) + curveTo(5.169f, 15.938f, 2.063f, 12.832f, 2.063f, 9f) + close() + } + } + }.build() + + return _CheckGreen18Dp!! + } + +@Suppress("ObjectPropertyName") +private var _CheckGreen18Dp: ImageVector? = null diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/DesignSystemIcons.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/DesignSystemIcons.kt new file mode 100644 index 0000000..36843d2 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/DesignSystemIcons.kt @@ -0,0 +1,3 @@ +package leegroup.module.designsystem.icon + +object DesignSystemIcons diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ErrorRed18Dp.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ErrorRed18Dp.kt new file mode 100644 index 0000000..2ccece1 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ErrorRed18Dp.kt @@ -0,0 +1,77 @@ +package leegroup.module.designsystem.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val DesignSystemIcons.ErrorRed18Dp: ImageVector + get() { + if (_ErrorRed18Dp != null) { + return _ErrorRed18Dp!! + } + _ErrorRed18Dp = ImageVector.Builder( + name = "ErrorRed18Dp", + defaultWidth = 18.dp, + defaultHeight = 18.dp, + viewportWidth = 18f, + viewportHeight = 18f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(18f) + verticalLineToRelative(18f) + horizontalLineToRelative(-18f) + close() + } + ) { + path(fill = SolidColor(Color(0xFFFF3E1C))) { + moveTo(7.523f, 6.727f) + curveTo(7.303f, 6.508f, 6.947f, 6.508f, 6.727f, 6.727f) + curveTo(6.508f, 6.947f, 6.508f, 7.303f, 6.727f, 7.523f) + lineTo(8.204f, 9f) + lineTo(6.727f, 10.477f) + curveTo(6.508f, 10.697f, 6.508f, 11.053f, 6.727f, 11.273f) + curveTo(6.947f, 11.492f, 7.303f, 11.492f, 7.523f, 11.273f) + lineTo(9f, 9.795f) + lineTo(10.477f, 11.273f) + curveTo(10.697f, 11.492f, 11.053f, 11.492f, 11.273f, 11.273f) + curveTo(11.492f, 11.053f, 11.492f, 10.697f, 11.273f, 10.477f) + lineTo(9.795f, 9f) + lineTo(11.273f, 7.523f) + curveTo(11.492f, 7.303f, 11.492f, 6.947f, 11.273f, 6.727f) + curveTo(11.053f, 6.508f, 10.697f, 6.508f, 10.477f, 6.727f) + lineTo(9f, 8.205f) + lineTo(7.523f, 6.727f) + close() + } + path( + fill = SolidColor(Color(0xFFFF3E1C)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(9f, 0.938f) + curveTo(4.547f, 0.938f, 0.938f, 4.547f, 0.938f, 9f) + curveTo(0.938f, 13.453f, 4.547f, 17.063f, 9f, 17.063f) + curveTo(13.453f, 17.063f, 17.063f, 13.453f, 17.063f, 9f) + curveTo(17.063f, 4.547f, 13.453f, 0.938f, 9f, 0.938f) + close() + moveTo(2.063f, 9f) + curveTo(2.063f, 5.169f, 5.169f, 2.063f, 9f, 2.063f) + curveTo(12.832f, 2.063f, 15.938f, 5.169f, 15.938f, 9f) + curveTo(15.938f, 12.832f, 12.832f, 15.938f, 9f, 15.938f) + curveTo(5.169f, 15.938f, 2.063f, 12.832f, 2.063f, 9f) + close() + } + } + }.build() + + return _ErrorRed18Dp!! + } + +@Suppress("ObjectPropertyName") +private var _ErrorRed18Dp: ImageVector? = null diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ImAvatarPlaceholder.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ImAvatarPlaceholder.kt new file mode 100644 index 0000000..41202b8 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/ImAvatarPlaceholder.kt @@ -0,0 +1,208 @@ +package leegroup.module.designsystem.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val DesignSystemIcons.ImAvatarPlaceholder: ImageVector + get() { + if (_ImAvatarPlaceholder != null) { + return _ImAvatarPlaceholder!! + } + _ImAvatarPlaceholder = ImageVector.Builder( + name = "ImAvatarPlaceholder", + defaultWidth = 800.dp, + defaultHeight = 800.dp, + viewportWidth = 1025f, + viewportHeight = 1025f + ).apply { + path(fill = SolidColor(Color(0xFFF08E83))) { + moveTo(513.9f, 0.5f) + curveTo(231.1f, 0.5f, 1.9f, 229.8f, 1.9f, 512.5f) + curveToRelative(0f, 174.2f, 87f, 328f, 219.9f, 420.5f) + curveToRelative(25.9f, -90.3f, 91.6f, -158.4f, 180.8f, -189.3f) + arcToRelative(310.1f, 310.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 10f, -3.3f) + curveToRelative(1f, -0.3f, 1.9f, -0.6f, 2.9f, -0.9f) + arcToRelative(328f, 328f, 0f, isMoreThanHalf = false, isPositiveArc = true, 26.5f, -6.7f) + curveToRelative(1.2f, -0.3f, 2.4f, -0.5f, 3.6f, -0.7f) + arcToRelative(332.8f, 332.8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 24.3f, -3.9f) + curveToRelative(1.4f, -0.2f, 2.8f, -0.4f, 4.1f, -0.5f) + arcToRelative(357.9f, 357.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 14.1f, -1.4f) + horizontalLineToRelative(18.5f) + curveToRelative(1.8f, 0.1f, 3.7f, 0.1f, 5.6f, 0f) + horizontalLineToRelative(1.7f) + curveToRelative(-130.5f, 0f, -236.7f, -103.7f, -240.9f, -233.1f) + curveToRelative(-0.1f, -0.1f, -0.3f, -0.2f, -0.4f, -0.3f) + curveToRelative(-8.3f, -155.1f, 131.8f, -250.5f, 233.4f, -248.5f) + curveToRelative(2.6f, -0.1f, 5.3f, -0.2f, 7.9f, -0.2f) + curveToRelative(2.5f, 0f, 5f, 0.1f, 7.5f, 0.2f) + curveToRelative(29.4f, -0.5f, 61.9f, 7.1f, 93.2f, 21.9f) + arcToRelative(241.1f, 241.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 57.3f, 37.1f) + curveToRelative(50.6f, 43.1f, 87.4f, 108.1f, 83f, 189.6f) + lineToRelative(-0.2f, 0.1f) + curveToRelative(-4.1f, 129.5f, -110.3f, 233.3f, -240.9f, 233.3f) + horizontalLineToRelative(26f) + arcToRelative(356.1f, 356.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 14.1f, 1.4f) + curveToRelative(1.4f, 0.2f, 2.8f, 0.3f, 4.2f, 0.5f) + arcToRelative(309.8f, 309.8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 14.4f, 2.1f) + curveToRelative(3.3f, 0.6f, 6.6f, 1.2f, 9.8f, 1.8f) + curveToRelative(1.3f, 0.3f, 2.5f, 0.5f, 3.8f, 0.7f) + arcToRelative(327.8f, 327.8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 26.4f, 6.7f) + curveToRelative(1f, 0.3f, 2.1f, 0.6f, 3.1f, 1f) + curveToRelative(3.3f, 1f, 6.5f, 2.1f, 9.8f, 3.2f) + curveToRelative(89.3f, 31f, 154.9f, 99f, 180.9f, 189.3f) + curveToRelative(132.8f, -92.5f, 219.7f, -246.3f, 219.7f, -420.4f) + curveTo(1025.9f, 229.8f, 796.6f, 0.5f, 513.9f, 0.5f) + close() + } + path(fill = SolidColor(Color(0xFFFCE9EA))) { + moveTo(754.8f, 492.9f) + curveToRelative(-63.8f, 39.8f, -230.9f, -17.8f, -280.6f, -105.5f) + curveToRelative(-46.3f, 81.3f, -124.8f, 153.2f, -201.1f, 105.6f) + curveToRelative(4.2f, 129.4f, 110.4f, 233.1f, 240.9f, 233.1f) + curveToRelative(130.5f, 0f, 236.7f, -103.7f, 240.9f, -233.3f) + close() + moveTo(614.6f, 266.1f) + arcToRelative(260.7f, 260.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 57.3f, 37.1f) + arcToRelative(241.2f, 241.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -57.3f, -37.1f) + close() + moveTo(521.4f, 244.2f) + curveToRelative(-2.5f, -0.1f, -5f, -0.2f, -7.5f, -0.2f) + curveToRelative(-2.7f, 0f, -5.3f, 0.1f, -7.9f, 0.2f) + curveToRelative(2.5f, 0.1f, 5.1f, 0.1f, 7.6f, 0.3f) + curveToRelative(2.6f, -0.2f, 5.2f, -0.2f, 7.8f, -0.3f) + close() + } + path(fill = SolidColor(Color(0xFFCFE07D))) { + moveTo(442f, 732.7f) + curveToRelative(1.2f, -0.3f, 2.4f, -0.5f, 3.6f, -0.7f) + curveToRelative(-1.2f, 0.2f, -2.4f, 0.5f, -3.6f, 0.7f) + close() + moveTo(455.6f, 730.1f) + close() + moveTo(553.9f, 727.5f) + curveToRelative(1.4f, 0.2f, 2.8f, 0.3f, 4.2f, 0.5f) + curveToRelative(-1.4f, -0.2f, -2.8f, -0.4f, -4.2f, -0.5f) + close() + moveTo(567.9f, 729.4f) + close() + moveTo(412.6f, 740.3f) + curveToRelative(1f, -0.3f, 1.9f, -0.6f, 2.9f, -0.9f) + curveToRelative(-1f, 0.3f, -1.9f, 0.6f, -2.9f, 0.9f) + close() + moveTo(582.3f, 732f) + curveToRelative(1.3f, 0.3f, 2.5f, 0.5f, 3.8f, 0.7f) + curveToRelative(-1.2f, -0.3f, -2.5f, -0.5f, -3.8f, -0.7f) + close() + moveTo(612.4f, 739.4f) + curveToRelative(1f, 0.3f, 2.1f, 0.6f, 3.1f, 1f) + curveToRelative(-1f, -0.3f, -2f, -0.7f, -3.1f, -1f) + close() + moveTo(625.3f, 743.6f) + lineToRelative(2.1f, 0.8f) + arcToRelative(119.5f, 119.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, -10.5f, 22.2f) + curveToRelative(17.1f, 26.4f, 12.8f, 141.5f, -12.9f, 123.3f) + lineToRelative(-45.1f, -31.7f) + lineToRelative(-45f, -31.7f) + lineToRelative(3f, -2.1f) + curveToRelative(-1f, 0f, -2f, 0.1f, -3f, 0.1f) + curveToRelative(-1f, 0f, -2f, -0.1f, -3.1f, -0.1f) + lineToRelative(3f, 2.1f) + lineToRelative(-45f, 31.7f) + lineToRelative(-45.1f, 31.7f) + curveToRelative(-25.9f, 18.2f, -30.2f, -97.3f, -12.9f, -123.5f) + arcToRelative(119.6f, 119.6f, 0f, isMoreThanHalf = false, isPositiveArc = true, -10.4f, -22f) + curveToRelative(0.7f, -0.3f, 1.5f, -0.5f, 2.2f, -0.8f) + curveToRelative(-89.3f, 31f, -154.9f, 99f, -180.8f, 189.3f) + curveToRelative(82.9f, 57.7f, 183.5f, 91.5f, 292.1f, 91.5f) + curveToRelative(108.7f, 0f, 209.4f, -33.9f, 292.3f, -91.6f) + curveToRelative(-26f, -90.3f, -91.7f, -158.3f, -180.9f, -189.3f) + close() + moveTo(469.9f, 728f) + curveToRelative(1.4f, -0.2f, 2.8f, -0.4f, 4.1f, -0.5f) + curveToRelative(-1.4f, 0.2f, -2.8f, 0.3f, -4.1f, 0.5f) + close() + } + path(fill = SolidColor(Color(0xFFFEFEFE))) { + moveTo(410.8f, 766.4f) + curveToRelative(3.4f, -5.2f, 7.7f, -6.9f, 12.9f, -3.3f) + lineToRelative(45.1f, 31.7f) + lineToRelative(42f, 29.7f) + curveToRelative(1f, 0f, 2f, 0.1f, 3.1f, 0.1f) + curveToRelative(1f, 0f, 2f, -0.1f, 3f, -0.1f) + lineToRelative(42.1f, -29.7f) + lineToRelative(45.1f, -31.7f) + curveToRelative(5.2f, -3.7f, 9.5f, -1.9f, 12.9f, 3.4f) + arcToRelative(119.5f, 119.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, 10.5f, -22.2f) + lineToRelative(-2.1f, -0.8f) + arcToRelative(318.4f, 318.4f, 0f, isMoreThanHalf = false, isPositiveArc = false, -9.8f, -3.2f) + curveToRelative(-1f, -0.3f, -2.1f, -0.7f, -3.1f, -1f) + arcToRelative(327.8f, 327.8f, 0f, isMoreThanHalf = false, isPositiveArc = false, -26.4f, -6.7f) + curveToRelative(-1.2f, -0.3f, -2.5f, -0.5f, -3.8f, -0.7f) + arcToRelative(349.5f, 349.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.3f, -2.6f) + arcToRelative(353.7f, 353.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, -9.8f, -1.4f) + curveToRelative(-1.4f, -0.2f, -2.8f, -0.4f, -4.2f, -0.5f) + arcToRelative(356.1f, 356.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.1f, -1.4f) + horizontalLineToRelative(-27.6f) + curveToRelative(-1.9f, 0.1f, -3.8f, 0.1f, -5.6f, 0f) + horizontalLineToRelative(-18.5f) + arcToRelative(357.9f, 357.9f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.1f, 1.4f) + curveToRelative(-1.4f, 0.2f, -2.8f, 0.3f, -4.1f, 0.5f) + arcToRelative(343.5f, 343.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.4f, 2.1f) + curveToRelative(-3.3f, 0.6f, -6.7f, 1.2f, -9.9f, 1.8f) + curveToRelative(-1.2f, 0.2f, -2.4f, 0.5f, -3.6f, 0.7f) + arcToRelative(328f, 328f, 0f, isMoreThanHalf = false, isPositiveArc = false, -26.5f, 6.7f) + curveToRelative(-1f, 0.3f, -1.9f, 0.6f, -2.9f, 0.9f) + arcToRelative(313.5f, 313.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, -12.2f, 4.1f) + arcToRelative(119.3f, 119.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 10.4f, 22f) + close() + } + path(fill = SolidColor(Color(0xFF7EA701))) { + moveTo(604f, 763.1f) + lineToRelative(-45.1f, 31.7f) + lineToRelative(-42.1f, 29.7f) + lineToRelative(-3f, 2.1f) + lineToRelative(45f, 31.7f) + lineToRelative(45.1f, 31.7f) + curveToRelative(25.8f, 18.2f, 30.1f, -96.9f, 12.9f, -123.3f) + curveToRelative(-3.4f, -5.3f, -7.7f, -7f, -12.9f, -3.4f) + close() + moveTo(423.7f, 763.1f) + curveToRelative(-5.2f, -3.6f, -9.4f, -1.9f, -12.9f, 3.3f) + curveToRelative(-17.3f, 26.1f, -13f, 141.7f, 12.9f, 123.5f) + lineToRelative(45.1f, -31.7f) + lineToRelative(45f, -31.7f) + lineToRelative(-3f, -2.1f) + lineToRelative(-42f, -29.7f) + lineToRelative(-45.1f, -31.6f) + close() + } + path(fill = SolidColor(Color(0xFFF7B970))) { + moveTo(474.1f, 387.4f) + curveToRelative(49.7f, 87.7f, 216.9f, 145.3f, 280.6f, 105.5f) + lineToRelative(0.2f, -0.1f) + curveToRelative(4.4f, -81.5f, -32.4f, -146.5f, -83f, -189.6f) + arcToRelative(260.5f, 260.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, -57.3f, -37.1f) + curveToRelative(-31.3f, -14.8f, -63.8f, -22.4f, -93.2f, -21.9f) + curveToRelative(-2.6f, 0f, -5.3f, 0.1f, -7.8f, 0.3f) + curveToRelative(8.3f, 29.5f, -8.3f, 88.3f, -39.4f, 142.9f) + close() + } + path(fill = SolidColor(Color(0xFFFBCE77))) { + moveTo(474.1f, 387.4f) + curveToRelative(31.1f, -54.6f, 47.7f, -113.4f, 39.4f, -142.9f) + arcToRelative(155.2f, 155.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -7.6f, -0.3f) + curveToRelative(-101.6f, -1.9f, -241.7f, 93.4f, -233.4f, 248.5f) + curveToRelative(0.1f, 0.1f, 0.3f, 0.2f, 0.4f, 0.3f) + curveToRelative(76.3f, 47.6f, 154.8f, -24.4f, 201.1f, -105.6f) + close() + } + }.build() + + return _ImAvatarPlaceholder!! + } + +@Suppress("ObjectPropertyName") +private var _ImAvatarPlaceholder: ImageVector? = null diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/WarningYellow18Dp.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/WarningYellow18Dp.kt new file mode 100644 index 0000000..571d3ca --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/icon/WarningYellow18Dp.kt @@ -0,0 +1,85 @@ +package leegroup.module.designsystem.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val DesignSystemIcons.WarningYellow18Dp: ImageVector + get() { + if (_WarningYellow18Dp != null) { + return _WarningYellow18Dp!! + } + _WarningYellow18Dp = ImageVector.Builder( + name = "WarningYellow18Dp", + defaultWidth = 18.dp, + defaultHeight = 18.dp, + viewportWidth = 18f, + viewportHeight = 18f + ).apply { + path(fill = SolidColor(Color(0xFFFBBC05))) { + moveTo(9f, 5.438f) + curveTo(9.311f, 5.438f, 9.562f, 5.689f, 9.562f, 6f) + verticalLineTo(9.75f) + curveTo(9.562f, 10.061f, 9.311f, 10.313f, 9f, 10.313f) + curveTo(8.689f, 10.313f, 8.437f, 10.061f, 8.437f, 9.75f) + verticalLineTo(6f) + curveTo(8.437f, 5.689f, 8.689f, 5.438f, 9f, 5.438f) + close() + } + path(fill = SolidColor(Color(0xFFFBBC05))) { + moveTo(9f, 12.75f) + curveTo(9.414f, 12.75f, 9.75f, 12.414f, 9.75f, 12f) + curveTo(9.75f, 11.586f, 9.414f, 11.25f, 9f, 11.25f) + curveTo(8.586f, 11.25f, 8.25f, 11.586f, 8.25f, 12f) + curveTo(8.25f, 12.414f, 8.586f, 12.75f, 9f, 12.75f) + close() + } + path( + fill = SolidColor(Color(0xFFFBBC05)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(6.221f, 3.357f) + curveTo(7.025f, 2.336f, 7.876f, 1.688f, 9f, 1.688f) + curveTo(10.124f, 1.688f, 10.975f, 2.336f, 11.779f, 3.357f) + curveTo(12.57f, 4.362f, 13.408f, 5.847f, 14.48f, 7.748f) + lineTo(14.806f, 8.327f) + curveTo(15.693f, 9.898f, 16.392f, 11.139f, 16.76f, 12.135f) + curveTo(17.136f, 13.153f, 17.225f, 14.077f, 16.657f, 14.893f) + curveTo(16.105f, 15.684f, 15.185f, 16.007f, 14.024f, 16.16f) + curveTo(12.868f, 16.313f, 11.313f, 16.313f, 9.319f, 16.313f) + horizontalLineTo(8.681f) + curveTo(6.687f, 16.313f, 5.132f, 16.313f, 3.976f, 16.16f) + curveTo(2.815f, 16.007f, 1.895f, 15.684f, 1.343f, 14.893f) + curveTo(0.775f, 14.077f, 0.864f, 13.153f, 1.24f, 12.135f) + curveTo(1.607f, 11.139f, 2.307f, 9.898f, 3.194f, 8.327f) + lineTo(3.52f, 7.748f) + curveTo(4.592f, 5.847f, 5.43f, 4.362f, 6.221f, 3.357f) + close() + moveTo(7.105f, 4.053f) + curveTo(6.374f, 4.981f, 5.578f, 6.39f, 4.474f, 8.347f) + lineTo(4.201f, 8.831f) + curveTo(3.281f, 10.462f, 2.628f, 11.624f, 2.295f, 12.525f) + curveTo(1.967f, 13.414f, 2.013f, 13.886f, 2.266f, 14.249f) + curveTo(2.537f, 14.637f, 3.044f, 14.903f, 4.122f, 15.045f) + curveTo(5.196f, 15.186f, 6.677f, 15.188f, 8.727f, 15.188f) + horizontalLineTo(9.273f) + curveTo(11.323f, 15.188f, 12.804f, 15.186f, 13.877f, 15.045f) + curveTo(14.956f, 14.903f, 15.464f, 14.637f, 15.734f, 14.249f) + curveTo(15.987f, 13.886f, 16.033f, 13.414f, 15.705f, 12.525f) + curveTo(15.372f, 11.624f, 14.719f, 10.462f, 13.799f, 8.831f) + lineTo(13.526f, 8.347f) + curveTo(12.422f, 6.39f, 11.626f, 4.981f, 10.895f, 4.053f) + curveTo(10.172f, 3.134f, 9.609f, 2.813f, 9f, 2.813f) + curveTo(8.391f, 2.813f, 7.828f, 3.134f, 7.105f, 4.053f) + close() + } + }.build() + + return _WarningYellow18Dp!! + } + +@Suppress("ObjectPropertyName") +private var _WarningYellow18Dp: ImageVector? = null diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/theme/AppSnackbar.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/theme/AppSnackbar.kt index 231bf2c..f7427d1 100644 --- a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/theme/AppSnackbar.kt +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/theme/AppSnackbar.kt @@ -15,12 +15,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import gituserkmm.core.designsystem.generated.resources.Res -import gituserkmm.core.designsystem.generated.resources.ic_check_green_18dp -import gituserkmm.core.designsystem.generated.resources.ic_error_red_18dp -import gituserkmm.core.designsystem.generated.resources.ic_warning_yellow_18dp +import leegroup.module.designsystem.icon.CheckGreen18Dp +import leegroup.module.designsystem.icon.DesignSystemIcons +import leegroup.module.designsystem.icon.ErrorRed18Dp +import leegroup.module.designsystem.icon.WarningYellow18Dp import leegroup.module.designsystem.ui.models.SnackbarType -import org.jetbrains.compose.resources.painterResource @Composable fun AppSnackBar( @@ -44,7 +43,7 @@ fun AppSnackBar( Image( modifier = Modifier .padding(end = 8.dp), - painter = painterResource(snackbarType.icon()), + imageVector = snackbarType.icon(), contentDescription = null, ) Text( @@ -75,7 +74,7 @@ fun SnackbarType.contentColor() = when (this) { @Composable fun SnackbarType.icon() = when (this) { - SnackbarType.Success -> Res.drawable.ic_check_green_18dp - SnackbarType.Warning -> Res.drawable.ic_warning_yellow_18dp - SnackbarType.Error -> Res.drawable.ic_error_red_18dp + SnackbarType.Success -> DesignSystemIcons.CheckGreen18Dp + SnackbarType.Warning -> DesignSystemIcons.WarningYellow18Dp + SnackbarType.Error -> DesignSystemIcons.ErrorRed18Dp } diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/ErrorView.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/ErrorView.kt similarity index 87% rename from core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/ErrorView.kt rename to core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/ErrorView.kt index be5dcab..cbe4b1f 100644 --- a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/ErrorView.kt +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/ErrorView.kt @@ -1,7 +1,9 @@ -package leegroup.module.designsystem.components +package leegroup.module.designsystem.ui.components import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import leegroup.module.designsystem.components.AlertDialogView +import leegroup.module.designsystem.theme.ComposeTheme import leegroup.module.designsystem.ui.models.ErrorModel import leegroup.module.designsystem.ui.models.ErrorState import org.jetbrains.compose.resources.painterResource @@ -44,7 +46,7 @@ fun ErrorView( @Preview @Composable private fun CommonErrorViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { ErrorView(ErrorState.Common) } } @@ -52,7 +54,7 @@ private fun CommonErrorViewPreview() { @Preview @Composable private fun NetworkErrorViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { ErrorView(ErrorState.Network) } } @@ -60,7 +62,7 @@ private fun NetworkErrorViewPreview() { @Preview @Composable private fun ServerErrorViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { ErrorView(ErrorState.Server) } } @@ -68,7 +70,7 @@ private fun ServerErrorViewPreview() { @Preview @Composable private fun ApiErrorViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { ErrorView(ErrorState.Api()) } } @@ -76,7 +78,7 @@ private fun ApiErrorViewPreview() { @Preview @Composable private fun CustomApiErrorViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { ErrorView( ErrorState.Api( error = ErrorModel( diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingProgress.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingProgress.kt similarity index 90% rename from core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingProgress.kt rename to core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingProgress.kt index 8d8f5a3..2c653be 100644 --- a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingProgress.kt +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingProgress.kt @@ -1,4 +1,4 @@ -package leegroup.module.designsystem.components +package leegroup.module.designsystem.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import gituserkmm.core.designsystem.generated.resources.loading +import leegroup.module.designsystem.theme.ComposeTheme import leegroup.module.designsystem.ui.models.LoadingState import org.jetbrains.compose.ui.tooling.preview.Preview import gituserkmm.core.designsystem.generated.resources.Res as R @@ -31,7 +32,7 @@ fun LoadingProgress(loading: LoadingState.Loading) { @Preview @Composable private fun LoadingProgressPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { LoadingProgress(loading = LoadingState.Loading(messageRes = R.string.loading)) } } \ No newline at end of file diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingView.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingView.kt similarity index 77% rename from core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingView.kt rename to core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingView.kt index 032f078..550c340 100644 --- a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/LoadingView.kt +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/components/LoadingView.kt @@ -1,6 +1,7 @@ -package leegroup.module.designsystem.components +package leegroup.module.designsystem.ui.components import androidx.compose.runtime.Composable +import leegroup.module.designsystem.theme.ComposeTheme import org.jetbrains.compose.ui.tooling.preview.Preview import leegroup.module.designsystem.ui.models.LoadingState @@ -15,7 +16,7 @@ fun LoadingView(loading: LoadingState) { @Preview @Composable private fun LoadingViewPreview() { - leegroup.module.designsystem.theme.ComposeTheme { + ComposeTheme { LoadingView(LoadingState.Loading()) } } \ No newline at end of file diff --git a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/BaseScreen.kt b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/screen/BaseScreen.kt similarity index 91% rename from core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/BaseScreen.kt rename to core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/screen/BaseScreen.kt index f67e82c..0311e7f 100644 --- a/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/components/BaseScreen.kt +++ b/core/designsystem/src/commonMain/kotlin/leegroup/module/designsystem/ui/screen/BaseScreen.kt @@ -1,4 +1,4 @@ -package leegroup.module.designsystem.components +package leegroup.module.designsystem.ui.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -7,6 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.Job import kotlinx.coroutines.launch import leegroup.module.core.extensions.compose.collectAsEffect +import leegroup.module.designsystem.ui.components.ErrorView +import leegroup.module.designsystem.ui.components.LoadingView import leegroup.module.designsystem.ui.models.LocalTopSnackbarHostStateManager import leegroup.module.designsystem.ui.models.Message import leegroup.module.designsystem.ui.viewmodel.BaseViewModel diff --git a/core/designsystem/src/commonTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt b/core/designsystem/src/commonTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt new file mode 100644 index 0000000..606ecbb --- /dev/null +++ b/core/designsystem/src/commonTest/kotlin/leegroup/module/designsystem/BaseViewModelTest.kt @@ -0,0 +1,271 @@ +package leegroup.module.designsystem + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.test.runTest +import leegroup.module.designsystem.ui.models.ErrorState +import leegroup.module.designsystem.ui.models.LoadingState +import leegroup.module.designsystem.ui.models.Message +import leegroup.module.designsystem.ui.viewmodel.BaseViewModel +import leegroup.module.designsystem.ui.viewmodel.sendErrorMessage +import leegroup.module.designsystem.ui.viewmodel.sendSuccessMessage +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class BaseViewModelTest { + + private lateinit var mockBaseViewModel: MockBaseViewModel + + @BeforeTest + fun setUp() { + + mockBaseViewModel = MockBaseViewModel() + } + + @Test + fun `test handleAction ShowLoading triggers showLoading`() = runTest { + // Call the ShowLoading action + mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) + + // Verify that loading state is set to Loading + assertTrue(mockBaseViewModel.assertIsLoading()) + } + + @Test + fun `test inject loading`() = runTest { + // Call the ShowLoading action + mockBaseViewModel.testInjectLoading() + .onCompletion { + assertEquals(LoadingState.None, mockBaseViewModel.loading.value) + } + .collect { + assertTrue(mockBaseViewModel.loading.value is LoadingState.Loading) + assertEquals(1, it) + } + + } + + @Test + fun `test handleAction HideLoading triggers hideLoading`() = runTest { + // First, set loading state to show + mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) + assertTrue(mockBaseViewModel.loading.value is LoadingState.Loading) + + // Now, hide loading + mockBaseViewModel.handleAction(MockBaseViewModel.Action.HideLoading) + + // Verify that the loading state is reset to None + assertEquals(LoadingState.None, mockBaseViewModel.loading.value) + } + + @Test + fun `test handleAction SendMessage emits message`() = runTest { + val msg = "Hello world" + val testMessage = Message.SnackBarMessage.buildSuccess(message = msg) + mockBaseViewModel.message.test { + mockBaseViewModel.handleAction(MockBaseViewModel.Action.SendMessage(testMessage)) + val item = awaitItem() + assertEquals(msg, (item as Message.SnackBarMessage).message) + } + } + + @Test + fun `test sendErrorState emits error`() = runTest { + val errorState = ErrorState.Common + + mockBaseViewModel.testSendErrorState(errorState) + + assertEquals(errorState, mockBaseViewModel.error.value) + assertIs(mockBaseViewModel.error.value) + } + + @Test + fun `test hideError resets error state to None`() = runTest { + val errorState = ErrorState.Network + mockBaseViewModel.testSendErrorState(errorState) + assertEquals(errorState, mockBaseViewModel.error.value) + + mockBaseViewModel.testHideError() + + assertEquals(ErrorState.None, mockBaseViewModel.error.value) + } + + @Test + fun `test handleError emits error state`() = runTest { + val exception = RuntimeException("Test exception") + + mockBaseViewModel.testHandleError(exception) + + // Verify that an error state was set (not None) + assertTrue(mockBaseViewModel.error.value !is ErrorState.None) + } + + @Test + fun `test onErrorConfirmation hides error`() = runTest { + val errorState = ErrorState.Server + mockBaseViewModel.testSendErrorState(errorState) + assertEquals(errorState, mockBaseViewModel.error.value) + + mockBaseViewModel.onErrorConfirmation(errorState) + + assertEquals(ErrorState.None, mockBaseViewModel.error.value) + } + + @Test + fun `test onErrorDismissClick hides error`() = runTest { + val errorState = ErrorState.Common + mockBaseViewModel.testSendErrorState(errorState) + assertEquals(errorState, mockBaseViewModel.error.value) + + mockBaseViewModel.onErrorDismissClick(errorState) + + assertEquals(ErrorState.None, mockBaseViewModel.error.value) + } + + @Test + fun `test navigator emits navigation events`() = runTest { + val navigationEvent = "TestDestination" + + mockBaseViewModel.navigator.test { + mockBaseViewModel.testNavigate(navigationEvent) + val item = awaitItem() + assertEquals(navigationEvent, item) + } + } + + @Test + fun `test sendSuccessMessage extension function`() = runTest { + val successMsg = "Success!" + + mockBaseViewModel.message.test { + mockBaseViewModel.sendSuccessMessage(alternativeMessage = successMsg) + val item = awaitItem() + assertIs(item) + assertEquals(successMsg, item.message) + } + } + + @Test + fun `test sendErrorMessage extension function`() = runTest { + val errorMsg = "Error occurred" + + mockBaseViewModel.message.test { + mockBaseViewModel.sendErrorMessage(alternativeMessage = errorMsg) + val item = awaitItem() + assertIs(item) + assertEquals(errorMsg, item.message) + } + } + + @Test + fun `test isLoading returns false when not loading`() = runTest { + assertEquals(LoadingState.None, mockBaseViewModel.loading.value) + assertFalse(mockBaseViewModel.assertIsLoading()) + } + + @Test + fun `test multiple showLoading calls maintain loading state`() = runTest { + mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) + assertTrue(mockBaseViewModel.assertIsLoading()) + + mockBaseViewModel.handleAction(MockBaseViewModel.Action.ShowLoading) + assertTrue(mockBaseViewModel.assertIsLoading()) + } + + @Test + fun `test error state transitions`() = runTest { + // Start with no error + assertEquals(ErrorState.None, mockBaseViewModel.error.value) + + // Set first error + val error1 = ErrorState.Network + mockBaseViewModel.testSendErrorState(error1) + assertEquals(error1, mockBaseViewModel.error.value) + + // Set second error (should replace first) + val error2 = ErrorState.Server + mockBaseViewModel.testSendErrorState(error2) + assertEquals(error2, mockBaseViewModel.error.value) + + // Hide error + mockBaseViewModel.testHideError() + assertEquals(ErrorState.None, mockBaseViewModel.error.value) + } + + @Test + fun `test message buffer handles emissions`() = runTest { + val msg = Message.SnackBarMessage.buildSuccess("Test message") + + mockBaseViewModel.message.test { + mockBaseViewModel.handleAction(MockBaseViewModel.Action.SendMessage(msg)) + + val item = awaitItem() + assertEquals("Test message", (item as Message.SnackBarMessage).message) + } + } + + @Test + fun `test multiple messages can be sent sequentially`() = runTest { + mockBaseViewModel.message.test { + val msg1 = Message.SnackBarMessage.buildSuccess("First") + mockBaseViewModel.handleAction(MockBaseViewModel.Action.SendMessage(msg1)) + + val item1 = awaitItem() + assertEquals("First", (item1 as Message.SnackBarMessage).message) + + val msg2 = Message.SnackBarMessage.buildSuccess("Second") + mockBaseViewModel.handleAction(MockBaseViewModel.Action.SendMessage(msg2)) + + val item2 = awaitItem() + assertEquals("Second", (item2 as Message.SnackBarMessage).message) + } + } +} + +private class MockBaseViewModel : BaseViewModel() { + + fun handleAction(action: Action) { + when (action) { + is Action.ShowLoading -> showLoading() + is Action.HideLoading -> hideLoading() + is Action.SendMessage -> sendMessage(action.message) + } + } + + fun testInjectLoading() = flow { + delay(100) + emit(1) + }.injectLoading() + + fun assertIsLoading() = isLoading() + + fun testSendErrorState(errorState: ErrorState) { + sendErrorState(errorState) + } + + fun testHideError() { + hideError() + } + + suspend fun testHandleError(e: Throwable) { + handleError(e) + } + + fun testNavigate(destination: Any) { + _navigator.tryEmit(destination) + } + + sealed interface Action { + data object ShowLoading : Action + data object HideLoading : Action + data class SendMessage(val message: Message) : Action + } +} diff --git a/core/designsystem/src/test/kotlin/leegroup/module/designsystem/SaveStateViewModelTest.kt b/core/designsystem/src/test/kotlin/leegroup/module/designsystem/SaveStateViewModelTest.kt new file mode 100644 index 0000000..3789503 --- /dev/null +++ b/core/designsystem/src/test/kotlin/leegroup/module/designsystem/SaveStateViewModelTest.kt @@ -0,0 +1,133 @@ +package leegroup.module.designsystem + +import android.os.Parcel +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import leegroup.module.designsystem.ui.viewmodel.SavedStateViewModel +import leegroup.module.test.BaseUnitTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class SavedStateBaseViewModelTest : BaseUnitTest() { + + private lateinit var savedStateHandle: SavedStateHandle + private lateinit var viewModel: TestSavedStateViewModel + private val initialState = SampleUiState("initial") + + private val key = "uiState_${SampleUiState::class.simpleName}" + + @Before + fun setup() { + savedStateHandle = mockk() // Mock SavedStateHandle + // Mock the initial state for the SavedStateHandle + every { savedStateHandle.get(key) } returns null + every { savedStateHandle[key] = any() } returns Unit + viewModel = TestSavedStateViewModel(savedStateHandle, initialState) + } + + @Test + fun `test initial state from SavedStateHandle`() = runTest { + // The initial state should be the provided default value since the saved state is null + assertEquals(initialState, viewModel.uiState.first()) + } + + @Test + fun `test state update without saving`() = runTest { + val newState = SampleUiState("updated") + + // Update the state using the `update` method + viewModel.updateState { currentState -> currentState.copy(value = newState.value) } + + // Assert that the state has been updated + assertEquals(newState, viewModel.uiState.first()) + + // Verify that the SavedStateHandle has NOT been updated (update doesn't persist state) + verify(exactly = 0) { savedStateHandle[key] = any() } + } + + @Test + fun `test state update with saving`() = runTest { + val newState = SampleUiState("updated") + + // Update the state using the `updateAndSave` method, which saves the new state + viewModel.updateStateAndSave { currentState -> currentState.copy(value = newState.value) } + + // Assert that the state has been updated + assertEquals(newState, viewModel.uiState.first()) + + // Verify that the state is saved to the SavedStateHandle + verify { savedStateHandle[key] = newState } + } + + @Test + fun `test state persistence when state changes`() = runTest { + val newState = SampleUiState("updated") + + // Initially set the state + viewModel.updateStateAndSave { currentState -> currentState.copy(value = "updated") } + + // Ensure that the state has been updated + assertEquals(newState, viewModel.uiState.first()) + + // Mock the SavedStateHandle to return the new state when the ViewModel is reinitialized + every { savedStateHandle.get(key) } returns newState + + // Reinitialize the ViewModel to simulate the process after a configuration change or process death + val newViewModel = TestSavedStateViewModel(savedStateHandle, initialState) + + // Verify that the persisted state is restored correctly + assertEquals(newState, newViewModel.uiState.first()) + } + + @Test + fun `test state not updated when value remains the same`() = runTest { + val initial = SampleUiState("unchanged") + val sameState = SampleUiState("unchanged") + + viewModel.updateStateAndSave { currentState -> currentState.copy(value = sameState.value) } + delay(10) + viewModel.updateStateAndSave { currentState -> currentState.copy(value = sameState.value) } + + // Verify the SavedStateHandle is not updated when the value doesn't change + verify(exactly = 1) { savedStateHandle[key] = any() } + + // Ensure that the state is still the same + assertEquals(initial, viewModel.uiState.first()) + } + +} + +// Sample Parcelable model +private data class SampleUiState(val value: String) : Parcelable { + override fun describeContents(): Int { + return 1 + } + + override fun writeToParcel(p0: Parcel, p1: Int) { + + } +} + +// A concrete implementation of the abstract ViewModel for testing +private class TestSavedStateViewModel( + savedStateHandle: SavedStateHandle, + initialUiState: SampleUiState +) : SavedStateViewModel(savedStateHandle, initialUiState) { + + fun updateState(function: (SampleUiState) -> SampleUiState) { + super.update(function) + } + + fun updateStateAndSave(function: (SampleUiState) -> SampleUiState) { + super.updateAndSave(function) + } +} diff --git a/core/test/.gitignore b/core/test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/test/build.gradle.kts b/core/test/build.gradle.kts new file mode 100644 index 0000000..4d3911d --- /dev/null +++ b/core/test/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.nowinandroid.kmp.library) +} + +kotlin { + sourceSets.commonMain.dependencies { + implementation(kotlin("test")) + implementation(libs.bundles.kmp.test) + implementation(projects.core.coreKtx) + } +} + +android { + namespace = "leegroup.module.test" +} + +dependencies { + implementation(libs.bundles.test) +} \ No newline at end of file diff --git a/core/test/consumer-rules.pro b/core/test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/test/proguard-rules.pro b/core/test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core/test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/test/src/commonMain/kotlin/leegroup/module/test/BaseKmpUnitTest.kt b/core/test/src/commonMain/kotlin/leegroup/module/test/BaseKmpUnitTest.kt new file mode 100644 index 0000000..644b31d --- /dev/null +++ b/core/test/src/commonMain/kotlin/leegroup/module/test/BaseKmpUnitTest.kt @@ -0,0 +1,28 @@ +package leegroup.module.test + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import leegroup.module.core.util.DispatchersProvider + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class BaseKmpUnitTest { + + protected val coroutinesRule = KmpCoroutineTestRule() + + protected val testDispatcher get() = coroutinesRule.testDispatcher + protected val testScope: TestScope = TestScope(testDispatcher) + protected val testDispatchersProvider = object : DispatchersProvider { + override val io: CoroutineDispatcher get() = testDispatcher + override val main: CoroutineDispatcher get() = testDispatcher + override val default: CoroutineDispatcher get() = testDispatcher + } + + open fun setUp() { + coroutinesRule.starting() + } + + open fun tearDown() { + coroutinesRule.finished() + } +} \ No newline at end of file diff --git a/core/test/src/commonMain/kotlin/leegroup/module/test/KmpCoroutineTestRule.kt b/core/test/src/commonMain/kotlin/leegroup/module/test/KmpCoroutineTestRule.kt new file mode 100644 index 0000000..99a17ea --- /dev/null +++ b/core/test/src/commonMain/kotlin/leegroup/module/test/KmpCoroutineTestRule.kt @@ -0,0 +1,23 @@ +package leegroup.module.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +class KmpCoroutineTestRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(TestCoroutineScheduler()), +) { + + internal fun starting() { + Dispatchers.setMain(testDispatcher) + } + + internal fun finished() { + Dispatchers.resetMain() + } +} diff --git a/core/test/src/main/AndroidManifest.xml b/core/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/core/test/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/test/src/main/java/leegroup/module/test/BaseUnitTest.kt b/core/test/src/main/java/leegroup/module/test/BaseUnitTest.kt new file mode 100644 index 0000000..e8446d0 --- /dev/null +++ b/core/test/src/main/java/leegroup/module/test/BaseUnitTest.kt @@ -0,0 +1,12 @@ +package leegroup.module.test + +import org.junit.Rule + +abstract class BaseUnitTest { + + @get:Rule + val coroutinesRule = CoroutineTestRule() + + val testDispatcher get() = coroutinesRule.testDispatcher + +} \ No newline at end of file diff --git a/core/test/src/main/java/leegroup/module/test/CoroutineTestRule.kt b/core/test/src/main/java/leegroup/module/test/CoroutineTestRule.kt new file mode 100644 index 0000000..34a8f62 --- /dev/null +++ b/core/test/src/main/java/leegroup/module/test/CoroutineTestRule.kt @@ -0,0 +1,24 @@ +package leegroup.module.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CoroutineTestRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + super.finished(description) + } +} \ No newline at end of file diff --git a/gituser/build.gradle.kts b/gituser/build.gradle.kts index 1645730..7d7e19f 100644 --- a/gituser/build.gradle.kts +++ b/gituser/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.nowinandroid.kmp.network) alias(libs.plugins.nowinandroid.kmp.room) alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.kotlinx.kover) } @@ -28,6 +29,7 @@ kotlin { commonTest { dependencies { implementation(libs.bundles.kmp.test) + implementation(projects.core.test) } } } @@ -35,6 +37,13 @@ kotlin { android { namespace = "leegroup.module.gituser" + + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } room { diff --git a/gituser/src/androidHostTest/kotlin/leegroup/module/gituser/ExampleUnitTest.kt b/gituser/src/androidHostTest/kotlin/leegroup/module/gituser/ExampleUnitTest.kt deleted file mode 100644 index f4059bb..0000000 --- a/gituser/src/androidHostTest/kotlin/leegroup/module/gituser/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package leegroup.module.gituser - -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/extensions/ResponseMapping.kt b/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/extensions/ResponseMapping.kt index cc1fa27..5cc8e95 100644 --- a/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/extensions/ResponseMapping.kt +++ b/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/extensions/ResponseMapping.kt @@ -8,21 +8,14 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow import kotlinx.io.IOException import kotlinx.serialization.SerializationException +import leegroup.module.core.util.JsonUtil import leegroup.module.gituser.data.remote.responses.ErrorResponse import leegroup.module.gituser.data.remote.responses.mapToError -import leegroup.module.gituser.data.util.JsonUtil import leegroup.module.gituser.domain.exceptions.ApiException import leegroup.module.gituser.domain.exceptions.NoConnectivityException import leegroup.module.gituser.domain.exceptions.ServerException import kotlin.experimental.ExperimentalTypeInference -@OptIn(ExperimentalTypeInference::class) -internal fun flowTransform(@BuilderInference block: suspend FlowCollector.() -> T) = flow { - runCatching { block() } - .onSuccess { result -> emit(result) } - .onFailure { exception -> throw exception.mapError() } -} - internal suspend fun transform(block: suspend () -> T): T { return runCatching { block() diff --git a/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/util/JsonUtil.kt b/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/util/JsonUtil.kt deleted file mode 100644 index 5eb3332..0000000 --- a/gituser/src/commonMain/kotlin/leegroup/module/gituser/data/util/JsonUtil.kt +++ /dev/null @@ -1,27 +0,0 @@ -package leegroup.module.gituser.data.util - -import kotlinx.serialization.SerializationException -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -internal object JsonUtil { - - val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - } - - inline fun decodeFromString(value: String): T? { - return try { - json.decodeFromString(value) - } catch (ex: SerializationException) { - null - } catch (ex: IllegalArgumentException) { - null - } - } - - inline fun encodeToString(value: T): String { - return json.encodeToString(value) - } -} diff --git a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/components/UserAvatar.kt b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/components/UserAvatar.kt index 62eb306..ad7baec 100644 --- a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/components/UserAvatar.kt +++ b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/components/UserAvatar.kt @@ -6,13 +6,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import gituserkmm.core.designsystem.generated.resources.Res -import gituserkmm.core.designsystem.generated.resources.im_avatar_placeholder +import leegroup.module.designsystem.icon.DesignSystemIcons +import leegroup.module.designsystem.icon.ImAvatarPlaceholder import leegroup.module.designsystem.theme.ComposeTheme -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -22,7 +22,8 @@ internal fun UserAvatar(modifier: Modifier = Modifier, avatarUrl: String?) { .fillMaxSize() .clip(CircleShape), model = avatarUrl, - error = painterResource(Res.drawable.im_avatar_placeholder), + + error = rememberVectorPainter(DesignSystemIcons.ImAvatarPlaceholder), contentDescription = null, contentScale = ContentScale.Crop, ) diff --git a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituser/components/GitUserListScreenContent.kt b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituser/components/GitUserListScreenContent.kt index cb44241..5dca40f 100644 --- a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituser/components/GitUserListScreenContent.kt +++ b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituser/components/GitUserListScreenContent.kt @@ -15,7 +15,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import leegroup.module.gituser.domain.models.GitUserModel import leegroup.module.gituser.ui.screens.gituser.GitUserListAction import leegroup.module.gituser.ui.screens.gituser.GitUserListViewModel -import leegroup.module.designsystem.components.BaseScreen +import leegroup.module.designsystem.ui.screen.BaseScreen import leegroup.module.designsystem.ui.models.LoadingState @Composable diff --git a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailScreen.kt b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailScreen.kt index 8798670..e2f2db6 100644 --- a/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailScreen.kt +++ b/gituser/src/commonMain/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import leegroup.module.core.extensions.compose.collectAsEffect -import leegroup.module.designsystem.components.BaseScreen +import leegroup.module.designsystem.ui.screen.BaseScreen import leegroup.module.designsystem.theme.ComposeTheme import leegroup.module.gituser.ui.models.GitUserDetailUiModel import leegroup.module.gituser.ui.screens.gituserdetail.components.GitUserDetailAppBar diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserDetailMapperTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserDetailMapperTest.kt new file mode 100644 index 0000000..d17c716 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserDetailMapperTest.kt @@ -0,0 +1,147 @@ +package leegroup.module.gituser.data.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GitUserDetailMapperTest { + + @Test + fun `test GitUserDetail mapToDomain maps all fields correctly`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = "testuser", + name = "Test User", + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = 150, + following = 75 + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals(12345L, result.id) + assertEquals("testuser", result.login) + assertEquals("Test User", result.name) + assertEquals("https://example.com/avatar.jpg", result.avatarUrl) + assertEquals("https://testblog.com", result.blog) + assertEquals("San Francisco", result.location) + assertEquals(150, result.followers) + assertEquals(75, result.following) + } + + @Test + fun `test GitUserDetail mapToDomain with null login defaults to empty string`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = null, + name = "Test User", + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = 150, + following = 75 + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals("", result.login) + } + + @Test + fun `test GitUserDetail mapToDomain with null name`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = "testuser", + name = null, + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = 150, + following = 75 + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals(null, result.name) + } + + @Test + fun `test GitUserDetail mapToDomain with null followers defaults to 0`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = "testuser", + name = "Test User", + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = null, + following = 75 + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals(0, result.followers) + } + + @Test + fun `test GitUserDetail mapToDomain with null following defaults to 0`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = "testuser", + name = "Test User", + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = 150, + following = null + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals(0, result.following) + } + + @Test + fun `test GitUserDetail mapToDomain with all nullable fields null`() { + // Given + val gitUserDetail = GitUserDetail( + id = 12345L, + login = null, + name = null, + avatarUrl = null, + blog = null, + location = null, + followers = null, + following = null + ) + + // When + val result = gitUserDetail.mapToDomain() + + // Then + assertEquals(12345L, result.id) + assertEquals("", result.login) + assertEquals(null, result.name) + assertEquals(null, result.avatarUrl) + assertEquals(null, result.blog) + assertEquals(null, result.location) + assertEquals(0, result.followers) + assertEquals(0, result.following) + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserMapperTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserMapperTest.kt new file mode 100644 index 0000000..7faa661 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/models/GitUserMapperTest.kt @@ -0,0 +1,111 @@ +package leegroup.module.gituser.data.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GitUserMapperTest { + + @Test + fun `test GitUser mapToDomain maps all fields correctly`() { + // Given + val gitUser = GitUser( + id = 12345L, + login = "testuser", + avatarUrl = "https://example.com/avatar.jpg", + htmlUrl = "https://github.com/testuser" + ) + + // When + val result = gitUser.mapToDomain() + + // Then + assertEquals(12345L, result.id) + assertEquals("testuser", result.login) + assertEquals("https://example.com/avatar.jpg", result.avatarUrl) + assertEquals("https://github.com/testuser", result.htmlUrl) + } + + @Test + fun `test GitUser mapToDomain with null avatarUrl`() { + // Given + val gitUser = GitUser( + id = 12345L, + login = "testuser", + avatarUrl = null, + htmlUrl = "https://github.com/testuser" + ) + + // When + val result = gitUser.mapToDomain() + + // Then + assertEquals(null, result.avatarUrl) + } + + @Test + fun `test GitUser mapToDomain with null htmlUrl`() { + // Given + val gitUser = GitUser( + id = 12345L, + login = "testuser", + avatarUrl = "https://example.com/avatar.jpg", + htmlUrl = null + ) + + // When + val result = gitUser.mapToDomain() + + // Then + assertEquals(null, result.htmlUrl) + } + + @Test + fun `test List of GitUser mapToDomain`() { + // Given + val gitUsers = listOf( + GitUser( + id = 1L, + login = "user1", + avatarUrl = "url1", + htmlUrl = "html1" + ), + GitUser( + id = 2L, + login = "user2", + avatarUrl = "url2", + htmlUrl = "html2" + ), + GitUser( + id = 3L, + login = "user3", + avatarUrl = null, + htmlUrl = null + ) + ) + + // When + val result = gitUsers.mapToDomain() + + // Then + assertEquals(3, result.size) + assertEquals(1L, result[0].id) + assertEquals("user1", result[0].login) + assertEquals(2L, result[1].id) + assertEquals("user2", result[1].login) + assertEquals(3L, result[2].id) + assertEquals(null, result[2].avatarUrl) + assertEquals(null, result[2].htmlUrl) + } + + @Test + fun `test empty List of GitUser mapToDomain`() { + // Given + val gitUsers = emptyList() + + // When + val result = gitUsers.mapToDomain() + + // Then + assertEquals(0, result.size) + } +} \ No newline at end of file diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserDetailRepositoryImplTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserDetailRepositoryImplTest.kt new file mode 100644 index 0000000..468bfd0 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserDetailRepositoryImplTest.kt @@ -0,0 +1,169 @@ +package leegroup.module.gituser.data.repositories + +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.data.local.room.GitUserDetailDao +import leegroup.module.gituser.data.models.GitUser +import leegroup.module.gituser.data.models.GitUserDetail +import leegroup.module.gituser.data.remote.GitUserApiService +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GitUserDetailRepositoryImplTest { + + private lateinit var repository: GitUserDetailRepositoryImpl + private lateinit var mockApiService: MockGitUserDetailApiService + private lateinit var mockUserDao: MockGitUserDetailDao + + @BeforeTest + fun setUp() { + mockApiService = MockGitUserDetailApiService() + mockUserDao = MockGitUserDetailDao() + repository = GitUserDetailRepositoryImpl(mockApiService, mockUserDao) + } + + @Test + fun `test getRemote fetches from API and saves to local`() = runTest { + // Given + val login = "testuser" + val apiUserDetail = GitUserDetail( + id = 1L, + login = login, + name = "Test User", + avatarUrl = "avatar", + blog = "blog", + location = "SF", + followers = 100, + following = 50 + ) + mockApiService.userDetail = apiUserDetail + + // When + val result = repository.getRemote(login) + + // Then + assertEquals(login, result.login) + assertEquals("Test User", result.name) + assertEquals(100, result.followers) + assertEquals(50, result.following) + assertEquals(login, mockApiService.lastLogin) + assertTrue(mockUserDao.upsertCalled) + assertEquals(login, mockUserDao.lastUpsertedUser?.login) + } + + @Test + fun `test getLocal retrieves from DAO`() = runTest { + // Given + val login = "localuser" + val localUserDetail = GitUserDetail( + id = 2L, + login = login, + name = "Local User", + avatarUrl = "avatar", + blog = "blog", + location = "NYC", + followers = 200, + following = 100 + ) + mockUserDao.userDetail = localUserDetail + + // When + val result = repository.getLocal(login) + + // Then + assertEquals(login, result?.login) + assertEquals("Local User", result?.name) + assertEquals(200, result?.followers) + assertEquals(100, result?.following) + assertEquals(login, mockUserDao.lastLogin) + } + + @Test + fun `test getLocal returns null when no data`() = runTest { + // Given + val login = "nonexistent" + mockUserDao.userDetail = null + + // When + val result = repository.getLocal(login) + + // Then + assertNull(result) + assertEquals(login, mockUserDao.lastLogin) + } + + @Test + fun `test getRemote maps data correctly`() = runTest { + // Given + val login = "mapper" + val apiUserDetail = GitUserDetail( + id = 3L, + login = login, + name = null, + avatarUrl = null, + blog = null, + location = null, + followers = null, + following = null + ) + mockApiService.userDetail = apiUserDetail + + // When + val result = repository.getRemote(login) + + // Then + assertEquals(3L, result.id) + assertEquals(login, result.login) + assertEquals(null, result.name) + assertEquals(0, result.followers) + assertEquals(0, result.following) + } +} + +private class MockGitUserDetailApiService : GitUserApiService { + var userDetail: GitUserDetail? = null + var lastLogin: String = "" + + override suspend fun getGitUser(since: Long, perPage: Int): List { + throw NotImplementedError() + } + + override suspend fun getGitUserDetail(login: String): GitUserDetail { + lastLogin = login + return userDetail ?: throw IllegalStateException("No user detail set") + } +} + +private class MockGitUserDetailDao : GitUserDetailDao { + var userDetail: GitUserDetail? = null + var lastLogin: String = "" + var upsertCalled: Boolean = false + var lastUpsertedUser: GitUserDetail? = null + + override suspend fun getUserDetail(login: String): GitUserDetail? { + lastLogin = login + return userDetail + } + + override suspend fun upsert(entity: GitUserDetail): Long { + upsertCalled = true + lastUpsertedUser = entity + return entity.id + } + + override suspend fun upsert(vararg entity: GitUserDetail) { + upsertCalled = true + lastUpsertedUser = entity.firstOrNull() + } + + override suspend fun upsert(entities: Collection) { + upsertCalled = true + lastUpsertedUser = entities.firstOrNull() + } + + override suspend fun delete(entity: GitUserDetail): Int { + return 1 + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserRepositoryImplTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserRepositoryImplTest.kt new file mode 100644 index 0000000..d4f3a6b --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/data/repositories/GitUserRepositoryImplTest.kt @@ -0,0 +1,150 @@ +package leegroup.module.gituser.data.repositories + +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.data.local.room.GitUserDao +import leegroup.module.gituser.data.models.GitUser +import leegroup.module.gituser.data.remote.GitUserApiService +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GitUserRepositoryImplTest { + + private lateinit var repository: GitUserRepositoryImpl + private lateinit var mockApiService: MockGitUserListApiService + private lateinit var mockUserDao: MockGitUserDao + + @BeforeTest + fun setUp() { + mockApiService = MockGitUserListApiService() + mockUserDao = MockGitUserDao() + repository = GitUserRepositoryImpl(mockApiService, mockUserDao) + } + + @Test + fun `test getRemote fetches from API and saves to local`() = runTest { + // Given + val since = 0L + val perPage = 20 + val apiUsers = listOf( + GitUser(1L, "user1", "avatar1", "html1"), + GitUser(2L, "user2", "avatar2", "html2") + ) + mockApiService.users = apiUsers + + // When + val result = repository.getRemote(since, perPage) + + // Then + assertEquals(2, result.size) + assertEquals("user1", result[0].login) + assertEquals("user2", result[1].login) + assertEquals(since, mockApiService.lastSince) + assertEquals(perPage, mockApiService.lastPerPage) + assertTrue(mockUserDao.upsertCalled) + assertEquals(2, mockUserDao.lastUpsertedUsers.size) + } + + @Test + fun `test getLocal retrieves from DAO`() = runTest { + // Given + val since = 10L + val perPage = 30 + val localUsers = listOf( + GitUser(11L, "localuser1", "avatar1", "html1"), + GitUser(12L, "localuser2", "avatar2", "html2"), + GitUser(13L, "localuser3", "avatar3", "html3") + ) + mockUserDao.users = localUsers + + // When + val result = repository.getLocal(since, perPage) + + // Then + assertEquals(3, result.size) + assertEquals("localuser1", result[0].login) + assertEquals("localuser2", result[1].login) + assertEquals("localuser3", result[2].login) + assertEquals(since, mockUserDao.lastSince) + assertEquals(perPage, mockUserDao.lastPerPage) + } + + @Test + fun `test getLocal returns empty list when no data`() = runTest { + // Given + mockUserDao.users = emptyList() + + // When + val result = repository.getLocal(0L, 20) + + // Then + assertTrue(result.isEmpty()) + } + + @Test + fun `test getRemote with pagination parameters`() = runTest { + // Given + val since = 100L + val perPage = 50 + val apiUsers = listOf(GitUser(101L, "user101", "avatar", "html")) + mockApiService.users = apiUsers + + // When + repository.getRemote(since, perPage) + + // Then + assertEquals(since, mockApiService.lastSince) + assertEquals(perPage, mockApiService.lastPerPage) + } +} + +private class MockGitUserListApiService : GitUserApiService { + var users: List = emptyList() + var lastSince: Long = -1 + var lastPerPage: Int = -1 + + override suspend fun getGitUser(since: Long, perPage: Int): List { + lastSince = since + lastPerPage = perPage + return users + } + + override suspend fun getGitUserDetail(login: String): leegroup.module.gituser.data.models.GitUserDetail { + throw NotImplementedError() + } +} + +private class MockGitUserDao : GitUserDao { + var users: List = emptyList() + var lastSince: Long = -1 + var lastPerPage: Int = -1 + var upsertCalled: Boolean = false + var lastUpsertedUsers: List = emptyList() + + override suspend fun getUsers(since: Long, perPage: Int): List { + lastSince = since + lastPerPage = perPage + return users + } + + override suspend fun upsert(entity: GitUser): Long { + upsertCalled = true + lastUpsertedUsers = listOf(entity) + return entity.id + } + + override suspend fun upsert(vararg entity: GitUser) { + upsertCalled = true + lastUpsertedUsers = entity.toList() + } + + override suspend fun upsert(entities: Collection) { + upsertCalled = true + lastUpsertedUsers = entities.toList() + } + + override suspend fun delete(entity: GitUser): Int { + return 1 + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailLocalUseCaseTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailLocalUseCaseTest.kt new file mode 100644 index 0000000..8d7a30c --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailLocalUseCaseTest.kt @@ -0,0 +1,91 @@ +package leegroup.module.gituser.domain.usecases + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.domain.models.GitUserDetailModel +import leegroup.module.gituser.domain.repositories.GitUserDetailRepository +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserDetailLocalUseCase +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetGitUserDetailLocalUseCaseTest { + + private lateinit var useCase: GetGitUserDetailLocalUseCase + private lateinit var mockRepository: MockGitUserDetailRepository + + @BeforeTest + fun setUp() { + mockRepository = MockGitUserDetailRepository() + useCase = GetGitUserDetailLocalUseCase(mockRepository) + } + + @Test + fun `test invoke emits data when local data exists`() = runTest { + // Given + val login = "testuser" + val userDetail = GitUserDetailModel( + id = 1L, + login = login, + name = "Test User", + avatarUrl = "avatar", + blog = "blog", + location = "location", + followers = 100, + following = 50 + ) + mockRepository.localUser = userDetail + + // When & Then + useCase(login).test { + val result = awaitItem() + assertEquals(login, result.login) + assertEquals("Test User", result.name) + assertEquals(100, result.followers) + awaitComplete() + } + } + + @Test + fun `test invoke does not emit when local data is null`() = runTest { + // Given + val login = "testuser" + mockRepository.localUser = null + + // When & Then + useCase(login).test { + awaitComplete() + } + } + + @Test + fun `test invoke calls repository with correct login`() = runTest { + // Given + val login = "specificuser" + mockRepository.localUser = null + + // When + useCase(login).test { + awaitComplete() + } + + // Then + assertEquals(login, mockRepository.lastLogin) + } +} + +private class MockGitUserDetailRepository : GitUserDetailRepository { + var localUser: GitUserDetailModel? = null + var remoteUser: GitUserDetailModel? = null + var lastLogin: String = "" + + override suspend fun getLocal(login: String): GitUserDetailModel? { + lastLogin = login + return localUser + } + + override suspend fun getRemote(login: String): GitUserDetailModel { + lastLogin = login + return remoteUser ?: throw IllegalStateException("No remote user set") + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailRemoteUseCaseTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailRemoteUseCaseTest.kt new file mode 100644 index 0000000..f4f8443 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserDetailRemoteUseCaseTest.kt @@ -0,0 +1,119 @@ +package leegroup.module.gituser.domain.usecases + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.domain.models.GitUserDetailModel +import leegroup.module.gituser.domain.repositories.GitUserDetailRepository +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserDetailRemoteUseCase +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetGitUserDetailRemoteUseCaseTest { + + private lateinit var useCase: GetGitUserDetailRemoteUseCase + private lateinit var mockRepository: MockGitUserDetailRemoteRepository + + @BeforeTest + fun setUp() { + mockRepository = MockGitUserDetailRemoteRepository() + useCase = GetGitUserDetailRemoteUseCase(mockRepository) + } + + @Test + fun `test invoke emits remote data`() = runTest { + // Given + val login = "testuser" + val userDetail = GitUserDetailModel( + id = 1L, + login = login, + name = "Test User", + avatarUrl = "avatar", + blog = "blog", + location = "San Francisco", + followers = 200, + following = 100 + ) + mockRepository.remoteUser = userDetail + + // When & Then + useCase(login).test { + val result = awaitItem() + assertEquals(login, result.login) + assertEquals("Test User", result.name) + assertEquals("San Francisco", result.location) + assertEquals(200, result.followers) + assertEquals(100, result.following) + awaitComplete() + } + } + + @Test + fun `test invoke calls repository with correct login`() = runTest { + // Given + val login = "specificuser" + val userDetail = GitUserDetailModel( + id = 1L, + login = login, + name = "Name", + avatarUrl = null, + blog = null, + location = null, + followers = 0, + following = 0 + ) + mockRepository.remoteUser = userDetail + + // When + useCase(login).test { + awaitItem() + awaitComplete() + } + + // Then + assertEquals(login, mockRepository.lastLogin) + } + + @Test + fun `test invoke handles user with minimal data`() = runTest { + // Given + val login = "minimaluser" + val userDetail = GitUserDetailModel( + id = 999L, + login = login, + name = null, + avatarUrl = null, + blog = null, + location = null, + followers = 0, + following = 0 + ) + mockRepository.remoteUser = userDetail + + // When & Then + useCase(login).test { + val result = awaitItem() + assertEquals(999L, result.id) + assertEquals(login, result.login) + assertEquals(null, result.name) + assertEquals(0, result.followers) + awaitComplete() + } + } +} + +private class MockGitUserDetailRemoteRepository : GitUserDetailRepository { + var localUser: GitUserDetailModel? = null + var remoteUser: GitUserDetailModel? = null + var lastLogin: String = "" + + override suspend fun getLocal(login: String): GitUserDetailModel? { + lastLogin = login + return localUser + } + + override suspend fun getRemote(login: String): GitUserDetailModel { + lastLogin = login + return remoteUser ?: throw IllegalStateException("No remote user set") + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserUseCaseTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserUseCaseTest.kt new file mode 100644 index 0000000..fb7ab53 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/domain/usecases/GetGitUserUseCaseTest.kt @@ -0,0 +1,126 @@ +package leegroup.module.gituser.domain.usecases + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.domain.models.GitUserModel +import leegroup.module.gituser.domain.repositories.GitUserRepository +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserUseCase +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetGitUserUseCaseTest { + + private lateinit var useCase: GetGitUserUseCase + private lateinit var mockRepository: MockGitUserRepository + + @BeforeTest + fun setUp() { + mockRepository = MockGitUserRepository() + useCase = GetGitUserUseCase(mockRepository) + } + + @Test + fun `test invoke returns remote data when local is empty`() = runTest { + // Given + val since = 0L + val perPage = 20 + val remoteUsers = listOf( + GitUserModel(1L, "user1", "avatar1", "html1"), + GitUserModel(2L, "user2", "avatar2", "html2") + ) + mockRepository.localUsers = emptyList() + mockRepository.remoteUsers = remoteUsers + + // When & Then + useCase(since, perPage).test { + val result = awaitItem() + assertEquals(2, result.size) + assertEquals("user1", result[0].login) + assertEquals("user2", result[1].login) + awaitComplete() + } + } + + @Test + fun `test invoke returns local data when available`() = runTest { + // Given + val since = 0L + val perPage = 20 + val localUsers = listOf( + GitUserModel(1L, "localuser1", "avatar1", "html1"), + GitUserModel(2L, "localuser2", "avatar2", "html2") + ) + mockRepository.localUsers = localUsers + + // When & Then + useCase(since, perPage).test { + val result = awaitItem() + assertEquals(2, result.size) + assertEquals("localuser1", result[0].login) + assertEquals("localuser2", result[1].login) + awaitComplete() + } + } + + @Test + fun `test invoke with pagination parameters`() = runTest { + // Given + val since = 100L + val perPage = 50 + val localUsers = emptyList() + val remoteUsers = listOf( + GitUserModel(101L, "user101", "avatar101", "html101") + ) + mockRepository.localUsers = localUsers + mockRepository.remoteUsers = remoteUsers + + // When & Then + useCase(since, perPage).test { + val result = awaitItem() + assertEquals(1, result.size) + assertEquals(101L, result[0].id) + assertEquals(since, mockRepository.lastSince) + assertEquals(perPage, mockRepository.lastPerPage) + awaitComplete() + } + } + + @Test + fun `test invoke prefers local over remote when local has data`() = runTest { + // Given + val localUsers = listOf(GitUserModel(1L, "local", "avatar", "html")) + val remoteUsers = listOf(GitUserModel(2L, "remote", "avatar", "html")) + mockRepository.localUsers = localUsers + mockRepository.remoteUsers = remoteUsers + + // When & Then + useCase(0L, 20).test { + val result = awaitItem() + assertEquals("local", result[0].login) + assertEquals(false, mockRepository.remoteCalled) + awaitComplete() + } + } +} + +private class MockGitUserRepository : GitUserRepository { + var localUsers: List = emptyList() + var remoteUsers: List = emptyList() + var lastSince: Long = -1 + var lastPerPage: Int = -1 + var remoteCalled: Boolean = false + + override suspend fun getLocal(since: Long, perPage: Int): List { + lastSince = since + lastPerPage = perPage + return localUsers + } + + override suspend fun getRemote(since: Long, perPage: Int): List { + remoteCalled = true + lastSince = since + lastPerPage = perPage + return remoteUsers + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/GitUserDetailUiMapperTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/GitUserDetailUiMapperTest.kt new file mode 100644 index 0000000..53f757c --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/GitUserDetailUiMapperTest.kt @@ -0,0 +1,190 @@ +package leegroup.module.gituser.ui.mapper + +import kotlinx.coroutines.test.runTest +import leegroup.module.gituser.domain.models.GitUserDetailModel +import leegroup.module.gituser.ui.models.GitUserDetailUiModel +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GitUserDetailUiMapperTest { + + private lateinit var mapper: GitUserDetailUiMapper + + @BeforeTest + fun setUp() { + mapper = GitUserDetailUiMapperImpl() + } + + @Test + fun `test mapToUiModel maps all fields correctly`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "testuser", + name = "Test User", + avatarUrl = "https://example.com/avatar.jpg", + blog = "https://testblog.com", + location = "San Francisco", + followers = 150, + following = 75 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("Test User", result.name) + assertEquals("https://example.com/avatar.jpg", result.avatarUrl) + assertEquals("https://testblog.com", result.blog) + assertEquals("San Francisco", result.location) + } + + @Test + fun `test mapToUiModel uses login when name is null`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "testlogin", + name = null, + avatarUrl = "avatar", + blog = "blog", + location = "location", + followers = 100, + following = 50 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("testlogin", result.name) + } + + @Test + fun `test mapToUiModel handles null avatarUrl`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "user", + name = "Name", + avatarUrl = null, + blog = "blog", + location = "location", + followers = 100, + following = 50 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("", result.avatarUrl) + } + + @Test + fun `test mapToUiModel handles null blog`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "user", + name = "Name", + avatarUrl = "avatar", + blog = null, + location = "location", + followers = 100, + following = 50 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("", result.blog) + } + + @Test + fun `test mapToUiModel formats followers correctly`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "user", + name = "Name", + avatarUrl = "avatar", + blog = "blog", + location = "location", + followers = 50, + following = 25 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("50", result.followers) + assertEquals("25", result.following) + } + + @Test + fun `test mapToUiModel formats large followers with plus`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel() + val model = GitUserDetailModel( + id = 1L, + login = "user", + name = "Name", + avatarUrl = "avatar", + blog = "blog", + location = "location", + followers = 150, + following = 200 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("100+", result.followers) + assertEquals("100+", result.following) + } + + @Test + fun `test mapToUiModel preserves old values and updates with new`() = runTest { + // Given + val oldUiModel = GitUserDetailUiModel( + login = "oldlogin", + name = "Old Name", + avatarUrl = "oldavatar", + blog = "oldblog", + location = "oldlocation", + followers = "50", + following = "25" + ) + val model = GitUserDetailModel( + id = 1L, + login = "newuser", + name = "New Name", + avatarUrl = "newavatar", + blog = "newblog", + location = "New Location", + followers = 75, + following = 80 + ) + + // When + val result = mapper.mapToUiModel(oldUiModel, model) + + // Then + assertEquals("New Name", result.name) + assertEquals("newavatar", result.avatarUrl) + assertEquals("newblog", result.blog) + assertEquals("New Location", result.location) + assertEquals("75", result.followers) + assertEquals("80", result.following) + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/util/FollowerFormatterTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/util/FollowerFormatterTest.kt new file mode 100644 index 0000000..7b58b79 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/mapper/util/FollowerFormatterTest.kt @@ -0,0 +1,108 @@ +package leegroup.module.gituser.ui.mapper.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FollowerFormatterTest { + + @Test + fun `test formatLargeNumber with value less than max returns value as string`() { + // Given + val value = 50 + val max = 100 + + // When + val result = FollowerFormatter.formatLargeNumber(value, max) + + // Then + assertEquals("50", result) + } + + @Test + fun `test formatLargeNumber with value equal to max returns value as string`() { + // Given + val value = 100 + val max = 100 + + // When + val result = FollowerFormatter.formatLargeNumber(value, max) + + // Then + assertEquals("100", result) + } + + @Test + fun `test formatLargeNumber with value greater than max returns max plus`() { + // Given + val value = 150 + val max = 100 + + // When + val result = FollowerFormatter.formatLargeNumber(value, max) + + // Then + assertEquals("100+", result) + } + + @Test + fun `test formatLargeNumber with default max of 100`() { + // Given + val value = 50 + + // When + val result = FollowerFormatter.formatLargeNumber(value) + + // Then + assertEquals("50", result) + } + + @Test + fun `test formatLargeNumber with value greater than default max`() { + // Given + val value = 250 + + // When + val result = FollowerFormatter.formatLargeNumber(value) + + // Then + assertEquals("100+", result) + } + + @Test + fun `test formatLargeNumber with zero value`() { + // Given + val value = 0 + + // When + val result = FollowerFormatter.formatLargeNumber(value) + + // Then + assertEquals("0", result) + } + + @Test + fun `test formatLargeNumber with custom max`() { + // Given + val value = 1500 + val max = 1000 + + // When + val result = FollowerFormatter.formatLargeNumber(value, max) + + // Then + assertEquals("1000+", result) + } + + @Test + fun `test formatLargeNumber with small custom max`() { + // Given + val value = 10 + val max = 5 + + // When + val result = FollowerFormatter.formatLargeNumber(value, max) + + // Then + assertEquals("5+", result) + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituser/GitUserListViewModelTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituser/GitUserListViewModelTest.kt new file mode 100644 index 0000000..dc0ba80 --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituser/GitUserListViewModelTest.kt @@ -0,0 +1,260 @@ +package leegroup.module.gituser.ui.screens.gituser + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import leegroup.module.designsystem.ui.models.ErrorState +import leegroup.module.gituser.domain.models.GitUserModel +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserUseCase +import leegroup.module.test.BaseKmpUnitTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class GitUserListViewModelTest : BaseKmpUnitTest() { + + private lateinit var viewModel: GitUserListViewModel + private lateinit var mockRepository: MockGitUserRepository + private lateinit var useCase: GetGitUserUseCase + + @BeforeTest + override fun setUp() { + super.setUp() + mockRepository = MockGitUserRepository() + useCase = GetGitUserUseCase(mockRepository) + viewModel = GitUserListViewModel(testDispatchersProvider, useCase) + } + + @AfterTest + override fun tearDown() { + super.tearDown() + } + + @Test + fun `test initial state has empty users list`() = runTest(testDispatcher) { + // Then + viewModel.uiModel.test { + val state = awaitItem() + assertTrue(state.users.isEmpty()) + } + } + + @Test + fun `test LoadIfEmpty action loads data when list is empty`() = runTest(testDispatcher) { + // Given + val users = listOf( + GitUserModel(1L, "user1", "avatar1", "html1"), + GitUserModel(2L, "user2", "avatar2", "html2") + ) + mockRepository.remoteUsers = users + + // When + viewModel.handleAction(GitUserListAction.LoadIfEmpty) + advanceUntilIdle() + + // Then + viewModel.uiModel.test { + val state = awaitItem() + assertEquals(2, state.users.size) + assertEquals("user1", state.users[0].login) + assertEquals("user2", state.users[1].login) + } + } + + @Test + fun `test LoadIfEmpty action does not load when list has data`() = runTest(testDispatcher) { + // Given - First load some data + mockRepository.remoteUsers = listOf(GitUserModel(1L, "existing", "avatar", "html")) + viewModel.handleAction(GitUserListAction.LoadIfEmpty) + advanceUntilIdle() + + // Reset mock to track new calls + mockRepository.remoteCalls = 0 + mockRepository.remoteUsers = listOf(GitUserModel(2L, "new", "avatar", "html")) + + // When - Try to load again + viewModel.handleAction(GitUserListAction.LoadIfEmpty) + advanceUntilIdle() + + // Then - Should not have called remote again + assertEquals(0, mockRepository.remoteCalls) + viewModel.uiModel.test { + val state = awaitItem() + assertEquals(1, state.users.size) + assertEquals("existing", state.users[0].login) + } + } + + @Test + fun `test LoadMore action appends new users`() = runTest(testDispatcher) { + // Given - First load + mockRepository.remoteUsers = listOf(GitUserModel(1L, "user1", "avatar1", "html1")) + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // When - Load more + mockRepository.remoteUsers = listOf(GitUserModel(2L, "user2", "avatar2", "html2")) + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // Then + viewModel.uiModel.test { + val state = awaitItem() + assertEquals(2, state.users.size) + assertEquals("user1", state.users[0].login) + assertEquals("user2", state.users[1].login) + } + } + + @Test + fun `test LoadMore removes duplicate users by id`() = runTest(testDispatcher) { + // Given - First load + mockRepository.remoteUsers = listOf( + GitUserModel(1L, "user1", "avatar1", "html1"), + GitUserModel(2L, "user2", "avatar2", "html2") + ) + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // When - Load more with duplicate + mockRepository.remoteUsers = listOf( + GitUserModel(2L, "user2", "avatar2", "html2"), + GitUserModel(3L, "user3", "avatar3", "html3") + ) + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // Then - Should have 3 unique users + viewModel.uiModel.test { + val state = awaitItem() + assertEquals(3, state.users.size) + assertEquals(1L, state.users[0].id) + assertEquals(2L, state.users[1].id) + assertEquals(3L, state.users[2].id) + } + } + + @Test + fun `test LoadMore uses correct since parameter`() = runTest(testDispatcher) { + // Given - First load + mockRepository.remoteUsers = listOf( + GitUserModel(10L, "user10", "avatar", "html"), + GitUserModel(20L, "user20", "avatar", "html") + ) + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // When - Load more + mockRepository.remoteUsers = emptyList() + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // Then - Should use the last user's id as since + assertEquals(20L, mockRepository.lastSince) + } + + @Test + fun `test LoadMore uses default since of 0 when empty`() = runTest(testDispatcher) { + // When + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // Then + assertEquals(0L, mockRepository.lastSince) + } + + @Test + fun `test LoadMore uses correct perPage parameter`() = runTest(testDispatcher) { + // When + viewModel.handleAction(GitUserListAction.LoadMore) + advanceUntilIdle() + + // Then + assertEquals(GitUserListViewModel.PER_PAGE, mockRepository.lastPerPage) + } + + @Test + fun `test loading state during data fetch`() = runTest(testDispatcher) { + // Given + mockRepository.remoteUsers = listOf(GitUserModel(1L, "user", "avatar", "html")) + + // When + viewModel.loading.test { + skipItems(1) // Skip initial state + viewModel.handleAction(GitUserListAction.LoadMore) + + // Then - Should be loading + val loadingState = awaitItem() + assertTrue(loadingState is leegroup.module.designsystem.ui.models.LoadingState.Loading) + + advanceUntilIdle() + + // Should stop loading + val finalState = awaitItem() + assertFalse(finalState is leegroup.module.designsystem.ui.models.LoadingState.Loading) + } + } + + @Test + fun `test onErrorConfirmation with Network error retries load`() = runTest(testDispatcher) { + // Given + mockRepository.remoteUsers = listOf(GitUserModel(1L, "user", "avatar", "html")) + + // When + viewModel.onErrorConfirmation(ErrorState.Network) + advanceUntilIdle() + + // Then - Should have called remote to retry + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `test onErrorConfirmation with Api error retries load`() = runTest(testDispatcher) { + // Given + mockRepository.remoteUsers = listOf(GitUserModel(1L, "user", "avatar", "html")) + + // When + viewModel.onErrorConfirmation(ErrorState.Api()) + advanceUntilIdle() + + // Then - Should have called remote to retry + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `test onErrorConfirmation with other error does not retry`() = runTest(testDispatcher) { + // When + viewModel.onErrorConfirmation(ErrorState.Common) + advanceUntilIdle() + + // Then - Should not have called remote + assertEquals(0, mockRepository.remoteCalls) + } +} + +private class MockGitUserRepository : + leegroup.module.gituser.domain.repositories.GitUserRepository { + var localUsers: List = emptyList() + var remoteUsers: List = emptyList() + var lastSince: Long = -1 + var lastPerPage: Int = -1 + var remoteCalls: Int = 0 + + override suspend fun getLocal(since: Long, perPage: Int): List { + lastSince = since + lastPerPage = perPage + return localUsers + } + + override suspend fun getRemote(since: Long, perPage: Int): List { + remoteCalls++ + lastSince = since + lastPerPage = perPage + return remoteUsers + } +} diff --git a/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailViewModelTest.kt b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailViewModelTest.kt new file mode 100644 index 0000000..ca304bf --- /dev/null +++ b/gituser/src/commonTest/kotlin/leegroup/module/gituser/ui/screens/gituserdetail/GitUserDetailViewModelTest.kt @@ -0,0 +1,263 @@ +package leegroup.module.gituser.ui.screens.gituserdetail + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import leegroup.module.core.util.JsonUtil +import leegroup.module.designsystem.ui.models.ErrorState +import leegroup.module.gituser.domain.models.GitUserDetailModel +import leegroup.module.gituser.domain.repositories.GitUserDetailRepository +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserDetailLocalUseCase +import leegroup.module.gituser.domain.usecases.gituser.GetGitUserDetailRemoteUseCase +import leegroup.module.gituser.ui.mapper.GitUserDetailUiMapper +import leegroup.module.gituser.ui.models.GitUserDetailUiModel +import leegroup.module.gituser.ui.navigation.GitUserDestination +import leegroup.module.test.BaseKmpUnitTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class GitUserDetailViewModelTest : BaseKmpUnitTest() { + + private lateinit var mockSavedStateHandle: SavedStateHandle + private lateinit var mockRepository: MockGitUserDetailRepository + private lateinit var mockUiMapper: MockGitUserDetailUiMapper + private lateinit var localUseCase: GetGitUserDetailLocalUseCase + private lateinit var remoteUseCase: GetGitUserDetailRemoteUseCase + + private lateinit var viewModel: GitUserDetailViewModel + + private val userLogin = "longdt57" + + private val gitUserDetailModel = GitUserDetailModel( + id = 1, + login = userLogin, + name = "Long DT", + avatarUrl = "https://avatars.githubusercontent.com/u/1?v=4", + blog = "https://longdt57.com", + location = "Vietnam", + followers = 1500, + following = 100 + ) + + private val gitUserDetailUiModel = GitUserDetailUiModel( + login = userLogin, + name = "Long DT", + avatarUrl = "https://avatars.githubusercontent.com/u/1?v=4", + blog = "https://longdt57.com", + location = "Vietnam", + followers = "999+", + following = "100" + ) + + @BeforeTest + override fun setUp() { + super.setUp() + val nav = GitUserDestination.GitUserDetail(userLogin) + mockSavedStateHandle = SavedStateHandle(JsonUtil.encodeToMap(nav)) + mockRepository = MockGitUserDetailRepository() + mockUiMapper = MockGitUserDetailUiMapper(gitUserDetailUiModel) + localUseCase = GetGitUserDetailLocalUseCase(mockRepository) + remoteUseCase = GetGitUserDetailRemoteUseCase(mockRepository) + } + + @AfterTest + override fun tearDown() { + super.tearDown() + } + + private fun initViewModel() { + viewModel = GitUserDetailViewModel( + savedStateHandle = mockSavedStateHandle, + dispatchersProvider = testDispatchersProvider, + getGitUserDetailLocalUseCase = localUseCase, + getGitUserDetailRemoteUseCase = remoteUseCase, + gitUserDetailModelMapper = mockUiMapper + ) + } + + @Test + fun `When setting user login, it fetches local and remote data`() = runTest(testDispatcher) { + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + viewModel.uiState.test { + val updatedModel = awaitItem() + assertEquals(gitUserDetailUiModel, updatedModel) + } + + assertTrue(mockRepository.localCalls > 0) + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `When remote fetch fails and local data exists, it updates UI with local data`() = + runTest(testDispatcher) { + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteException = RuntimeException("Remote fetch error") + + initViewModel() + advanceUntilIdle() + + viewModel.uiState.test { + val updatedModel = awaitItem() + assertEquals(gitUserDetailUiModel, updatedModel) + } + + assertTrue(mockRepository.localCalls > 0) + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `When local return error, it still fetches remote data`() = runTest(testDispatcher) { + mockRepository.localException = RuntimeException("Local fetch error") + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + viewModel.uiState.test { + val updatedModel = awaitItem() + assertEquals(gitUserDetailUiModel, updatedModel) + } + + assertTrue(mockRepository.localCalls > 0) + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `When local and remote both succeed, remote data takes precedence`() = + runTest(testDispatcher) { + val remoteModel = gitUserDetailModel.copy(name = "Remote Long DT") + val remoteUiModel = gitUserDetailUiModel.copy(name = "Remote Long DT") + + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = remoteModel + mockUiMapper.resultForModel[remoteModel] = remoteUiModel + + initViewModel() + advanceUntilIdle() + + viewModel.uiState.test { + val updatedModel = awaitItem() + assertEquals(remoteUiModel, updatedModel) + } + + assertTrue(mockRepository.localCalls > 0) + assertTrue(mockRepository.remoteCalls > 0) + } + + @Test + fun `When Api onErrorConfirmation is called, it calls fetchRemote again`() = + runTest(testDispatcher) { + // Setup with successful data to avoid error handling issues + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + val callsAfterInit = mockRepository.remoteCalls + + viewModel.onErrorConfirmation(ErrorState.Api()) + advanceUntilIdle() + + assertEquals(callsAfterInit + 1, mockRepository.remoteCalls) + } + + @Test + fun `When Network onErrorConfirmation is called, it calls fetchRemote again`() = + runTest(testDispatcher) { + // Setup with successful data to avoid error handling issues + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + val callsAfterInit = mockRepository.remoteCalls + + viewModel.onErrorConfirmation(ErrorState.Network) + advanceUntilIdle() + + assertEquals(callsAfterInit + 1, mockRepository.remoteCalls) + } + + @Test + fun `When Api onDismissClick is called, it hides error`() = runTest(testDispatcher) { + // Setup with successful data to avoid error handling issues + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + viewModel.onErrorDismissClick(ErrorState.Api()) + advanceUntilIdle() + + viewModel.error.test { + assertEquals(ErrorState.None, awaitItem()) + } + } + + @Test + fun `When Common onErrorConfirmation is called, it doesn't call fetchRemote`() = + runTest(testDispatcher) { + // Setup with successful data to avoid error handling issues + mockRepository.localResult = gitUserDetailModel + mockRepository.remoteResult = gitUserDetailModel + + initViewModel() + advanceUntilIdle() + + val callsAfterInit = mockRepository.remoteCalls + + viewModel.onErrorConfirmation(ErrorState.Common) + advanceUntilIdle() + + assertEquals(callsAfterInit, mockRepository.remoteCalls) + } + +} + +private class MockGitUserDetailRepository : GitUserDetailRepository { + var localResult: GitUserDetailModel? = null + var remoteResult: GitUserDetailModel? = null + var localException: Throwable? = null + var remoteException: Throwable? = null + var localCalls: Int = 0 + var remoteCalls: Int = 0 + + override suspend fun getLocal(login: String): GitUserDetailModel? { + localCalls++ + localException?.let { throw it } + return localResult + } + + override suspend fun getRemote(login: String): GitUserDetailModel { + remoteCalls++ + remoteException?.let { throw it } + return remoteResult ?: throw RuntimeException("No remote result set") + } +} + +private class MockGitUserDetailUiMapper( + private val defaultResult: GitUserDetailUiModel +) : GitUserDetailUiMapper { + val resultForModel = mutableMapOf() + + override suspend fun mapToUiModel( + oldUiModel: GitUserDetailUiModel, + model: GitUserDetailModel + ): GitUserDetailUiModel { + return resultForModel[model] ?: defaultResult + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ca7c03..9b1db9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ koinCore = "4.1.1" kotline-coroutines-core = "1.10.2" junit = "4.13.2" kotlin = "2.2.21" +kover = "0.9.3" kotlinxCollectionsImmutable = "0.4.0" kotlinxDatetime = "0.7.1" ktorClient = "3.3.3" @@ -38,6 +39,8 @@ runner = "1.7.0" core = "1.7.0" kotlinxSerializationJson = "1.9.0" turbine = "1.2.1" +kotestAssertionsCore = "6.0.3" +mockk = "1.14.5" room = "2.8.4" sqlite = "2.6.1" ksp = "2.2.21-2.0.4" # Should match kotlin version @@ -95,6 +98,8 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotestAssertionsCore" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } @@ -121,6 +126,7 @@ cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", ver cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptographyCore" } [bundles] +test = ["junit", "androidx-core", "turbine", "kotest-assertions-core", "mockk", "kotlinx-coroutines-test", "ktor-client-mock"] androidKoin = ["koin-android", "koin-androidx-compose"] androidNetwork = ["ktor-client-android", "ktor-client-okhttp"] @@ -145,6 +151,7 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidLint = { id = "com.android.lint", version.ref = "agp" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlinx-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } easylauncher = { id = "com.starter.easylauncher", version.ref = "easyLauncher" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8f68b7a..4ad0461 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,7 +30,10 @@ dependencyResolutionManagement { } include(":composeApp") + include(":core:designsystem") include(":core:core-ktx") include(":core:data") +include(":core:test") + include(":gituser")