Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
package to.bitkit.test.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class CalculatorWidget
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
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 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.CalculatorWidget
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)
@CalculatorWidget
@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()
viewModelStore = ViewModelStore()

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 = emptyCalculatorValues,
),
)
).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 == emptyCalculatorValues
}
}

calculatorViewModel = createCalculatorViewModel()
currencyViewModel = createCurrencyViewModel()
}

@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()

replaceInput(BTC_INPUT_INDEX, "12340")

waitForValues(
btcValue = "12340",
fiatValue = "12.34",
)

assertInputText(BTC_INPUT_INDEX, "12 340")
assertInputText(FIAT_INPUT_INDEX, "12.34")
assertPersistedValues(
btcValue = "12340",
fiatValue = "12.34",
)
}

@Test
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
setCalculatorCard()

replaceInput(FIAT_INPUT_INDEX, "10.00")

waitForValues(
btcValue = "10000",
fiatValue = "10.00",
)

assertInputText(BTC_INPUT_INDEX, "10 000")
assertInputText(FIAT_INPUT_INDEX, "10.00")
assertPersistedValues(
btcValue = "10000",
fiatValue = "10.00",
)
}

private fun createCalculatorViewModel(): CalculatorViewModel {
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 thread
ovitrif marked this conversation as resolved.
}
},
)[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 thread
ovitrif marked this conversation as resolved.
)
}
}
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT
}
}

private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index]
Comment thread
ovitrif marked this conversation as resolved.

private fun replaceInput(
index: Int,
text: String,
) {
inputAt(index).performTextClearance()
inputAt(index).performTextInput(text)
}

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(
inputIndex: Int,
text: String,
) {
inputAt(inputIndex).assertTextContains(text, substring = true)
composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true)
.assertIsDisplayed()
}

private fun assertPersistedValues(
btcValue: String,
fiatValue: String,
) {
assertEquals(
CalculatorValues(
btcValue = btcValue,
fiatValue = fiatValue,
),
widgetsRepo.widgetsDataFlow.value.calculatorValues,
)
}

companion object {
private const val BTC_INPUT_INDEX = 0
private const val FIAT_INPUT_INDEX = 1
private const val INPUT_COUNT = 2
private const val TIMEOUT_MS = 5_000L
private const val TEST_CREATED_AT = 0L
private const val TEST_USD_RATE = "100000"

private val emptyCalculatorValues = CalculatorValues(
btcValue = "",
fiatValue = "",
)

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