Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +207 to +209
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align test with current CalculatorViewModel constructor

This test creates CalculatorViewModel with both widgetsRepo and currencyRepo, but the production class in this commit only accepts widgetsRepo (see app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt constructor) and the test later calls members that are not on that API. In this state, Android test sources do not compile, so compileDevDebugAndroidTestKotlin/device UI lanes are blocked until the test is rebased to the actual calculator view-model interface.

Useful? React with 👍 / 👎.

}
},
)[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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the required calculator card dependencies

On this PR's base, CalculatorCard still requires a CurrencyViewModel and showWidgetTitle in addition to the calculator view model, but this test only supplies calculatorViewModel and modifier. That makes compileDevDebugAndroidTestKotlin fail before the new DeviceUiIntegration lane can run, even after fixing the separate CalculatorViewModel constructor/API mismatch already noted in the existing review.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Target existing semantics nodes in calculator UI test

The interaction helper assumes UI elements that do not exist in this commit: selectInput clicks CalculatorBtcInput/CalculatorFiatInput and then waits for CalculatorNumberPad, but CalculatorCard renders plain CalculatorInput fields and does not attach those test tags or a calculator number pad (checked app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt). In this state both new tests fail in selectInput (or later pressKeys) before validating conversion logic, so the new Device UI lane cannot provide the intended coverage.

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
}
}
Loading