Skip to content

Commit 61cc72c

Browse files
committed
test: add calculator device tests
1 parent 3c440c9 commit 61cc72c

2 files changed

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

0 commit comments

Comments
 (0)