Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-12 14:24:35 +05:00
parent ea180bc503
commit 79aaeaf74f
10 changed files with 429 additions and 2 deletions

View file

@ -30,6 +30,8 @@ import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import javax.inject.Inject
abstract class BaseTestCase : TestCase(
@ -68,6 +70,12 @@ abstract class BaseTestCase : TestCase(
*/
val composeTestRule = createEmptyComposeRule()
private val semanticTreePrinterRule = object : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
runCatching { printAllRoots() }
}
}
@Rule
@JvmField
val ruleChain: TestRule = RuleChain
@ -76,6 +84,7 @@ abstract class BaseTestCase : TestCase(
.around(permissionRule)
.around(apiEnvironmentRule)
.around(composeTestRule)
.around(semanticTreePrinterRule)
/**
* Initialization order is important:
@ -140,6 +149,16 @@ abstract class BaseTestCase : TestCase(
.printToLog(tag, maxDepth)
}
fun printAllRoots(
tag: String = "ComposeTree",
) {
val roots = composeTestRule.onAllNodes(isRoot())
val count = roots.fetchSemanticsNodes().size
repeat(count) { index ->
roots[index].printToLog("$tag[$index]")
}
}
fun waitForIdle() = composeTestRule.waitForIdle()
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {

View file

@ -1,10 +1,14 @@
package com.tangem.common.extensions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.components.buttons.actions.HasBadgeKey
import com.tangem.core.ui.components.buttons.actions.IsDimmedKey
import io.github.kakaocup.compose.node.element.KNode
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
fun assertElementDoesNotExist(
elementProvider: () -> KNode,
@ -42,4 +46,51 @@ fun Any.assertHasBadge(expectedValue: Boolean = true) {
is KNode, is LazyListItemNode -> this.assert(matcher)
else -> throw IllegalArgumentException("Unsupported type: ${this::class}")
}
}
fun List<SemanticsNode>.assertSortedByVolumeDescending() {
val volumes = this.mapNotNull { parseVolume(it) }
assertFalse("Trading volumes list should not be empty", volumes.isEmpty())
assertTrue(
"Exchanges list should be sorted by volume in descending order",
volumes == volumes.sortedDescending(),
)
}
fun List<SemanticsNode>.assertExchangeTypesAreCexOrDex() {
assertFalse("Exchange types list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Exchange type should be 'CEX' or 'DEX', but found: $text",
text == "CEX" || text == "DEX",
)
}
}
fun List<SemanticsNode>.assertTrustScoresValid() {
val validScores = setOf("Risky", "Caution", "Trusted")
assertFalse("Trust scores list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Trust score should be one of $validScores, but found: $text",
text in validScores,
)
}
}
/**
* Extracts the text value from a semantic node's config.
*/
private fun extractText(node: SemanticsNode): String? {
if (SemanticsProperties.Text in node.config) {
return node.config[SemanticsProperties.Text].firstOrNull()?.text
}
return null
}
private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
}

View file

@ -1,8 +1,12 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeMarketsBlock
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
@ -23,4 +27,50 @@ fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName:
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
}
fun BaseTestCase.assertMarketsExchangesScreen() {
step("Assert 'Title' is displayed") {
onMarketsExchangesScreen { exchangesTitle.assertIsDisplayed() }
}
step("Assert 'Exchange name' is displayed") {
onMarketsExchangesScreen { exchangeName.assertIsDisplayed() }
}
step("Assert 'Logo' is displayed") {
onMarketsExchangesScreen { exchangeLogo.assertIsDisplayed() }
}
step("Assert 'Exchange type' is displayed") {
onMarketsExchangesScreen { exchangeType.assertIsDisplayed() }
}
step("Assert 'Trust score' is displayed") {
onMarketsExchangesScreen { trustScore.assertIsDisplayed() }
}
}
fun BaseTestCase.openMarketsScreen() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
}
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String) {
openMarketsScreen()
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsExchangesPageObject>(semanticsProvider = provider) {
fun allTradingVolumeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT))
.fetchSemanticsNodes()
fun allExchangeTypeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
.fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
.fetchSemanticsNodes()
val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true
}
val exchangeName: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
}
val exchangeLogo: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_ICON)
}
val tradingVolume: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT)
}
val exchangeType: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_PRICE)
}
val trustScore: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)
}
val tryAgainButton: KNode = child {
hasText(getResourceString(R.string.alert_button_try_again))
}
val errorMessage: KNode = child {
hasText(getResourceString(R.string.markets_loading_error_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onMarketsExchangesScreen(function: MarketsExchangesPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -35,6 +35,25 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true
}
val listedOnExchangesCount: KNode = child {
hasTestTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT)
useUnmergedTree = true
}
val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
}
val listedOnEmptyText: KNode = child {
hasText(getResourceString(R.string.markets_token_details_empty_exchanges))
useUnmergedTree = true
}
val seeAllButton: KNode = child {
hasText(getResourceString(com.tangem.core.ui.R.string.common_see_all))
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)

View file

@ -0,0 +1,209 @@
package com.tangem.tests.markets
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.scenarios.*
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class MarketsExchangesTest : BaseTestCase() {
@Test
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("58")
@DisplayName("Markets: verify exchanges list screen")
fun marketsExchangesListTest() {
val tokenName = "Solana"
setupHooks().run {
step("Open 'Markets' screen") {
openMarketsScreen()
}
step("Click on 'See all' button") {
onMarketsScreen { seeAllButton.clickWithAssertion() }
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges block is displayed in token details")
fun marketsExchangesBlockDisplayedTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
}
step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has exchanges count") {
onMarketsScreen { listedOnExchangesCount.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has arrow button") {
onMarketsScreen { listedOnBlockContainer.assertHasClickAction() }
}
step("Tap on 'Listed on exchanges' block and navigate to exchanges list") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("60")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges list is sorted by volume descending")
fun marketsExchangesListSortedByVolumeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchanges are sorted by trading volume in descending order") {
flakySafely {
onMarketsExchangesScreen { allTradingVolumeNodes().assertSortedByVolumeDescending() }
}
}
}
}
@Test
@AllureId("61")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange types are CEX or DEX")
fun marketsExchangesTypeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchange types list is not empty and all types are 'CEX' or 'DEX'") {
flakySafely {
onMarketsExchangesScreen { allExchangeTypeNodes().assertExchangeTypesAreCexOrDex() }
}
}
}
}
@Test
@AllureId("62")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange trust scores are valid")
fun marketsExchangesTrustScoreTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert trust scores list is not empty and all scores are valid") {
flakySafely {
onMarketsExchangesScreen { allTrustScoreNodes().assertTrustScoresValid() }
}
}
}
}
@Test
@AllureId("57")
@DisplayName("Markets: verify empty exchanges state")
fun marketsExchangesEmptyTest() {
val tokenName = "Bitcoin"
val scenarioName = "coins_bitcoin"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "EmptyExchanges")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Listed on exchanges' block shows no exchanges") {
onMarketsScreen { listedOnEmptyText.assertIsDisplayed() }
}
}
}
@Test
@AllureId("59")
@DisplayName("Markets: verify exchanges error state and retry")
fun marketsExchangesErrorStateTest() {
val tokenName = "Bitcoin"
val scenarioName = "bitcoin_exchange"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "Unreachable")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Unable to load data' error state is displayed") {
step("Assert 'Error message' is displayed") {
onMarketsExchangesScreen { errorMessage.assertIsDisplayed() }
}
step("Assert 'Try again button' is displayed") {
onMarketsExchangesScreen { tryAgainButton.assertIsDisplayed() }
}
}
step("Set WireMock scenario '$scenarioName' to state 'Started'") {
setWireMockScenarioState(scenarioName = scenarioName, state = "Started")
}
step("Tap 'Try again' button") {
onMarketsExchangesScreen { tryAgainButton.clickWithAssertion() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed after retry") {
assertMarketsExchangesScreen()
}
}
}
}

View file

@ -13,6 +13,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import com.tangem.core.ui.test.TokenElementsTestTags
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
@ -121,7 +124,7 @@ private fun FiatAmountText(
isFlickering: Boolean = false,
) {
Text(
modifier = modifier,
modifier = modifier.semantics { testTag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT },
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View file

@ -4,4 +4,5 @@ object MarketsTestTags {
const val TOKENS_LIST = "MARKETS_TOKENS_LIST"
const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM"
const val ADD_TO_PORTFOLIO_SWITCH = "MARKETS_ADD_TO_PORTFOLIO_SWITCH"
const val LISTED_ON_EXCHANGES_COUNT = "MARKETS_LISTED_ON_EXCHANGES_COUNT"
}

View file

@ -5,6 +5,7 @@ object TokenElementsTestTags {
const val TOKEN_ICON = "TOKEN_ICON"
const val TOKEN_PRICE = "TOKEN_PRICE"
const val TOKEN_FIAT_AMOUNT = "TOKEN_FIAT_AMOUNT"
const val TOKEN_FIAT_AMOUNT_TEXT = "TOKEN_FIAT_AMOUNT_TEXT"
const val TOKEN_CRYPTO_AMOUNT = "TOKEN_CRYPTO_AMOUNT"
const val TOKEN_NON_FIAT_BLOCK = "TOKEN_NON_FIAT_BLOCK"
const val TOKEN_YIELD_PROMO_BANNER = "TOKEN_YIELD_PROMO_BANNER"

View file

@ -21,12 +21,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import com.tangem.common.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM
import kotlinx.coroutines.delay
@ -99,7 +102,7 @@ internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) {
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier,
modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT },
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,