Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Increase formula coverage. #388

Merged
merged 2 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion formula-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ dependencies {
api(libs.rxandroid)
api(libs.rxrelay)


testImplementation(libs.androidx.test.rules)
testImplementation(libs.androidx.test.runner)
testImplementation(libs.androidx.test.junit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ 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
@@ -1,6 +1,5 @@
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.instacart.formula.android.internal

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class UtilsTest {

@Test
fun `assertMainThread does nothing on main thread`() {
Truth.assertThat(Utils.isMainThread()).isTrue()
Utils.assertMainThread()
}

@Test
fun `assertMainThread throws exception when on bg thread`() {
val observer = Observable.fromCallable {
Truth.assertThat(Utils.isMainThread()).isFalse()
runCatching { Utils.assertMainThread() }
}.subscribeOn(Schedulers.newThread()).test()

observer.awaitCount(1)
Truth.assertThat(observer.values().first().exceptionOrNull()).hasMessageThat().contains(
"should be called on main thread:"
)
}
}
4 changes: 2 additions & 2 deletions formula/src/main/java/com/instacart/formula/DeferredAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ class DeferredAction<Event>(
) {
private var cancelable: Cancelable? = null

internal var listener: (Event) -> Unit = initial
internal var listener: ((Event) -> Unit)? = initial
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of setting empty lambda when disabling this, I made it nullable and set it to null.


internal fun start() {
cancelable = action.start() { message ->
listener.invoke(message)
listener?.invoke(message)
}
}

Expand Down
117 changes: 53 additions & 64 deletions formula/src/main/java/com/instacart/formula/internal/ActionManager.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.instacart.formula.internal

import com.instacart.formula.DeferredAction
import com.instacart.formula.Evaluation
import com.instacart.formula.plugin.Inspector
import kotlin.reflect.KClass

Expand All @@ -12,18 +13,21 @@ internal class ActionManager(
private val loggingType: KClass<*>,
private val inspector: Inspector?,
) {
companion object {
val NO_OP: (Any?) -> Unit = {}
}

/**
* Currently running actions
*/
private var running: LinkedHashSet<DeferredAction<*>>? = null
private var actions: Set<DeferredAction<*>>? = null

private var startListInvalidated: Boolean = false
private var scheduledToStart: MutableList<DeferredAction<*>>? = null
/**
* Action list provided by [Evaluation.actions]
*/
private var actions: Set<DeferredAction<*>> = emptySet()

private var removeListInvalidated: Boolean = false
private var scheduledForRemoval: MutableList<DeferredAction<*>>? = null
private var recomputeCheckToStartList: Boolean = false
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming these variables to have more descriptive names

private var checkToStartActionList: MutableList<DeferredAction<*>>? = null

private var recomputeCheckToRemoveList: Boolean = false
private var checkToRemoveActionList: MutableList<DeferredAction<*>>? = null

/**
* After evaluation, we might have a new list of actions that we need
Expand All @@ -33,28 +37,26 @@ internal class ActionManager(
fun prepareForPostEvaluation(new: Set<DeferredAction<*>>) {
actions = new

startListInvalidated = true
removeListInvalidated = true
recomputeCheckToStartList = true
recomputeCheckToRemoveList = true
}

/**
* Returns true if there was a transition while terminating streams.
*/
fun terminateOld(evaluationId: Long): Boolean {
prepareStoppedActionList()
recomputeCheckToRemoveActionListIfNeeded()

if (scheduledForRemoval.isNullOrEmpty()) {
return false
}
val runningActionList = running ?: return false
val scheduled = checkToRemoveActionList?.takeIf { it.isNotEmpty() } ?: return false

val actions = actions ?: emptyList()
val iterator = scheduledForRemoval?.iterator()
while (iterator?.hasNext() == true) {
val iterator = scheduled.iterator()
while (iterator.hasNext()) {
val action = iterator.next()
iterator.remove()

if (!actions.contains(action)) {
running?.remove(action)
runningActionList.remove(action)
finishAction(action)

if (manager.isTerminated()) {
Expand All @@ -70,22 +72,20 @@ internal class ActionManager(
}

fun startNew(evaluationId: Long): Boolean {
prepareNewActionList()
recomputeCheckToStartActionListIfNeeded()

val scheduled = scheduledToStart ?: return false
if (scheduled.isEmpty()) {
return false
}
val scheduled = checkToStartActionList?.takeIf { it.isNotEmpty() } ?: return false

val iterator = scheduled.iterator()
while (iterator.hasNext()) {
val action = iterator.next()
iterator.remove()

if (!isRunning(action)) {
val runningActions = getOrInitRunningActions()
if (!runningActions.contains(action)) {
inspector?.onActionStarted(loggingType, action)

getOrInitRunningActions().add(action)
runningActions.add(action)
action.start()

if (manager.isTerminated()) {
Expand All @@ -109,55 +109,44 @@ internal class ActionManager(
}
}

private fun prepareNewActionList() {
if (!startListInvalidated) {
return
}

startListInvalidated = false
scheduledToStart?.clear()

val actionList = actions ?: emptyList()
if (!actionList.isEmpty()) {
if (scheduledToStart == null) {
scheduledToStart = mutableListOf()
}
scheduledToStart?.addAll(actionList)
for (action in actionList) {
if (!isRunning(action)) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic was poorly written as we were potentially adding to the list twice and checking isRunning() multiple times.

val list = scheduledToStart ?: mutableListOf<DeferredAction<*>>().apply {
scheduledToStart = this
}
list.add(action)
}
private fun recomputeCheckToStartActionListIfNeeded() {
if (recomputeCheckToStartList) {
recomputeCheckToStartList = false

checkToStartActionList?.clear()
val list = checkToStartActionList
if (actions.isEmpty()) {
list?.clear()
} else if (list != null) {
list.clear()
list.addAll(actions)
} else {
checkToStartActionList = ArrayList(actions)
}
}
}

private fun prepareStoppedActionList() {
if (!removeListInvalidated) {
return
}
removeListInvalidated = false

scheduledForRemoval?.clear()
if (!running.isNullOrEmpty()) {
if (scheduledForRemoval == null) {
scheduledForRemoval = mutableListOf()
private fun recomputeCheckToRemoveActionListIfNeeded() {
if (recomputeCheckToRemoveList) {
recomputeCheckToRemoveList = false

val list = checkToRemoveActionList
val runningList = running?.takeIf { it.isNotEmpty() }
if (runningList == null) {
list?.clear()
} else if (list != null) {
list.clear()
list.addAll(runningList)
} else {
checkToRemoveActionList = ArrayList(runningList)
}

scheduledForRemoval?.addAll(running ?: emptyList())
}
}

private fun isRunning(update: DeferredAction<*>): Boolean {
return running?.contains(update) ?: false
}

private fun finishAction(action: DeferredAction<*>) {
inspector?.onActionFinished(loggingType, action)
action.tearDown()
action.listener = NO_OP
action.listener = null
}

private fun getOrInitRunningActions(): LinkedHashSet<DeferredAction<*>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,13 @@ class FormulaPluginTest {
FormulaPlugins.setPlugin(TestPlugin(mainDispatcher = myDispatcher))
assertThat(FormulaPlugins.mainThreadDispatcher()).isEqualTo(myDispatcher)
}

@Test fun `default plugin implementation does nothing on duplicate child key`() {
val plugin = object : Plugin {}
plugin.onDuplicateChildKey(
parentType = Any::class.java,
childFormulaType = Any::class.java,
key = Unit,
)
}
}
117 changes: 117 additions & 0 deletions formula/src/test/java/com/instacart/formula/FormulaRuntimeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,123 @@ class FormulaRuntimeTest(val runtime: TestableRuntime, val name: String) {
}
}

// TODO: I'm not sure if this is the right behavior
@Test
fun `action termination events are ignored`() {
val formula = object : Formula<Boolean, Int, Int>() {
override fun initialState(input: Boolean): Int = 0

override fun Snapshot<Boolean, Int>.evaluate(): Evaluation<Int> {
return Evaluation(
output = state,
actions = context.actions {
if (input) {
val action = object : Action<Unit> {
override fun start(send: (Unit) -> Unit): Cancelable {
return Cancelable {
send(Unit)
}
}

override fun key(): Any? = null
}

action.onEvent {
transition(state + 1)
}
}
}
)
}
}

val observer = runtime.test(formula)
observer.input(true)
observer.input(false)
observer.input(true)
observer.input(false)
observer.output { assertThat(this).isEqualTo(0) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the output value be equal to 2?

true - state (0) + 1
false - state = 1
true - state (1) + 1
false - state = 2

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, this is why the test name + comment are describing the situation

// TODO: I'm not sure if this is the right behavior
@Test
fun `action termination events are ignored`()

}

@Test
fun `action triggers another transition on termination`() {
val newRelay = runtime.newRelay()
val formula = object : Formula<Boolean, Int, Int>() {
override fun initialState(input: Boolean): Int = 0

override fun Snapshot<Boolean, Int>.evaluate(): Evaluation<Int> {
return Evaluation(
output = state,
actions = context.actions {
newRelay.action().onEvent {
transition(state + 1)
}

if (input) {
val action = object : Action<Unit> {
override fun start(send: (Unit) -> Unit): Cancelable {
return Cancelable {
newRelay.triggerEvent()
}
}

override fun key(): Any? = null
}

action.onEvent {
none()
}
}
}
)
}
}

val observer = runtime.test(formula)
observer.input(true)
observer.input(false)
observer.input(true)
observer.input(false)
observer.output { assertThat(this).isEqualTo(2) }
}

@Test
fun `action triggers formula termination during termination`() {
var terminate = {}
val formula = object : Formula<Boolean, Int, Int>() {
override fun initialState(input: Boolean): Int = 0

override fun Snapshot<Boolean, Int>.evaluate(): Evaluation<Int> {
return Evaluation(
output = state,
actions = context.actions {
if (input) {
val action = object : Action<Unit> {
override fun start(send: (Unit) -> Unit): Cancelable {
return Cancelable {
terminate()
}
}

override fun key(): Any? = null
}

action.onEvent {
none()
}
}
}
)
}
}

val observer = runtime.test(formula)
terminate = { observer.dispose() }
observer.input(true)
observer.input(false)
observer.output { assertThat(this).isEqualTo(0) }
}

@Test
fun `using from observable with input`() {
val onItem = TestEventCallback<FromObservableWithInputFormula.Item>()
Expand Down
Loading
Loading