-
Notifications
You must be signed in to change notification settings - Fork 3
test: add calculator widget device tests #944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test/ai-dev-tests-suite-setup
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package to.bitkit.test.annotations | ||
|
|
||
| @Retention(AnnotationRetention.RUNTIME) | ||
| @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) | ||
| annotation class DeviceUiIntegration |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,371 @@ | ||
| package to.bitkit.ui.screens.widgets.calculator | ||
|
|
||
| import androidx.compose.foundation.layout.fillMaxWidth | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.test.SemanticsMatcher | ||
| import androidx.compose.ui.test.assertIsDisplayed | ||
| import androidx.compose.ui.test.hasAnyAncestor | ||
| import androidx.compose.ui.test.hasTestTag | ||
| import androidx.compose.ui.test.hasText | ||
| import androidx.compose.ui.test.junit4.createComposeRule | ||
| import androidx.compose.ui.test.onAllNodesWithTag | ||
| import androidx.compose.ui.test.onNodeWithTag | ||
| import androidx.compose.ui.test.onRoot | ||
| import androidx.compose.ui.test.performClick | ||
| import androidx.compose.ui.test.printToString | ||
| import androidx.lifecycle.ViewModel | ||
| import androidx.lifecycle.ViewModelProvider | ||
| import androidx.lifecycle.ViewModelStore | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import dagger.Module | ||
| import dagger.Provides | ||
| import dagger.hilt.InstallIn | ||
| import dagger.hilt.android.testing.HiltAndroidRule | ||
| import dagger.hilt.android.testing.HiltAndroidTest | ||
| import dagger.hilt.android.testing.UninstallModules | ||
| import dagger.hilt.components.SingletonComponent | ||
| import kotlinx.coroutines.flow.first | ||
| import kotlinx.coroutines.runBlocking | ||
| import org.junit.After | ||
| import org.junit.Before | ||
| import org.junit.Rule | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import to.bitkit.data.AppCacheData | ||
| import to.bitkit.data.CacheStore | ||
| import to.bitkit.data.SettingsData | ||
| import to.bitkit.data.SettingsStore | ||
| import to.bitkit.data.WidgetsData | ||
| import to.bitkit.data.WidgetsStore | ||
| import to.bitkit.di.RepoModule | ||
| import to.bitkit.models.BitcoinDisplayUnit | ||
| import to.bitkit.models.FxRate | ||
| import to.bitkit.models.USD | ||
| import to.bitkit.models.WidgetType | ||
| import to.bitkit.models.WidgetWithPosition | ||
| import to.bitkit.models.WidgetsBackupV1 | ||
| import to.bitkit.models.widget.CalculatorValues | ||
| import to.bitkit.repositories.AmountInputHandler | ||
| import to.bitkit.repositories.CurrencyRepo | ||
| import to.bitkit.repositories.WidgetsRepo | ||
| import to.bitkit.test.annotations.DeviceIntegration | ||
| import to.bitkit.test.annotations.DeviceUiIntegration | ||
| import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard | ||
| import to.bitkit.ui.theme.AppThemeSurface | ||
| import to.bitkit.viewmodels.CurrencyViewModel | ||
| import java.util.Locale | ||
| import javax.inject.Inject | ||
| import javax.inject.Named | ||
| import kotlin.test.assertEquals | ||
|
|
||
| @HiltAndroidTest | ||
| @UninstallModules(RepoModule::class) | ||
| @RunWith(AndroidJUnit4::class) | ||
| @DeviceIntegration | ||
| @DeviceUiIntegration | ||
| class CalculatorCardIntegrationTest { | ||
|
|
||
| @get:Rule | ||
| val hiltRule = HiltAndroidRule(this) | ||
|
|
||
| @get:Rule | ||
| val composeTestRule = createComposeRule() | ||
|
|
||
| @Inject | ||
| lateinit var widgetsRepo: WidgetsRepo | ||
|
|
||
| @Inject | ||
| lateinit var currencyRepo: CurrencyRepo | ||
|
|
||
| @Inject | ||
| lateinit var widgetsStore: WidgetsStore | ||
|
|
||
| @Inject | ||
| lateinit var settingsStore: SettingsStore | ||
|
|
||
| @Inject | ||
| lateinit var cacheStore: CacheStore | ||
|
|
||
| private lateinit var viewModelStore: ViewModelStore | ||
| private lateinit var calculatorViewModel: CalculatorViewModel | ||
| private lateinit var currencyViewModel: CurrencyViewModel | ||
| private lateinit var previousWidgetsData: WidgetsData | ||
| private lateinit var previousSettingsData: SettingsData | ||
| private lateinit var previousCacheData: AppCacheData | ||
| private lateinit var previousLocale: Locale | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| previousLocale = Locale.getDefault() | ||
| Locale.setDefault(Locale.US) | ||
| hiltRule.inject() | ||
|
|
||
| runBlocking { | ||
| previousWidgetsData = widgetsStore.data.first() | ||
| previousSettingsData = settingsStore.data.first() | ||
| previousCacheData = cacheStore.data.first() | ||
|
|
||
| settingsStore.update { | ||
| it.copy( | ||
| selectedCurrency = USD, | ||
| displayUnit = BitcoinDisplayUnit.MODERN, | ||
| showWidgetTitles = true, | ||
| ) | ||
| } | ||
| cacheStore.update { it.copy(cachedRates = listOf(testUsdRate)) } | ||
| widgetsStore.restoreFromBackup( | ||
| WidgetsBackupV1( | ||
| createdAt = TEST_CREATED_AT, | ||
| widgets = WidgetsData( | ||
| widgets = listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)), | ||
| calculatorValues = CalculatorValues(), | ||
| ), | ||
| ) | ||
| ).getOrThrow() | ||
|
|
||
| currencyRepo.currencyState.first { | ||
| it.selectedCurrency == USD && | ||
| it.displayUnit == BitcoinDisplayUnit.MODERN && | ||
| it.rates.any { rate -> rate.quote == USD && rate.lastPrice == TEST_USD_RATE } | ||
| } | ||
| widgetsRepo.widgetsDataFlow.first { | ||
| it.widgets == listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)) && | ||
| it.calculatorValues == CalculatorValues() | ||
| } | ||
| } | ||
|
|
||
| calculatorViewModel = createCalculatorViewModel() | ||
| currencyViewModel = createCurrencyViewModel() | ||
| clearCalculatorValues() | ||
| } | ||
|
|
||
| @After | ||
| fun tearDown() { | ||
| if (::viewModelStore.isInitialized) { | ||
| viewModelStore.clear() | ||
| } | ||
| runBlocking { | ||
| widgetsStore.restoreFromBackup( | ||
| WidgetsBackupV1( | ||
| createdAt = TEST_CREATED_AT, | ||
| widgets = previousWidgetsData, | ||
| ) | ||
| ).getOrThrow() | ||
| settingsStore.update { previousSettingsData } | ||
| cacheStore.update { previousCacheData } | ||
| } | ||
| Locale.setDefault(previousLocale) | ||
| } | ||
|
|
||
| @Test | ||
| fun btcInputUpdatesFiatValueAndPersistsWidgetState() { | ||
| setCalculatorCard() | ||
|
|
||
| selectInput(BTC_INPUT_TAG) | ||
| pressKeys("1", "2", "3", "4", "0") | ||
|
|
||
| waitForValues( | ||
| btcValue = "12340", | ||
| fiatValue = "12.34", | ||
| ) | ||
|
|
||
| assertInputText(BTC_INPUT_TAG, "12 340") | ||
| assertInputText(FIAT_INPUT_TAG, "12.34") | ||
| assertPersistedValues( | ||
| btcValue = "12340", | ||
| fiatValue = "12.34", | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun fiatInputUpdatesBtcValueAndPersistsWidgetState() { | ||
| setCalculatorCard() | ||
|
|
||
| selectInput(FIAT_INPUT_TAG) | ||
| pressKeys("1", "0", KEY_DECIMAL_TAG, "0", "0") | ||
|
|
||
| waitForValues( | ||
| btcValue = "10000", | ||
| fiatValue = "10.00", | ||
| ) | ||
|
|
||
| assertInputText(BTC_INPUT_TAG, "10 000") | ||
| assertInputText(FIAT_INPUT_TAG, "10.00") | ||
| assertPersistedValues( | ||
| btcValue = "10000", | ||
| fiatValue = "10.00", | ||
| ) | ||
| } | ||
|
|
||
| private fun createCalculatorViewModel(): CalculatorViewModel { | ||
| viewModelStore = ViewModelStore() | ||
| return ViewModelProvider( | ||
| viewModelStore, | ||
| object : ViewModelProvider.Factory { | ||
| @Suppress("UNCHECKED_CAST") | ||
| override fun <T : ViewModel> create(modelClass: Class<T>): T { | ||
| return CalculatorViewModel( | ||
| widgetsRepo = widgetsRepo, | ||
| ) as T | ||
| } | ||
| }, | ||
| )[CalculatorViewModel::class.java] | ||
| } | ||
|
|
||
| private fun createCurrencyViewModel(): CurrencyViewModel = ViewModelProvider( | ||
| viewModelStore, | ||
| object : ViewModelProvider.Factory { | ||
| @Suppress("UNCHECKED_CAST") | ||
| override fun <T : ViewModel> create(modelClass: Class<T>): T { | ||
| return CurrencyViewModel( | ||
| currencyRepo = currencyRepo, | ||
| ) as T | ||
| } | ||
| }, | ||
| )[CurrencyViewModel::class.java] | ||
|
|
||
| private fun setCalculatorCard() { | ||
| composeTestRule.setContent { | ||
| AppThemeSurface { | ||
| CalculatorCard( | ||
| currencyViewModel = currencyViewModel, | ||
| calculatorViewModel = calculatorViewModel, | ||
| showWidgetTitle = true, | ||
| modifier = Modifier.fillMaxWidth() | ||
|
Comment on lines
+230
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On this PR's base, Useful? React with 👍 / 👎. |
||
| ) | ||
| } | ||
| } | ||
| composeTestRule.waitForIdle() | ||
| } | ||
|
|
||
| private fun clearCalculatorValues() { | ||
| calculatorViewModel.updateCalculatorValues( | ||
| fiatValue = "", | ||
| btcValue = "", | ||
| ) | ||
| composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { | ||
| val calculatorValues = widgetsRepo.widgetsDataFlow.value.calculatorValues | ||
| calculatorViewModel.calculatorValues.value.btcValue.isEmpty() && | ||
| calculatorViewModel.calculatorValues.value.fiatValue.isEmpty() && | ||
| calculatorValues.btcValue.isEmpty() && | ||
| calculatorValues.fiatValue.isEmpty() | ||
| } | ||
| } | ||
|
|
||
| private fun selectInput(tag: String) { | ||
| composeTestRule.onNodeWithTag(tag) | ||
| .assertIsDisplayed() | ||
| .performClick() | ||
| composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { | ||
| composeTestRule.onAllNodesWithTag(NUMBER_PAD_TAG).fetchSemanticsNodes().isNotEmpty() | ||
|
Comment on lines
+256
to
+260
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The interaction helper assumes UI elements that do not exist in this commit: Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| private fun pressKeys(vararg keys: String) { | ||
| keys.forEach { | ||
| composeTestRule.onNodeWithTag("N$it") | ||
| .assertIsDisplayed() | ||
| .performClick() | ||
| } | ||
| } | ||
|
|
||
| private fun waitForValues( | ||
| btcValue: String, | ||
| fiatValue: String, | ||
| ) { | ||
| runCatching { | ||
| composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { | ||
| calculatorViewModel.calculatorValues.value.btcValue == btcValue && | ||
| calculatorViewModel.calculatorValues.value.fiatValue == fiatValue | ||
| } | ||
| }.onFailure { | ||
| throw AssertionError( | ||
| buildString { | ||
| append("Expected calculatorValues btcValue='$btcValue', fiatValue='$fiatValue', ") | ||
| append("but was '${calculatorViewModel.calculatorValues.value}'. Persisted values were ") | ||
| append("'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'. Semantics tree:\n") | ||
| append(composeTestRule.onRoot(useUnmergedTree = true).printToString()) | ||
| }, | ||
| it, | ||
| ) | ||
| } | ||
|
|
||
| val expectedValues = CalculatorValues( | ||
| btcValue = btcValue, | ||
| fiatValue = fiatValue, | ||
| ) | ||
| runCatching { | ||
| composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { | ||
| widgetsRepo.widgetsDataFlow.value.calculatorValues == expectedValues | ||
| } | ||
| }.onFailure { | ||
| throw AssertionError( | ||
| "Expected persisted values '$expectedValues', but was " + | ||
| "'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'", | ||
| it, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private fun assertInputText( | ||
| inputTag: String, | ||
| text: String, | ||
| ) { | ||
| composeTestRule.onNode( | ||
| inputTextMatcher(inputTag = inputTag, text = text), | ||
| useUnmergedTree = true, | ||
| ).assertIsDisplayed() | ||
| } | ||
|
|
||
| private fun inputTextMatcher( | ||
| inputTag: String, | ||
| text: String, | ||
| ): SemanticsMatcher = hasText(text, substring = true) and hasAnyAncestor(hasTestTag(inputTag)) | ||
|
|
||
| private fun assertPersistedValues( | ||
| btcValue: String, | ||
| fiatValue: String, | ||
| ) { | ||
| assertEquals( | ||
| CalculatorValues( | ||
| btcValue = btcValue, | ||
| fiatValue = fiatValue, | ||
| ), | ||
| widgetsRepo.widgetsDataFlow.value.calculatorValues, | ||
| ) | ||
| } | ||
|
|
||
| companion object { | ||
| private const val BTC_INPUT_TAG = "CalculatorBtcInput" | ||
| private const val FIAT_INPUT_TAG = "CalculatorFiatInput" | ||
| private const val NUMBER_PAD_TAG = "CalculatorNumberPad" | ||
| private const val KEY_DECIMAL_TAG = "Decimal" | ||
| private const val TIMEOUT_MS = 5_000L | ||
| private const val TEST_CREATED_AT = 0L | ||
| private const val TEST_USD_RATE = "100000" | ||
|
|
||
| private val testUsdRate = FxRate( | ||
| symbol = "BTCUSD", | ||
| lastPrice = TEST_USD_RATE, | ||
| base = "BTC", | ||
| baseName = "Bitcoin", | ||
| quote = USD, | ||
| quoteName = "US Dollar", | ||
| currencySymbol = "$", | ||
| currencyFlag = "US", | ||
| lastUpdatedAt = TEST_CREATED_AT, | ||
| ) | ||
| } | ||
|
|
||
| @Module | ||
| @InstallIn(SingletonComponent::class) | ||
| object TestRepoModule { | ||
|
|
||
| @Provides | ||
| fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo | ||
|
|
||
| @Provides | ||
| @Named("enablePolling") | ||
| fun provideEnablePolling(): Boolean = false | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test creates
CalculatorViewModelwith bothwidgetsRepoandcurrencyRepo, but the production class in this commit only acceptswidgetsRepo(seeapp/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.ktconstructor) and the test later calls members that are not on that API. In this state, Android test sources do not compile, socompileDevDebugAndroidTestKotlin/device UI lanes are blocked until the test is rebased to the actual calculator view-model interface.Useful? React with 👍 / 👎.