Skip to content

Commit

Permalink
Increase formula-android code coverage. (#384)
Browse files Browse the repository at this point in the history
* Increase formula-android code coverage.

* Temp.
  • Loading branch information
Laimiux authored Sep 10, 2024
1 parent 3c093b8 commit 8fde402
Show file tree
Hide file tree
Showing 23 changed files with 361 additions and 100 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ abstract class ComposeViewFactory<RenderModel : Any> : ViewFactory<RenderModel>
return FeatureView(
view = view,
setOutput = outputRelay::accept,
lifecycleCallbacks = null,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,17 +34,23 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

@RunWith(AndroidJUnit4::class)
class FragmentFlowRenderViewTest {
class FormulaFragmentTest {

class HeadlessFragment : Fragment()

private var lastState: FragmentState? = null
private val stateChangeRelay = PublishRelay.create<Pair<FragmentKey, Any>>()
private var onPreCreated: (TestFragmentActivity) -> Unit = {}
private var updateThreads = linkedSetOf<Thread>()
private val errors = mutableListOf<Throwable>()
private val formulaRule = TestFormulaRule(
initFormula = { app ->
FormulaAndroid.init(app) {
val environment = FragmentEnvironment(
onScreenError = { _, error ->
errors.add(error)
}
)
FormulaAndroid.init(app, environment) {
activity<TestFragmentActivity> {
ActivityStore(
configureActivity = { activity ->
Expand All @@ -53,7 +64,16 @@ class FragmentFlowRenderViewTest {
},
fragmentStore = FragmentStore.init {
bind(TestFeatureFactory<TestKey> { stateChanges(it) })
bind(TestFeatureFactory<TestKeyWithId> { stateChanges(it) })
bind(TestFeatureFactory<TestKeyWithId>(
applyOutput = { output ->
if (output == "crash") {
throw IllegalStateException("crashing")
}
},
state = {
stateChanges(it)
}
))
}
)
}
Expand All @@ -63,7 +83,8 @@ class FragmentFlowRenderViewTest {
cleanUp = {
lastState = null
updateThreads = linkedSetOf()
})
}
)

private val activityRule = ActivityScenarioRule(TestFragmentActivity::class.java)

Expand Down Expand Up @@ -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() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,12 +29,14 @@ class FragmentLifecycleTest {
private lateinit var activityController: ActivityController<TestFragmentActivity>
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<TestFragmentActivity> {
ActivityStore(
configureActivity = { activity ->
activityRef = activity
lifecycleCallback = TestFragmentLifecycleCallback()
contract = TestLifecycleKey()
activity.initialContract = contract
Expand Down Expand Up @@ -85,6 +88,15 @@ class FragmentLifecycleTest {
assertThat(lifecycleCallback.hasOnSaveInstanceState).isTrue()
}

@Test fun `low memory`() {
val fragment = activityRef.supportFragmentManager.fragments
.filterIsInstance<FormulaFragment>()
.first()

fragment.onLowMemory()
assertThat(lifecycleCallback.hasCalledLowMemory).isTrue()
}

// Unfortunately, we cannot test destroy view with Robolectric
// https://github.com/robolectric/robolectric/issues/1945
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import com.instacart.formula.test.TestFragmentActivity
import io.reactivex.rxjava3.core.Observable

class TestFeatureFactory<Key : FragmentKey>(
private val state: (Key) -> Observable<Any>
private val applyOutput: (Any) -> Unit = {},
private val state: (Key) -> Observable<Any>,
) : FeatureFactory<Unit, Key> {
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<Any> {
override val render: Renderer<Any> = Renderer {
(view.context as TestFragmentActivity).renderCalls.add(Pair(key, it))
override val render: Renderer<Any> = Renderer { value ->
(view.context as TestFragmentActivity).renderCalls.add(Pair(key, value))
applyOutput(value)
}
}
featureView(renderView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ import io.reactivex.rxjava3.core.Observable
class FeatureView<RenderModel>(
val view: View,
val setOutput: (RenderModel) -> Unit,
val lifecycleCallbacks: FragmentLifecycleCallback? = null,
val lifecycleCallbacks: FragmentLifecycleCallback?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import kotlin.reflect.KClass
*/
class FeaturesBuilder<Dependencies> {
companion object {
inline fun <Dependencies> build(
fun <Dependencies> build(
init: FeaturesBuilder<Dependencies>.() -> Unit
): Features<Dependencies> {
return FeaturesBuilder<Dependencies>().apply(init).build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any> {
Expand All @@ -17,11 +18,14 @@ class FormulaFragment : Fragment(), BaseFormulaFragment<Any> {

@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
}
}

Expand All @@ -39,27 +43,12 @@ class FormulaFragment : Fragment(), BaseFormulaFragment<Any> {
private val fragmentDelegate: FragmentEnvironment.FragmentDelegate
get() = environment.fragmentDelegate

private var calledNewInstance = false

private var featureView: FeatureView<Any>? = 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ class FragmentStore @PublishedApi internal constructor(
companion object {
val EMPTY = init { }

inline fun init(
crossinline init: FeaturesBuilder<Unit>.() -> Unit
fun init(
init: FeaturesBuilder<Unit>.() -> Unit
): FragmentStore {
return init(Unit, init)
}

inline fun <Component> init(
fun <Component> init(
rootComponent: Component,
crossinline init: FeaturesBuilder<Component>.() -> Unit
init: FeaturesBuilder<Component>.() -> Unit
): FragmentStore {
val features = FeaturesBuilder.build(init)
return init(rootComponent, features)
Expand Down
Loading

0 comments on commit 8fde402

Please sign in to comment.