Skip to content

Commit 67755de

Browse files
authored
Merge pull request #944 from synonymdev/test/calculator-widget-tests
test: add calculator widget device tests
2 parents c666e85 + 24a68a4 commit 67755de

3 files changed

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

0 commit comments

Comments
 (0)