diff --git a/formula-android-tests/src/main/AndroidManifest.xml b/formula-android-tests/src/main/AndroidManifest.xml index c48ff6712..3ae9bb658 100644 --- a/formula-android-tests/src/main/AndroidManifest.xml +++ b/formula-android-tests/src/main/AndroidManifest.xml @@ -34,5 +34,19 @@ + + + + + + + + + + + + + + diff --git a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt index 2b097bd86..29c8569c5 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt @@ -57,4 +57,12 @@ class ActivityLifecycleEventTest { val expected = listOf(INITIALIZED) + lifecycle + lifecycle assertThat(events).containsExactlyElementsIn(expected).inOrder() } + + @Test fun `calling onPreCreate() twice will throw an exception`() { + val activity = scenario.activity() + val result = runCatching { FormulaAndroid.onPreCreate(activity, null) } + assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "Activity TestActivity was already initialized. Did you call FormulaAndroid.onPreCreate() twice?" + ) + } } diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt index e7e1152ad..7669b1274 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt @@ -15,6 +15,7 @@ import com.instacart.formula.android.BackCallback import com.instacart.formula.android.FormulaFragment import com.instacart.formula.android.FragmentEnvironment import com.instacart.formula.android.FragmentStore +import com.instacart.formula.android.events.FragmentLifecycleEvent import com.instacart.formula.test.TestBackCallbackRenderModel import com.instacart.formula.test.TestKey import com.instacart.formula.test.TestKeyWithId @@ -43,6 +44,7 @@ class FormulaFragmentTest { private var onPreCreated: (TestFragmentActivity) -> Unit = {} private var updateThreads = linkedSetOf() private val errors = mutableListOf() + private val fragmentLifecycleEvents = mutableListOf() private val formulaRule = TestFormulaRule( initFormula = { app -> val environment = FragmentEnvironment( @@ -74,6 +76,9 @@ class FormulaFragmentTest { stateChanges(it) } )) + }, + onFragmentLifecycleEvent = { + fragmentLifecycleEvents.add(it) } ) } @@ -83,6 +88,7 @@ class FormulaFragmentTest { cleanUp = { lastState = null updateThreads = linkedSetOf() + fragmentLifecycleEvents.clear() } ) @@ -104,6 +110,11 @@ class FormulaFragmentTest { navigateBack() assertThat(activeContracts()).containsExactly(TestKey()).inOrder() + + assertThat(fragmentLifecycleEvents).hasSize(3) + assertThat(fragmentLifecycleEvents[0]).isInstanceOf(FragmentLifecycleEvent.Added::class.java) + assertThat(fragmentLifecycleEvents[1]).isInstanceOf(FragmentLifecycleEvent.Added::class.java) + assertThat(fragmentLifecycleEvents[2]).isInstanceOf(FragmentLifecycleEvent.Removed::class.java) } @Test fun `navigating forward should have both keys in backstack`() { diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt index f857a5c9a..00f7efaf1 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt @@ -53,7 +53,7 @@ class FragmentLifecycleTest { } } bind(featureFactory) - } + }, ) } } diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt new file mode 100644 index 000000000..decae86ff --- /dev/null +++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt @@ -0,0 +1,43 @@ +package com.instacart.formula + +import android.app.Activity +import androidx.fragment.app.FragmentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +/** + * Tests that formula-android module handles non-bound activities gracefully. + */ +@RunWith(AndroidJUnit4::class) +class NonBoundActivityTest { + class TestActivity : Activity() + + private val formulaRule = TestFormulaRule( + initFormula = { app -> + FormulaAndroid.init(app) {} + } + ) + + private val activityRule = ActivityScenarioRule(TestActivity::class.java) + + @get:Rule + val rule = RuleChain.outerRule(formulaRule).around(activityRule) + lateinit var scenario: ActivityScenario + + @Before + fun setup() { + scenario = activityRule.scenario + } + + @Test + fun `full lifecycle`() { + scenario.recreate() + scenario.close() + } +} \ No newline at end of file diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt new file mode 100644 index 000000000..56144c143 --- /dev/null +++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt @@ -0,0 +1,42 @@ +package com.instacart.formula + +import androidx.fragment.app.FragmentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +/** + * Tests that formula-android module handles non-bound activities gracefully. + */ +@RunWith(AndroidJUnit4::class) +class NonBoundFragmentActivityTest { + class TestActivity : FragmentActivity() + + private val formulaRule = TestFormulaRule( + initFormula = { app -> + FormulaAndroid.init(app) {} + } + ) + + private val activityRule = ActivityScenarioRule(TestActivity::class.java) + + @get:Rule + val rule = RuleChain.outerRule(formulaRule).around(activityRule) + lateinit var scenario: ActivityScenario + + @Before + fun setup() { + scenario = activityRule.scenario + } + + @Test + fun `full lifecycle`() { + scenario.recreate() + scenario.close() + } +} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt b/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt index 40ea1c766..5663c3f0b 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt @@ -8,7 +8,7 @@ import kotlin.reflect.KClass */ class ActivityConfigurator { internal class Binding( - val init: ActivityStoreContext.() -> ActivityStore? + val init: ActivityStoreContext.() -> ActivityStore ) internal val bindings = mutableMapOf, Binding<*>>() diff --git a/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt b/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt index d30a2639d..8fc523770 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt @@ -6,7 +6,7 @@ import com.instacart.formula.android.FragmentId * Models when a fragment key is attached and detached. Provides a way to indicate * when to initialize state stream and when to destroy it. */ -sealed class FragmentLifecycleEvent() { +sealed class FragmentLifecycleEvent { abstract val fragmentId: FragmentId diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt index 27129ce4e..e2f88b34b 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt @@ -24,7 +24,7 @@ internal class ActivityStoreFactory internal constructor( ?: return null val activityDelegate = ActivityStoreContextImpl() - return initializer.init.invoke(activityDelegate)?.let { store -> + return initializer.init.invoke(activityDelegate).let { store -> ActivityManager( environment = environment, delegate = activityDelegate, diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt index 175bd45b7..02f28279f 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt @@ -99,7 +99,12 @@ internal class AppManager( private fun findOrInitActivityStore( activity: FragmentActivity, savedState: Bundle? ): ActivityManager { - val key = findOrGenerateActivityKey(activity, savedState) // generate new key + if (activityToKeyMap.containsKey(activity)) { + throw IllegalStateException("Activity ${activity::class.java.simpleName} was already initialized. Did you call FormulaAndroid.onPreCreate() twice?") + } + + val key = savedState?.getString(BUNDLE_KEY) // Activity recreated, let's use saved key + ?: UUID.randomUUID().toString() // New activity, create new key activityToKeyMap[activity] = key val cached = componentMap[key] as? ActivityManager? @@ -121,13 +126,4 @@ internal class AppManager( val component = componentMap.remove(key) component?.dispose() } - - /** - * Key is persisted across configuration changes. - */ - private fun findOrGenerateActivityKey(activity: Activity, savedState: Bundle?): String { - return (activityToKeyMap[activity] // Try the map - ?: savedState?.getString(BUNDLE_KEY) // Try the bundle - ?: UUID.randomUUID().toString()) - } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt index 08c3511c4..f6ac5f105 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt @@ -2,6 +2,7 @@ package com.instacart.formula.android.internal import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import com.instacart.formula.android.FeatureView import com.instacart.formula.android.ViewFactory import com.instacart.formula.android.FeatureEvent @@ -18,9 +19,22 @@ internal class FormulaFragmentViewFactory( private var factory: ViewFactory? = null override fun create(inflater: LayoutInflater, container: ViewGroup?): FeatureView { + val viewFactory = viewFactory() + val delegate = environment.fragmentDelegate + return delegate.createView(fragmentId, viewFactory, inflater, container) + } + + @VisibleForTesting + internal fun viewFactory(): ViewFactory { + return factory ?: findViewFactory().apply { + factory = this + } + } + + private fun findViewFactory(): ViewFactory { val key = fragmentId.key val featureEvent = featureProvider.getFeature(fragmentId) ?: throw IllegalStateException("Could not find feature for $key.") - val viewFactory = factory ?: when (featureEvent) { + return when (featureEvent) { is FeatureEvent.MissingBinding -> { throw IllegalStateException("Missing feature factory or integration for $key. Please check your FragmentStore configuration.") } @@ -31,8 +45,5 @@ internal class FormulaFragmentViewFactory( featureEvent.feature.viewFactory } } - this.factory = viewFactory - val delegate = environment.fragmentDelegate - return delegate.createView(fragmentId, viewFactory, inflater, container) } } \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt index a26acc19a..da23b3dfd 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt @@ -35,11 +35,12 @@ internal class FragmentFlowRenderView( ) { private var fragmentState: FragmentState? = null + private var features: Map = emptyMap() private val visibleFragments: LinkedList = LinkedList() private val featureProvider = object : FeatureProvider { override fun getFeature(id: FragmentId): FeatureEvent? { - return fragmentState?.features?.get(id) + return features[id] } } @@ -53,10 +54,7 @@ internal class FragmentFlowRenderView( super.onFragmentViewCreated(fm, f, v, savedInstanceState) visibleFragments.add(f) - - fragmentState?.let { - updateVisibleFragments(it) - } + updateVisibleFragments() onFragmentViewStateChanged(f.getFormulaFragmentId(), true) notifyLifecycleStateChanged(f, Lifecycle.State.CREATED) @@ -93,7 +91,10 @@ internal class FragmentFlowRenderView( override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { super.onFragmentAttached(fm, f, context) if (FragmentLifecycle.shouldTrack(f)) { - onLifecycleEvent(FragmentLifecycle.createAddedEvent(f)) + val event = FragmentLifecycleEvent.Added( + fragmentId = f.getFormulaFragmentId(), + ) + onLifecycleEvent(event) } else { fragmentEnvironment.logger("Ignoring attach event for fragment: $f") } @@ -103,8 +104,12 @@ internal class FragmentFlowRenderView( super.onFragmentDetached(fm, f) // Only trigger detach, when fragment is actually being removed from the backstack - if (FragmentLifecycle.shouldTrack(f) && !FragmentLifecycle.isKept(fm, f)) { - val event = FragmentLifecycle.createRemovedEvent(f) + if (FragmentLifecycle.shouldTrack(f) && f.isRemoving) { + val formulaFragment = f as? BaseFormulaFragment<*> + val event = FragmentLifecycleEvent.Removed( + fragmentId = f.getFormulaFragmentId(), + lastState = formulaFragment?.currentState(), + ) onLifecycleEvent(event) } } @@ -118,7 +123,8 @@ internal class FragmentFlowRenderView( Utils.assertMainThread() fragmentState = state - updateVisibleFragments(state) + features = state.features + updateVisibleFragments() } fun onBackPressed(): Boolean { @@ -149,7 +155,8 @@ internal class FragmentFlowRenderView( onLifecycleState.invoke(fragment.getFormulaFragmentId(), newState) } - private fun updateVisibleFragments(state: FragmentState) { + private fun updateVisibleFragments() { + val state = fragmentState ?: return visibleFragments.forEachIndices { fragment -> if (fragment is BaseFormulaFragment<*>) { state.outputs[fragment.getFormulaFragmentId()]?.let { diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt index 6c9f2dd7c..5ea3d78e2 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt @@ -3,12 +3,10 @@ package com.instacart.formula.android.internal import android.os.Bundle import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentInspector -import androidx.fragment.app.FragmentManager import com.instacart.formula.android.FragmentId import com.instacart.formula.android.FragmentKey import com.instacart.formula.android.BaseFormulaFragment import com.instacart.formula.android.FormulaFragment -import com.instacart.formula.android.events.FragmentLifecycleEvent import java.util.UUID /** @@ -19,19 +17,6 @@ internal object FragmentLifecycle { internal fun shouldTrack(fragment: Fragment): Boolean { return !fragment.retainInstance && !FragmentInspector.isHeadless(fragment) } - - internal fun isKept(fragmentManager: FragmentManager, fragment: Fragment): Boolean { - return !fragment.isRemoving - } - - internal fun createAddedEvent(f: Fragment): FragmentLifecycleEvent.Added { - return FragmentLifecycleEvent.Added(f.getFormulaFragmentId()) - } - - internal fun createRemovedEvent(f: Fragment): FragmentLifecycleEvent.Removed { - val fragment = f as? BaseFormulaFragment<*> - return FragmentLifecycleEvent.Removed(f.getFormulaFragmentId(), fragment?.currentState()) - } } private fun Fragment.getFragmentKey(): FragmentKey { @@ -47,12 +32,10 @@ private fun Fragment.getFragmentInstanceId(): String { return if (this is BaseFormulaFragment<*>) { val arguments = getOrSetArguments() val id = arguments.getString(FormulaFragment.ARG_FORMULA_ID, "") - if (id.isNullOrBlank()) { + id.ifBlank { val initializedId = UUID.randomUUID().toString() arguments.putString(FormulaFragment.ARG_FORMULA_ID, initializedId) initializedId - } else { - id } } else { "" diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt index 1f3e7da8a..3b4be24cf 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt @@ -94,12 +94,8 @@ internal class FragmentStoreFormula( feature = feature, ) action.onEvent { - if (state.activeIds.contains(fragmentId)) { - val keyState = FragmentOutput(fragmentId.key, it) - transition(state.copy(outputs = state.outputs.plus(fragmentId to keyState))) - } else { - none() - } + val keyState = FragmentOutput(fragmentId.key, it) + transition(state.copy(outputs = state.outputs.plus(fragmentId to keyState))) } } } diff --git a/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt b/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt index 13414be72..a1b78726a 100644 --- a/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt +++ b/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt @@ -26,4 +26,14 @@ class ActivityStoreFactoryTest { val store = factory.init(mock())!! assertThat(store.stateSubscription.isDisposed).isFalse() } + + @Test fun `returns null if no binding for activity is found`() { + val factory = ActivityStoreFactory( + environment = FragmentEnvironment(), + activities = {} + ) + + val store = factory.init(mock()) + assertThat(store).isNull() + } } diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt index f8d067c0f..fecd2ab61 100644 --- a/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt +++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt @@ -8,11 +8,12 @@ import com.instacart.formula.android.fakes.FakeComponent import com.instacart.formula.android.fakes.MainKey import com.instacart.formula.android.events.FragmentLifecycleEvent import com.instacart.formula.android.fakes.NoOpViewFactory +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.observers.TestObserver +import io.reactivex.rxjava3.subjects.PublishSubject import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows -import java.lang.RuntimeException import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -204,6 +205,130 @@ class FragmentStoreTest { ) } + @Test fun `fragment store ignores events after key is removed`() { + val stateSubject = PublishSubject.create() + + val store = FragmentStore.init { + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: Any, key: MainKey): Feature { + return Feature( + state = stateSubject, + viewFactory = NoOpViewFactory(), + ) + } + } + bind(featureFactory) + } + + val observer = store.state(FragmentEnvironment()).test() + val fragmentId = FragmentId("", MainKey(1)) + store.onLifecycleEffect( + FragmentLifecycleEvent.Added(fragmentId = fragmentId) + ) + stateSubject.onNext("value") + + // Check that first event was shown + val firstModel = observer.values().last().outputs[fragmentId]?.renderModel + assertThat(firstModel).isEqualTo("value") + + // Remove fragment + store.onLifecycleEffect( + FragmentLifecycleEvent.Removed(fragmentId = fragmentId) + ) + + // Check that new events are ignored + stateSubject.onNext("new-value") + + // Output should not exist + val secondModel = observer.values().last().outputs[fragmentId] + assertThat(secondModel).isNull() + } + + @Test fun `feature observable error emits on screen error and finishes`() { + val stateSubject = PublishSubject.create() + + val store = FragmentStore.init { + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: Any, key: MainKey): Feature { + return Feature( + state = stateSubject, + viewFactory = NoOpViewFactory(), + ) + } + } + bind(featureFactory) + } + + val screenErrors = mutableListOf>() + val environment = FragmentEnvironment( + onScreenError = { key, error -> + screenErrors.add(key to error) + } + ) + val observer = store.state(environment).test() + val fragmentId = FragmentId("", MainKey(1)) + store.onLifecycleEffect( + FragmentLifecycleEvent.Added(fragmentId = fragmentId) + ) + stateSubject.onNext("value") + + val firstModel = observer.values().last().outputs[fragmentId]?.renderModel + assertThat(firstModel).isEqualTo("value") + + // Emit error + val error = RuntimeException("error") + stateSubject.onError(error) + + // Model didn't change + val secondModel = observer.values().last().outputs[fragmentId]?.renderModel + assertThat(secondModel).isEqualTo("value") + + // Store observable didn't crash + observer.assertNoErrors() + + assertThat(screenErrors).containsExactly( + fragmentId.key to error + ) + } + + @Test fun `fragment store visible output`() { + val store = FragmentStore.init { + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: Any, key: MainKey): Feature { + return Feature( + state = Observable.just("value"), + viewFactory = NoOpViewFactory(), + ) + } + } + bind(featureFactory) + } + + val observer = store.state(FragmentEnvironment()).test() + val fragmentId = FragmentId("", MainKey(1)) + store.onLifecycleEffect( + FragmentLifecycleEvent.Added(fragmentId = fragmentId) + ) + + // No visible output yet + val firstModel = observer.values().last().visibleOutput() + assertThat(firstModel).isNull() + + // Toggle visibility + store.onVisibilityChanged(fragmentId, true) + + // Check that visible output is now present + val secondModel = observer.values().last().visibleOutput() + assertThat(secondModel).isNotNull() + + // Toggle visibility again + store.onVisibilityChanged(fragmentId, false) + + // Check that visible output is null again + val third = observer.values().last().visibleOutput() + assertThat(third).isNull() + } + private fun FragmentStore.toStates(): TestObserver> { return state(FragmentEnvironment()) .map { it.outputs.mapKeys { entry -> entry.key.key } } diff --git a/formula-android/src/test/java/com/instacart/formula/android/internal/FormulaFragmentViewFactoryTest.kt b/formula-android/src/test/java/com/instacart/formula/android/internal/FormulaFragmentViewFactoryTest.kt new file mode 100644 index 000000000..b460fa85e --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/internal/FormulaFragmentViewFactoryTest.kt @@ -0,0 +1,79 @@ +package com.instacart.formula.android.internal + +import com.google.common.truth.Truth +import com.instacart.formula.android.Feature +import com.instacart.formula.android.FeatureEvent +import com.instacart.formula.android.FragmentEnvironment +import com.instacart.formula.android.FragmentId +import com.instacart.formula.android.ViewFactory +import io.reactivex.rxjava3.core.Observable +import org.junit.Test +import java.lang.RuntimeException + +class FormulaFragmentViewFactoryTest { + + @Test fun `throws an exception if feature provider returns null`() { + val viewFactory = viewFactory { null } + val result = runCatching { viewFactory.viewFactory() } + Truth.assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "Could not find feature for " + ) + } + + @Test fun `throws an exception if feature is not registered`() { + val viewFactory = viewFactory { + FeatureEvent.MissingBinding(it) + } + val result = runCatching { viewFactory.viewFactory() } + Truth.assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "Missing feature factory or integration for" + ) + } + + @Test fun `throws an exception if feature failed to initialize`() { + val viewFactory = viewFactory { + FeatureEvent.Failure(it, RuntimeException("Something went wrong")) + } + val result = runCatching { viewFactory.viewFactory() } + Truth.assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "Feature failed to initialize:" + ) + } + + @Test fun `only initializes the view factory once`() { + var timesCalled = 0 + val viewFactory = viewFactory { + timesCalled += 1 + val feature = Feature( + state = Observable.empty(), + viewFactory = ViewFactory { _, _ -> + error("should not be called") + } + ) + FeatureEvent.Init(it, feature) + } + + viewFactory.viewFactory() + viewFactory.viewFactory() + viewFactory.viewFactory() + viewFactory.viewFactory() + Truth.assertThat(timesCalled).isEqualTo(1) + } + + private fun viewFactory( + delegateGetFeature: (FragmentId) -> FeatureEvent?, + ): FormulaFragmentViewFactory { + return FormulaFragmentViewFactory( + environment = FragmentEnvironment(), + fragmentId = FragmentId( + instanceId = "instanceId", + key = EmptyFragmentKey(tag = "tag") + ), + featureProvider = object : FeatureProvider { + override fun getFeature(id: FragmentId): FeatureEvent? { + return delegateGetFeature(id) + } + } + ) + } +} \ No newline at end of file diff --git a/jacoco.sh b/jacoco.sh index 62696ba20..67c417551 100755 --- a/jacoco.sh +++ b/jacoco.sh @@ -2,7 +2,10 @@ # Should match with what we run in .github/workflows/build-workflow.yml ./gradlew clean ./gradlew :formula:test +./gradlew :formula-coroutines:test ./gradlew :formula-android:testRelease ./gradlew :formula-android-tests:testRelease +./gradlew :formula-test:test +./gradlew :formula-lint:build ./gradlew jacocoTestReportMerged open build/reports/jacoco/index.html