From 8fde4020793bed252fe0c320b7325f00fc7ced72 Mon Sep 17 00:00:00 2001 From: Laimonas Turauskas Date: Tue, 10 Sep 2024 10:20:55 -0400 Subject: [PATCH] Increase formula-android code coverage. (#384) * Increase formula-android code coverage. * Temp. --- .../android/compose/ComposeViewFactory.kt | 1 + .../test/TestBackCallbackRenderModel.kt | 13 +++ .../test/TestFragmentLifecycleCallback.kt | 16 +++- .../formula/test/TestLifecycleKey.kt | 2 +- ...nderViewTest.kt => FormulaFragmentTest.kt} | 80 ++++++++++++++++++- .../formula/FragmentLifecycleTest.kt | 12 +++ .../instacart/formula/TestFeatureFactory.kt | 8 +- .../instacart/formula/android/BackCallback.kt | 10 +-- .../instacart/formula/android/FeatureView.kt | 2 +- .../formula/android/FeaturesBuilder.kt | 2 +- .../formula/android/FormulaFragment.kt | 27 ++----- .../android/FragmentLifecycleCallback.kt | 3 + .../formula/android/FragmentStore.kt | 8 +- .../formula/android/LayoutViewFactory.kt | 2 +- .../instacart/formula/android/ViewInstance.kt | 19 +---- .../formula/android/internal/AppManager.kt | 35 ++------ .../internal/FormulaFragmentDelegate.kt | 7 +- .../android/internal/FragmentLifecycle.kt | 14 ++-- .../formula/android/FeatureEventTest.kt | 21 +++++ .../formula/android/FragmentDataClassTest.kt | 58 ++++++++++++++ .../android/FragmentEnvironmentTest.kt | 22 +++++ .../formula/android/FragmentStateTest.kt | 53 ++++++++++++ .../formula/android/FragmentStoreTest.kt | 46 +++++++++++ 23 files changed, 361 insertions(+), 100 deletions(-) create mode 100644 formula-android-tests/src/main/java/com/instacart/formula/test/TestBackCallbackRenderModel.kt rename formula-android-tests/src/test/java/com/instacart/formula/{FragmentFlowRenderViewTest.kt => FormulaFragmentTest.kt} (80%) create mode 100644 formula-android/src/test/java/com/instacart/formula/android/FeatureEventTest.kt create mode 100644 formula-android/src/test/java/com/instacart/formula/android/FragmentDataClassTest.kt create mode 100644 formula-android/src/test/java/com/instacart/formula/android/FragmentEnvironmentTest.kt create mode 100644 formula-android/src/test/java/com/instacart/formula/android/FragmentStateTest.kt diff --git a/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt b/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt index 9e994616b..2f6cf8983 100644 --- a/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt +++ b/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt @@ -24,6 +24,7 @@ abstract class ComposeViewFactory : ViewFactory return FeatureView( view = view, setOutput = outputRelay::accept, + lifecycleCallbacks = null, ) } diff --git a/formula-android-tests/src/main/java/com/instacart/formula/test/TestBackCallbackRenderModel.kt b/formula-android-tests/src/main/java/com/instacart/formula/test/TestBackCallbackRenderModel.kt new file mode 100644 index 000000000..6065ffdb2 --- /dev/null +++ b/formula-android-tests/src/main/java/com/instacart/formula/test/TestBackCallbackRenderModel.kt @@ -0,0 +1,13 @@ +package com.instacart.formula.test + +import com.instacart.formula.android.BackCallback + +data class TestBackCallbackRenderModel( + private val onBackPressed: () -> Unit, + val blockBackCallback: Boolean = false, +) : BackCallback { + override fun onBackPressed(): Boolean { + this.onBackPressed.invoke() + return blockBackCallback + } +} \ No newline at end of file diff --git a/formula-android-tests/src/main/java/com/instacart/formula/test/TestFragmentLifecycleCallback.kt b/formula-android-tests/src/main/java/com/instacart/formula/test/TestFragmentLifecycleCallback.kt index 155b98744..ec6ebab39 100644 --- a/formula-android-tests/src/main/java/com/instacart/formula/test/TestFragmentLifecycleCallback.kt +++ b/formula-android-tests/src/main/java/com/instacart/formula/test/TestFragmentLifecycleCallback.kt @@ -13,37 +13,51 @@ class TestFragmentLifecycleCallback : FragmentLifecycleCallback { var hasOnStop = false var hasOnSaveInstanceState = false var hasOnDestroyView = false + var hasCalledLowMemory = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hasOnViewCreated = true } override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) hasOnActivityCreated = true } override fun onStart() { + super.onStart() hasOnStart = true } override fun onResume() { + super.onResume() hasOnResume = true } // teardown override fun onPause() { + super.onPause() hasOnPauseEvent = true } override fun onStop() { + super.onStop() hasOnStop = true } override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) hasOnSaveInstanceState = true } override fun onDestroyView() { - hasOnDestroyView + super.onDestroyView() + hasOnDestroyView = true + } + + override fun onLowMemory() { + super.onLowMemory() + hasCalledLowMemory = true } } \ No newline at end of file diff --git a/formula-android-tests/src/main/java/com/instacart/formula/test/TestLifecycleKey.kt b/formula-android-tests/src/main/java/com/instacart/formula/test/TestLifecycleKey.kt index b604426fb..9efacaf39 100644 --- a/formula-android-tests/src/main/java/com/instacart/formula/test/TestLifecycleKey.kt +++ b/formula-android-tests/src/main/java/com/instacart/formula/test/TestLifecycleKey.kt @@ -5,5 +5,5 @@ import kotlinx.parcelize.Parcelize @Parcelize data class TestLifecycleKey( - override val tag: String = "task list", + override val tag: String = "test-lifecycle", ) : FragmentKey diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FragmentFlowRenderViewTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt similarity index 80% rename from formula-android-tests/src/test/java/com/instacart/formula/FragmentFlowRenderViewTest.kt rename to formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt index 9906cdb29..e7e1152ad 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FragmentFlowRenderViewTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt @@ -6,15 +6,20 @@ import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth import com.google.common.truth.Truth.assertThat import com.instacart.formula.android.ActivityStore import com.instacart.formula.android.FragmentState import com.instacart.formula.android.FragmentKey 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.test.TestBackCallbackRenderModel import com.instacart.formula.test.TestKey import com.instacart.formula.test.TestKeyWithId import com.instacart.formula.test.TestFragmentActivity +import com.instacart.formula.test.TestLifecycleKey import com.jakewharton.rxrelay3.PublishRelay import io.reactivex.rxjava3.core.Observable import org.junit.Before @@ -29,7 +34,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) -class FragmentFlowRenderViewTest { +class FormulaFragmentTest { class HeadlessFragment : Fragment() @@ -37,9 +42,15 @@ class FragmentFlowRenderViewTest { private val stateChangeRelay = PublishRelay.create>() private var onPreCreated: (TestFragmentActivity) -> Unit = {} private var updateThreads = linkedSetOf() + private val errors = mutableListOf() private val formulaRule = TestFormulaRule( initFormula = { app -> - FormulaAndroid.init(app) { + val environment = FragmentEnvironment( + onScreenError = { _, error -> + errors.add(error) + } + ) + FormulaAndroid.init(app, environment) { activity { ActivityStore( configureActivity = { activity -> @@ -53,7 +64,16 @@ class FragmentFlowRenderViewTest { }, fragmentStore = FragmentStore.init { bind(TestFeatureFactory { stateChanges(it) }) - bind(TestFeatureFactory { stateChanges(it) }) + bind(TestFeatureFactory( + applyOutput = { output -> + if (output == "crash") { + throw IllegalStateException("crashing") + } + }, + state = { + stateChanges(it) + } + )) } ) } @@ -63,7 +83,8 @@ class FragmentFlowRenderViewTest { cleanUp = { lastState = null updateThreads = linkedSetOf() - }) + } + ) private val activityRule = ActivityScenarioRule(TestFragmentActivity::class.java) @@ -255,6 +276,57 @@ class FragmentFlowRenderViewTest { assertThat(updateThreads).containsExactly(Thread.currentThread()) } + @Test fun `back callback blocks navigation`() { + val key = TestKeyWithId(1) + navigateToTaskDetail(id = key.id) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + var onBackPressed = 0 + sendStateUpdate(key, TestBackCallbackRenderModel( + onBackPressed = { + onBackPressed += 1 + }, + blockBackCallback = true + )) + + navigateBack() + + // We blocked navigation so visible fragment should still be details + assertThat(onBackPressed).isEqualTo(1) + assertVisibleContract(key) + + sendStateUpdate(key, TestBackCallbackRenderModel( + onBackPressed = { onBackPressed += 1 }, + blockBackCallback = false + )) + + navigateBack() + + assertThat(onBackPressed).isEqualTo(2) + assertVisibleContract(TestKey()) + } + + @Test fun `notify fragment environment if setOutput throws an error`() { + val key = TestKeyWithId(1) + navigateToTaskDetail(id = key.id) + + val activity = activity() + sendStateUpdate(key, "crash") + assertThat(activity.renderCalls).isNotEmpty() + + assertThat(errors).hasSize(1) + } + + @Test + fun toStringContainsTagAndKey() { + val fragment = FormulaFragment.newInstance(TestLifecycleKey()) + val toStringValue = fragment.toString() + assertThat(toStringValue).isEqualTo( + "test-lifecycle -> TestLifecycleKey(tag=test-lifecycle)" + ) + } + private fun navigateBack() { scenario.onActivity { it.onBackPressed() } } 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 3e3993961..f857a5c9a 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 @@ -9,6 +9,7 @@ import com.google.common.truth.Truth.assertThat import com.instacart.formula.android.ActivityStore import com.instacart.formula.android.Feature import com.instacart.formula.android.FeatureFactory +import com.instacart.formula.android.FormulaFragment import com.instacart.formula.android.FragmentStore import com.instacart.formula.android.ViewFactory import com.instacart.formula.test.TestFragmentActivity @@ -28,12 +29,14 @@ class FragmentLifecycleTest { private lateinit var activityController: ActivityController private lateinit var lifecycleCallback: TestFragmentLifecycleCallback private lateinit var contract: TestLifecycleKey + private lateinit var activityRef: TestFragmentActivity @get:Rule val formulaRule = TestFormulaRule(initFormula = { app -> FormulaAndroid.init(app) { activity { ActivityStore( configureActivity = { activity -> + activityRef = activity lifecycleCallback = TestFragmentLifecycleCallback() contract = TestLifecycleKey() activity.initialContract = contract @@ -85,6 +88,15 @@ class FragmentLifecycleTest { assertThat(lifecycleCallback.hasOnSaveInstanceState).isTrue() } + @Test fun `low memory`() { + val fragment = activityRef.supportFragmentManager.fragments + .filterIsInstance() + .first() + + fragment.onLowMemory() + assertThat(lifecycleCallback.hasCalledLowMemory).isTrue() + } + // Unfortunately, we cannot test destroy view with Robolectric // https://github.com/robolectric/robolectric/issues/1945 } diff --git a/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt b/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt index 515d20cbd..64afaa6f9 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt @@ -8,15 +8,17 @@ import com.instacart.formula.test.TestFragmentActivity import io.reactivex.rxjava3.core.Observable class TestFeatureFactory( - private val state: (Key) -> Observable + private val applyOutput: (Any) -> Unit = {}, + private val state: (Key) -> Observable, ) : FeatureFactory { override fun initialize(dependencies: Unit, key: Key): Feature { return Feature( state = state(key), viewFactory = ViewFactory.fromLayout(R.layout.test_empty_layout) { val renderView = object : RenderView { - override val render: Renderer = Renderer { - (view.context as TestFragmentActivity).renderCalls.add(Pair(key, it)) + override val render: Renderer = Renderer { value -> + (view.context as TestFragmentActivity).renderCalls.add(Pair(key, value)) + applyOutput(value) } } featureView(renderView) diff --git a/formula-android/src/main/java/com/instacart/formula/android/BackCallback.kt b/formula-android/src/main/java/com/instacart/formula/android/BackCallback.kt index affd54ab4..c438fb847 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/BackCallback.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/BackCallback.kt @@ -4,18 +4,10 @@ package com.instacart.formula.android * Used to indicate that a screen render model * handles back presses. */ -interface BackCallback { +fun interface BackCallback { /** * Returns true if it handles back press. */ fun onBackPressed(): Boolean - - companion object { - inline operator fun invoke(crossinline op: () -> Boolean): BackCallback { - return object : BackCallback { - override fun onBackPressed(): Boolean = op() - } - } - } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt index 55d4ad977..21926a87c 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt @@ -19,5 +19,5 @@ import io.reactivex.rxjava3.core.Observable class FeatureView( val view: View, val setOutput: (RenderModel) -> Unit, - val lifecycleCallbacks: FragmentLifecycleCallback? = null, + val lifecycleCallbacks: FragmentLifecycleCallback?, ) diff --git a/formula-android/src/main/java/com/instacart/formula/android/FeaturesBuilder.kt b/formula-android/src/main/java/com/instacart/formula/android/FeaturesBuilder.kt index a5765af92..e5ddbaa68 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FeaturesBuilder.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FeaturesBuilder.kt @@ -13,7 +13,7 @@ import kotlin.reflect.KClass */ class FeaturesBuilder { companion object { - inline fun build( + fun build( init: FeaturesBuilder.() -> Unit ): Features { return FeaturesBuilder().apply(init).build() diff --git a/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt b/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt index 5d237b160..96a3a2e38 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt @@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment import com.instacart.formula.FormulaAndroid import com.instacart.formula.android.internal.FormulaFragmentDelegate import com.instacart.formula.android.internal.getFormulaFragmentId +import com.instacart.formula.android.internal.getOrSetArguments import java.lang.Exception class FormulaFragment : Fragment(), BaseFormulaFragment { @@ -17,11 +18,14 @@ class FormulaFragment : Fragment(), BaseFormulaFragment { @JvmStatic fun newInstance(key: FragmentKey): FormulaFragment { - return FormulaFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_CONTRACT, key) - } + val fragment = FormulaFragment() + fragment.getOrSetArguments().apply { + putParcelable(ARG_CONTRACT, key) } + FormulaAndroid.fragmentEnvironment().fragmentDelegate.onNewInstance( + fragmentId = fragment.formulaFragmentId + ) + return fragment } } @@ -39,27 +43,12 @@ class FormulaFragment : Fragment(), BaseFormulaFragment { private val fragmentDelegate: FragmentEnvironment.FragmentDelegate get() = environment.fragmentDelegate - private var calledNewInstance = false - private var featureView: FeatureView? = null private var output: Any? = null private val lifecycleCallback: FragmentLifecycleCallback? get() = featureView?.lifecycleCallbacks - override fun setArguments(args: Bundle?) { - super.setArguments(args) - - /** - * To ensure that we have both fragment key and formula instance id, we need - * to wait for arguments to be set. - */ - if (!calledNewInstance) { - calledNewInstance = true - fragmentDelegate.onNewInstance(formulaFragmentId) - } - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val viewFactory = FormulaFragmentDelegate.viewFactory(this) ?: run { // No view factory, no view diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentLifecycleCallback.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentLifecycleCallback.kt index 76afbfdd5..81b49daa9 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentLifecycleCallback.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentLifecycleCallback.kt @@ -10,6 +10,9 @@ import android.view.View * [androidx.fragment.app.Fragment.onDestroy] or [androidx.fragment.app.Fragment.onDetach] */ interface FragmentLifecycleCallback { + companion object { + internal val NO_OP = object : FragmentLifecycleCallback {} + } /** * See [androidx.fragment.app.Fragment.onViewCreated] diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentStore.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentStore.kt index 17f6617e6..7a3fe8a19 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentStore.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentStore.kt @@ -17,15 +17,15 @@ class FragmentStore @PublishedApi internal constructor( companion object { val EMPTY = init { } - inline fun init( - crossinline init: FeaturesBuilder.() -> Unit + fun init( + init: FeaturesBuilder.() -> Unit ): FragmentStore { return init(Unit, init) } - inline fun init( + fun init( rootComponent: Component, - crossinline init: FeaturesBuilder.() -> Unit + init: FeaturesBuilder.() -> Unit ): FragmentStore { val features = FeaturesBuilder.build(init) return init(rootComponent, features) diff --git a/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt index 07f843d9c..eca8bee5d 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt @@ -28,7 +28,7 @@ import com.instacart.formula.android.views.InflatedViewInstance * } * ``` */ -abstract class LayoutViewFactory(@LayoutRes val layoutId: Int): ViewFactory { +abstract class LayoutViewFactory(@LayoutRes private val layoutId: Int): ViewFactory { abstract fun ViewInstance.create(): FeatureView diff --git a/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt b/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt index aafb9b1a4..2f61469d2 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt @@ -21,22 +21,10 @@ abstract class ViewInstance { fun featureView( lifecycleCallbacks: FragmentLifecycleCallback? = null, render: (RenderModel) -> Unit - ): FeatureView { - return featureView( - renderer = Renderer.create(render), - lifecycleCallbacks = lifecycleCallbacks - ) - } - /** - * Creates a [FeatureView] from a [Renderer]. - */ - fun featureView( - renderer: Renderer, - lifecycleCallbacks: FragmentLifecycleCallback? = null, ): FeatureView { return FeatureView( view = view, - setOutput = renderer, + setOutput = Renderer.create(render), lifecycleCallbacks = lifecycleCallbacks ) } @@ -48,8 +36,9 @@ abstract class ViewInstance { renderView: RenderView, lifecycleCallbacks: FragmentLifecycleCallback? = null, ): FeatureView { - return featureView( - renderer = renderView.render, + return FeatureView( + view = view, + setOutput = renderView.render, lifecycleCallbacks = lifecycleCallbacks, ) } 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 45a42d2a7..175bd45b7 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 @@ -29,36 +29,21 @@ internal class AppManager( override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { if (activity is FragmentActivity) { val store: ActivityManager? = findStore(activity) - if (store == null) { - // TODO log missing store - return - } - - store.onActivityCreated(activity) + store?.onActivityCreated(activity) } } override fun onActivityStarted(activity: Activity) { if (activity is FragmentActivity) { val store: ActivityManager? = findStore(activity) - if (store == null) { - // TODO log missing store - return - } - - store.onActivityStarted(activity) + store?.onActivityStarted(activity) } } override fun onActivityResumed(activity: Activity) { if (activity is FragmentActivity) { val store: ActivityManager? = findStore(activity) - if (store == null) { - // TODO log missing store - return - } - - store.onActivityResumed(activity) + store?.onActivityResumed(activity) } } @@ -74,24 +59,14 @@ internal class AppManager( override fun onActivityPaused(activity: Activity) { if (activity is FragmentActivity) { val store: ActivityManager? = findStore(activity) - if (store == null) { - // TODO log missing store - return - } - - store.onActivityPaused(activity) + store?.onActivityPaused(activity) } } override fun onActivityStopped(activity: Activity) { if (activity is FragmentActivity) { val store: ActivityManager? = findStore(activity) - if (store == null) { - // TODO log missing store - return - } - - store.onActivityStopped(activity) + store?.onActivityStopped(activity) } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentDelegate.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentDelegate.kt index 3f3f7aa4f..9b12e71a6 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentDelegate.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentDelegate.kt @@ -8,12 +8,7 @@ import com.instacart.formula.android.ViewFactory internal object FormulaFragmentDelegate { fun viewFactory(fragment: FormulaFragment): ViewFactory? { val appManager = FormulaAndroid.appManagerOrThrow() - - val activity = fragment.activity ?: run { - fragmentEnvironment().logger("FormulaFragment has no activity attached: ${fragment.getFragmentKey()}") - return null - } - + val activity = fragment.requireActivity() val viewFactory = appManager.findStore(activity)?.viewFactory(fragment) ?: run { // Log view factory is missing if (activity.isDestroyed) { 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 dd574f1b7..6c9f2dd7c 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 @@ -45,11 +45,7 @@ private fun Fragment.getFragmentKey(): FragmentKey { */ private fun Fragment.getFragmentInstanceId(): String { return if (this is BaseFormulaFragment<*>) { - val arguments = arguments ?: run { - Bundle().apply { - arguments = this - } - } + val arguments = getOrSetArguments() val id = arguments.getString(FormulaFragment.ARG_FORMULA_ID, "") if (id.isNullOrBlank()) { val initializedId = UUID.randomUUID().toString() @@ -68,4 +64,12 @@ internal fun Fragment.getFormulaFragmentId(): FragmentId { instanceId = getFragmentInstanceId(), key = getFragmentKey() ) +} + +internal fun Fragment.getOrSetArguments(): Bundle { + return arguments ?: run { + Bundle().apply { + arguments = this + } + } } \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FeatureEventTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FeatureEventTest.kt new file mode 100644 index 000000000..33cd2e748 --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/FeatureEventTest.kt @@ -0,0 +1,21 @@ +package com.instacart.formula.android + +import com.google.common.truth.Truth +import com.instacart.formula.android.fakes.MainKey +import org.junit.Test +import java.lang.RuntimeException + +class FeatureEventTest { + + @Test fun failureEvent() { + val fragmentId = FragmentId( + instanceId = "random", + key = MainKey(id = 100) + ) + + val error = RuntimeException("error") + val event = FeatureEvent.Failure(fragmentId, error) + Truth.assertThat(event.id).isEqualTo(fragmentId) + Truth.assertThat(event.error).isEqualTo(error) + } +} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentDataClassTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentDataClassTest.kt new file mode 100644 index 000000000..ae0a68128 --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentDataClassTest.kt @@ -0,0 +1,58 @@ +package com.instacart.formula.android + +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import com.instacart.formula.android.events.ActivityResult +import com.instacart.formula.android.events.FragmentLifecycleEvent +import com.instacart.formula.android.fakes.MainKey +import org.junit.Test + +class FragmentDataClassTest { + + @Test fun fragmentId() { + val fragmentKey = MainKey(id = 1) + val fragmentId = FragmentId( + instanceId = "instanceId", + key = fragmentKey + ) + + assertThat(fragmentId.instanceId).isEqualTo("instanceId") + assertThat(fragmentId.key).isEqualTo(fragmentKey) + } + + @Test fun fragmentOutput() { + val key = MainKey(id = 1) + val output = FragmentOutput( + key = key, + renderModel = Unit + ) + assertThat(output.key).isEqualTo(key) + assertThat(output.renderModel).isEqualTo(Unit) + } + + @Test fun activityResult() { + val result = ActivityResult( + requestCode = 0, + resultCode = 1, + data = null + ) + assertThat(result.requestCode).isEqualTo(0) + assertThat(result.resultCode).isEqualTo(1) + assertThat(result.data).isNull() + } + + @Test fun fragmentLifecycleEventRemoved() { + val fragmentKey = MainKey(id = 1) + val fragmentId = FragmentId( + instanceId = "instanceId", + key = fragmentKey + ) + + val event = FragmentLifecycleEvent.Removed( + fragmentId = fragmentId, + lastState = "last-state" + ) + assertThat(event.fragmentId).isEqualTo(fragmentId) + assertThat(event.lastState).isEqualTo("last-state") + } +} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentEnvironmentTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentEnvironmentTest.kt new file mode 100644 index 000000000..45113d621 --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentEnvironmentTest.kt @@ -0,0 +1,22 @@ +package com.instacart.formula.android + +import com.google.common.truth.Truth +import com.instacart.formula.android.fakes.MainKey +import org.junit.Test + +class FragmentEnvironmentTest { + + @Test + fun `default onError throws exception`() { + + val fragmentEnvironment = FragmentEnvironment() + val mainKey = MainKey(id = 1) + val exception = RuntimeException("huh") + try { + fragmentEnvironment.onScreenError(mainKey, exception) + error("should not happen") + } catch (e: Exception) { + Truth.assertThat(e).isEqualTo(exception) + } + } +} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentStateTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentStateTest.kt new file mode 100644 index 000000000..878213033 --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentStateTest.kt @@ -0,0 +1,53 @@ +package com.instacart.formula.android + +import com.google.common.truth.Truth +import com.instacart.formula.android.fakes.MainKey +import org.junit.Test + +class FragmentStateTest { + @Test fun `visibleOutput is null when visible ids are empty`() { + val state = FragmentState( + visibleIds = emptyList(), + ) + + Truth.assertThat(state.visibleOutput()).isNull() + } + + @Test + fun `visible output is null until output is set`() { + val fragmentKey = MainKey(id = 1) + val fragmentId = FragmentId( + instanceId = "instanceId", + key = fragmentKey + ) + + val state = FragmentState( + visibleIds = listOf(fragmentId) + ) + + Truth.assertThat(state.visibleOutput()).isNull() + } + + @Test + fun `visible output is not null when visible fragment has an output`() { + val fragmentKey = MainKey(id = 1) + val fragmentId = FragmentId( + instanceId = "instanceId", + key = fragmentKey + ) + + val fragmentOutput = FragmentOutput( + key = fragmentKey, + renderModel = "value" + ) + + val state = FragmentState( + visibleIds = listOf(fragmentId), + outputs = mapOf( + fragmentId to fragmentOutput + ) + ) + + Truth.assertThat(state.visibleOutput()).isEqualTo(fragmentOutput) + } +} \ No newline at end of file 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 619fe748f..f8d067c0f 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 @@ -12,6 +12,7 @@ import io.reactivex.rxjava3.observers.TestObserver 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 @@ -158,6 +159,51 @@ class FragmentStoreTest { assertThat(updateThreads).containsExactly(Thread.currentThread()) } + @Test fun `store returns missing binding event when no feature factory is present`() { + val store = FragmentStore.init(FakeComponent()) { + bind(TestFeatureFactory()) + } + val observer = store.state(FragmentEnvironment()).test() + val fragmentId = FragmentId( + instanceId = "random", + key = DetailKey(id = 100) + ) + store.onLifecycleEffect( + FragmentLifecycleEvent.Added(fragmentId = fragmentId) + ) + + val lastState = observer.values().last() + assertThat(lastState.features[fragmentId]).isEqualTo( + FeatureEvent.MissingBinding(fragmentId) + ) + } + + @Test fun `store returns failure event when feature factory initialization throws an error`() { + val expectedError = RuntimeException("something happened") + val store = FragmentStore.init(FakeComponent()) { + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: FakeComponent, key: MainKey): Feature { + throw expectedError + } + } + bind(featureFactory) + } + + val observer = store.state(FragmentEnvironment()).test() + val fragmentId = FragmentId( + instanceId = "random", + key = MainKey(id = 100) + ) + store.onLifecycleEffect( + FragmentLifecycleEvent.Added(fragmentId = fragmentId) + ) + + val lastState = observer.values().last() + assertThat(lastState.features[fragmentId]).isEqualTo( + FeatureEvent.Failure(fragmentId, expectedError) + ) + } + private fun FragmentStore.toStates(): TestObserver> { return state(FragmentEnvironment()) .map { it.outputs.mapKeys { entry -> entry.key.key } }