Skip to content

Commit fa75fbd

Browse files
committed
refactor(amount-entry): extract AmountEntryController interface for testability
AmountEntryDelegate now implements AmountEntryController, which captures the screen-facing API (state, config, keypad input). AmountEntryScreen takes the interface instead of the concrete class, enabling Compose UI tests with a trivial FakeController — no Exchange mock, coroutine scope, or flow wiring required. Adds 10 Robolectric Compose UI tests covering action labels, enabled state, loading transitions, confirm callback, slide variant, hints, and keypad forwarding. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 72f41cc commit fa75fbd

12 files changed

Lines changed: 289 additions & 16 deletions

File tree

apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal fun GiveScreenContent(viewModel: CashScreenViewModel) {
1010
val navigator = LocalCodeNavigator.current
1111

1212
AmountEntryScreen(
13-
delegate = viewModel.amountDelegate,
13+
controller = viewModel.amountDelegate,
1414
onConfirm = { viewModel.dispatchEvent(CashScreenViewModel.Event.OnGive) },
1515
onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) },
1616
)

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ private fun SendAmountEntryScreen() {
116116
}
117117

118118
AmountEntryScreen(
119-
delegate = sharedVm.amountDelegate,
119+
controller = sharedVm.amountDelegate,
120120
onConfirm = { sharedVm.dispatchEvent(SendFlowViewModel.Event.OnConfirmRequested) },
121121
onChangeCurrency = { outerNavigator.push(AppRoute.Main.RegionSelection) },
122122
appBar = {

apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ internal fun SwapEntryScreenContent(
1313
val navigator = LocalCodeNavigator.current
1414

1515
AmountEntryScreen(
16-
delegate = viewModel.amountDelegate,
16+
controller = viewModel.amountDelegate,
1717
onConfirm = { viewModel.dispatchEvent(SwapViewModel.Event.OnAmountConfirmed) },
1818
onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) },
1919
)

apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/entry/WithdrawalEntryScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ internal fun WithdrawalEntryScreen(
1313
onOpenRegionSelection: () -> Unit,
1414
) {
1515
AmountEntryScreen(
16-
delegate = viewModel.amountDelegate,
16+
controller = viewModel.amountDelegate,
1717
onConfirm = { viewModel.dispatchEvent(WithdrawalViewModel.Event.OnAmountConfirmed) },
1818
onChangeCurrency = onOpenRegionSelection,
1919
)

apps/flipcash/shared/amount-entry/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ android {
66
namespace = "${Gradle.flipcashNamespace}.shared.amountentry"
77
}
88

9+
android {
10+
testOptions {
11+
unitTests.isIncludeAndroidResources = true
12+
}
13+
}
14+
915
dependencies {
1016
testImplementation(kotlin("test"))
1117
testImplementation(libs.bundles.unit.testing)
18+
testImplementation(libs.robolectric)
1219
testImplementation(project(":libs:test-utils"))
1320
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.flipcash.shared.amountentry
2+
3+
import kotlinx.coroutines.flow.StateFlow
4+
5+
interface AmountEntryController {
6+
val state: StateFlow<AmountEntryDelegate.State>
7+
val config: StateFlow<AmountEntryConfig>
8+
9+
fun onNumber(number: Int)
10+
fun onDecimal()
11+
fun onBackspace()
12+
}

apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class AmountEntryDelegate(
2727
loadingState: StateFlow<LoadingSuccessState> = MutableStateFlow(LoadingSuccessState()),
2828
maxAmount: StateFlow<Fiat?> = MutableStateFlow(null),
2929
minimumAmount: StateFlow<Fiat?> = MutableStateFlow(null),
30-
) {
30+
) : AmountEntryController {
3131
constructor(
3232
exchange: Exchange,
3333
scope: CoroutineScope,
@@ -51,9 +51,9 @@ class AmountEntryDelegate(
5151

5252
private val numberInputHelper = NumberInputHelper()
5353
private val _state = MutableStateFlow(State())
54-
val state: StateFlow<State> = _state.asStateFlow()
54+
override val state: StateFlow<State> = _state.asStateFlow()
5555

56-
val config: StateFlow<AmountEntryConfig> = combine(
56+
override val config: StateFlow<AmountEntryConfig> = combine(
5757
_state, style, loadingState, maxAmount, minimumAmount,
5858
) { delegateState, currentStyle, loading, max, min ->
5959
val isBelowMin = min != null && currentStyle.belowMinHint != null &&
@@ -108,19 +108,19 @@ class AmountEntryDelegate(
108108
_state.update { it.copy(currency = CurrencyHolder(currency)) }
109109
}
110110

111-
fun onNumber(number: Int) {
111+
override fun onNumber(number: Int) {
112112
numberInputHelper.fractionUnits = _state.value.currency.fractionUnits
113113
numberInputHelper.maxLength = maxLength
114114
numberInputHelper.onNumber(number)
115115
updateAnimatedModel(backspace = false)
116116
}
117117

118-
fun onDecimal() {
118+
override fun onDecimal() {
119119
numberInputHelper.onDot()
120120
updateAnimatedModel(backspace = false)
121121
}
122122

123-
fun onBackspace() {
123+
override fun onBackspace() {
124124
numberInputHelper.onBackspace()
125125
updateAnimatedModel(backspace = true)
126126
}

apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryScreen.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ import com.getcode.ui.theme.CodeButton
1919

2020
@Composable
2121
fun AmountEntryScreen(
22-
delegate: AmountEntryDelegate,
22+
controller: AmountEntryController,
2323
onConfirm: () -> Unit,
2424
onChangeCurrency: () -> Unit = {},
2525
appBar: (@Composable () -> Unit)? = null,
2626
) {
27-
val delegateState by delegate.state.collectAsStateWithLifecycle()
28-
val config by delegate.config.collectAsStateWithLifecycle()
27+
val delegateState by controller.state.collectAsStateWithLifecycle()
28+
val config by controller.config.collectAsStateWithLifecycle()
2929

3030
Column(
3131
modifier = Modifier.fillMaxSize(),
@@ -49,9 +49,9 @@ fun AmountEntryScreen(
4949
isClickable = config.canChangeCurrency,
5050
onAmountClicked = onChangeCurrency,
5151
isError = config.hint is AmountEntryHint.Error,
52-
onNumberPressed = { delegate.onNumber(it) },
53-
onBackspace = { delegate.onBackspace() },
54-
onDecimal = { delegate.onDecimal() },
52+
onNumberPressed = { controller.onNumber(it) },
53+
onBackspace = { controller.onBackspace() },
54+
onDecimal = { controller.onDecimal() },
5555
)
5656

5757
Box(modifier = Modifier.fillMaxWidth()) {
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package com.flipcash.shared.amountentry
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.ui.test.assertIsDisplayed
5+
import androidx.compose.ui.test.assertIsEnabled
6+
import androidx.compose.ui.test.assertIsNotEnabled
7+
import androidx.compose.ui.test.junit4.createComposeRule
8+
import androidx.compose.ui.test.onNodeWithText
9+
import androidx.compose.ui.test.performClick
10+
import com.flipcash.app.core.ui.ConfirmationStyle
11+
import com.getcode.theme.DesignSystem
12+
import com.getcode.view.LoadingSuccessState
13+
import kotlinx.coroutines.flow.MutableStateFlow
14+
import kotlinx.coroutines.flow.StateFlow
15+
import kotlinx.coroutines.flow.update
16+
import org.junit.Rule
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.annotation.Config
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertFalse
23+
import kotlin.test.assertTrue
24+
25+
@RunWith(RobolectricTestRunner::class)
26+
@Config(sdk = [35])
27+
class AmountEntryScreenTest {
28+
29+
@get:Rule
30+
val composeTestRule = createComposeRule()
31+
32+
private class FakeController(
33+
state: AmountEntryDelegate.State = AmountEntryDelegate.State(),
34+
config: AmountEntryConfig = AmountEntryConfig(
35+
action = AmountEntryAction(label = "Next"),
36+
),
37+
) : AmountEntryController {
38+
override val state = MutableStateFlow(state)
39+
override val config = MutableStateFlow(config)
40+
41+
val numbersPressed = mutableListOf<Int>()
42+
var decimalPressed = false
43+
var backspacePressed = false
44+
45+
override fun onNumber(number: Int) { numbersPressed += number }
46+
override fun onDecimal() { decimalPressed = true }
47+
override fun onBackspace() { backspacePressed = true }
48+
49+
fun updateConfig(block: (AmountEntryConfig) -> AmountEntryConfig) {
50+
config.update(block)
51+
}
52+
}
53+
54+
private fun setScreen(
55+
controller: FakeController = FakeController(),
56+
onConfirm: () -> Unit = {},
57+
onChangeCurrency: () -> Unit = {},
58+
appBar: (@Composable () -> Unit)? = null,
59+
): FakeController {
60+
composeTestRule.setContent {
61+
DesignSystem {
62+
AmountEntryScreen(
63+
controller = controller,
64+
onConfirm = onConfirm,
65+
onChangeCurrency = onChangeCurrency,
66+
appBar = appBar,
67+
)
68+
}
69+
}
70+
return controller
71+
}
72+
73+
// ---------------------------------------------------------------
74+
// Button action label
75+
// ---------------------------------------------------------------
76+
77+
@Test
78+
fun `displays action button with label from config`() {
79+
setScreen(FakeController(
80+
config = AmountEntryConfig(
81+
action = AmountEntryAction(label = "Send"),
82+
),
83+
))
84+
85+
composeTestRule.onNodeWithText("Send").assertIsDisplayed()
86+
}
87+
88+
@Test
89+
fun `button label updates when config changes`() {
90+
val controller = FakeController(
91+
config = AmountEntryConfig(
92+
action = AmountEntryAction(label = "Buy"),
93+
),
94+
)
95+
setScreen(controller)
96+
97+
composeTestRule.onNodeWithText("Buy").assertIsDisplayed()
98+
99+
controller.updateConfig {
100+
it.copy(action = it.action.copy(label = "Sell"))
101+
}
102+
composeTestRule.waitForIdle()
103+
104+
composeTestRule.onNodeWithText("Sell").assertIsDisplayed()
105+
}
106+
107+
// ---------------------------------------------------------------
108+
// Button enabled state
109+
// ---------------------------------------------------------------
110+
111+
@Test
112+
fun `button is disabled when canConfirm is false`() {
113+
setScreen(FakeController(
114+
config = AmountEntryConfig(
115+
canConfirm = false,
116+
action = AmountEntryAction(label = "Next"),
117+
),
118+
))
119+
120+
composeTestRule.onNodeWithText("Next").assertIsNotEnabled()
121+
}
122+
123+
@Test
124+
fun `button is enabled when canConfirm is true`() {
125+
setScreen(FakeController(
126+
config = AmountEntryConfig(
127+
canConfirm = true,
128+
action = AmountEntryAction(label = "Next"),
129+
),
130+
))
131+
132+
composeTestRule.onNodeWithText("Next").assertIsEnabled()
133+
}
134+
135+
@Test
136+
fun `button does not trigger confirm while loading`() {
137+
var confirmed = false
138+
val controller = FakeController(
139+
config = AmountEntryConfig(
140+
canConfirm = true,
141+
action = AmountEntryAction(
142+
label = "Next",
143+
loadingState = LoadingSuccessState(loading = true),
144+
),
145+
),
146+
)
147+
setScreen(controller, onConfirm = { confirmed = true })
148+
149+
// During loading the button text is replaced with a spinner,
150+
// so we can't find it by text. Instead verify that transitioning
151+
// back to idle re-enables the button and that confirm wasn't fired.
152+
assertFalse(confirmed)
153+
154+
controller.updateConfig {
155+
it.copy(action = it.action.copy(loadingState = LoadingSuccessState()))
156+
}
157+
composeTestRule.waitForIdle()
158+
composeTestRule.onNodeWithText("Next").assertIsEnabled()
159+
}
160+
161+
// ---------------------------------------------------------------
162+
// Confirm callback
163+
// ---------------------------------------------------------------
164+
165+
@Test
166+
fun `clicking button triggers onConfirm`() {
167+
var confirmed = false
168+
setScreen(
169+
controller = FakeController(
170+
config = AmountEntryConfig(
171+
canConfirm = true,
172+
action = AmountEntryAction(label = "Next"),
173+
),
174+
),
175+
onConfirm = { confirmed = true },
176+
)
177+
178+
composeTestRule.onNodeWithText("Next").performClick()
179+
assertTrue(confirmed)
180+
}
181+
182+
// ---------------------------------------------------------------
183+
// Slide-to-confirm variant
184+
// ---------------------------------------------------------------
185+
186+
@Test
187+
fun `displays slide label when action style is Slide`() {
188+
setScreen(FakeController(
189+
config = AmountEntryConfig(
190+
action = AmountEntryAction(
191+
label = "Swipe to send",
192+
style = ConfirmationStyle.Slide,
193+
),
194+
),
195+
))
196+
197+
composeTestRule.onNodeWithText("Swipe to send").assertIsDisplayed()
198+
}
199+
200+
// ---------------------------------------------------------------
201+
// Hint text
202+
// ---------------------------------------------------------------
203+
204+
@Test
205+
fun `displays info hint text`() {
206+
setScreen(FakeController(
207+
config = AmountEntryConfig(
208+
hint = AmountEntryHint.Info("Send up to \$100"),
209+
action = AmountEntryAction(label = "Next"),
210+
),
211+
))
212+
213+
composeTestRule.onNodeWithText("Send up to \$100").assertIsDisplayed()
214+
}
215+
216+
@Test
217+
fun `displays error hint text`() {
218+
setScreen(FakeController(
219+
config = AmountEntryConfig(
220+
hint = AmountEntryHint.Error("Limit exceeded"),
221+
action = AmountEntryAction(label = "Next"),
222+
),
223+
))
224+
225+
composeTestRule.onNodeWithText("Limit exceeded").assertIsDisplayed()
226+
}
227+
228+
// ---------------------------------------------------------------
229+
// Keypad interaction
230+
// ---------------------------------------------------------------
231+
232+
@Test
233+
fun `tapping number key invokes controller onNumber`() {
234+
val controller = setScreen()
235+
236+
composeTestRule.onNodeWithText("5").performClick()
237+
assertEquals(listOf(5), controller.numbersPressed)
238+
}
239+
240+
@Test
241+
fun `tapping multiple number keys invokes controller in order`() {
242+
val controller = setScreen()
243+
244+
composeTestRule.onNodeWithText("1").performClick()
245+
composeTestRule.onNodeWithText("2").performClick()
246+
composeTestRule.onNodeWithText("3").performClick()
247+
assertEquals(listOf(1, 2, 3), controller.numbersPressed)
248+
}
249+
}

libs/datetime/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ dependencies {
1111

1212
testImplementation(kotlin("test"))
1313
testImplementation(libs.robolectric)
14+
testImplementation(project(":libs:test-utils"))
1415
}

0 commit comments

Comments
 (0)