From fe5cce8a71231cc310df71879a58c743b17a134d Mon Sep 17 00:00:00 2001 From: Laimonas Turauskas Date: Fri, 15 Mar 2024 18:10:26 -0400 Subject: [PATCH] Refactoring + adding new tests to increase code coverage. (#351) * Add coverage for RuntimeConfig. * Formula plugin coverage. * Miscellaneous coverage improvements. * Increase runtime coverage. * More coverage improvements. * PR feedback. --- .../formula/coroutines/FlowRuntime.kt | 2 +- .../formula/rxjava3/RxJavaRuntime.kt | 2 +- .../com/instacart/formula/ActionBuilder.kt | 2 +- .../com/instacart/formula/FormulaRuntime.kt | 99 ++++++------ .../formula/internal/ChildrenManager.kt | 12 +- .../formula/internal/FormulaManagerImpl.kt | 5 +- .../instacart/formula/internal/Listeners.kt | 10 +- .../formula/internal/SingleRequestHolder.kt | 2 +- .../formula/internal/SnapshotImpl.kt | 17 +- .../instacart/formula/DirectRuntimeTest.kt | 106 ++++++++++++ .../instacart/formula/FormulaRuntimeTest.kt | 152 +++++++++++++++++- .../instacart/formula/RuntimeConfigTest.kt | 24 +++ .../subjects/OptionalCallbackFormula.kt | 13 +- 13 files changed, 367 insertions(+), 79 deletions(-) create mode 100644 formula/src/test/java/com/instacart/formula/DirectRuntimeTest.kt create mode 100644 formula/src/test/java/com/instacart/formula/RuntimeConfigTest.kt diff --git a/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt index 694ca9166..6535c9e57 100644 --- a/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt +++ b/formula-coroutines/src/main/java/com/instacart/formula/coroutines/FlowRuntime.kt @@ -25,7 +25,7 @@ object FlowRuntime { formula = formula, onOutput = this::trySendBlocking, onError = this::close, - config = config, + config = config ?: RuntimeConfig(), ) input.onEach(runtime::onInput).launchIn(this) diff --git a/formula-rxjava3/src/main/java/com/instacart/formula/rxjava3/RxJavaRuntime.kt b/formula-rxjava3/src/main/java/com/instacart/formula/rxjava3/RxJavaRuntime.kt index 27b7994d8..d91d97f04 100644 --- a/formula-rxjava3/src/main/java/com/instacart/formula/rxjava3/RxJavaRuntime.kt +++ b/formula-rxjava3/src/main/java/com/instacart/formula/rxjava3/RxJavaRuntime.kt @@ -19,7 +19,7 @@ object RxJavaRuntime { formula = formula, onOutput = emitter::onNext, onError = emitter::onError, - config = config, + config = config ?: RuntimeConfig(), ) val disposables = CompositeDisposable() diff --git a/formula/src/main/java/com/instacart/formula/ActionBuilder.kt b/formula/src/main/java/com/instacart/formula/ActionBuilder.kt index 11a499dca..ce4ec212b 100644 --- a/formula/src/main/java/com/instacart/formula/ActionBuilder.kt +++ b/formula/src/main/java/com/instacart/formula/ActionBuilder.kt @@ -36,7 +36,7 @@ abstract class ActionBuilder( */ abstract fun events( action: Action, - executionType: Transition.ExecutionType? = null, + executionType: Transition.ExecutionType?, transition: Transition, ) diff --git a/formula/src/main/java/com/instacart/formula/FormulaRuntime.kt b/formula/src/main/java/com/instacart/formula/FormulaRuntime.kt index d9a3a590e..f658f334b 100644 --- a/formula/src/main/java/com/instacart/formula/FormulaRuntime.kt +++ b/formula/src/main/java/com/instacart/formula/FormulaRuntime.kt @@ -17,14 +17,14 @@ class FormulaRuntime( private val formula: IFormula, private val onOutput: (Output) -> Unit, private val onError: (Throwable) -> Unit, - config: RuntimeConfig?, + config: RuntimeConfig, ) : ManagerDelegate, BatchManager.Executor { - private val isValidationEnabled = config?.isValidationEnabled ?: false + private val isValidationEnabled = config.isValidationEnabled private val inspector = FormulaPlugins.inspector( type = formula.type(), - local = config?.inspector, + local = config.inspector, ) - private val defaultDispatcher: Dispatcher = config?.defaultDispatcher ?: FormulaPlugins.defaultDispatcher() + private val defaultDispatcher: Dispatcher = config.defaultDispatcher ?: FormulaPlugins.defaultDispatcher() private val implementation = formula.implementation() private val synchronizedUpdateQueue = SynchronizedUpdateQueue( onEmpty = { emitOutputIfNeeded() } @@ -67,7 +67,7 @@ class FormulaRuntime( /** * Global transition effect queue which executes side-effects after all formulas are idle. */ - private var globalEffectQueue = LinkedList() + private val globalEffectQueue = LinkedList() /** * Determines if we are iterating through [globalEffectQueue]. It prevents us from @@ -166,12 +166,9 @@ class FormulaRuntime( } } - if (effects.isNotEmpty() || evaluate) { - if (isRunEnabled) { - run(evaluate = evaluate) - } else { - pendingEvaluation = pendingEvaluation || evaluate - } + pendingEvaluation = pendingEvaluation || evaluate + if (isRunEnabled) { + runIfNeeded() } } @@ -190,15 +187,19 @@ class FormulaRuntime( */ isRunEnabled = true - if (globalEffectQueue.isNotEmpty() || pendingEvaluation) { - val evaluate = pendingEvaluation - pendingEvaluation = false - run(evaluate = evaluate) - } + runIfNeeded() } } } + private fun runIfNeeded() { + if (globalEffectQueue.isNotEmpty() || pendingEvaluation) { + val evaluate = pendingEvaluation + pendingEvaluation = false + run(evaluate = evaluate) + } + } + /** * Performs the evaluation and execution phases. */ @@ -206,45 +207,42 @@ class FormulaRuntime( if (isRunning) return try { - val manager = checkNotNull(manager) + val manager = requireManager() if (evaluate) { var shouldRun = true while (shouldRun) { val localInputId = inputId - if (!manager.isTerminated()) { - isRunning = true - inspector?.onRunStarted(true) + isRunning = true + inspector?.onRunStarted(true) - val currentInput = checkNotNull(input) - runFormula(manager, currentInput) - isRunning = false + val currentInput = requireInput() + runFormula(manager, currentInput) + isRunning = false - inspector?.onRunFinished() + inspector?.onRunFinished() + + /** + * If termination happened during runFormula() execution, let's perform + * termination side-effects here. + */ + if (manager.isTerminated()) { + shouldRun = false + terminateManager(manager) /** - * If termination happened during runFormula() execution, let's perform - * termination side-effects here. + * If runtime has been terminated, there is nothing else to do. */ - if (manager.isTerminated()) { - shouldRun = false - terminateManager(manager) - - // If runtime has been terminated, we are stopping and do - // not need to do anything else. - if (!isRuntimeTerminated) { - // Terminated manager with input change indicates that formula - // key changed and we are resetting formula state. We need to - // start a new formula manager. - if (localInputId != inputId) { - input?.let(this::startNewManager) - } - } - } else { - shouldRun = localInputId != inputId + if (!isRuntimeTerminated) { + /** + * Terminated manager with non-terminated runtime indicates that + * formula input has triggered a key change and we are resetting + * formula state. We need to start a new formula manager here. + */ + startNewManager(requireInput()) } } else { - shouldRun = false + shouldRun = localInputId != inputId } } } @@ -255,9 +253,10 @@ class FormulaRuntime( } catch (e: Throwable) { isRunning = false - manager?.markAsTerminated() + val manager = requireManager() + manager.markAsTerminated() onError(e) - manager?.let(this::terminateManager) + manager.let(this::terminateManager) } } @@ -359,4 +358,14 @@ class FormulaRuntime( defaultDispatcher = defaultDispatcher, ) } + + // Visible for testing + internal fun requireInput(): Input { + return checkNotNull(input) + } + + // Visible for testing + internal fun requireManager(): FormulaManagerImpl { + return checkNotNull(manager) + } } diff --git a/formula/src/main/java/com/instacart/formula/internal/ChildrenManager.kt b/formula/src/main/java/com/instacart/formula/internal/ChildrenManager.kt index acc6b544e..295ebf28b 100644 --- a/formula/src/main/java/com/instacart/formula/internal/ChildrenManager.kt +++ b/formula/src/main/java/com/instacart/formula/internal/ChildrenManager.kt @@ -26,11 +26,7 @@ internal class ChildrenManager( fun prepareForPostEvaluation() { indexes?.clear() - children?.clearUnrequested { - pendingRemoval = pendingRemoval ?: mutableListOf() - it.markAsTerminated() - pendingRemoval?.add(it) - } + children?.clearUnrequested(this::prepareForTermination) } fun terminateChildren(evaluationId: Long): Boolean { @@ -95,6 +91,12 @@ internal class ChildrenManager( } } + private fun prepareForTermination(it: FormulaManager<*, *>) { + pendingRemoval = pendingRemoval ?: mutableListOf() + it.markAsTerminated() + pendingRemoval?.add(it) + } + private fun childFormulaHolder( key: Any, formula: IFormula, diff --git a/formula/src/main/java/com/instacart/formula/internal/FormulaManagerImpl.kt b/formula/src/main/java/com/instacart/formula/internal/FormulaManagerImpl.kt index 4dbe34ac0..6afe79650 100644 --- a/formula/src/main/java/com/instacart/formula/internal/FormulaManagerImpl.kt +++ b/formula/src/main/java/com/instacart/formula/internal/FormulaManagerImpl.kt @@ -57,7 +57,7 @@ internal class FormulaManagerImpl( * each formula output with an identifier value and compare it for validity with * the global value. */ - var globalEvaluationId: Long = 0 + private var globalEvaluationId: Long = 0 /** * Determines if we are executing within [run] block. Enables optimizations @@ -194,7 +194,6 @@ internal class FormulaManagerImpl( val snapshot = SnapshotImpl( input = input, state = state, - associatedEvaluationId = evaluationId, listeners = listeners, delegate = this, ) @@ -220,7 +219,7 @@ internal class FormulaManagerImpl( listeners.prepareForPostEvaluation() childrenManager?.prepareForPostEvaluation() - snapshot.running = true + snapshot.markRunning() if (!isValidationEnabled) { inspector?.onEvaluateFinished(loggingType, newFrame.evaluation.output, evaluated = true) } diff --git a/formula/src/main/java/com/instacart/formula/internal/Listeners.kt b/formula/src/main/java/com/instacart/formula/internal/Listeners.kt index b25a08fd4..21b3d4080 100644 --- a/formula/src/main/java/com/instacart/formula/internal/Listeners.kt +++ b/formula/src/main/java/com/instacart/formula/internal/Listeners.kt @@ -35,11 +35,7 @@ internal class Listeners { */ fun prepareForPostEvaluation() { indexes?.clear() - - listeners?.clearUnrequested { - // TODO log that disabled listener was invoked. - it.disable() - } + listeners?.clearUnrequested(this::disableListener) } fun disableAll() { @@ -50,6 +46,10 @@ internal class Listeners { listeners?.clear() } + private fun disableListener(listener: ListenerImpl<*, *, *>) { + listener.disable() + } + /** * Function which returns next index for a given key. It will * mutate the [indexes] map. diff --git a/formula/src/main/java/com/instacart/formula/internal/SingleRequestHolder.kt b/formula/src/main/java/com/instacart/formula/internal/SingleRequestHolder.kt index 14a336fc7..13db8166b 100644 --- a/formula/src/main/java/com/instacart/formula/internal/SingleRequestHolder.kt +++ b/formula/src/main/java/com/instacart/formula/internal/SingleRequestHolder.kt @@ -23,7 +23,7 @@ internal class SingleRequestHolder(val value: T) { internal typealias SingleRequestMap = MutableMap> -internal inline fun SingleRequestMap<*, Value>.clearUnrequested(onUnrequested: (Value) -> Unit) { +internal fun SingleRequestMap<*, Value>.clearUnrequested(onUnrequested: (Value) -> Unit) { val callbackIterator = this.iterator() while (callbackIterator.hasNext()) { val callback = callbackIterator.next() diff --git a/formula/src/main/java/com/instacart/formula/internal/SnapshotImpl.kt b/formula/src/main/java/com/instacart/formula/internal/SnapshotImpl.kt index a709a56e4..72494d67d 100644 --- a/formula/src/main/java/com/instacart/formula/internal/SnapshotImpl.kt +++ b/formula/src/main/java/com/instacart/formula/internal/SnapshotImpl.kt @@ -8,20 +8,18 @@ import com.instacart.formula.Listener import com.instacart.formula.Snapshot import com.instacart.formula.Transition import com.instacart.formula.TransitionContext -import com.instacart.formula.plugin.Dispatcher import java.lang.IllegalStateException import kotlin.reflect.KClass internal class SnapshotImpl internal constructor( override val input: Input, override val state: State, - private val associatedEvaluationId: Long, listeners: Listeners, private val delegate: FormulaManagerImpl, ) : FormulaContext(listeners), Snapshot, TransitionContext { private var scopeKey: Any? = null - var running = false + private var running = false override val context: FormulaContext = this @@ -88,15 +86,6 @@ internal class SnapshotImpl internal constructor( } fun dispatch(transition: Transition, event: Event) { - if (!running) { - throw IllegalStateException("Transitions are not allowed during evaluation") - } - - if (!delegate.isTerminated() && delegate.isEvaluationNeeded(associatedEvaluationId)) { - // We have already transitioned, this should not happen. - throw IllegalStateException("Transition already happened. This is using old event listener: $transition & $event. Transition: $associatedEvaluationId != ${delegate.globalEvaluationId}") - } - val result = transition.toResult(this, event) if (TransitionUtils.isEmpty(result)) { return @@ -105,6 +94,10 @@ internal class SnapshotImpl internal constructor( delegate.handleTransitionResult(event, result) } + fun markRunning() { + running = true + } + private fun ensureNotRunning() { if (running) { throw IllegalStateException("Cannot call this transition after evaluation finished. See https://instacart.github.io/formula/faq/#after-evaluation-finished") diff --git a/formula/src/test/java/com/instacart/formula/DirectRuntimeTest.kt b/formula/src/test/java/com/instacart/formula/DirectRuntimeTest.kt new file mode 100644 index 000000000..7d7a0111d --- /dev/null +++ b/formula/src/test/java/com/instacart/formula/DirectRuntimeTest.kt @@ -0,0 +1,106 @@ +package com.instacart.formula + +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import com.instacart.formula.internal.Try +import com.instacart.formula.test.TestEventCallback +import com.instacart.formula.types.InputIdentityFormula +import org.junit.Test + +/** + * [FormulaRuntimeTest] runs both `toObservable` and `toFlow` internally to ensure that both + * implementations function identically. This test is used to capture various edge-cases + * within [FormulaRuntime] that are not possible via indirect tests. + */ +class DirectRuntimeTest { + + @Test fun `requireInput will throw illegal state exception if it is null`() { + val root = InputIdentityFormula() + + val onOutput = TestEventCallback() + val onError = TestEventCallback() + val runtime = FormulaRuntime( + formula = root, + onOutput = onOutput, + onError = onError, + config = RuntimeConfig() + ) + + val result = Try { + runtime.requireInput() + } + assertThat(result.errorOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test fun `requireManager will throw illegal state exception if it is null`() { + val root = InputIdentityFormula() + + val onOutput = TestEventCallback() + val onError = TestEventCallback() + val runtime = FormulaRuntime( + formula = root, + onOutput = onOutput, + onError = onError, + config = RuntimeConfig() + ) + + val result = Try { + runtime.requireManager() + } + assertThat(result.errorOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `input change when runtime is terminated does nothing`() { + val root = InputIdentityFormula() + + val onOutput = TestEventCallback() + val onError = TestEventCallback() + val runtime = FormulaRuntime( + formula = root, + onOutput = onOutput, + onError = onError, + config = RuntimeConfig() + ) + + runtime.onInput(0) + runtime.terminate() + runtime.onInput(1) + + assertThat(onOutput.values()).containsExactly(0).inOrder() + } + + @Test fun `it is safe to call terminate before first input initializes formula`() { + val root = InputIdentityFormula() + + val onOutput = TestEventCallback() + val onError = TestEventCallback() + val runtime = FormulaRuntime( + formula = root, + onOutput = onOutput, + onError = onError, + config = RuntimeConfig() + ) + + runtime.terminate() + } + + @Test fun `it is safe to call terminate multiple times`() { + val root = InputIdentityFormula() + + val onOutput = TestEventCallback() + val onError = TestEventCallback() + val runtime = FormulaRuntime( + formula = root, + onOutput = onOutput, + onError = onError, + config = RuntimeConfig() + ) + + runtime.onInput(0) + runtime.terminate() + runtime.terminate() + runtime.terminate() + } + +} \ No newline at end of file diff --git a/formula/src/test/java/com/instacart/formula/FormulaRuntimeTest.kt b/formula/src/test/java/com/instacart/formula/FormulaRuntimeTest.kt index 4ff51fecf..b2a486697 100644 --- a/formula/src/test/java/com/instacart/formula/FormulaRuntimeTest.kt +++ b/formula/src/test/java/com/instacart/formula/FormulaRuntimeTest.kt @@ -8,6 +8,7 @@ import com.instacart.formula.internal.ClearPluginsRule import com.instacart.formula.internal.FormulaKey import com.instacart.formula.internal.TestInspector import com.instacart.formula.internal.Try +import com.instacart.formula.plugin.Dispatcher import com.instacart.formula.plugin.Inspector import com.instacart.formula.plugin.Plugin import com.instacart.formula.rxjava3.RxAction @@ -76,6 +77,7 @@ import com.instacart.formula.test.CountingInspector import com.instacart.formula.test.RxJavaTestableRuntime import com.instacart.formula.test.TestCallback import com.instacart.formula.test.TestEventCallback +import com.instacart.formula.test.TestFormulaObserver import com.instacart.formula.test.TestableRuntime import com.instacart.formula.types.ActionDelegateFormula import com.instacart.formula.types.IncrementActionFormula @@ -212,6 +214,119 @@ class FormulaRuntimeTest(val runtime: TestableRuntime, val name: String) { robot.test.output { assertThat(this).isEqualTo(3) } } + @Test fun `input change while running triggers root formula restart`() { + val terminationCallback = TestEventCallback() + var observer: TestFormulaObserver? = null + val root = object : StatelessFormula() { + override fun key(input: Int): Any { + return input + } + + override fun Snapshot.evaluate(): Evaluation { + return Evaluation( + output = input, + actions = context.actions { + Action.onTerminate().onEvent { + transition { + terminationCallback.invoke(input) + } + } + + if (input == 0) { + Action.onInit().onEvent { + /** + * We call observer explicitly outside of effect block to ensure + * that input change happens while formula is running + */ + observer?.input(1) + none() + } + } + } + ) + } + } + + observer = runtime.test(root) + observer.input(0) + observer.output { assertThat(this).isEqualTo(1) } + + // Check that termination was called + assertThat(terminationCallback.values()).containsExactly(0).inOrder() + } + + @Test fun `runtime termination triggered while formula is running`() { + val terminationCallback = TestEventCallback() + var observer: TestFormulaObserver? = null + val root = object : StatelessFormula() { + override fun Snapshot.evaluate(): Evaluation { + return Evaluation( + output = input, + actions = context.actions { + Action.onTerminate().onEvent { + transition { + terminationCallback.invoke(input) + } + } + + if (input == 0) { + Action.onInit().onEvent { + // This is outside of effect to trigger termination while running + observer?.dispose() + none() + } + } + } + ) + } + } + + observer = runtime.test(root) + observer.input(0) + + // No output since formula exited before producing an output + observer.assertOutputCount(0) + + // Check that termination was called + assertThat(terminationCallback.values()).containsExactly(0).inOrder() + } + + @Test fun `runtime termination triggered by an effect`() { + val terminationCallback = TestEventCallback() + var observer: TestFormulaObserver? = null + val root = object : StatelessFormula() { + override fun Snapshot.evaluate(): Evaluation { + return Evaluation( + output = input, + actions = context.actions { + Action.onTerminate().onEvent { + transition { + terminationCallback.invoke(input) + } + } + + if (input == 0) { + Action.onInit().onEvent { + transition { + observer?.dispose() + } + } + } + } + ) + } + } + + observer = runtime.test(root) + observer.input(0) + + // No output since formula exited before producing an output + observer.assertOutputCount(0) + + // Check that termination was called + assertThat(terminationCallback.values()).containsExactly(0).inOrder() + } + @Test fun `multiple event updates`() { runtime.test(StartStopFormula(runtime), Unit) @@ -545,6 +660,28 @@ class FormulaRuntimeTest(val runtime: TestableRuntime, val name: String) { } } + @Test fun `listener removed while dispatching an event will drop the event`() { + var observer: TestFormulaObserver? = null + FormulaPlugins.setPlugin(object : Plugin { + override fun backgroundThreadDispatcher(): Dispatcher { + return object : Dispatcher { + override fun dispatch(executable: () -> Unit) { + // We disable callback before executing increment + observer?.output { toggleCallback() } + executable() + } + } + } + }) + + val root = OptionalCallbackFormula( + incrementExecutionType = Transition.Background + ) + observer = runtime.test(root, Unit) + observer.output { listener?.invoke() } + observer.output { assertThat(state).isEqualTo(0) } + } + @Test fun `listeners are not the same after removing then adding it again`() { runtime.test(OptionalCallbackFormula(), Unit) @@ -1114,7 +1251,20 @@ class FormulaRuntimeTest(val runtime: TestableRuntime, val name: String) { } } - @Test fun `adding duplicate child logs global event`() { + @Test fun `child formulas with duplicate key are supported`() { + val result = Try { + val formula = DynamicParentFormula() + runtime.test(formula, Unit) + .output { addChild(TestKey("1")) } + .output { addChild(TestKey("1")) } + } + + // No errors + val error = result.errorOrNull()?.cause + assertThat(error).isNull() + } + + @Test fun `when child formulas with duplicate key are added, plugin is notified`() { val duplicateKeys = mutableListOf() FormulaPlugins.setPlugin(object : Plugin { diff --git a/formula/src/test/java/com/instacart/formula/RuntimeConfigTest.kt b/formula/src/test/java/com/instacart/formula/RuntimeConfigTest.kt new file mode 100644 index 000000000..d639c3ddc --- /dev/null +++ b/formula/src/test/java/com/instacart/formula/RuntimeConfigTest.kt @@ -0,0 +1,24 @@ +package com.instacart.formula + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RuntimeConfigTest { + @Test + fun `validation is not enabled by default`() { + val config = RuntimeConfig() + assertThat(config.isValidationEnabled).isFalse() + } + + @Test + fun `default dispatcher is null`() { + val config = RuntimeConfig() + assertThat(config.defaultDispatcher).isNull() + } + + @Test + fun `default inspector is null`() { + val config = RuntimeConfig() + assertThat(config.inspector).isNull() + } +} \ No newline at end of file diff --git a/formula/src/test/java/com/instacart/formula/subjects/OptionalCallbackFormula.kt b/formula/src/test/java/com/instacart/formula/subjects/OptionalCallbackFormula.kt index fb75365c5..8ad771d85 100644 --- a/formula/src/test/java/com/instacart/formula/subjects/OptionalCallbackFormula.kt +++ b/formula/src/test/java/com/instacart/formula/subjects/OptionalCallbackFormula.kt @@ -4,9 +4,12 @@ import com.instacart.formula.Evaluation import com.instacart.formula.Formula import com.instacart.formula.Listener import com.instacart.formula.Snapshot +import com.instacart.formula.Transition -class OptionalCallbackFormula : - Formula() { +class OptionalCallbackFormula( + private val toggleExecutionType: Transition.ExecutionType? = null, + private val incrementExecutionType: Transition.ExecutionType? = null, +) : Formula() { data class State( val callbackEnabled: Boolean = true, val state: Int = 0 @@ -22,7 +25,9 @@ class OptionalCallbackFormula : override fun Snapshot.evaluate(): Evaluation { val callback = if (state.callbackEnabled) { - context.onEvent { transition(state.copy(state = state.state + 1)) } + context.onEventWithExecutionType(incrementExecutionType) { + transition(state.copy(state = state.state + 1)) + } } else { null } @@ -31,7 +36,7 @@ class OptionalCallbackFormula : output = Output( state = state.state, listener = callback, - toggleCallback = context.onEvent { + toggleCallback = context.onEventWithExecutionType(toggleExecutionType) { transition(state.copy(callbackEnabled = !state.callbackEnabled)) } )