From 86d8877629973b4bbcbc16cbe75db85dde650850 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 22 Jul 2025 16:33:40 +0400 Subject: [PATCH] Updated on 2026-08-14 --- core/utils/build.gradle.kts | 9 + .../utils/extensions/ByteArrayExtTest.kt | 115 +++++++++++ domain/models/build.gradle.kts | 9 + .../models/account/CryptoPortfolioIconTest.kt | 181 ++++++++++++++++++ .../lib/visa/VisaContractInfoProvider.kt | 2 +- .../com/tangem/lib/visa/utils/ByteArrayExt.kt | 3 - 6 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 core/utils/src/test/kotlin/com/tangem/utils/extensions/ByteArrayExtTest.kt create mode 100644 domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt delete mode 100644 libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/ByteArrayExt.kt diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index bf7c86b763..4a731b1f09 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region DI @@ -18,4 +22,9 @@ dependencies { // region Time dependencies implementation(deps.jodatime) // endregion + + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/extensions/ByteArrayExtTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/extensions/ByteArrayExtTest.kt new file mode 100644 index 0000000000..17788e7ae7 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/extensions/ByteArrayExtTest.kt @@ -0,0 +1,115 @@ +package com.tangem.utils.extensions + +import com.google.common.truth.Truth +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ByteArrayExtTest { + + @Test + fun `toHexString converts byte array to hexadecimal string`() { + // Arrange + val byteArray = byteArrayOf(0x0F, 0xA5.toByte(), 0xFF.toByte()) + + // Act + val actual = byteArray.toHexString() + + // Assert + val expected = "0FA5FF" + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `hexToBytes converts hexadecimal string to byte array`() { + // Arrange + val hexString = "0FA5FF" + + // Act + val actual = hexString.hexToBytes() + + // Assert + val expected = byteArrayOf(0x0F, 0xA5.toByte(), 0xFF.toByte()) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `hexToBytes handles hex string with prefix`() { + // Arrange + val hexString = "0x0FA5FF" + + // Act + val actual = hexString.hexToBytes() + + // Assert + val expected = byteArrayOf(0x0F, 0xA5.toByte(), 0xFF.toByte()) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `hexToBytes throws exception for invalid hex string`() { + // Arrange + val invalidHexString = "GHIJKL" + + // Act + val actual = runCatching { invalidHexString.hexToBytes() }.exceptionOrNull() + + // Assert + val expected = NumberFormatException() + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo("For input string: \"GH\" under radix 16") + } + + @Test + fun `hexToBytes handles empty string`() { + // Arrange + val emptyHexString = "" + + // Act + val actual = emptyHexString.hexToBytes() + + // Assert + Truth.assertThat(actual).isEqualTo(byteArrayOf()) + } + + @Test + fun `hexToBytesOrNull returns byte array for valid hex string`() { + // Arrange + val hexString = "0FA5FF" + + // Act + val actual = hexString.hexToBytesOrNull() + + // Assert + val expected = byteArrayOf(0x0F, 0xA5.toByte(), 0xFF.toByte()) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `hexToBytesOrNull returns null for invalid hex string`() { + // Arrange + val invalidHexString = "GHIJKL" + + // Act + val actual = invalidHexString.hexToBytesOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun hexToBytesOrNullHandlesEmptyString() { + // Arrange + val emptyHexString = "" + + // Act + val actual = emptyHexString.hexToBytesOrNull() + + // Assert + val expected = byteArrayOf() + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/domain/models/build.gradle.kts b/domain/models/build.gradle.kts index 84d1fbea94..1743a4fa54 100644 --- a/domain/models/build.gradle.kts +++ b/domain/models/build.gradle.kts @@ -7,6 +7,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.visa.models) api(projects.core.utils) @@ -17,4 +21,9 @@ dependencies { implementation(deps.kotlin.serialization) ksp(deps.moshi.kotlin.codegen) implementation(deps.arrow.core) + + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt new file mode 100644 index 0000000000..3706e6921d --- /dev/null +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt @@ -0,0 +1,181 @@ +package com.tangem.domain.models.account + +import com.google.common.truth.Truth +import com.tangem.domain.models.account.CryptoPortfolioIcon.* +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import kotlin.random.Random + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoPortfolioIconTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OfMainAccount { + + @Test + fun `ofMainAccount with empty exclude`() { + // Act + val actual = CryptoPortfolioIcon.ofMainAccount(exclude = emptySet()) + + // Assert + val expectedColor = Color.Azure + Truth.assertThat(actual.color).isEqualTo(expectedColor) + + val expectedType = Type.Icon(value = Icon.Star) + Truth.assertThat(actual.type).isEqualTo(expectedType) + } + + @ParameterizedTest + @MethodSource("provideTestModels") + fun ofMainAccount(model: OfMainAccountModel) { + // Arrange + mockkObject(Random.Default) + + val size = (Color.entries.size - model.exclude.size).takeIf { it > 0 } ?: Color.entries.size + every { Random.nextInt(size) } returns model.randomNextInt + + // Act + val actual = CryptoPortfolioIcon.ofMainAccount(exclude = model.exclude) + + // Assert + val expectedColor = model.expectedColor + Truth.assertThat(actual.color).isEqualTo(expectedColor) + + val expectedType = Type.Icon(value = Icon.Star) + Truth.assertThat(actual.type).isEqualTo(expectedType) + + verify(exactly = 1) { Random.nextInt(size) } + + unmockkObject(Random.Default) + } + + private fun provideTestModels() = listOf( + // If the default color is already occupied (present in the exclude set), a random color from the + // remaining available colors will be selected for the main account icon. + OfMainAccountModel( + exclude = setOf(Color.Azure), + randomNextInt = 0, + expectedColor = Color.entries[1], + ), + OfMainAccountModel( + exclude = setOf(Color.Azure, Color.CaribbeanBlue), + randomNextInt = 0, + expectedColor = Color.entries[2], + ), + // If all colors are already occupied, a random one will be selected. + OfMainAccountModel( + exclude = Color.entries.toSet(), + randomNextInt = 1, + expectedColor = Color.entries[1], + ), + ) + } + + data class OfMainAccountModel( + val exclude: Set, + val randomNextInt: Int, + val expectedColor: Color, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OfCustomAccountBasedOnName { + + @ParameterizedTest + @MethodSource("provideTestModels") + fun ofCustomAccount(model: OfCustomAccountModel.BasedOnName) { + // Arrange + mockkObject(Random.Default) + + every { Random.nextInt(until = Color.entries.size) } returns model.randomNextInt + + // Act + val actual = CryptoPortfolioIcon.ofCustomAccount(accountName = model.accountName) + + // Assert + val expectedColor = model.expectedColor + Truth.assertThat(actual.color).isEqualTo(expectedColor) + + val expectedType = Type.Symbol(value = model.accountName.first()) + Truth.assertThat(actual.type).isEqualTo(expectedType) + + verify(exactly = 1) { Random.nextInt(until = Color.entries.size) } + + unmockkObject(Random.Default) + } + + private fun provideTestModels() = listOf( + OfCustomAccountModel.BasedOnName( + accountName = "New account", + randomNextInt = 0, + expectedColor = Color.entries[0], + ), + OfCustomAccountModel.BasedOnName( + accountName = "Awesome", + randomNextInt = Color.entries.lastIndex, + expectedColor = Color.entries.last(), + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OfCustomAccountWithTypeAndColor { + + @ParameterizedTest + @MethodSource("provideTestModels") + fun ofCustomAccount(model: OfCustomAccountModel.WithTypeAndColor) { + // Act + val actual = CryptoPortfolioIcon.ofCustomAccount(type = model.type, color = model.color) + + // Assert + val expectedColor = model.expectedColor + Truth.assertThat(actual.color).isEqualTo(expectedColor) + + val expectedType = model.expectedType + Truth.assertThat(actual.type).isEqualTo(expectedType) + } + + private fun provideTestModels() = listOf( + OfCustomAccountModel.WithTypeAndColor( + type = Type.Icon(value = Icon.User), + color = Color.CaribbeanBlue, + expectedType = Type.Icon(value = Icon.User), + expectedColor = Color.CaribbeanBlue, + ), + OfCustomAccountModel.WithTypeAndColor( + type = Type.Symbol(value = 'A'), + color = Color.DullLavender, + expectedType = Type.Symbol(value = 'A'), + expectedColor = Color.DullLavender, + ), + ) + } + + sealed interface OfCustomAccountModel { + + data class BasedOnName( + val accountName: String, + val randomNextInt: Int, + val expectedColor: Color, + ) : OfCustomAccountModel + + data class WithTypeAndColor( + val type: Type, + val color: Color, + val expectedType: Type, + val expectedColor: Color, + ) : OfCustomAccountModel + } +} \ No newline at end of file diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt index 4587bfabf1..91a6527263 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt @@ -5,8 +5,8 @@ import com.ihsanbal.logging.LoggingInterceptor import com.tangem.lib.visa.model.VisaContractInfo import com.tangem.lib.visa.utils.Constants import com.tangem.lib.visa.utils.Constants.NETWORK_LOGS_TAG -import com.tangem.lib.visa.utils.toHexString import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.toHexString import okhttp3.OkHttpClient import org.web3j.crypto.Credentials import org.web3j.protocol.Web3j diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/ByteArrayExt.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/ByteArrayExt.kt deleted file mode 100644 index 3efd2be245..0000000000 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/ByteArrayExt.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tangem.lib.visa.utils - -internal fun ByteArray.toHexString(): String = joinToString("") { "%02X".format(it) } \ No newline at end of file