Skip to content

Commit 566af19

Browse files
committed
test: add calculator device tests
1 parent d4e542d commit 566af19

2 files changed

Lines changed: 360 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: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
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+
viewModel.uiState.value.btcValue.isEmpty() &&
234+
viewModel.uiState.value.fiatValue.isEmpty() &&
235+
widgetsRepo.widgetsDataFlow.value.calculatorValues == CalculatorValues()
236+
}
237+
}
238+
239+
private fun selectInput(tag: String) {
240+
composeTestRule.onNodeWithTag(tag)
241+
.assertIsDisplayed()
242+
.performClick()
243+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
244+
composeTestRule.onAllNodesWithTag(NUMBER_PAD_TAG).fetchSemanticsNodes().isNotEmpty()
245+
}
246+
}
247+
248+
private fun pressKeys(vararg keys: String) {
249+
keys.forEach {
250+
composeTestRule.onNodeWithTag("N$it")
251+
.assertIsDisplayed()
252+
.performClick()
253+
}
254+
}
255+
256+
private fun waitForValues(
257+
btcValue: String,
258+
fiatValue: String,
259+
) {
260+
runCatching {
261+
composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) {
262+
viewModel.uiState.value.btcValue == btcValue &&
263+
viewModel.uiState.value.fiatValue == fiatValue
264+
}
265+
}.onFailure {
266+
throw AssertionError(
267+
buildString {
268+
append("Expected uiState btcValue='$btcValue', fiatValue='$fiatValue', ")
269+
append("but was '${viewModel.uiState.value}'. Persisted values were ")
270+
append("'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'. Semantics tree:\n")
271+
append(composeTestRule.onRoot(useUnmergedTree = true).printToString())
272+
},
273+
it,
274+
)
275+
}
276+
277+
val expectedValues = CalculatorValues(
278+
btcValue = btcValue,
279+
fiatValue = fiatValue,
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+
),
318+
widgetsRepo.widgetsDataFlow.value.calculatorValues,
319+
)
320+
}
321+
322+
companion object {
323+
private const val BTC_INPUT_TAG = "CalculatorBtcInput"
324+
private const val FIAT_INPUT_TAG = "CalculatorFiatInput"
325+
private const val NUMBER_PAD_TAG = "CalculatorNumberPad"
326+
private const val KEY_DECIMAL_TAG = "Decimal"
327+
private const val TIMEOUT_MS = 5_000L
328+
private const val TEST_CREATED_AT = 0L
329+
private const val TEST_USD_RATE = "100000"
330+
331+
private val testUsdRate = FxRate(
332+
symbol = "BTCUSD",
333+
lastPrice = TEST_USD_RATE,
334+
base = "BTC",
335+
baseName = "Bitcoin",
336+
quote = USD,
337+
quoteName = "US Dollar",
338+
currencySymbol = "$",
339+
currencyFlag = "US",
340+
lastUpdatedAt = TEST_CREATED_AT,
341+
)
342+
}
343+
344+
@Module
345+
@InstallIn(SingletonComponent::class)
346+
object TestRepoModule {
347+
348+
@Provides
349+
fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo
350+
351+
@Provides
352+
@Named("enablePolling")
353+
fun provideEnablePolling(): Boolean = false
354+
}
355+
}

0 commit comments

Comments
 (0)