Skip to content

Commit 78253c8

Browse files
committed
test: add calculator device tests
1 parent 74056bd commit 78253c8

2 files changed

Lines changed: 362 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package to.bitkit.test.annotations
2+
3+
@Retention(AnnotationRetention.RUNTIME)
4+
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
5+
annotation class DeviceUiIntegration
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package to.bitkit.ui.screens.widgets.calculator
2+
3+
import androidx.compose.foundation.layout.fillMaxWidth
4+
import androidx.compose.ui.Modifier
5+
import androidx.compose.ui.test.SemanticsMatcher
6+
import androidx.compose.ui.test.assertIsDisplayed
7+
import androidx.compose.ui.test.hasAnyAncestor
8+
import androidx.compose.ui.test.hasTestTag
9+
import androidx.compose.ui.test.hasText
10+
import androidx.compose.ui.test.junit4.createComposeRule
11+
import androidx.compose.ui.test.onAllNodesWithTag
12+
import androidx.compose.ui.test.onNodeWithTag
13+
import androidx.compose.ui.test.onRoot
14+
import androidx.compose.ui.test.performClick
15+
import androidx.compose.ui.test.printToString
16+
import androidx.lifecycle.ViewModel
17+
import androidx.lifecycle.ViewModelProvider
18+
import androidx.lifecycle.ViewModelStore
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import dagger.Module
21+
import dagger.Provides
22+
import dagger.hilt.InstallIn
23+
import dagger.hilt.android.testing.HiltAndroidRule
24+
import dagger.hilt.android.testing.HiltAndroidTest
25+
import dagger.hilt.android.testing.UninstallModules
26+
import dagger.hilt.components.SingletonComponent
27+
import kotlinx.coroutines.flow.first
28+
import kotlinx.coroutines.runBlocking
29+
import org.junit.After
30+
import org.junit.Before
31+
import org.junit.Rule
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
import to.bitkit.data.AppCacheData
35+
import to.bitkit.data.CacheStore
36+
import to.bitkit.data.SettingsData
37+
import to.bitkit.data.SettingsStore
38+
import to.bitkit.data.WidgetsData
39+
import to.bitkit.data.WidgetsStore
40+
import to.bitkit.di.RepoModule
41+
import to.bitkit.models.BitcoinDisplayUnit
42+
import to.bitkit.models.FxRate
43+
import to.bitkit.models.USD
44+
import to.bitkit.models.WidgetType
45+
import to.bitkit.models.WidgetWithPosition
46+
import to.bitkit.models.WidgetsBackupV1
47+
import to.bitkit.models.widget.CalculatorValues
48+
import to.bitkit.repositories.AmountInputHandler
49+
import to.bitkit.repositories.CurrencyRepo
50+
import to.bitkit.repositories.WidgetsRepo
51+
import to.bitkit.test.annotations.DeviceIntegration
52+
import to.bitkit.test.annotations.DeviceUiIntegration
53+
import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard
54+
import to.bitkit.ui.theme.AppThemeSurface
55+
import java.util.Locale
56+
import javax.inject.Inject
57+
import javax.inject.Named
58+
import kotlin.test.assertEquals
59+
60+
@HiltAndroidTest
61+
@UninstallModules(RepoModule::class)
62+
@RunWith(AndroidJUnit4::class)
63+
@DeviceIntegration
64+
@DeviceUiIntegration
65+
class CalculatorCardIntegrationTest {
66+
67+
@get:Rule
68+
val hiltRule = HiltAndroidRule(this)
69+
70+
@get:Rule
71+
val composeTestRule = createComposeRule()
72+
73+
@Inject
74+
lateinit var widgetsRepo: WidgetsRepo
75+
76+
@Inject
77+
lateinit var currencyRepo: CurrencyRepo
78+
79+
@Inject
80+
lateinit var widgetsStore: WidgetsStore
81+
82+
@Inject
83+
lateinit var settingsStore: SettingsStore
84+
85+
@Inject
86+
lateinit var cacheStore: CacheStore
87+
88+
private lateinit var viewModelStore: ViewModelStore
89+
private lateinit var viewModel: CalculatorViewModel
90+
private lateinit var previousWidgetsData: WidgetsData
91+
private lateinit var previousSettingsData: SettingsData
92+
private lateinit var previousCacheData: AppCacheData
93+
private lateinit var previousLocale: Locale
94+
95+
@Before
96+
fun setUp() {
97+
previousLocale = Locale.getDefault()
98+
Locale.setDefault(Locale.US)
99+
hiltRule.inject()
100+
101+
runBlocking {
102+
previousWidgetsData = widgetsStore.data.first()
103+
previousSettingsData = settingsStore.data.first()
104+
previousCacheData = cacheStore.data.first()
105+
106+
settingsStore.update {
107+
it.copy(
108+
selectedCurrency = USD,
109+
displayUnit = BitcoinDisplayUnit.MODERN,
110+
showWidgetTitles = true,
111+
)
112+
}
113+
cacheStore.update { it.copy(cachedRates = listOf(testUsdRate)) }
114+
widgetsStore.restoreFromBackup(
115+
WidgetsBackupV1(
116+
createdAt = TEST_CREATED_AT,
117+
widgets = WidgetsData(
118+
widgets = listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)),
119+
calculatorValues = CalculatorValues(),
120+
),
121+
)
122+
).getOrThrow()
123+
124+
currencyRepo.currencyState.first {
125+
it.selectedCurrency == USD &&
126+
it.displayUnit == BitcoinDisplayUnit.MODERN &&
127+
it.rates.any { rate -> rate.quote == USD && rate.lastPrice == TEST_USD_RATE }
128+
}
129+
widgetsRepo.widgetsDataFlow.first {
130+
it.widgets == listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)) &&
131+
it.calculatorValues == CalculatorValues()
132+
}
133+
}
134+
135+
viewModel = createViewModel()
136+
clearCalculatorValues()
137+
}
138+
139+
@After
140+
fun tearDown() {
141+
if (::viewModelStore.isInitialized) {
142+
viewModelStore.clear()
143+
}
144+
runBlocking {
145+
widgetsStore.restoreFromBackup(
146+
WidgetsBackupV1(
147+
createdAt = TEST_CREATED_AT,
148+
widgets = previousWidgetsData,
149+
)
150+
).getOrThrow()
151+
settingsStore.update { previousSettingsData }
152+
cacheStore.update { previousCacheData }
153+
}
154+
Locale.setDefault(previousLocale)
155+
}
156+
157+
@Test
158+
fun btcInputUpdatesFiatValueAndPersistsWidgetState() {
159+
setCalculatorCard()
160+
161+
selectInput(BTC_INPUT_TAG)
162+
pressKeys("1", "2", "3", "4", "0")
163+
164+
waitForValues(
165+
btcValue = "12340",
166+
fiatValue = "12.34",
167+
)
168+
169+
assertInputText(BTC_INPUT_TAG, "12 340")
170+
assertInputText(FIAT_INPUT_TAG, "12.34")
171+
assertPersistedValues(
172+
btcValue = "12340",
173+
fiatValue = "12.34",
174+
)
175+
}
176+
177+
@Test
178+
fun fiatInputUpdatesBtcValueAndPersistsWidgetState() {
179+
setCalculatorCard()
180+
181+
selectInput(FIAT_INPUT_TAG)
182+
pressKeys("1", "0", KEY_DECIMAL_TAG, "0", "0")
183+
184+
waitForValues(
185+
btcValue = "10000",
186+
fiatValue = "10.00",
187+
)
188+
189+
assertInputText(BTC_INPUT_TAG, "10 000")
190+
assertInputText(FIAT_INPUT_TAG, "10.00")
191+
assertPersistedValues(
192+
btcValue = "10000",
193+
fiatValue = "10.00",
194+
)
195+
}
196+
197+
private fun createViewModel(): CalculatorViewModel {
198+
viewModelStore = ViewModelStore()
199+
return ViewModelProvider(
200+
viewModelStore,
201+
object : ViewModelProvider.Factory {
202+
@Suppress("UNCHECKED_CAST")
203+
override fun <T : ViewModel> create(modelClass: Class<T>): T {
204+
return CalculatorViewModel(
205+
widgetsRepo = widgetsRepo,
206+
currencyRepo = currencyRepo,
207+
) as T
208+
}
209+
},
210+
)[CalculatorViewModel::class.java]
211+
}
212+
213+
private fun setCalculatorCard() {
214+
composeTestRule.setContent {
215+
AppThemeSurface {
216+
CalculatorCard(
217+
calculatorViewModel = viewModel,
218+
modifier = Modifier.fillMaxWidth()
219+
)
220+
}
221+
}
222+
composeTestRule.waitForIdle()
223+
}
224+
225+
private fun clearCalculatorValues() {
226+
viewModel.onBtcInputChanged("")
227+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
228+
val calculatorValues = widgetsRepo.widgetsDataFlow.value.calculatorValues
229+
viewModel.uiState.value.btcValue.isEmpty() &&
230+
viewModel.uiState.value.fiatValue.isEmpty() &&
231+
calculatorValues.btcValue.isEmpty() &&
232+
calculatorValues.fiatValue.isEmpty() &&
233+
calculatorValues.satsValue == 0L
234+
}
235+
}
236+
237+
private fun selectInput(tag: String) {
238+
composeTestRule.onNodeWithTag(tag)
239+
.assertIsDisplayed()
240+
.performClick()
241+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
242+
composeTestRule.onAllNodesWithTag(NUMBER_PAD_TAG).fetchSemanticsNodes().isNotEmpty()
243+
}
244+
}
245+
246+
private fun pressKeys(vararg keys: String) {
247+
keys.forEach {
248+
composeTestRule.onNodeWithTag("N$it")
249+
.assertIsDisplayed()
250+
.performClick()
251+
}
252+
}
253+
254+
private fun waitForValues(
255+
btcValue: String,
256+
fiatValue: String,
257+
) {
258+
runCatching {
259+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
260+
viewModel.uiState.value.btcValue == btcValue &&
261+
viewModel.uiState.value.fiatValue == fiatValue
262+
}
263+
}.onFailure {
264+
throw AssertionError(
265+
buildString {
266+
append("Expected uiState btcValue='$btcValue', fiatValue='$fiatValue', ")
267+
append("but was '${viewModel.uiState.value}'. Persisted values were ")
268+
append("'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'. Semantics tree:\n")
269+
append(composeTestRule.onRoot(useUnmergedTree = true).printToString())
270+
},
271+
it,
272+
)
273+
}
274+
275+
val expectedValues = CalculatorValues(
276+
btcValue = btcValue,
277+
fiatValue = fiatValue,
278+
satsValue = btcValue.toLong(),
279+
displayUnit = BitcoinDisplayUnit.MODERN,
280+
)
281+
runCatching {
282+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
283+
widgetsRepo.widgetsDataFlow.value.calculatorValues == expectedValues
284+
}
285+
}.onFailure {
286+
throw AssertionError(
287+
"Expected persisted values '$expectedValues', but was " +
288+
"'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'",
289+
it,
290+
)
291+
}
292+
}
293+
294+
private fun assertInputText(
295+
inputTag: String,
296+
text: String,
297+
) {
298+
composeTestRule.onNode(
299+
inputTextMatcher(inputTag = inputTag, text = text),
300+
useUnmergedTree = true,
301+
).assertIsDisplayed()
302+
}
303+
304+
private fun inputTextMatcher(
305+
inputTag: String,
306+
text: String,
307+
): SemanticsMatcher = hasText(text, substring = true) and hasAnyAncestor(hasTestTag(inputTag))
308+
309+
private fun assertPersistedValues(
310+
btcValue: String,
311+
fiatValue: String,
312+
) {
313+
assertEquals(
314+
CalculatorValues(
315+
btcValue = btcValue,
316+
fiatValue = fiatValue,
317+
satsValue = btcValue.toLong(),
318+
displayUnit = BitcoinDisplayUnit.MODERN,
319+
),
320+
widgetsRepo.widgetsDataFlow.value.calculatorValues,
321+
)
322+
}
323+
324+
companion object {
325+
private const val BTC_INPUT_TAG = "CalculatorBtcInput"
326+
private const val FIAT_INPUT_TAG = "CalculatorFiatInput"
327+
private const val NUMBER_PAD_TAG = "CalculatorNumberPad"
328+
private const val KEY_DECIMAL_TAG = "Decimal"
329+
private const val TIMEOUT_MS = 5_000L
330+
private const val TEST_CREATED_AT = 0L
331+
private const val TEST_USD_RATE = "100000"
332+
333+
private val testUsdRate = FxRate(
334+
symbol = "BTCUSD",
335+
lastPrice = TEST_USD_RATE,
336+
base = "BTC",
337+
baseName = "Bitcoin",
338+
quote = USD,
339+
quoteName = "US Dollar",
340+
currencySymbol = "$",
341+
currencyFlag = "US",
342+
lastUpdatedAt = TEST_CREATED_AT,
343+
)
344+
}
345+
346+
@Module
347+
@InstallIn(SingletonComponent::class)
348+
object TestRepoModule {
349+
350+
@Provides
351+
fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo
352+
353+
@Provides
354+
@Named("enablePolling")
355+
fun provideEnablePolling(): Boolean = false
356+
}
357+
}

0 commit comments

Comments
 (0)