diff --git a/apps/flipcash/features/cash/build.gradle.kts b/apps/flipcash/features/cash/build.gradle.kts index 198a072b3..d4d70a7f1 100644 --- a/apps/flipcash/features/cash/build.gradle.kts +++ b/apps/flipcash/features/cash/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) diff --git a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt index a6edbba75..f3b2f3439 100644 --- a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt +++ b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt @@ -4,7 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.cash.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange @@ -19,7 +18,7 @@ import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithLocalizedBalance import com.getcode.solana.keys.Mint -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,7 +44,7 @@ class CashScreenViewModelTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val resources: ResourceHelper = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val exchange: Exchange = mockk(relaxed = true) private val verifiedFiatCalculator: VerifiedFiatCalculator = mockk(relaxed = true) private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) @@ -59,14 +58,6 @@ class CashScreenViewModelTest { fun setUp() { BottomBarManager.clear() - // Stub resource strings used by the ViewModel - every { resources.getString(R.string.error_title_youNeedMoreCash) } returns "error_title_youNeedMoreCash" - every { resources.getString(R.string.error_description_youNeedMoreCash) } returns "error_description_youNeedMoreCash" - every { resources.getString(R.string.action_addMoreCash) } returns "action_addMoreCash" - every { resources.getString(R.string.action_dismiss) } returns "action_dismiss" - every { resources.getString(R.string.error_title_sendLimitReached) } returns "error_title_sendLimitReached" - every { resources.getString(R.string.error_description_sendLimitReached) } returns "error_description_sendLimitReached" - // Default stubs for flows consumed in init every { exchange.observePreferredRate() } returns emptyFlow() every { exchange.preferredRate } returns Rate.oneToOne diff --git a/apps/flipcash/features/contact-verification/build.gradle.kts b/apps/flipcash/features/contact-verification/build.gradle.kts index 4dfcb93d3..79ce2da5c 100644 --- a/apps/flipcash/features/contact-verification/build.gradle.kts +++ b/apps/flipcash/features/contact-verification/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.mockito.kotlin) implementation(libs.bundles.kotlinx.serialization) diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt index aeb7907de..8f52910a1 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt @@ -1,15 +1,13 @@ package com.flipcash.app.contact.verification.internal.email -import com.flipcash.features.contact.verification.R import com.flipcash.app.core.verification.email.EmailCodeChannel import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.EmailVerificationError import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -33,26 +31,13 @@ class EmailVerificationViewModelErrorTest { // Mockito for Result-returning methods (MockK double-boxes Result inline class) private val verificationController: ContactVerificationController = mock() private val profileController = mockk(relaxed = true) - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private lateinit var dispatchers: TestDispatchers @Before fun setUp() { BottomBarManager.clear() - - every { resources.getString(R.string.error_title_failedToSendCodeToEmail) } returns "error_title_failedToSendCodeToEmail" - every { resources.getString(R.string.error_description_failedToSendCodeToEmail) } returns "error_description_failedToSendCodeToEmail" - every { resources.getString(R.string.error_title_maxAttemptsReached) } returns "error_title_maxAttemptsReached" - every { resources.getString(R.string.error_description_maxAttemptsReached) } returns "error_description_maxAttemptsReached" - every { resources.getString(R.string.error_title_emailVerificationFailed) } returns "error_title_emailVerificationFailed" - every { resources.getString(R.string.error_description_emailVerificationFailed) } returns "error_description_emailVerificationFailed" - every { resources.getString(R.string.error_title_emailVerificationLinkInvalid) } returns "error_title_emailVerificationLinkInvalid" - every { resources.getString(R.string.error_description_emailVerificationLinkInvalid) } returns "error_description_emailVerificationLinkInvalid" - every { resources.getString(R.string.error_title_emailVerificationLinkExpired) } returns "error_title_emailVerificationLinkExpired" - every { resources.getString(R.string.error_description_emailVerificationLinkExpired) } returns "error_description_emailVerificationLinkExpired" - every { resources.getString(R.string.action_resendVerificationEmail) } returns "action_resendVerificationEmail" - every { resources.getString(R.string.action_ok) } returns "OK" } @After diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt index b994678ce..3741fdfd6 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt @@ -2,16 +2,14 @@ package com.flipcash.app.contact.verification.internal.phone import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.PhoneUtils -import com.flipcash.features.contact.verification.R import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.PhoneVerificationError import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -38,22 +36,13 @@ class PhoneVerificationViewModelErrorTest { private val profileController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) private val featureFlags = mockk(relaxed = true) - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private lateinit var dispatchers: TestDispatchers @Before fun setUp() { BottomBarManager.clear() - - every { resources.getString(R.string.error_title_failedToSendCodeToPhone) } returns "error_title_failedToSendCodeToPhone" - every { resources.getString(R.string.error_description_failedToSendCodeToPhone) } returns "error_description_failedToSendCodeToPhone" - every { resources.getString(R.string.error_title_maxAttemptsReached) } returns "error_title_maxAttemptsReached" - every { resources.getString(R.string.error_description_maxAttemptsReached) } returns "error_description_maxAttemptsReached" - every { resources.getString(R.string.error_title_deviceNotSupported) } returns "error_title_deviceNotSupported" - every { resources.getString(R.string.error_description_deviceNotSupported) } returns "error_description_deviceNotSupported" - every { resources.getString(R.string.error_description_invalidVerificationCode) } returns "error_description_invalidVerificationCode" - every { resources.getString(R.string.error_description_codeTimedOut) } returns "error_description_codeTimedOut" } @After diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index c32940a2c..84c19ccfb 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.mockito.kotlin) implementation(project(":apps:flipcash:shared:accesskey")) diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt index 412f40607..0072f29ca 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt @@ -6,11 +6,10 @@ import com.flipcash.app.auth.AuthManager import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers -import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -41,7 +40,7 @@ class LoginViewModelErrorTest { private val accounts: AccountController = mock() // MockK for everything else - private val resources: ResourceHelper = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) private val userManager: UserManager = mockk(relaxed = true) private val featureFlags: FeatureFlagController = mockk(relaxed = true) @@ -54,10 +53,6 @@ class LoginViewModelErrorTest { // android.util.Base64 is stubbed in unit tests; mock it so encodeBase64() doesn't NPE mockkStatic(android.util.Base64::class) every { android.util.Base64.encodeToString(any(), any()) } answers { java.util.Base64.getEncoder().encodeToString(firstArg()) } - every { resources.getString(R.string.error_title_createAccountFailed) } returns "error_title_createAccountFailed" - every { resources.getString(R.string.error_description_createAccountFailed) } returns "error_description_createAccountFailed" - every { resources.getString(R.string.error_title_loginFailed) } returns "error_title_loginFailed" - every { resources.getString(R.string.error_description_loginFailed) } returns "error_description_loginFailed" } @After diff --git a/apps/flipcash/features/transactions/build.gradle.kts b/apps/flipcash/features/transactions/build.gradle.kts index dbf9728e9..01090039d 100644 --- a/apps/flipcash/features/transactions/build.gradle.kts +++ b/apps/flipcash/features/transactions/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.mockito.kotlin) implementation(libs.compose.paging) diff --git a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionHistoryViewModelErrorTest.kt b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionHistoryViewModelErrorTest.kt index 5e288b880..afcc4eb07 100644 --- a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionHistoryViewModelErrorTest.kt +++ b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionHistoryViewModelErrorTest.kt @@ -3,13 +3,12 @@ package com.flipcash.app.transactions.internal import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.transactions.R import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.solana.keys.PublicKey -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import io.mockk.every @@ -39,7 +38,7 @@ class TransactionHistoryViewModelErrorTest { private val transactionController: TransactionOperations = mock() private val featureFlags = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val accountCluster = mockk(relaxed = true) @@ -50,8 +49,6 @@ class TransactionHistoryViewModelErrorTest { BottomBarManager.clear() every { userManager.accountCluster } returns accountCluster - every { resources.getString(R.string.error_title_failedToCancelTransfer) } returns "error_title_failedToCancelTransfer" - every { resources.getString(R.string.error_description_failedToCancelTransfer) } returns "error_description_failedToCancelTransfer" } @After diff --git a/apps/flipcash/features/withdrawal/build.gradle.kts b/apps/flipcash/features/withdrawal/build.gradle.kts index e259c982f..cac773750 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(testFixtures(project(":ui:resources"))) testImplementation(libs.bundles.compose.ui.testing) implementation(project(":apps:flipcash:shared:amount-entry")) diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt index bddea72f3..586afae30 100644 --- a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt @@ -5,13 +5,12 @@ import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.features.withdrawal.R import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiatCalculator -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import io.mockk.every @@ -32,7 +31,7 @@ class WithdrawalViewModelErrorTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val exchange = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) private val userManager = mockk(relaxed = true) @@ -48,9 +47,6 @@ class WithdrawalViewModelErrorTest { @Before fun setUp() { BottomBarManager.clear() - - every { resources.getString(R.string.error_title_failedWithdrawal) } returns "error_title_failedWithdrawal" - every { resources.getString(R.string.error_description_failedWithdrawal) } returns "error_description_failedWithdrawal" } @After diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index cad89c8e1..572525c26 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -28,7 +28,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -124,7 +125,8 @@ class ChatCoordinatorEagerBalanceTest { triggerCollection() val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD) chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = otherId, amount = amount))) - advanceUntilIdle() + advanceTimeBy(1_000) + runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(mint, amount) } coordinator.reset() @@ -134,7 +136,8 @@ class ChatCoordinatorEagerBalanceTest { fun `self-sent cash message does not trigger tokenCoordinator add`() = runTest(testDispatchers.dispatcher) { triggerCollection() chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = selfId))) - advanceUntilIdle() + advanceTimeBy(1_000) + runCurrent() coVerify(exactly = 0) { tokenCoordinator.add(any(), any()) } coordinator.reset() @@ -144,7 +147,8 @@ class ChatCoordinatorEagerBalanceTest { fun `text message does not trigger tokenCoordinator add`() = runTest(testDispatchers.dispatcher) { triggerCollection() chatUpdatesChannel.send(chatUpdate(textMessage(senderId = otherId))) - advanceUntilIdle() + advanceTimeBy(1_000) + runCurrent() coVerify(exactly = 0) { tokenCoordinator.add(any(), any()) } coordinator.reset() @@ -160,7 +164,8 @@ class ChatCoordinatorEagerBalanceTest { val msg2 = cashMessage(senderId = otherId, amount = amount2, mint = mintB).copy(messageId = 3L) chatUpdatesChannel.send(chatUpdate(msg1, msg2)) - advanceUntilIdle() + advanceTimeBy(1_000) + runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(mint, amount1) } coVerify(exactly = 1) { tokenCoordinator.add(mintB, amount2) } @@ -174,7 +179,8 @@ class ChatCoordinatorEagerBalanceTest { val outgoing = cashMessage(senderId = selfId).copy(messageId = 3L) chatUpdatesChannel.send(chatUpdate(incoming, outgoing)) - advanceUntilIdle() + advanceTimeBy(1_000) + runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(any(), any()) } coordinator.reset() diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index 2753d7315..ce533dd75 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:contacts")) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index e1f3fca25..3a53e19fa 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.session.internal import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.core.MainCoroutineRule +import kotlinx.coroutines.test.TestCoroutineScheduler import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.internal.bill.BillController @@ -17,7 +19,7 @@ import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.GiftCardAccount import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.getcode.utils.network.NetworkConnectivityListener import io.mockk.every import io.mockk.mockk @@ -42,12 +44,13 @@ class SessionControllerGiftCardErrorTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val tokenCoordinator = mockk(relaxed = true) private val analytics = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) + private val dispatchers = TestDispatchers(TestCoroutineScheduler()) @Before fun setUp() { @@ -55,17 +58,6 @@ class SessionControllerGiftCardErrorTest { every { userManager.accountCluster } returns accountCluster every { networkObserver.isConnected } returns true - - every { resources.getString(R.string.error_title_alreadyCollected) } returns "error_title_alreadyCollected" - every { resources.getString(R.string.error_description_alreadyCollected) } returns "error_description_alreadyCollected" - every { resources.getString(R.string.error_title_linkExpired) } returns "error_title_linkExpired" - every { resources.getString(R.string.error_description_linkExpired) } returns "error_description_linkExpired" - every { resources.getString(R.string.error_title_failedToCollect) } returns "error_title_failedToCollect" - every { resources.getString(R.string.error_description_failedToCollect) } returns "error_description_failedToCollect" - every { resources.getString(R.string.error_title_CashReturnedToWallet) } returns "error_title_CashReturnedToWallet" - every { resources.getString(R.string.error_description_CashReturnedToWallet) } returns "error_description_CashReturnedToWallet" - every { resources.getString(R.string.error_title_failedToCreateGiftCard) } returns "error_title_failedToCreateGiftCard" - every { resources.getString(R.string.error_description_failedToCreateGiftCard) } returns "error_description_failedToCreateGiftCard" } @After @@ -101,6 +93,7 @@ class SessionControllerGiftCardErrorTest { appSettingsCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), purchaseMethodController = mockk(relaxed = true), + dispatchers = dispatchers, ) } @@ -237,8 +230,8 @@ class SessionControllerGiftCardErrorTest { val sendAction = updatedState.primaryAction as BillState.Action.SendAsLink sendAction.action() - // Wait for IO-dispatched coroutines to execute - Thread.sleep(1000) + // Advance test dispatcher so IO-dispatched coroutines execute + dispatchers.dispatcher.scheduler.advanceUntilIdle() // The guard should ensure fundGiftCard is called exactly once, not twice verify(exactly = 1) { diff --git a/apps/flipcash/shared/tokens/build.gradle.kts b/apps/flipcash/shared/tokens/build.gradle.kts index dc744b5ae..a107d66cf 100644 --- a/apps/flipcash/shared/tokens/build.gradle.kts +++ b/apps/flipcash/shared/tokens/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.mockito.kotlin) api(project(":apps:flipcash:shared:tokens:core")) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt index 0e4b6fcaa..44f8c7ec2 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt @@ -8,11 +8,13 @@ import com.getcode.opencode.model.accounts.AccountType import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey +import com.flipcash.app.core.dispatchers.TestDispatchers import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After @@ -30,6 +32,8 @@ class UsdcDepositSweepTest { private val owner: AccountCluster = mockk(relaxed = true) + private val testDispatchers = TestDispatchers(TestCoroutineScheduler()) + private lateinit var sweep: UsdcDepositSweep @Before @@ -43,6 +47,7 @@ class UsdcDepositSweepTest { accountController = accountController, tokenCoordinator = tokenCoordinator, balancePoller = balancePoller, + dispatchers = testDispatchers, maxRetries = 3, initialDelay = 10.milliseconds, backoffFactor = 1.0, @@ -74,12 +79,11 @@ class UsdcDepositSweepTest { } @Test - fun `gives up after max retries when USDC account is not found`() = runTest { + fun `gives up after max retries when USDC account is not found`() = runTest(testDispatchers.dispatcher) { stubNoUsdcAccount() sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -87,12 +91,11 @@ class UsdcDepositSweepTest { } @Test - fun `gives up after max retries when account type is not AssociatedToken`() = runTest { + fun `gives up after max retries when account type is not AssociatedToken`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L, type = AccountType.Primary) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -100,12 +103,11 @@ class UsdcDepositSweepTest { } @Test - fun `gives up after max retries when USDC balance stays zero`() = runTest { + fun `gives up after max retries when USDC balance stays zero`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 0L) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -113,7 +115,7 @@ class UsdcDepositSweepTest { } @Test - fun `retries until balance appears then sweeps`() = runTest { + fun `retries until balance appears then sweeps`() = runTest(testDispatchers.dispatcher) { var callCount = 0 coEvery { accountController.getAccount(any(), any(), any()) @@ -131,7 +133,6 @@ class UsdcDepositSweepTest { sweep.execute(owner) advanceUntilIdle() - Thread.sleep(300) coVerify { transactionOperations.swapUsdc(owner, 2_000_000L) @@ -139,14 +140,13 @@ class UsdcDepositSweepTest { } @Test - fun `calls swapUsdc with correct amount when balance is positive`() = runTest { + fun `calls swapUsdc with correct amount when balance is positive`() = runTest(testDispatchers.dispatcher) { val amount = 5_000_000L stubUsdcAccount(balance = amount) coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify { transactionOperations.swapUsdc(owner, amount) @@ -154,13 +154,12 @@ class UsdcDepositSweepTest { } @Test - fun `polls for USDF balance on successful swap`() = runTest { + fun `polls for USDF balance on successful swap`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L) coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify { balancePoller.awaitBalanceChange( @@ -174,7 +173,7 @@ class UsdcDepositSweepTest { } @Test - fun `does not poll for USDF balance when swap fails`() = runTest { + fun `does not poll for USDF balance when swap fails`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L) coEvery { transactionOperations.swapUsdc(any(), any()) @@ -182,7 +181,6 @@ class UsdcDepositSweepTest { sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify(exactly = 0) { balancePoller.awaitBalanceChange(any(), any(), any(), any(), any()) @@ -190,7 +188,7 @@ class UsdcDepositSweepTest { } @Test - fun `completes gracefully when USDF balance poll times out`() = runTest { + fun `completes gracefully when USDF balance poll times out`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L) coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) coEvery { @@ -199,7 +197,6 @@ class UsdcDepositSweepTest { sweep.execute(owner) advanceUntilIdle() - Thread.sleep(200) coVerify { balancePoller.awaitBalanceChange(any(), any(), any(), any(), any()) @@ -207,7 +204,7 @@ class UsdcDepositSweepTest { } @Test - fun `does not execute concurrently when job is active`() = runTest { + fun `does not execute concurrently when job is active`() = runTest(testDispatchers.dispatcher) { stubUsdcAccount(balance = 1_000_000L) coEvery { transactionOperations.swapUsdc(any(), any()) } coAnswers { kotlinx.coroutines.delay(500) @@ -216,8 +213,7 @@ class UsdcDepositSweepTest { sweep.execute(owner) sweep.execute(owner) - - Thread.sleep(700) + advanceUntilIdle() coVerify(exactly = 1) { transactionOperations.swapUsdc(any(), any()) @@ -225,7 +221,7 @@ class UsdcDepositSweepTest { } @Test - fun `cancel stops active job`() = runTest { + fun `cancel stops active job`() = runTest(testDispatchers.dispatcher) { coEvery { accountController.getAccount(any(), any(), any()) } coAnswers { @@ -240,9 +236,8 @@ class UsdcDepositSweepTest { coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) sweep.execute(owner) - Thread.sleep(50) sweep.cancel() - Thread.sleep(100) + advanceUntilIdle() coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 6275c5e46..10e7187a5 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -7,7 +7,6 @@ import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.user.UserManager -import com.flipcash.shared.tokens.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange @@ -21,7 +20,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.utils.generate import com.getcode.solana.keys.PublicKey -import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.onramp.PhantomWalletController @@ -57,7 +56,7 @@ class SwapViewModelErrorTest { private val verifiedFiatCalculator = mockk(relaxed = true) // Mockito for Result-returning methods (MockK double-boxes Result inline class) private val transactionController: TransactionOperations = mock() - private val resources = mockk(relaxed = true) + private val resources = FakeResourceHelper() private val tokenCoordinator = mockk(relaxed = true) private val feedCoordinator = mockk(relaxed = true) private val analytics = mockk(relaxed = true) @@ -79,8 +78,6 @@ class SwapViewModelErrorTest { every { PublicKey.generate() } returns mockk(relaxed = true) every { userManager.accountCluster } returns accountCluster - every { resources.getString(R.string.error_title_buySellFailed) } returns "error_title_buySellFailed" - every { resources.getString(R.string.error_description_buySellFailed) } returns "error_description_buySellFailed" // Stub limits StateFlow so init block doesn't NPE on null flow whenever(transactionController.limits).thenReturn(MutableStateFlow(null)) diff --git a/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt b/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt index 7ff35f732..a735ba2f6 100644 --- a/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt +++ b/libs/currency/src/main/kotlin/com/getcode/utils/Currency.kt @@ -62,8 +62,8 @@ fun formatAmountString( resources: ResourceHelper, currency: Currency, amount: Double, - kinSuffix: String = resources.getKinSuffix(), - suffix: String = resources.getOfKinSuffix() + kinSuffix: String = resources.getString(com.getcode.util.resources.R.string.core_kin), + suffix: String = resources.getString(com.getcode.util.resources.R.string.core_ofKin) ): String { val isKin = currency.code == Currency.Kin.code diff --git a/ui/resources/build.gradle.kts b/ui/resources/build.gradle.kts index 2feac0555..cd0dc3c18 100644 --- a/ui/resources/build.gradle.kts +++ b/ui/resources/build.gradle.kts @@ -4,9 +4,15 @@ plugins { android { namespace = "${Gradle.codeNamespace}.util.resources" + testFixtures { + enable = true + } } dependencies { + testFixturesImplementation(platform(libs.compose.bom)) + testFixturesImplementation(libs.compose.ui) + api(libs.androidx.annotation) api(libs.androidx.appcompat) api(libs.androidx.core) diff --git a/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt b/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt index 311276abf..a2fc26c4f 100644 --- a/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt +++ b/ui/resources/src/main/java/com/getcode/util/resources/AndroidResources.kt @@ -96,12 +96,4 @@ class AndroidResources( override fun getFont(fontResId: Int): Typeface? { return runCatching { ResourcesCompat.getFont(context, fontResId) }.getOrNull() } - - override fun getOfKinSuffix(): String { - return getString(R.string.core_ofKin) - } - - override fun getKinSuffix(): String { - return getString(R.string.core_kin) - } } \ No newline at end of file diff --git a/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt b/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt index be2efab2a..f129e1b0f 100644 --- a/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt +++ b/ui/resources/src/main/java/com/getcode/util/resources/ResourceHelper.kt @@ -38,9 +38,6 @@ interface ResourceHelper { fun getIdentifier(name: String, type: ResourceType): Int? fun getFont(@FontRes fontResId: Int): Typeface? - - fun getOfKinSuffix(): String - fun getKinSuffix(): String } sealed interface ResourceType { @@ -84,8 +81,4 @@ private object NoOpResources : ResourceHelper { override fun getFont(fontResId: Int): Typeface? = null - override fun getOfKinSuffix(): String = "" - - override fun getKinSuffix(): String = "" - } \ No newline at end of file diff --git a/ui/resources/src/testFixtures/kotlin/com/getcode/util/resources/FakeResourceHelper.kt b/ui/resources/src/testFixtures/kotlin/com/getcode/util/resources/FakeResourceHelper.kt new file mode 100644 index 000000000..973ede92b --- /dev/null +++ b/ui/resources/src/testFixtures/kotlin/com/getcode/util/resources/FakeResourceHelper.kt @@ -0,0 +1,123 @@ +package com.getcode.util.resources + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import java.io.File +import java.net.URL +import java.net.URLClassLoader +import java.util.jar.JarFile + +class FakeResourceHelper(vararg rClasses: Class<*>) : ResourceHelper { + + private val nameMap: Map = if (rClasses.isNotEmpty()) { + buildMapFrom(rClasses) + } else { + discoverFromClassLoader() + } + + private val overrides = mutableMapOf() + + fun stub(resourceId: Int, value: String): FakeResourceHelper = apply { + overrides[resourceId] = value + } + + override fun getString(resourceId: Int): String = + overrides[resourceId] ?: nameMap[resourceId] ?: resourceId.toString() + + override fun getString(resourceId: Int, vararg formatArgs: Any): String { + val template = overrides[resourceId] ?: nameMap[resourceId] ?: return resourceId.toString() + return runCatching { template.format(*formatArgs) }.getOrDefault(template) + } + + override fun getRawResource(resourceId: Int): String = "" + override fun getQuantityString(id: Int, quantity: Int, vararg formatArgs: Any, default: String): String = default + override fun getDimension(dimenId: Int, default: Float): Float = default + override fun getDimensionPixelSize(dimenId: Int, default: Int): Int = default + override fun getDir(name: String, mode: Int): File? = null + override val displayMetrics: DisplayMetrics get() = DisplayMetrics() + override fun getDrawable(drawableResId: Int): Drawable? = null + override fun getIdentifier(name: String, type: ResourceType): Int? = null + override fun getFont(fontResId: Int): Typeface? = null + + companion object { + private fun buildMapFrom(rClasses: Array>): Map = buildMap { + for (rClass in rClasses) { + for (nested in rClass.declaredClasses) { + for (field in nested.declaredFields) { + runCatching { put(field.getInt(null), field.name) } + } + } + } + } + + private fun discoverFromClassLoader(): Map = buildMap { + val urls = collectUrls() + for (url in urls) { + runCatching { + val file = File(url.toURI()) + if (file.isDirectory) { + scanDirectory(file, file, this) + } else if (file.extension == "jar") { + scanJar(file, this) + } + } + } + } + + private fun collectUrls(): List { + val urls = mutableListOf() + var cl: ClassLoader? = FakeResourceHelper::class.java.classLoader + while (cl != null) { + if (cl is URLClassLoader) { + urls.addAll(cl.urLs) + } + cl = cl.parent + } + if (urls.isEmpty()) { + val classpath = System.getProperty("java.class.path") ?: return emptyList() + for (entry in classpath.split(File.pathSeparator)) { + runCatching { urls.add(File(entry).toURI().toURL()) } + } + } + return urls + } + + private fun scanDirectory(root: File, dir: File, map: MutableMap) { + for (file in dir.listFiles() ?: return) { + if (file.isDirectory) { + scanDirectory(root, file, map) + } else if (file.name.startsWith("R\$") && file.name.endsWith(".class")) { + val className = file.relativeTo(root).path + .removeSuffix(".class") + .replace(File.separatorChar, '.') + loadRClassFields(className, map) + } + } + } + + private fun scanJar(jar: File, map: MutableMap) { + runCatching { + JarFile(jar).use { jf -> + val entries = jf.entries() + while (entries.hasMoreElements()) { + val name = entries.nextElement().name + if (name.contains("/R\$") && name.endsWith(".class")) { + val className = name.removeSuffix(".class").replace('/', '.') + loadRClassFields(className, map) + } + } + } + } + } + + private fun loadRClassFields(className: String, map: MutableMap) { + runCatching { + val clazz = Class.forName(className, false, FakeResourceHelper::class.java.classLoader) + for (field in clazz.declaredFields) { + runCatching { map[field.getInt(null)] = field.name } + } + } + } + } +}