From b20572e0c1edce4b3c497e91319e00ba381843b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 3 Jun 2026 11:01:01 -0400 Subject: [PATCH] test(ui): add Compose UI tests for UserProfile, WithdrawalDestination, and WithdrawalConfirmation Add Robolectric-based Compose UI tests following the established pattern from AmountEntryScreenTest. Includes receipt, currency conversion, and bonding curve token rendering tests. Extract compose-ui-testing bundle and enable isIncludeAndroidResources globally in the convention plugin. Signed-off-by: Brandon McAnsh --- .../features/myaccount/build.gradle.kts | 3 + .../internal/UserProfileScreenContentTest.kt | 144 +++++++ .../features/withdrawal/build.gradle.kts | 1 + .../WithdrawalConfirmationScreen.kt | 2 +- .../WithdrawalDestinationScreen.kt | 2 +- ...WithdrawalConfirmationScreenContentTest.kt | 369 ++++++++++++++++++ .../WithdrawalDestinationScreenContentTest.kt | 133 +++++++ .../shared/amount-entry/build.gradle.kts | 8 +- .../amountentry/AmountEntryScreenTest.kt | 2 - .../kotlin/AndroidLibraryConventionPlugin.kt | 1 + docs/compose-ui-testing.md | 126 ++++++ gradle/libs.versions.toml | 1 + 12 files changed, 781 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt create mode 100644 apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreenContentTest.kt create mode 100644 apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreenContentTest.kt create mode 100644 docs/compose-ui-testing.md diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 0886a813a..6dc9bc5e6 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -8,6 +8,9 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.bundles.compose.ui.testing) + testImplementation(project(":libs:test-utils")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:contacts")) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt new file mode 100644 index 000000000..4584873ea --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt @@ -0,0 +1,144 @@ +package com.flipcash.app.myaccount.internal + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.flipcash.services.models.SocialAccount +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class UserProfileScreenContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var lastEvent: UserProfileViewModel.Event? = null + + private fun setScreen(state: UserProfileViewModel.State = UserProfileViewModel.State()) { + lastEvent = null + composeTestRule.setContent { + DesignSystem { + UserProfileScreenContent(state = state, dispatch = { lastEvent = it }) + } + } + } + + // --------------------------------------------------------------- + // Display name + // --------------------------------------------------------------- + + @Test + fun `display name shown when present`() { + setScreen(UserProfileViewModel.State(displayName = "Alice")) + composeTestRule.onNodeWithText("Alice").assertIsDisplayed() + } + + @Test + fun `no display name placeholder when null`() { + setScreen(UserProfileViewModel.State(displayName = null)) + composeTestRule.onNodeWithText("No display name set").assertIsDisplayed() + } + + @Test + fun `no display name placeholder when empty`() { + setScreen(UserProfileViewModel.State(displayName = "")) + composeTestRule.onNodeWithText("No display name set").assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Phone + // --------------------------------------------------------------- + + @Test + fun `phone number shown when present`() { + setScreen(UserProfileViewModel.State(phoneNumber = "+1 555-1234")) + composeTestRule.onNodeWithText("+1 555-1234").assertIsDisplayed() + } + + @Test + fun `add phone shown when phone is null`() { + setScreen(UserProfileViewModel.State(phoneNumber = null)) + composeTestRule.onNodeWithText("Add Phone Number").assertIsDisplayed() + } + + @Test + fun `linked for payments subtitle shown when true`() { + setScreen( + UserProfileViewModel.State( + phoneNumber = "+1 555-1234", + phoneLinkedForPayment = true, + ) + ) + composeTestRule.onNodeWithText("Linked for payments").assertIsDisplayed() + } + + @Test + fun `linked for payments subtitle not shown when false`() { + setScreen( + UserProfileViewModel.State( + phoneNumber = "+1 555-1234", + phoneLinkedForPayment = false, + ) + ) + composeTestRule.onNodeWithText("Linked for payments").assertDoesNotExist() + } + + // --------------------------------------------------------------- + // Email + // --------------------------------------------------------------- + + @Test + fun `email shown when present`() { + setScreen(UserProfileViewModel.State(emailAddress = "alice@example.com")) + composeTestRule.onNodeWithText("alice@example.com").assertIsDisplayed() + } + + @Test + fun `add email shown when email is null`() { + setScreen(UserProfileViewModel.State(emailAddress = null)) + composeTestRule.onNodeWithText("Add Email Address").assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Social accounts + // --------------------------------------------------------------- + + @Test + fun `social account username and name displayed`() { + val account = SocialAccount.TwitterX( + id = "1", + username = "testuser", + name = "Test User", + description = "", + profilePicUrl = "", + verifiedType = null, + followerCount = 0, + ) + setScreen(UserProfileViewModel.State(socialAccounts = listOf(account))) + composeTestRule.onNodeWithText("@testuser").assertIsDisplayed() + composeTestRule.onNodeWithText("Test User").assertIsDisplayed() + } + + @Test + fun `no social accounts placeholder when list empty`() { + setScreen(UserProfileViewModel.State(socialAccounts = emptyList())) + composeTestRule.onNodeWithText("No social accounts linked").assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Event dispatch + // --------------------------------------------------------------- + + @Test + fun `tapping add phone dispatches ConnectPhoneClicked`() { + setScreen(UserProfileViewModel.State(phoneNumber = null)) + composeTestRule.onNodeWithText("Add Phone Number").performClick() + assertTrue(lastEvent is UserProfileViewModel.Event.ConnectPhoneClicked) + } +} diff --git a/apps/flipcash/features/withdrawal/build.gradle.kts b/apps/flipcash/features/withdrawal/build.gradle.kts index df7c4e96e..a08848883 100644 --- a/apps/flipcash/features/withdrawal/build.gradle.kts +++ b/apps/flipcash/features/withdrawal/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.bundles.compose.ui.testing) testImplementation(project(":libs:test-utils")) implementation(project(":apps:flipcash:shared:amount-entry")) diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt index de7c222fb..76d361eeb 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt @@ -37,7 +37,7 @@ internal fun WithdrawalConfirmationScreen(viewModel: WithdrawalViewModel) { } @Composable -private fun WithdrawalConfirmationScreenContent( +internal fun WithdrawalConfirmationScreenContent( state: WithdrawalViewModel.State, dispatchEvent: (WithdrawalViewModel.Event) -> Unit ) { diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt index ce9002839..99e0c9a95 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt @@ -44,7 +44,7 @@ internal fun WithdrawalDestinationScreen(viewModel: WithdrawalViewModel) { } @Composable -private fun WithdrawalDestinationScreenContent( +internal fun WithdrawalDestinationScreenContent( state: WithdrawalViewModel.State, dispatchEvent: (WithdrawalViewModel.Event) -> Unit ) { diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreenContentTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreenContentTest.kt new file mode 100644 index 000000000..38e944f67 --- /dev/null +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreenContentTest.kt @@ -0,0 +1,369 @@ +package com.flipcash.app.withdrawal.internal.confirmation + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.flipcash.app.withdrawal.DestinationState +import com.flipcash.app.withdrawal.WithdrawalViewModel +import com.getcode.opencode.compose.ExchangeStub +import com.getcode.opencode.compose.LocalExchange +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.opencode.model.financial.usdf +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.theme.DesignSystem +import com.getcode.utils.network.LocalNetworkObserver +import com.getcode.utils.network.NetworkObserverStub +import com.getcode.view.LoadingSuccessState +import androidx.test.core.app.ApplicationProvider +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertTrue +import kotlin.time.Clock + +@RunWith(RobolectricTestRunner::class) +class WithdrawalConfirmationScreenContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var lastEvent: WithdrawalViewModel.Event? = null + + private val dummyKey = PublicKey(ByteArray(32) { 1 }.toList()) + + private val usdToCadRate = Rate(fx = 1.37161, currency = CurrencyCode.CAD) + private val cadToUsdRate = Rate(fx = 0.72894, currency = CurrencyCode.USD) + + /** + * Creates a custom launchpad token with a specific circulating supply. + * The supply determines the bonding curve price for token-amount rendering. + */ + private fun customToken( + name: String = "TestCoin", + symbol: String = "TEST", + supplyQuarks: Long = 1_000_000_000_000L, // 1M tokens at 6 decimals + ) = MintMetadata( + address = Mint(ByteArray(32) { 2 }.toList()), + decimals = 6, + name = name, + symbol = symbol, + createdAt = Clock.System.now(), + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + authority = dummyKey, + vm = dummyKey, + lockDurationInDays = 21, + ), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = dummyKey, + liquidityPool = dummyKey, + seed = dummyKey, + authority = dummyKey, + mintVault = dummyKey, + coreMintVault = dummyKey, + currentCirculatingSupplyQuarks = supplyQuarks, + sellFeeBps = 100, + price = Fiat(0.01, CurrencyCode.USD), + marketCap = Fiat(10_000.0, CurrencyCode.USD), + ), + socialLinks = emptyList(), + billCustomizations = null, + holderMetrics = HolderMetrics.None, + ) + + private fun testState( + token: TokenWithBalance = TokenWithBalance( + token = Token.usdf, + balance = Fiat(10.0, CurrencyCode.USD), + ), + withdrawalState: LoadingSuccessState = LoadingSuccessState(), + destinationAddress: String = "ABC123def456", + amount: Double = 5.0, + fee: Fiat? = null, + entryRate: Rate = Rate.oneToOne, + ): WithdrawalViewModel.State { + val textFieldState = TextFieldState() + textFieldState.setTextAndPlaceCursorAtEnd(destinationAddress) + return WithdrawalViewModel.State( + token = token, + selectedAmount = VerifiedFiat( + LocalFiat.fromUsd( + usdf = Fiat(amount, CurrencyCode.USD), + rate = entryRate, + ), + null, + ), + entryRate = entryRate, + destinationState = DestinationState(textFieldState = textFieldState), + withdrawalState = withdrawalState, + feeAmount = fee, + ) + } + + private fun setScreen( + state: WithdrawalViewModel.State, + exchange: Exchange? = null, + ) { + lastEvent = null + composeTestRule.setContent { + DesignSystem { + CompositionLocalProvider( + LocalNetworkObserver provides NetworkObserverStub(), + LocalExchange provides (exchange ?: ExchangeStub(context = LocalContext.current)), + ) { + WithdrawalConfirmationScreenContent( + state = state, + dispatchEvent = { lastEvent = it }, + ) + } + } + } + } + + // --------------------------------------------------------------- + // Withdraw button + // --------------------------------------------------------------- + + @Test + fun `withdraw button displayed and enabled when idle`() { + setScreen(testState()) + composeTestRule.onNodeWithText("Withdraw").assertIsDisplayed() + composeTestRule.onNodeWithText("Withdraw").assertIsEnabled() + } + + @Test + fun `withdraw not dispatched during loading`() { + setScreen(testState(withdrawalState = LoadingSuccessState(loading = true))) + // During loading the button text is replaced with a spinner, + // so "Withdraw" text isn't rendered. Verify no event can be dispatched. + composeTestRule.onNodeWithText("Withdraw").assertDoesNotExist() + } + + // --------------------------------------------------------------- + // Destination + // --------------------------------------------------------------- + + @Test + fun `destination address displayed`() { + setScreen(testState(destinationAddress = "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU")) + composeTestRule.onNodeWithText("7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU") + .assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Receipt + // --------------------------------------------------------------- + + @Test + fun `receipt shows withdrawal amount label`() { + setScreen(testState()) + composeTestRule.onNodeWithText("Withdrawal amount").assertIsDisplayed() + } + + @Test + fun `receipt shows you receive label`() { + setScreen(testState()) + composeTestRule.onNodeWithText("You Receive").assertIsDisplayed() + } + + @Test + fun `receipt shows fee lines when fee is present`() { + setScreen(testState(fee = Fiat(0.50, CurrencyCode.USD))) + composeTestRule.onNodeWithText("Less fee").assertIsDisplayed() + composeTestRule.onNodeWithText("Net amount").assertIsDisplayed() + } + + @Test + fun `receipt hides fee lines when fee is null`() { + setScreen(testState(fee = null)) + composeTestRule.onNodeWithText("Less fee").assertDoesNotExist() + composeTestRule.onNodeWithText("Net amount").assertDoesNotExist() + } + + @Test + fun `receipt shows amount in token label with token name`() { + setScreen(testState()) + composeTestRule.onNodeWithText("Amount in USDF", substring = true).assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Receipt — USDF / USD + // --------------------------------------------------------------- + + @Test + fun `receipt shows formatted USD withdrawal amount`() { + setScreen(testState(amount = 5.0)) + // balance is Fiat(10.0, USD) → formatted as "$10.00" + // selectedAmount.localFiat.nativeAmount is Fiat(5.0, USD) + // The receipt renders tokenWithBalance.balance.formatted() + // where balance = selectedAmount.localFiat.nativeAmount = $5.00 + composeTestRule.onNodeWithText("$5.00", substring = true).assertIsDisplayed() + } + + @Test + fun `receipt shows formatted USD fee with negative prefix`() { + setScreen(testState(amount = 5.0, fee = Fiat(0.50, CurrencyCode.USD))) + // fee.formatted(extraPrefix = "-") → "- $0.50" + composeTestRule.onNodeWithText("- $0.50", substring = true).assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Receipt — CAD currency conversion + // --------------------------------------------------------------- + + @Test + fun `receipt shows CAD formatted amounts when entry rate is CAD`() { + val cadRate = usdToCadRate + // $5 USD → ~$6.86 CAD via LocalFiat.fromUsd + setScreen( + testState( + token = TokenWithBalance( + token = Token.usdf, + balance = Fiat(6.86, CurrencyCode.CAD), + ), + amount = 5.0, + entryRate = cadRate, + ), + exchange = ExchangeStub( + providedRates = mapOf( + CurrencyCode.CAD to cadRate, + CurrencyCode.USD to cadToUsdRate, + ), + context = ApplicationProvider.getApplicationContext(), + ), + ) + // nativeAmount = Fiat(5.0 USD).convertingTo(cadRate) ≈ $6.86 CAD + // The receipt renders this amount in the withdrawal line and the "you receive" display. + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Withdrawal amount").assertExists() + val cadNodes = composeTestRule.onAllNodesWithText("6.86", substring = true) + cadNodes.fetchSemanticsNodes().let { nodes -> + assertTrue(nodes.isNotEmpty(), "Expected CAD-formatted amount '6.86' in receipt") + } + } + + @Test + fun `receipt shows CAD fee converted via entry rate`() { + val cadRate = usdToCadRate + // fee = $0.50 USD, entryRate = CAD → feeInEntryCurrency = 0.50 * 1.37161 ≈ $0.69 CAD + setScreen( + testState( + token = TokenWithBalance( + token = Token.usdf, + balance = Fiat(6.86, CurrencyCode.CAD), + ), + amount = 5.0, + fee = Fiat(0.50, CurrencyCode.USD), + entryRate = cadRate, + ), + exchange = ExchangeStub( + providedRates = mapOf( + CurrencyCode.CAD to cadRate, + CurrencyCode.USD to cadToUsdRate, + ), + context = ApplicationProvider.getApplicationContext(), + ), + ) + composeTestRule.onNodeWithText("Less fee").assertExists() + composeTestRule.onNodeWithText("Net amount").assertExists() + } + + // --------------------------------------------------------------- + // Receipt — custom token with bonding curve + // --------------------------------------------------------------- + + @Test + fun `receipt shows token name for custom launchpad token`() { + val token = customToken(name = "Jeffy") + setScreen(testState( + token = TokenWithBalance( + token = token, + balance = Fiat(5.0, CurrencyCode.USD), + displayName = "Jeffy", + ), + amount = 5.0, + )) + composeTestRule.onNodeWithText("Amount in Jeffy", substring = true).assertIsDisplayed() + } + + @Test + fun `receipt renders bonding curve token amount for custom token`() { + // With launchpadMetadata.currentCirculatingSupplyQuarks set, the receipt + // computes token amounts through Estimator.valueExchangeAsTokens + val token = customToken(name = "Jeffy", supplyQuarks = 1_000_000_000_000L) + setScreen(testState( + token = TokenWithBalance( + token = token, + balance = Fiat(5.0, CurrencyCode.USD), + displayName = "Jeffy", + ), + amount = 5.0, + )) + // The "Amount in Jeffy" line should display a computed token amount (not a $ value) + // We verify the label exists; the exact number depends on the bonding curve math. + composeTestRule.onNodeWithText("Amount in Jeffy", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("You Receive").assertIsDisplayed() + } + + @Test + fun `low supply token renders bonding curve amount`() { + // Low supply → tokens are cheaper → more tokens per dollar + val lowSupplyToken = customToken(name = "CoinA", supplyQuarks = 100_000_000L) // 100 tokens + setScreen(testState( + token = TokenWithBalance( + token = lowSupplyToken, + balance = Fiat(5.0, CurrencyCode.USD), + displayName = "CoinA", + ), + amount = 5.0, + )) + composeTestRule.onNodeWithText("Amount in CoinA", substring = true).assertIsDisplayed() + } + + @Test + fun `high supply token renders bonding curve amount`() { + // High supply → tokens are more expensive → fewer tokens per dollar + val highSupplyToken = customToken(name = "CoinB", supplyQuarks = 10_000_000_000_000L) // 10M tokens + setScreen(testState( + token = TokenWithBalance( + token = highSupplyToken, + balance = Fiat(5.0, CurrencyCode.USD), + displayName = "CoinB", + ), + amount = 5.0, + )) + composeTestRule.onNodeWithText("Amount in CoinB", substring = true).assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Event dispatch + // --------------------------------------------------------------- + + @Test + fun `withdraw click dispatches OnWithdraw`() { + setScreen(testState()) + composeTestRule.onNodeWithText("Withdraw").performClick() + assertTrue(lastEvent is WithdrawalViewModel.Event.OnWithdraw) + } +} diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreenContentTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreenContentTest.kt new file mode 100644 index 000000000..5df8813da --- /dev/null +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreenContentTest.kt @@ -0,0 +1,133 @@ +package com.flipcash.app.withdrawal.internal.destination + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.flipcash.app.withdrawal.DestinationState +import com.flipcash.app.withdrawal.WithdrawalViewModel +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.usdf +import com.getcode.opencode.model.transactions.WithdrawalAvailability +import com.getcode.solana.keys.PublicKey +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class WithdrawalDestinationScreenContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var lastEvent: WithdrawalViewModel.Event? = null + + private val testPublicKey = PublicKey(ByteArray(32) { 1 }.toList()) + + private fun testAvailability(isValid: Boolean) = WithdrawalAvailability( + destination = testPublicKey, + isValid = isValid, + kind = WithdrawalAvailability.Kind.TokenAccount, + hasResolvedDestination = false, + resolvedDestination = testPublicKey, + requiresInitialization = false, + feeAmount = null, + ) + + private fun testState( + tokenDisplayName: String = "USDF", + availability: WithdrawalAvailability? = null, + ) = WithdrawalViewModel.State( + token = TokenWithBalance( + token = Token.usdf, + balance = Fiat(10.0, CurrencyCode.USD), + displayName = tokenDisplayName, + ), + selectedAmount = VerifiedFiat(LocalFiat.Zero, null), + destinationState = DestinationState(availability = availability), + ) + + private fun setScreen(state: WithdrawalViewModel.State) { + lastEvent = null + composeTestRule.setContent { + DesignSystem { + WithdrawalDestinationScreenContent(state = state, dispatchEvent = { lastEvent = it }) + } + } + } + + // --------------------------------------------------------------- + // Next button enabled state + // --------------------------------------------------------------- + + @Test + fun `next button disabled when availability is null`() { + setScreen(testState(availability = null)) + composeTestRule.onNodeWithText("Next").assertIsNotEnabled() + } + + @Test + fun `next button disabled when availability is invalid`() { + setScreen(testState(availability = testAvailability(isValid = false))) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Next").assertIsNotEnabled() + } + + @Test + fun `next button enabled when availability is valid`() { + setScreen(testState(availability = testAvailability(isValid = true))) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Next").assertIsEnabled() + } + + // --------------------------------------------------------------- + // Address validity indicators + // --------------------------------------------------------------- + + @Test + fun `valid address text shown when isValid is true`() { + setScreen(testState(availability = testAvailability(isValid = true))) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Valid address").assertIsDisplayed() + } + + @Test + fun `invalid address text shown when isValid is false`() { + setScreen(testState(availability = testAvailability(isValid = false))) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Invalid address").assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Token name in subtitle + // --------------------------------------------------------------- + + @Test + fun `token name appears in subtitle text`() { + setScreen(testState(tokenDisplayName = "USDF")) + composeTestRule.onNodeWithText("Where would you like to withdraw your USDF to?") + .assertIsDisplayed() + } + + // --------------------------------------------------------------- + // Event dispatch + // --------------------------------------------------------------- + + @Test + fun `next click dispatches OnDestinationConfirmed`() { + setScreen(testState(availability = testAvailability(isValid = true))) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Next").performClick() + assertTrue(lastEvent is WithdrawalViewModel.Event.OnDestinationConfirmed) + } +} diff --git a/apps/flipcash/shared/amount-entry/build.gradle.kts b/apps/flipcash/shared/amount-entry/build.gradle.kts index a1db4e8a4..30cd5bbed 100644 --- a/apps/flipcash/shared/amount-entry/build.gradle.kts +++ b/apps/flipcash/shared/amount-entry/build.gradle.kts @@ -6,15 +6,9 @@ android { namespace = "${Gradle.flipcashNamespace}.shared.amountentry" } -android { - testOptions { - unitTests.isIncludeAndroidResources = true - } -} - dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) - testImplementation(libs.robolectric) + testImplementation(libs.bundles.compose.ui.testing) testImplementation(project(":libs:test-utils")) } diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt index 32d6c181e..51d208467 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt @@ -17,13 +17,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) class AmountEntryScreenTest { @get:Rule diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 2aa1e9f26..68c701643 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -36,6 +36,7 @@ class AndroidLibraryConventionPlugin : Plugin { testOptions { unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true } compileOptions { diff --git a/docs/compose-ui-testing.md b/docs/compose-ui-testing.md new file mode 100644 index 000000000..b34bba10e --- /dev/null +++ b/docs/compose-ui-testing.md @@ -0,0 +1,126 @@ +# Compose UI Testing Guide + +This project uses **Robolectric + Compose UI Test** to run Compose screen tests as local JVM unit tests — no emulator or device required. + +## Quick start + +### 1. Add the dependency bundle + +```kotlin +// build.gradle.kts +dependencies { + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.bundles.compose.ui.testing) // adds Robolectric + testImplementation(project(":libs:test-utils")) +} +``` + +The `unit-testing` bundle already includes `ui-test-junit4` and `ui-test-manifest`. The `compose-ui-testing` bundle adds Robolectric. The convention plugin (`flipcash.android.library`) configures `isIncludeAndroidResources = true` globally. + +A global `robolectric.properties` in `:libs:test-utils` pins SDK 35, so you don't need `@Config(sdk = [...])` on each class. + +### 2. Write a test + +```kotlin +@RunWith(RobolectricTestRunner::class) +class MyScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `displays expected text`() { + composeTestRule.setContent { + DesignSystem { + MyScreen(/* ... */) + } + } + + composeTestRule.onNodeWithText("Expected").assertIsDisplayed() + } +} +``` + +### 3. Run + +```bash +./gradlew :your:module:test +``` + +## Pattern: Interface + Fake + +For screens that depend on a ViewModel or delegate, extract an interface for the screen-facing API and test against a fake. This avoids heavy DI/mock setup. + +**Interface** — captures what the screen reads and writes: + +```kotlin +interface MyScreenController { + val state: StateFlow + fun onAction() +} +``` + +**Production** — ViewModel or delegate implements it: + +```kotlin +class MyViewModel : ViewModel(), MyScreenController { + override val state = MutableStateFlow(MyState()) + override fun onAction() { /* ... */ } +} +``` + +**Screen** — takes the interface: + +```kotlin +@Composable +fun MyScreen(controller: MyScreenController) { + val state by controller.state.collectAsStateWithLifecycle() + // ... +} +``` + +**Fake** — trivial test double: + +```kotlin +class FakeMyScreenController( + override val state: MutableStateFlow = MutableStateFlow(MyState()), +) : MyScreenController { + var actionCount = 0 + override fun onAction() { actionCount++ } +} +``` + +**Test** — no mocks, no DI: + +```kotlin +@Test +fun `tapping button triggers action`() { + val controller = FakeMyScreenController() + composeTestRule.setContent { + DesignSystem { MyScreen(controller) } + } + + composeTestRule.onNodeWithText("Do it").performClick() + assertEquals(1, controller.actionCount) +} +``` + +## Key APIs + +| API | Purpose | +|---|---| +| `onNodeWithText("...")` | Find node by visible text | +| `onNodeWithContentDescription("...")` | Find by accessibility label | +| `assertIsDisplayed()` | Assert node is visible | +| `assertIsEnabled()` / `assertIsNotEnabled()` | Assert enabled state | +| `performClick()` | Simulate tap | +| `performTextInput("...")` | Type text | +| `waitForIdle()` | Wait for recomposition after state changes | + +## Tips + +- **Wrap with `DesignSystem { }`** — provides the app's theme, colors, typography, and dimensions. +- **Use `MutableStateFlow` in fakes** — update `.value` to simulate state changes, then call `composeTestRule.waitForIdle()`. +- **Loading spinners hide text** — `CodeButton` replaces text with a spinner during loading. Find the button by test tag or verify behavior after loading completes. +- **First run downloads Robolectric JARs** — subsequent runs are cached. +- **Keep tests in `src/test/`** — these are local JVM tests, not `androidTest`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 297112835..aaccf03bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -313,6 +313,7 @@ firebase = ["firebase-messaging", "firebase-installations"] kotlinx-serialization = ["kotlinx-serialization-core", "kotlinx-serialization-json"] testing = ["junit", "androidx-junit", "espresso-core"] unit-testing = ["kotlinx-coroutines-test", "mockk", "turbine", "testing-slf4j-simple", "testing-compose-uiTestManifest", "testing-androidx-compose-test-junit4", "testing-lifecycle", "testing-androidx-arch-core", "testing-androidx-test-rules"] +compose-ui-testing = ["robolectric"] grpc = ["grpc-okhttp", "grpc-kotlin", "grpc-protobuf-lite", "grpc-stub"] [plugins]