From 02765d3b14dd2075b38f12dd42ac0202f8c4a650 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 7 May 2026 02:47:56 +0200 Subject: [PATCH 1/3] test: add calculator device coverage --- app/build.gradle.kts | 60 ++++ .../to/bitkit/data/keychain/KeychainTest.kt | 4 + .../java/to/bitkit/services/BlocktankTest.kt | 4 + .../to/bitkit/services/OnchainServiceTests.kt | 4 + .../services/RoutingFeeEstimationTest.kt | 4 + .../java/to/bitkit/services/TxBumpingTests.kt | 4 + .../to/bitkit/services/UtxoSelectionTests.kt | 4 + .../bitkit/test/annotations/ComposeUiTest.kt | 5 + .../annotations/CoreServiceIntegrationTest.kt | 5 + .../test/annotations/DeviceIntegrationTest.kt | 5 + .../DeviceStorageIntegrationTest.kt | 5 + .../annotations/DeviceUiIntegrationTest.kt | 5 + .../wallets/send/SendAmountContentTest.kt | 2 + .../screens/widgets/blocks/BlockCardTest.kt | 2 + .../widgets/blocks/BlocksEditScreenTest.kt | 2 + .../widgets/blocks/BlocksPreviewScreenTest.kt | 2 + .../CalculatorCardIntegrationTest.kt | 287 +++++++++++++++++ .../ui/screens/widgets/facts/FactsCardTest.kt | 2 + .../widgets/facts/FactsEditScreenTest.kt | 2 + .../widgets/facts/FactsPreviewScreenTest.kt | 2 + .../widgets/headlines/HeadlineCardTest.kt | 2 + .../headlines/HeadlinesEditContentTest.kt | 2 + .../headlines/HeadlinesPreviewContentTest.kt | 2 + .../widgets/weather/WeatherCardTest.kt | 2 + .../widgets/weather/WeatherEditScreenTest.kt | 2 + .../weather/WeatherPreviewScreenTest.kt | 2 + .../settings/backups/BackupIntroScreenTest.kt | 2 + .../quickPay/QuickPaySettingsScreenTest.kt | 2 + .../settings/support/ReportIssueScreenTest.kt | 2 + .../ui/sheets/BoostTransactionSheetTest.kt | 2 + .../ui/sheets/NewTransactionSheetViewTest.kt | 2 + app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 12 +- .../calculator/CalculatorPreviewScreen.kt | 18 +- .../widgets/calculator/CalculatorViewModel.kt | 291 +++++++++++++++++- .../calculator/components/CalculatorCard.kt | 153 +-------- .../calculator/components/CalculatorInput.kt | 31 -- .../CalculatorFormatter.kt | 56 ---- .../calculator/CalculatorViewModelTest.kt | 210 +++++++++++++ .../components/CalculatorCardStateTest.kt | 3 + 40 files changed, 950 insertions(+), 260 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/test/annotations/ComposeUiTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/annotations/CoreServiceIntegrationTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/annotations/DeviceIntegrationTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/annotations/DeviceStorageIntegrationTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/annotations/DeviceUiIntegrationTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt delete mode 100644 app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f36e188837..30c9ce9920 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,33 @@ val bcp47Locales = listOf( ) val e2eBackendEnv = System.getenv("E2E_BACKEND") ?: "local" val e2eHomegateUrlEnv = System.getenv("E2E_HOMEGATE_URL") ?: "http://127.0.0.1:6288" +val coreServiceIntegrationTestAnnotation = "to.bitkit.test.annotations.CoreServiceIntegrationTest" +val composeUiTestAnnotation = "to.bitkit.test.annotations.ComposeUiTest" +val deviceIntegrationTestAnnotation = "to.bitkit.test.annotations.DeviceIntegrationTest" +val deviceStorageIntegrationTestAnnotation = "to.bitkit.test.annotations.DeviceStorageIntegrationTest" +val deviceUiIntegrationTestAnnotation = "to.bitkit.test.annotations.DeviceUiIntegrationTest" +val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(":") } +val bitkitAndroidTestSuite = providers.gradleProperty("bitkitAndroidTestSuite").orNull +val bitkitAndroidTestAnnotation = when { + requestedTaskNames.any { it == "connectedDevDebugComposeAndroidTest" } -> composeUiTestAnnotation + requestedTaskNames.any { it == "connectedDevDebugCoreServiceIntegrationAndroidTest" } -> { + coreServiceIntegrationTestAnnotation + } + requestedTaskNames.any { it == "connectedDevDebugDeviceStorageIntegrationAndroidTest" } -> { + deviceStorageIntegrationTestAnnotation + } + requestedTaskNames.any { it == "connectedDevDebugDeviceUiIntegrationAndroidTest" } -> { + deviceUiIntegrationTestAnnotation + } + requestedTaskNames.any { it == "connectedDevDebugDeviceIntegrationAndroidTest" } -> deviceIntegrationTestAnnotation + bitkitAndroidTestSuite == "compose" -> composeUiTestAnnotation + bitkitAndroidTestSuite == "core-service" -> coreServiceIntegrationTestAnnotation + bitkitAndroidTestSuite == "device-storage" -> deviceStorageIntegrationTestAnnotation + bitkitAndroidTestSuite == "device-ui" -> deviceUiIntegrationTestAnnotation + bitkitAndroidTestSuite == "integration" -> deviceIntegrationTestAnnotation + bitkitAndroidTestSuite == null -> null + else -> error("Unsupported bitkitAndroidTestSuite '$bitkitAndroidTestSuite'") +} android { namespace = "to.bitkit" @@ -59,6 +86,9 @@ android { versionCode = 181 versionName = "2.2.0" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" + bitkitAndroidTestAnnotation?.let { + testInstrumentationRunnerArguments["annotation"] = it + } vectorDrawables { useSupportLibrary = true } @@ -360,4 +390,34 @@ tasks.withType().configureEach { jvmArgs("-XX:+EnableDynamicAgentLoading") } +tasks.register("connectedDevDebugComposeAndroidTest") { + group = "verification" + description = "Runs devDebug Android tests annotated as Compose UI tests." + dependsOn("connectedDevDebugAndroidTest") +} + +tasks.register("connectedDevDebugDeviceIntegrationAndroidTest") { + group = "verification" + description = "Runs devDebug Android tests annotated as device integration tests." + dependsOn("connectedDevDebugAndroidTest") +} + +tasks.register("connectedDevDebugCoreServiceIntegrationAndroidTest") { + group = "verification" + description = "Runs devDebug Android tests annotated as core service integration tests." + dependsOn("connectedDevDebugAndroidTest") +} + +tasks.register("connectedDevDebugDeviceStorageIntegrationAndroidTest") { + group = "verification" + description = "Runs devDebug Android tests annotated as device storage integration tests." + dependsOn("connectedDevDebugAndroidTest") +} + +tasks.register("connectedDevDebugDeviceUiIntegrationAndroidTest") { + group = "verification" + description = "Runs devDebug Android tests annotated as device UI integration tests." + dependsOn("connectedDevDebugAndroidTest") +} + // endregion diff --git a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt index 206adabe52..406b269566 100644 --- a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt +++ b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt @@ -11,6 +11,8 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import to.bitkit.test.annotations.DeviceStorageIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.data.AppDb import to.bitkit.data.entities.ConfigEntity import to.bitkit.test.BaseAndroidTest @@ -20,6 +22,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) +@DeviceIntegrationTest +@DeviceStorageIntegrationTest class KeychainTest : BaseAndroidTest() { private val appContext by lazy { ApplicationProvider.getApplicationContext() } diff --git a/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt b/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt index dafbce5e2d..f4d55f36b3 100644 --- a/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.CoreServiceIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.env.Env import javax.inject.Inject import kotlin.test.assertEquals @@ -22,6 +24,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue @HiltAndroidTest +@DeviceIntegrationTest +@CoreServiceIntegrationTest class BlocktankTest { @get:Rule var hiltRule = HiltAndroidRule(this) diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt index 706f868725..2532886935 100644 --- a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt @@ -7,12 +7,16 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Network +import to.bitkit.test.annotations.CoreServiceIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.models.toDerivationPath import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) +@DeviceIntegrationTest +@CoreServiceIntegrationTest class OnchainServiceTests { private lateinit var onchainService: OnchainService diff --git a/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt b/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt index c707257347..035ea64921 100644 --- a/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/RoutingFeeEstimationTest.kt @@ -14,6 +14,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.NodeException +import to.bitkit.test.annotations.CoreServiceIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.data.CacheStore import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env @@ -27,6 +29,8 @@ import kotlin.test.assertTrue @HiltAndroidTest @RunWith(AndroidJUnit4::class) +@DeviceIntegrationTest +@CoreServiceIntegrationTest class RoutingFeeEstimationTest { companion object { diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index e46347f454..8ab619d5ed 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -12,6 +12,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import to.bitkit.test.annotations.CoreServiceIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.repositories.WalletRepo @@ -23,6 +25,8 @@ import kotlin.test.assertTrue @HiltAndroidTest @RunWith(AndroidJUnit4::class) +@DeviceIntegrationTest +@CoreServiceIntegrationTest class TxBumpingTests { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt index e239093ce6..5b10e1ea0d 100644 --- a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt @@ -13,6 +13,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.CoinSelectionAlgorithm +import to.bitkit.test.annotations.CoreServiceIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.repositories.WalletRepo @@ -25,6 +27,8 @@ import kotlin.test.fail @HiltAndroidTest @RunWith(AndroidJUnit4::class) +@DeviceIntegrationTest +@CoreServiceIntegrationTest class UtxoSelectionTests { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/test/annotations/ComposeUiTest.kt b/app/src/androidTest/java/to/bitkit/test/annotations/ComposeUiTest.kt new file mode 100644 index 0000000000..0b7581810e --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/annotations/ComposeUiTest.kt @@ -0,0 +1,5 @@ +package to.bitkit.test.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class ComposeUiTest diff --git a/app/src/androidTest/java/to/bitkit/test/annotations/CoreServiceIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/test/annotations/CoreServiceIntegrationTest.kt new file mode 100644 index 0000000000..f36be5a1cc --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/annotations/CoreServiceIntegrationTest.kt @@ -0,0 +1,5 @@ +package to.bitkit.test.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class CoreServiceIntegrationTest diff --git a/app/src/androidTest/java/to/bitkit/test/annotations/DeviceIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceIntegrationTest.kt new file mode 100644 index 0000000000..d3fe905adf --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceIntegrationTest.kt @@ -0,0 +1,5 @@ +package to.bitkit.test.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class DeviceIntegrationTest diff --git a/app/src/androidTest/java/to/bitkit/test/annotations/DeviceStorageIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceStorageIntegrationTest.kt new file mode 100644 index 0000000000..2a99d5c581 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceStorageIntegrationTest.kt @@ -0,0 +1,5 @@ +package to.bitkit.test.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class DeviceStorageIntegrationTest diff --git a/app/src/androidTest/java/to/bitkit/test/annotations/DeviceUiIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceUiIntegrationTest.kt new file mode 100644 index 0000000000..de2a9f00c7 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/annotations/DeviceUiIntegrationTest.kt @@ -0,0 +1,5 @@ +package to.bitkit.test.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class DeviceUiIntegrationTest diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index b0f6392b9a..887f5f527f 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -6,11 +6,13 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.NodeLifecycleState import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel +@ComposeUiTest class SendAmountContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlockCardTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlockCardTest.kt index 3cc2aaee73..e1d9c9909d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlockCardTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlockCardTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class BlockCardTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt index 906a21ebaf..3d4905e240 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt @@ -7,10 +7,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.BlockModel import to.bitkit.models.widget.BlocksPreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class BlocksEditScreenTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt index 3a5393d50e..1cc881b5b9 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt @@ -6,10 +6,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.BlockModel import to.bitkit.models.widget.BlocksPreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class BlocksPreviewContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt new file mode 100644 index 0000000000..f3a41e3c1e --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt @@ -0,0 +1,287 @@ +package to.bitkit.ui.screens.widgets.calculator + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString +import dagger.Module +import dagger.Provides +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import dagger.hilt.components.SingletonComponent +import dagger.hilt.InstallIn +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 to.bitkit.test.annotations.DeviceUiIntegrationTest +import to.bitkit.test.annotations.DeviceIntegrationTest +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.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.USD +import to.bitkit.models.WidgetType +import to.bitkit.models.WidgetsBackupV1 +import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.di.RepoModule +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard +import to.bitkit.ui.theme.AppThemeSurface +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named +import kotlin.test.assertEquals + +@HiltAndroidTest +@UninstallModules(RepoModule::class) +@DeviceIntegrationTest +@DeviceUiIntegrationTest +class CalculatorCardIntegrationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @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 viewModel: CalculatorViewModel + 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(btcValue = "", fiatValue = ""), + ), + ) + ) + currencyRepo.currencyState.first { + it.selectedCurrency == USD && + it.displayUnit == BitcoinDisplayUnit.MODERN && + it.rates.any { rate -> + rate.quote == USD && rate.lastPrice == TEST_USD_RATE + } + } + } + + viewModel = CalculatorViewModel( + widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, + ) + } + + @After + fun tearDown() { + runBlocking { + widgetsStore.restoreFromBackup( + WidgetsBackupV1( + createdAt = TEST_CREATED_AT, + widgets = previousWidgetsData, + ) + ) + settingsStore.update { previousSettingsData } + cacheStore.update { previousCacheData } + } + Locale.setDefault(previousLocale) + } + + @Test + fun btcInputUpdatesFiatValueAndPersistsWidgetState() { + setCalculatorCard() + + composeTestRule.onNodeWithTag(BTC_INPUT_TAG) + .performTextClearance() + composeTestRule.onNodeWithTag(BTC_INPUT_TAG) + .performTextInput("12345") + + waitForValues( + btcValue = "12345", + fiatValue = "12.34", + ) + + composeTestRule.onNodeWithTag(BTC_INPUT_TAG) + .assertTextContains("12 345") + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG) + .assertTextContains("12.34") + assertPersistedValues( + btcValue = "12345", + fiatValue = "12.34", + ) + } + + @Test + fun fiatInputUpdatesBtcValueAndPersistsWidgetState() { + setCalculatorCard() + + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG) + .performTextClearance() + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG) + .performTextInput("10.00") + + waitForValues( + btcValue = "10000", + fiatValue = "10.00", + ) + + composeTestRule.onNodeWithTag(BTC_INPUT_TAG) + .assertTextContains("10 000") + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG) + .assertTextContains("10.00") + assertPersistedValues( + btcValue = "10000", + fiatValue = "10.00", + ) + } + + private fun setCalculatorCard() { + composeTestRule.setContent { + AppThemeSurface { + CalculatorCard( + calculatorViewModel = viewModel, + showWidgetTitle = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + composeTestRule.waitForIdle() + } + + private fun waitForValues( + btcValue: String, + fiatValue: String, + ) { + runCatching { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + viewModel.uiState.value.btcValue == btcValue && + viewModel.uiState.value.fiatValue == fiatValue + } + }.onFailure { + throw AssertionError( + buildString { + append("Expected uiState btcValue='$btcValue', fiatValue='$fiatValue', ") + append("but was '${viewModel.uiState.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 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 TIMEOUT_MS = 5_000L + private const val TEST_CREATED_AT = 0L + + private val testUsdRate = FxRate( + symbol = "BTCUSD", + lastPrice = TEST_USD_RATE, + base = "BTC", + baseName = "Bitcoin", + quote = USD, + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "πŸ‡ΊπŸ‡Έ", + lastUpdatedAt = TEST_CREATED_AT, + ) + + private const val TEST_USD_RATE = "100000" + } + + @Module + @InstallIn(SingletonComponent::class) + object TestRepoModule { + + @Provides + fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo + + @Provides + @Named("enablePolling") + fun provideEnablePolling(): Boolean = false + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsCardTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsCardTest.kt index 5d062aa534..fd8d8be9fa 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsCardTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsCardTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class FactsCardTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsEditScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsEditScreenTest.kt index 36d1d5ebfa..9f4b750779 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsEditScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsEditScreenTest.kt @@ -7,9 +7,11 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.FactsPreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class FactsEditContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreenTest.kt index 14c269e498..507f1eb477 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreenTest.kt @@ -6,9 +6,11 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.FactsPreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class FactsPreviewContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCardTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCardTest.kt index 3f8a6d3ea9..135caf192e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCardTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCardTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class HeadlineCardTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditContentTest.kt index b3ff9239e0..defb84d0c6 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditContentTest.kt @@ -7,10 +7,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class HeadlinesEditContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewContentTest.kt index a69371fc43..bc3046daa2 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewContentTest.kt @@ -5,10 +5,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class HeadlinesPreviewContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt index f7e8eb998a..e39932f000 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt @@ -5,12 +5,14 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.R import to.bitkit.data.dto.FeeCondition import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class WeatherCardTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt index b58f05596d..82b1ab02e4 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt @@ -7,12 +7,14 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.R import to.bitkit.data.dto.FeeCondition import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class WeatherEditScreenTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt index be8dee9726..4096f78a46 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt @@ -6,12 +6,14 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.R import to.bitkit.data.dto.FeeCondition import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class WeatherPreviewContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/backups/BackupIntroScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/backups/BackupIntroScreenTest.kt index d3730264e0..7d5c6137bd 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/backups/BackupIntroScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/backups/BackupIntroScreenTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class BackupIntroScreenTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt index 39fa168f28..28047ed00f 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt @@ -9,9 +9,11 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface @HiltAndroidTest +@ComposeUiTest class QuickPaySettingsScreenTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/support/ReportIssueScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/support/ReportIssueScreenTest.kt index d7e9f2c2b7..a6ca7db6e5 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/support/ReportIssueScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/support/ReportIssueScreenTest.kt @@ -8,8 +8,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.theme.AppThemeSurface +@ComposeUiTest class ReportIssueContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/sheets/BoostTransactionSheetTest.kt b/app/src/androidTest/java/to/bitkit/ui/sheets/BoostTransactionSheetTest.kt index 60da21a7d0..af4e931d4e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/sheets/BoostTransactionSheetTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/sheets/BoostTransactionSheetTest.kt @@ -20,6 +20,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.ui.sheets.BoostTransactionContent import to.bitkit.ui.sheets.BoostTransactionTestTags import to.bitkit.ui.sheets.BoostTransactionUiState @@ -28,6 +29,7 @@ import to.bitkit.ui.theme.AppThemeSurface @HiltAndroidTest @RunWith(AndroidJUnit4::class) +@ComposeUiTest class BoostTransactionContentTest { @get:Rule diff --git a/app/src/androidTest/java/to/bitkit/ui/sheets/NewTransactionSheetViewTest.kt b/app/src/androidTest/java/to/bitkit/ui/sheets/NewTransactionSheetViewTest.kt index 5a7769d36f..6908a4821e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/sheets/NewTransactionSheetViewTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/sheets/NewTransactionSheetViewTest.kt @@ -8,11 +8,13 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before import org.junit.Rule import org.junit.Test +import to.bitkit.test.annotations.ComposeUiTest import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @HiltAndroidTest +@ComposeUiTest class NewTransactionSheetViewTest { @get:Rule diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 262b99f512..d9f6175801 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -590,7 +590,7 @@ private fun RootNavHost( logs(navController) suggestions(navController) support(navController) - widgets(navController, settingsViewModel, currencyViewModel) + widgets(navController, settingsViewModel) update() recoveryMode(navController, appViewModel) @@ -1443,7 +1443,6 @@ private fun NavGraphBuilder.support( private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, - currencyViewModel: CurrencyViewModel, ) { composableWithDefaultTransitions { WidgetsIntroScreen( @@ -1486,7 +1485,6 @@ private fun NavGraphBuilder.widgets( CalculatorPreviewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, - currencyViewModel = currencyViewModel ) } navigationWithDefaultTransitions( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 0553c78fb7..1ec724d2f5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -118,7 +118,6 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToActivityItem import to.bitkit.ui.navigateToAllActivity @@ -752,13 +751,10 @@ private fun Widgets( } WidgetType.CALCULATOR -> { - currencyViewModel?.let { - CalculatorCard( - currencyViewModel = it, - showWidgetTitle = homeUiState.showWidgetTitles, - modifier = Modifier.fillMaxWidth() - ) - } + CalculatorCard( + showWidgetTitle = homeUiState.showWidgetTitles, + modifier = Modifier.fillMaxWidth() + ) } WidgetType.FACTS -> { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 8352afda1f..b7b802332b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -41,22 +41,22 @@ import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.keyboardAsState -import to.bitkit.viewmodels.CurrencyViewModel @Composable fun CalculatorPreviewScreen( viewModel: CalculatorViewModel = hiltViewModel(), - currencyViewModel: CurrencyViewModel?, onClose: () -> Unit, onBack: () -> Unit, ) { val showWidgetTitles by viewModel.showWidgetTitles.collectAsStateWithLifecycle() val isCalculatorWidgetEnabled by viewModel.isCalculatorWidgetEnabled.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() CalculatorPreviewContent( onBack = onBack, isCalculatorWidgetEnabled = isCalculatorWidgetEnabled, showWidgetTitles = showWidgetTitles, + currencySymbol = uiState.currencySymbol, onClickDelete = { viewModel.removeWidget() onClose() @@ -65,7 +65,6 @@ fun CalculatorPreviewScreen( viewModel.saveWidget() onClose() }, - currencyViewModel = currencyViewModel ) } @@ -75,8 +74,9 @@ fun CalculatorPreviewContent( onClickDelete: () -> Unit, onClickSave: () -> Unit, showWidgetTitles: Boolean, - currencyViewModel: CurrencyViewModel?, isCalculatorWidgetEnabled: Boolean, + currencySymbol: String = "$", + showCalculatorCard: Boolean = true, ) { val isKeyboardVisible by keyboardAsState() @@ -129,7 +129,10 @@ fun CalculatorPreviewContent( } BodyM( - text = stringResource(R.string.widgets__facts__description), + text = stringResource(R.string.widgets__calculator__description).replace( + "{fiatSymbol}", + currencySymbol + ), color = Colors.White64, modifier = Modifier .padding(vertical = 16.dp) @@ -154,10 +157,9 @@ fun CalculatorPreviewContent( .testTag("preview_label") ) - currencyViewModel?.let { + if (showCalculatorCard) { CalculatorCard( showWidgetTitle = showWidgetTitles, - currencyViewModel = it, modifier = Modifier.fillMaxWidth() ) } @@ -203,7 +205,7 @@ private fun Preview() { onClickDelete = {}, onClickSave = {}, isCalculatorWidgetEnabled = false, - currencyViewModel = null + showCalculatorCard = false ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 129a93f746..63b99bc6ca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -1,23 +1,55 @@ package to.bitkit.ui.screens.widgets.calculator +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.ext.removeSpaces +import to.bitkit.ext.toLongOrDefault +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.WidgetType +import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency +import to.bitkit.models.formatToModernDisplay import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormatSymbols +import java.util.Locale import javax.inject.Inject +internal const val CALCULATOR_FIAT_DECIMAL_PLACES = 2 + @HiltViewModel class CalculatorViewModel @Inject constructor( - private val widgetsRepo: WidgetsRepo + private val widgetsRepo: WidgetsRepo, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { + companion object { + private const val SUBSCRIPTION_TIMEOUT = 5000L + } + + private val _uiState = MutableStateFlow(CalculatorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var pendingValues: CalculatorValues? = null + private var lastCurrencyKey: CalculatorCurrencyKey? = null + val isCalculatorWidgetEnabled: StateFlow = widgetsRepo.widgetsDataFlow .map { widgetsData -> widgetsData.widgets.any { it.type == WidgetType.CALCULATOR } @@ -27,15 +59,6 @@ class CalculatorViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), initialValue = false ) - val calculatorValues: StateFlow = widgetsRepo.widgetsDataFlow - .map { widgetsData -> - widgetsData.calculatorValues - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = CalculatorValues() - ) val showWidgetTitles: StateFlow = widgetsRepo.showWidgetTitles .stateIn( @@ -43,6 +66,11 @@ class CalculatorViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), initialValue = true ) + + init { + observeCalculatorState() + } + fun removeWidget() { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.CALCULATOR) @@ -55,18 +83,247 @@ class CalculatorViewModel @Inject constructor( } } - fun updateCalculatorValues(fiatValue: String, btcValue: String) { + fun onBtcInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val btcValue = if (displayUnit.isModern()) { + sanitizeIntegerInput(rawValue) + } else { + sanitizeDecimalInput(rawValue) + } + val fiatValue = if (btcValue.isEmpty()) { + "" + } else { + convertBtcToFiat(btcValue, displayUnit) + } + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + ) + ) + } + + fun onFiatInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val fiatValue = sanitizeDecimalInput(rawValue, maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES) + val btcValue = if (fiatValue.isEmpty()) { + "" + } else { + val converted = convertFiatToBtc(fiatValue, displayUnit) + if (displayUnit.isModern()) { + converted.filter { it.isDigit() } + } else { + converted + } + } + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + ) + ) + } + + private fun observeCalculatorState() { viewModelScope.launch { - widgetsRepo.updateCalculatorValues( - calculatorValues = CalculatorValues( - fiatValue = fiatValue, - btcValue = btcValue + combine( + widgetsRepo.widgetsDataFlow + .map { it.calculatorValues } + .distinctUntilChanged(), + currencyRepo.currencyState, + ) { calculatorValues, currencyState -> + calculatorValues to currencyState + }.collect { (storedValues, currencyState) -> + val activeValues = resolveActiveValues(storedValues) + val nextValues = deriveValuesForCurrency( + activeValues = activeValues, + storedValues = storedValues, + currencyState = currencyState, ) + updateUiState(nextValues, currencyState) + } + } + } + + private fun resolveActiveValues(storedValues: CalculatorValues): CalculatorValues { + val pending = pendingValues ?: return storedValues + if (pending == storedValues) { + pendingValues = null + return storedValues + } + return pending + } + + private fun deriveValuesForCurrency( + activeValues: CalculatorValues, + storedValues: CalculatorValues, + currencyState: CurrencyState, + ): CalculatorValues { + val currencyKey = CalculatorCurrencyKey( + selectedCurrency = currencyState.selectedCurrency, + displayUnit = currencyState.displayUnit, + ) + val previousCurrencyKey = lastCurrencyKey + lastCurrencyKey = currencyKey + + val currencyChanged = previousCurrencyKey != null && previousCurrencyKey != currencyKey + val isInitialSync = previousCurrencyKey == null + val shouldRefreshFiat = isInitialSync || currencyChanged || shouldHydrateFiatFromStoredBtc( + storedBtcValue = storedValues.btcValue, + storedFiatValue = storedValues.fiatValue, + currentFiatValue = activeValues.fiatValue, + displayUnit = currencyState.displayUnit, + ) + + if (!shouldRefreshFiat) { + return activeValues + } + if (activeValues.btcValue.isEmpty() || isZeroBtcValue(activeValues.btcValue, currencyState.displayUnit)) { + return activeValues + } + + val convertedFiat = convertBtcToFiat( + btcValue = activeValues.btcValue, + displayUnit = currencyState.displayUnit, + ) + if (convertedFiat.isEmpty()) { + return activeValues + } + + val updatedValues = activeValues.copy(fiatValue = convertedFiat) + updateCalculatorValues(updatedValues) + return updatedValues + } + + private fun updateCalculatorValues(calculatorValues: CalculatorValues) { + pendingValues = calculatorValues + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, + ) + } + viewModelScope.launch { + widgetsRepo.updateCalculatorValues(calculatorValues) + } + } + + private fun updateUiState( + calculatorValues: CalculatorValues, + currencyState: CurrencyState, + ) { + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, + displayUnit = currencyState.displayUnit, + currencySymbol = currencyState.currencySymbol, + selectedCurrency = currencyState.selectedCurrency, ) } } - companion object { - private const val SUBSCRIPTION_TIMEOUT = 5000L + private fun convertBtcToFiat( + btcValue: String, + displayUnit: BitcoinDisplayUnit, + ): String { + val satsOrBtc = btcValue.removeSpaces() + val satsLong = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrDefault() + BitcoinDisplayUnit.CLASSIC -> { + val btcDecimal = satsOrBtc.toBigDecimalOrNull() ?: BigDecimal.ZERO + val satsDecimal = btcDecimal.multiply(BigDecimal(SATS_IN_BTC)) + val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) + roundedNumber.toLong() + } + } + + return currencyRepo.convertSatsToFiat(sats = satsLong).getOrNull()?.formatted.orEmpty() + } + + private fun convertFiatToBtc( + fiatValue: String, + displayUnit: BitcoinDisplayUnit, + ): String { + val fiatDecimal = fiatValue.toBigDecimalOrNull() ?: BigDecimal.ZERO + val satsValue = currencyRepo.convertFiatToSats(fiatDecimal).getOrNull()?.toLong() ?: 0L + + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsValue.formatToModernDisplay() + BitcoinDisplayUnit.CLASSIC -> { + satsValue.asBtc() + .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) + .orEmpty() + } + } + } +} + +@Immutable +data class CalculatorUiState( + val btcValue: String = CalculatorValues().btcValue, + val fiatValue: String = CalculatorValues().fiatValue, + val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, + val currencySymbol: String = "$", + val selectedCurrency: String = "USD", +) + +private data class CalculatorCurrencyKey( + val selectedCurrency: String, + val displayUnit: BitcoinDisplayUnit, +) + +internal fun shouldHydrateFiatFromStoredBtc( + storedBtcValue: String, + storedFiatValue: String, + currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (isZeroBtcValue(storedBtcValue, displayUnit)) { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + +internal fun isZeroBtcValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> btcValue == "0" + BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 +} + +internal fun sanitizeIntegerInput(raw: String): String { + val digits = raw.filter { it.isDigit() } + if (digits.isEmpty()) return digits + return digits.trimStart('0').ifEmpty { "0" } +} + +internal fun sanitizeDecimalInput( + raw: String, + locale: Locale = Locale.getDefault(), + maxDecimalPlaces: Int? = null, +): String { + val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator + val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw + val filtered = normalized.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + val singleDot = if (dotIndex == -1) { + filtered + } else { + filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") } + if (maxDecimalPlaces == null) return singleDot + val cappedDot = singleDot.indexOf('.') + if (cappedDot == -1) return singleDot + val fraction = singleDot.substring(cappedDot + 1) + if (fraction.length <= maxDecimalPlaces) return singleDot + return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 65883ab86d..77ce34190e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -15,11 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,126 +33,31 @@ import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation -import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal - -private const val FIAT_DECIMAL_PLACES = 2 @Composable fun CalculatorCard( modifier: Modifier = Modifier, - currencyViewModel: CurrencyViewModel, calculatorViewModel: CalculatorViewModel = hiltViewModel(), showWidgetTitle: Boolean, ) { - val currencyUiState by currencyViewModel.uiState.collectAsStateWithLifecycle() - val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle() - var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) } - var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) } - val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue } - val displayedFiatValue = fiatValue - - LaunchedEffect( - calculatorValues.btcValue, - calculatorValues.fiatValue, - currencyUiState.displayUnit, - currencyUiState.selectedCurrency, - ) { - if (!shouldHydrateFiatFromStoredBtc( - storedBtcValue = calculatorValues.btcValue, - storedFiatValue = calculatorValues.fiatValue, - currentFiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - ) - ) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = calculatorValues.btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect - } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = calculatorValues.btcValue, - ) - } - - LaunchedEffect(currencyUiState.selectedCurrency, currencyUiState.displayUnit) { - val sourceBtc = btcValue.ifEmpty { calculatorValues.btcValue } - if (sourceBtc.isEmpty() || isZeroBtcValue(sourceBtc, currencyUiState.displayUnit)) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = sourceBtc, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect - } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = sourceBtc, - ) - } + val uiState by calculatorViewModel.uiState.collectAsStateWithLifecycle() CalculatorCardContent( modifier = modifier, showWidgetTitle = showWidgetTitle, - btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = displayedBtcValue, - onBtcChange = { rawValue -> - val sanitized = if (currencyUiState.displayUnit.isModern()) { - sanitizeIntegerInput(rawValue) - } else { - sanitizeDecimalInput(rawValue) - } - btcValue = sanitized - fiatValue = if (sanitized.isEmpty()) { - "" - } else { - CalculatorFormatter.convertBtcToFiat( - btcValue = btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - }, - fiatSymbol = currencyUiState.currencySymbol, - fiatName = currencyUiState.selectedCurrency, - fiatValue = displayedFiatValue, - onFiatChange = { rawValue -> - val sanitized = sanitizeDecimalInput(rawValue, maxDecimalPlaces = FIAT_DECIMAL_PLACES) - fiatValue = sanitized - btcValue = if (sanitized.isEmpty()) { - "" - } else { - val converted = CalculatorFormatter.convertFiatToBtc( - fiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ) - if (currencyUiState.displayUnit.isModern()) { - converted.filter { it.isDigit() } - } else { - converted - } - } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - }, + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + onBtcChange = calculatorViewModel::onBtcInputChanged, + fiatSymbol = uiState.currencySymbol, + fiatName = uiState.selectedCurrency, + fiatValue = uiState.fiatValue, + onFiatChange = calculatorViewModel::onFiatInputChanged, ) } @@ -195,7 +96,9 @@ fun CalculatorCardContent( currencyName = stringResource(R.string.settings__general__unit_bitcoin), keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorBtcInput") ) VerticalSpacer(16.dp) @@ -207,39 +110,15 @@ fun CalculatorCardContent( currencySymbol = fiatSymbol, currencyName = fiatName, keyboardType = KeyboardType.Decimal, - visualTransformation = MonetaryVisualTransformation(decimalPlaces = FIAT_DECIMAL_PLACES), - modifier = Modifier.fillMaxWidth() + visualTransformation = MonetaryVisualTransformation(decimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES), + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorFiatInput") ) } } } -internal fun shouldHydrateFiatFromStoredBtc( - storedBtcValue: String, - storedFiatValue: String, - currentFiatValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean { - if (storedBtcValue.isEmpty()) { - return false - } - if (isZeroBtcValue(storedBtcValue, displayUnit)) { - return false - } - if (storedFiatValue.isNotEmpty()) { - return false - } - return currentFiatValue.isEmpty() -} - -internal fun isZeroBtcValue( - btcValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> btcValue == "0" - BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 -} - @Composable private fun WidgetTitleRow() { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index d7f646d7f8..786f25419f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -23,8 +23,6 @@ import to.bitkit.ui.components.TextInput import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import java.text.DecimalFormatSymbols -import java.util.Locale @Composable fun CalculatorInput( @@ -65,35 +63,6 @@ fun CalculatorInput( ) } -internal fun sanitizeIntegerInput(raw: String): String { - val digits = raw.filter { it.isDigit() } - if (digits.isEmpty()) return digits - return digits.trimStart('0').ifEmpty { "0" } -} - -internal fun sanitizeDecimalInput( - raw: String, - locale: Locale = Locale.getDefault(), - maxDecimalPlaces: Int? = null, -): String { - val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator - val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw - val filtered = normalized.filter { it.isDigit() || it == '.' } - val dotIndex = filtered.indexOf('.') - val singleDot = if (dotIndex == -1) { - filtered - } else { - filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") - } - if (maxDecimalPlaces == null) return singleDot - val cappedDot = singleDot.indexOf('.') - if (cappedDot == -1) return singleDot - val fraction = singleDot.substring(cappedDot + 1) - if (fraction.length <= maxDecimalPlaces) return singleDot - return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) -} - internal fun String.toCalculatorDisplaySymbol(): String { val symbol = trim() return if (symbol.length >= 3) { diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt deleted file mode 100644 index a20270fe3d..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import to.bitkit.ext.removeSpaces -import to.bitkit.ext.toLongOrDefault -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.CLASSIC_DECIMALS -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.models.asBtc -import to.bitkit.models.formatCurrency -import to.bitkit.models.formatToModernDisplay -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal -import java.math.RoundingMode - -object CalculatorFormatter { - - fun convertBtcToFiat( - btcValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String? { - val satsOrBtc = btcValue.removeSpaces() - val satsLong = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrDefault() - BitcoinDisplayUnit.CLASSIC -> { - val btcDecimal = BigDecimal.valueOf(satsOrBtc.toDoubleOrNull() ?: 0.0) - val satsDecimal = btcDecimal.multiply(BigDecimal(SATS_IN_BTC)) - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - roundedNumber.toLong() - } - } - - val fiat = currencyViewModel.convert(sats = satsLong) - return fiat?.formatted - } - - fun convertFiatToBtc( - fiatValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String { - val satsValue = currencyViewModel.convertFiatToSats(fiatValue.toDoubleOrNull() ?: 0.0) - - return when (displayUnit) { - BitcoinDisplayUnit.MODERN -> { - satsValue.formatToModernDisplay() - } - - BitcoinDisplayUnit.CLASSIC -> { - satsValue.asBtc() - .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) - .orEmpty() - } - } - } -} diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt new file mode 100644 index 0000000000..8673206ce2 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -0,0 +1,210 @@ +package to.bitkit.ui.screens.widgets.calculator + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.WidgetsData +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CalculatorViewModelTest : BaseUnitTest() { + + private val widgetsRepo: WidgetsRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val widgetsData = MutableStateFlow(WidgetsData()) + private val currencyState = MutableStateFlow(CurrencyState()) + private var lastConvertedSats = 0L + + private lateinit var sut: CalculatorViewModel + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + widgetsData.value = WidgetsData() + currencyState.value = CurrencyState() + lastConvertedSats = 0L + + whenever(widgetsRepo.widgetsDataFlow).thenReturn(widgetsData) + whenever(widgetsRepo.showWidgetTitles).thenReturn(flowOf(true)) + whenever(currencyRepo.currencyState).thenReturn(currencyState) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + lastConvertedSats = sats + ConvertedAmount( + value = BigDecimal(currentFiatValue()), + formatted = currentFiatValue(), + symbol = currencyState.value.currencySymbol, + currency = currencyState.value.selectedCurrency, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { 12_345uL } + whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { + val calculatorValues = it.getArgument(0) + widgetsData.value = widgetsData.value.copy(calculatorValues = calculatorValues) + Unit + } + } + + @Test + fun `init hydrates fiat value from stored btc`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `init refreshes fiat value when stored fiat already exists`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "1.00", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `onBtcInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0888,,,,,,,.00000000") + advanceUntilIdle() + + assertEquals("88800000000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "88800000000", + fiatValue = "6.25", + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onBtcInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals(CalculatorValues(btcValue = "", fiatValue = ""), widgetsData.value.calculatorValues) + } + + @Test + fun `onBtcInputChanged converts classic btc input to sats`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0.00010000") + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, lastConvertedSats) + } + + @Test + fun `onFiatInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("12.345") + advanceUntilIdle() + + assertEquals("12345", sut.uiState.value.btcValue) + assertEquals("12.34", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "12345", + fiatValue = "12.34", + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onFiatInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals(CalculatorValues(btcValue = "", fiatValue = ""), widgetsData.value.calculatorValues) + } + + @Test + fun `currency change refreshes fiat from active btc value`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + ) + ) + sut = createSut() + advanceUntilIdle() + + currencyState.value = CurrencyState( + selectedCurrency = "EUR", + currencySymbol = "EUR", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("5.50", sut.uiState.value.fiatValue) + assertEquals("EUR", sut.uiState.value.selectedCurrency) + assertEquals("EUR", sut.uiState.value.currencySymbol) + assertEquals("5.50", widgetsData.value.calculatorValues.fiatValue) + } + + private fun createSut() = CalculatorViewModel( + widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, + ) + + private fun currentFiatValue() = when (currencyState.value.selectedCurrency) { + "EUR" -> "5.50" + else -> "6.25" + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt index 7574713a6e..2c593654db 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -3,6 +3,9 @@ package to.bitkit.ui.screens.widgets.calculator.components import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.ui.screens.widgets.calculator.sanitizeDecimalInput +import to.bitkit.ui.screens.widgets.calculator.sanitizeIntegerInput +import to.bitkit.ui.screens.widgets.calculator.shouldHydrateFiatFromStoredBtc import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse From ef5a7e3bbe0bb1ce3723f6c0e7fe700c62c267e9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 7 May 2026 13:52:26 +0200 Subject: [PATCH 2/3] docs: shorten pr test file refs --- .agents/commands/pr.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.agents/commands/pr.md b/.agents/commands/pr.md index c986b8fde5..dabc730bf3 100644 --- a/.agents/commands/pr.md +++ b/.agents/commands/pr.md @@ -136,7 +136,8 @@ When the user provides custom instructions after `--`: #### Automated Checks ``` - Keep local verification commands, Gradle tasks, detekt, lint, unit tests, build passes, cargo test, cargo clippy, npm test, typecheck, CI coverage, or similar automated checks out of `#### Manual Tests`; summarize them under `#### Automated Checks` when they add useful context. -- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed with file paths and a short explanation. +- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed with the test file name and a short explanation. +- When referencing changed test files in QA notes, use the file name only by default, e.g. `SendInvoiceTest.kt`. If multiple changed files share the same file name, use the shortest unique path suffix, e.g. `wallets/send/SendInvoiceTest.kt`. - For removed automated coverage, state why it was removed. - Do not list standard CI or PR bot commands as checkbox items just because they run for every PR. If standard CI coverage is worth mentioning, summarize it in one sentence. - List raw commands only when they were run locally, are non-standard, use special flags or environment values, validate workflow behavior, or explain a meaningful verification gap. @@ -184,9 +185,9 @@ Concrete style target: - [ ] **5b.** back: returns to Connections List. - [ ] **6.** `regression:` Channel Detail β†’ tap Close Connection: works. #### Automated Checks -- Unit tests added: cover invoice timeout handling in `app/src/test/.../SendInvoiceTest.kt`. -- Unit tests modified: update channel navigation assertions in `app/src/test/.../ChannelDetailTest.kt`. -- Test coverage removed: delete stale mock-only assertions from `app/src/test/.../OldFlowTest.kt` because the flow no longer exists. +- Unit tests added: cover invoice timeout handling in `SendInvoiceTest.kt`. +- Unit tests modified: update channel navigation assertions in `ChannelDetailTest.kt`. +- Test coverage removed: delete stale mock-only assertions from `OldFlowTest.kt` because the flow no longer exists. - CI: standard compile, unit test, and detekt checks run by the PR bot. ``` From 1ccc859e39c466df1fb8ec3ae64fbf17f5f65522 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 7 May 2026 14:00:12 +0200 Subject: [PATCH 3/3] docs: split pr qa checks --- .agents/commands/pr.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/.agents/commands/pr.md b/.agents/commands/pr.md index dabc730bf3..9c644a2227 100644 --- a/.agents/commands/pr.md +++ b/.agents/commands/pr.md @@ -125,25 +125,28 @@ When the user provides custom instructions after `--`: - Structure QA Notes according to user's specific manual testing instructions and automated coverage notes - Custom instructions take priority over default generation rules for sections they address - Preserve exact manual testing steps provided by the user (don't summarize or omit details) -- If custom instructions include automated checks or coverage notes, place them under `#### Automated Checks` +- If custom instructions include automated test coverage notes, place them under `#### Automated Tests` +- If custom instructions include commands that were run or validation checks, place them under `#### Checks` **QA Notes / Validation:** -- QA Notes separate actionable human QA instructions from automated verification coverage. +- QA Notes separate actionable human QA instructions, automated test coverage, and command-based validation checks. - Always use this structure: ```md ### QA Notes #### Manual Tests - #### Automated Checks + #### Automated Tests + #### Checks ``` -- Keep local verification commands, Gradle tasks, detekt, lint, unit tests, build passes, cargo test, cargo clippy, npm test, typecheck, CI coverage, or similar automated checks out of `#### Manual Tests`; summarize them under `#### Automated Checks` when they add useful context. -- Use `#### Automated Checks` to summarize automated verification evidence, prioritizing coverage added, modified, or removed with the test file name and a short explanation. +- Keep local verification commands, Gradle tasks, detekt, lint, unit tests, build passes, cargo test, cargo clippy, npm test, typecheck, CI coverage, or similar automated checks out of `#### Manual Tests`. +- Use `#### Automated Tests` to summarize automated test coverage added, modified, or removed with the test file name and a short explanation. +- Use `#### Checks` to list raw commands only when they were run locally, are non-standard, use special flags or environment values, validate workflow behavior, or explain a meaningful verification gap. - When referencing changed test files in QA notes, use the file name only by default, e.g. `SendInvoiceTest.kt`. If multiple changed files share the same file name, use the shortest unique path suffix, e.g. `wallets/send/SendInvoiceTest.kt`. - For removed automated coverage, state why it was removed. - Do not list standard CI or PR bot commands as checkbox items just because they run for every PR. If standard CI coverage is worth mentioning, summarize it in one sentence. -- List raw commands only when they were run locally, are non-standard, use special flags or environment values, validate workflow behavior, or explain a meaningful verification gap. -- For workflow behavior validation, include `(after merge)` in the automated check item because workflow changes only take effect for PRs opened after the workflow update merges. +- For workflow behavior validation, include `(after merge)` in the checks item because workflow changes only take effect for PRs opened after the workflow update merges. - If no actionable manual validation exists, write `N/A` under `#### Manual Tests`. -- If no automated checks were run and no automated coverage changed, write `N/A` under `#### Automated Checks`. +- If no automated test coverage changed, write `N/A` under `#### Automated Tests`. +- If no commands or validation checks are worth listing, write `N/A` under `#### Checks`. - Write manual tests using this template: ```md - [ ] **{numbering}.** {optional_condition + β†’} {screen_action} β†’ {next_screen_action}: expectation @@ -158,7 +161,7 @@ When the user provides custom instructions after `--`: - Use short-form wording like `in-sheet` for sheet screens, `nav` for navigation, `back` for back nav, and `LN` for Lightning Network. **For library repos (has `bindings/` directory or `Cargo.toml`):** -Structure manual QA around integration validation only. Automated checks belong under `#### Automated Checks`. +Structure manual QA around integration validation only. Automated test coverage belongs under `#### Automated Tests`, and commands belong under `#### Checks`. Example: ``` @@ -166,8 +169,9 @@ Example: #### Manual Tests - [ ] **1.** Consumer app β†’ exercise updated binding flow: behavior matches previous release. - [ ] **2.** `regression:` Android integration screen β†’ trigger changed API path: no crash or stale data. -#### Automated Checks +#### Automated Tests - Binding tests added: cover updated Android API path in `bindings/android/...`. +#### Checks - CI: standard cargo and binding checks run by the PR bot. ``` @@ -184,10 +188,11 @@ Concrete style target: - [ ] **5a.** Settings β†’ Lightning Connections β†’ tap channel: still opens Channel Detail. - [ ] **5b.** back: returns to Connections List. - [ ] **6.** `regression:` Channel Detail β†’ tap Close Connection: works. -#### Automated Checks +#### Automated Tests - Unit tests added: cover invoice timeout handling in `SendInvoiceTest.kt`. - Unit tests modified: update channel navigation assertions in `ChannelDetailTest.kt`. - Test coverage removed: delete stale mock-only assertions from `OldFlowTest.kt` because the flow no longer exists. +#### Checks - CI: standard compile, unit test, and detekt checks run by the PR bot. ```