diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt index d6c64532e..916745f3c 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable import com.squareup.benchmarks.performance.complex.poetry.instrumentation.ActionHandlingTracingInterceptor import com.squareup.benchmarks.performance.complex.poetry.instrumentation.asTraceableWorker import com.squareup.benchmarks.performance.complex.poetry.views.LoaderSpinner @@ -26,6 +27,7 @@ class MaybeLoadingGatekeeperWorkflow( snapshot: Snapshot? ): IsLoading = false + @Composable override fun render( renderProps: Unit, renderState: IsLoading, diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt index a24d2cf55..ce549240a 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.ClearSelection import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.HandleStanzaListOutput import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.SelectNext @@ -90,6 +91,7 @@ class PerformancePoemWorkflow( } @OptIn(WorkflowUiExperimentalApi::class) + @Composable override fun render( renderProps: Poem, renderState: State, diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt index bb12e905c..e279e2f5e 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State.ComplexCall import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State.Initializing @@ -71,6 +72,7 @@ class PerformancePoemsBrowserWorkflow( } @OptIn(WorkflowUiExperimentalApi::class) + @Composable override fun render( renderProps: List, renderState: State, diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt index 9d851dbb1..ccaece153 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import androidx.tracing.trace import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.WorkflowAction @@ -56,11 +57,12 @@ class ActionHandlingTracingInterceptor : WorkflowInterceptor, Resettable { } } + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { return proceed( diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt index 4b0756915..d6905ef70 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import androidx.tracing.Trace import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow @@ -21,11 +22,12 @@ class PerformanceTracingInterceptor( ) : WorkflowInterceptor, Resettable { private var totalRenderPasses = 0 + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { val isRoot = session.parent == null diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt index 748b69132..0a0bf4df9 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor @@ -18,11 +19,12 @@ class RenderPassCountingInterceptor : WorkflowInterceptor, Resettable { lateinit var renderPassStats: RenderStats private val nodeStates: MutableMap = mutableMapOf() + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { val isRoot = session.parent == null diff --git a/build.gradle.kts b/build.gradle.kts index 5ab4bf20c..463d690d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ buildscript { classpath(libs.kotlin.serialization.gradle.plugin) classpath(libs.kotlinx.binaryCompatibility.gradle.plugin) classpath(libs.kotlin.gradle.plugin) + classpath(libs.molecule.gradle.plugin) classpath(libs.google.ksp) classpath(libs.ktlint.gradle) classpath(libs.vanniktech.publish) @@ -19,6 +20,9 @@ buildscript { mavenCentral() gradlePluginPortal() google() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots/") + } // For binary compatibility validator. maven { url = uri("https://kotlin.bintray.com/kotlinx") } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 8bb0b8adc..2affe5e59 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -7,6 +7,8 @@ plugins { repositories { mavenCentral() google() + // For molecule SNAPSHOT + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/")} } dependencies { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e572fc5df..07aebe781 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,8 +9,8 @@ targetSdk = "30" androidx-activity = "1.3.0" androidx-appcompat = "1.3.1" androidx-benchmark = "1.1.0-rc03" -androidx-compose = "1.1.0-rc01" -androidx-compose-compiler = "1.1.0-rc02" +compose = "1.1.0" +compose-compiler = "1.1.0" androidx-constraintlayout = "2.1.2" androidx-core = "1.6.0" androidx-fragment = "1.3.6" @@ -49,8 +49,8 @@ kotest = "5.1.0" kotlin = "1.6.10" kotlinx-binary-compatibility = "0.6.0" -kotlinx-coroutines = "1.5.1" -# The 1.5.1 test artifact is jvm-only. The commonTest module should use 1.6.1. +kotlinx-coroutines = "1.5.2" +# The 1.5.2 test artifact is jvm-only. The commonTest module should use 1.6.1. kotlinx-coroutines-test-common = "1.6.1" kotlinx-serialization-json = "1.3.2" kotlinx-benchmark = "0.4.2" @@ -64,6 +64,9 @@ mockito-core = "3.3.3" mockito-kotlin = "3.2.0" mockk = "1.11.0" + +molecule = "0.3.0-SNAPSHOT" + robolectric = "4.6.1" rxjava2-android = "2.1.1" @@ -100,8 +103,12 @@ kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kot ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" } +molecule = { id = "app.cash.molecule", version.ref = "molecule"} + [libraries] +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "molecule"} + android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "androidTools" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -112,13 +119,17 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-macro-benchmark = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } -androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } + +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } -androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } -androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" } -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" } -androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose"} +compose-compiler = { module = "org.jetbrains.compose.compiler:compiler", version.ref = "compose"} androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } @@ -203,6 +214,9 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = " mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule"} +molecule-testing = { module = "app.cash.molecule:molecule-testing", version.ref = "molecule"} + robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } rxjava2-rxandroid = { module = "io.reactivex.rxjava2:rxandroid", version.ref = "rxjava2-android" } diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index c7ee68f31..91908dac2 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -14,7 +14,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } diff --git a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt index 570cdee4e..296476e18 100644 --- a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt +++ b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.sample.poetry +import androidx.compose.runtime.Composable import com.squareup.sample.poetry.StanzaListWorkflow.Props import com.squareup.sample.poetry.model.Poem import com.squareup.workflow1.StatelessWorkflow @@ -20,6 +21,7 @@ object StanzaListWorkflow : StatelessWorkflow() { ShowNextStanza } + @Composable override fun render( renderProps: Props, context: RenderContext diff --git a/settings.gradle.kts b/settings.gradle.kts index 662672fed..29143c826 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,8 @@ pluginManagement { google() // For binary compatibility validator. maven { url = uri("https://kotlin.bintray.com/kotlinx") } + // For molecule SNAPSHOT + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/")} } } @@ -29,6 +31,8 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + // For molecule SNAPSHOT + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/")} // See androidx.dev (can use this for Snapshot builds of AndroidX) // maven { url = java.net.URI.create("https://androidx.dev/snapshots/builds/8224905/artifacts/repository") } } diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index 6a29d177e..3c14f1d82 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -1,13 +1,14 @@ plugins { `kotlin-multiplatform` id("org.jetbrains.dokka") + id("app.cash.molecule") } apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) kotlin { jvm { withJava() } - ios() + // ios() sourceSets { all { @@ -19,8 +20,10 @@ kotlin { dependencies { api(libs.kotlin.jdk6) api(libs.kotlinx.coroutines.core) + api(libs.compose.runtime) // For Snapshot. api(libs.squareup.okio) + implementation(libs.molecule.runtime) } } val commonTest by getting { @@ -28,6 +31,7 @@ kotlin { implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.test.common) implementation(libs.kotlin.test.jdk) + implementation(libs.molecule.testing) } } } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index c727556de..6fdfadd80 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -4,6 +4,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowAction.Companion.noAction import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass @@ -71,6 +72,7 @@ public interface BaseRenderContext { * @param key An optional string key that is used to distinguish between workflows of the same * type. */ + @Composable public fun renderChild( child: Workflow, props: ChildPropsT, @@ -217,6 +219,7 @@ public interface BaseRenderContext { /** * Convenience alias of [BaseRenderContext.renderChild] for workflows that don't take props. */ +@Composable public fun BaseRenderContext.renderChild( child: Workflow, @@ -226,6 +229,7 @@ BaseRenderContext.renderChild( /** * Convenience alias of [BaseRenderContext.renderChild] for workflows that don't emit output. */ +@Composable public fun BaseRenderContext.renderChild( child: Workflow, @@ -236,6 +240,7 @@ BaseRenderContext.renderChild( * Convenience alias of [BaseRenderContext.renderChild] for children that don't take props or emit * output. */ +@Composable public fun BaseRenderContext.renderChild( child: Workflow, diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt index 031235ff4..a1e68217a 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt @@ -4,6 +4,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.StatefulWorkflow.RenderContext import com.squareup.workflow1.WorkflowAction.Companion.toString import kotlin.jvm.JvmMultifileClass @@ -129,6 +130,13 @@ public abstract class StatefulWorkflow< context: RenderContext ): RenderingT + @Composable + public open fun renderComposed( + renderProps: PropsT, + renderState: StateT, + context: RenderContext + ): RenderingT = render(renderProps, renderState, context) + /** * Called whenever the state changes to generate a new [Snapshot] of the state. * @@ -166,14 +174,14 @@ public fun RenderContext( /** * Returns a stateful [Workflow] implemented via the given functions. */ -public inline fun Workflow.Companion.stateful( - crossinline initialState: (PropsT, Snapshot?) -> StateT, - crossinline render: BaseRenderContext.( +public fun Workflow.Companion.stateful( + initialState: (PropsT, Snapshot?) -> StateT, + render: @Composable BaseRenderContext.( props: PropsT, state: StateT ) -> RenderingT, - crossinline snapshot: (StateT) -> Snapshot?, - crossinline onPropsChanged: ( + snapshot: (StateT) -> Snapshot?, + onPropsChanged: ( old: PropsT, new: PropsT, state: StateT @@ -203,10 +211,10 @@ public inline fun Workflow.Companion.state /** * Returns a stateful [Workflow], with no props, implemented via the given functions. */ -public inline fun Workflow.Companion.stateful( - crossinline initialState: (Snapshot?) -> StateT, - crossinline render: BaseRenderContext.(state: StateT) -> RenderingT, - crossinline snapshot: (StateT) -> Snapshot? +public fun Workflow.Companion.stateful( + initialState: (Snapshot?) -> StateT, + render: @Composable BaseRenderContext.(state: StateT) -> RenderingT, + snapshot: (StateT) -> Snapshot? ): StatefulWorkflow = stateful( { _, initialSnapshot -> initialState(initialSnapshot) }, { _, state -> render(state) }, @@ -218,13 +226,13 @@ public inline fun Workflow.Companion.stateful( * * This overload does not support snapshotting, but there are other overloads that do. */ -public inline fun Workflow.Companion.stateful( - crossinline initialState: (PropsT) -> StateT, - crossinline render: BaseRenderContext.( +public fun Workflow.Companion.stateful( + initialState: (PropsT) -> StateT, + render: @Composable BaseRenderContext.( props: PropsT, state: StateT ) -> RenderingT, - crossinline onPropsChanged: ( + onPropsChanged: ( old: PropsT, new: PropsT, state: StateT @@ -241,9 +249,9 @@ public inline fun Workflow.Companion.state * * This overload does not support snapshots, but there are others that do. */ -public inline fun Workflow.Companion.stateful( +public fun Workflow.Companion.stateful( initialState: StateT, - crossinline render: BaseRenderContext.(state: StateT) -> RenderingT + render: @Composable BaseRenderContext.(state: StateT) -> RenderingT ): StatefulWorkflow = stateful( { initialState }, { _, state -> render(state) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt index f6545524e..4f42999ac 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -35,7 +36,12 @@ public abstract class StatelessWorkflow @Suppress("UNCHECKED_CAST") private val statefulWorkflow = Workflow.stateful( initialState = { Unit }, - render = { props, _ -> render(props, RenderContext(this, this@StatelessWorkflow)) } + render = @Composable fun RenderContext.( + props: PropsT, + _: Nothing + ) { + render(props, RenderContext(this, this@StatelessWorkflow)) + } ) /** @@ -57,6 +63,12 @@ public abstract class StatelessWorkflow context: RenderContext ): RenderingT + @Composable + public open fun renderComposed( + renderProps: PropsT, + context: RenderContext + ): RenderingT = render(renderProps, context) + /** * Satisfies the [Workflow] interface by wrapping `this` in a [StatefulWorkflow] with `Unit` * state. @@ -86,10 +98,11 @@ public fun RenderContext( * [props][PropsT] received from its parent, and it may render child workflows that do have * their own internal state. */ -public inline fun Workflow.Companion.stateless( - crossinline render: BaseRenderContext.(props: PropsT) -> RenderingT +public fun Workflow.Companion.stateless( + render: @Composable BaseRenderContext.(props: PropsT) -> RenderingT ): Workflow = object : StatelessWorkflow() { + @Composable override fun render( renderProps: PropsT, context: RenderContext @@ -113,7 +126,7 @@ public fun Workflow.Companion.rendering( * @param update Function that defines the workflow update. */ public fun -StatelessWorkflow.action( + StatelessWorkflow.action( name: String = "", update: WorkflowAction.Updater.() -> Unit ): WorkflowAction = action({ name }, update) @@ -128,7 +141,7 @@ StatelessWorkflow.action( * @param update Function that defines the workflow update. */ public fun -StatelessWorkflow.action( + StatelessWorkflow.action( name: () -> String, update: WorkflowAction.Updater.() -> Unit ): WorkflowAction = object : WorkflowAction() { diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt index c5ec0bba9..466fb491e 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkerWorkflow.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @@ -48,6 +49,7 @@ internal class WorkerWorkflow( state: Int ): Int = if (!old.doesSameWorkAs(new)) state + 1 else state + @Composable override fun render( renderProps: Worker, renderState: Int, diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Workflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Workflow.kt index ce2aaf685..ecaad66c9 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Workflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Workflow.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -122,6 +123,7 @@ Workflow.mapRendering( object : StatelessWorkflow(), ImpostorWorkflow { override val realIdentifier: WorkflowIdentifier get() = this@mapRendering.identifier + @Composable override fun render( renderProps: PropsT, context: RenderContext diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index bac7d553b..8bf0c7313 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -1,8 +1,9 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/NoopWorkflowInterceptor; public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; - public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; + public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function5;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; } @@ -13,6 +14,7 @@ public final class com/squareup/workflow1/RenderWorkflowKt { } public final class com/squareup/workflow1/RenderingAndSnapshot { + public static final field $stable I public fun (Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Lcom/squareup/workflow1/TreeSnapshot; @@ -29,6 +31,7 @@ public final class com/squareup/workflow1/RuntimeConfig$Companion { } public final class com/squareup/workflow1/RuntimeConfig$FrameTimeout : com/squareup/workflow1/RuntimeConfig { + public static final field $stable I public fun ()V public fun (J)V public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -42,10 +45,12 @@ public final class com/squareup/workflow1/RuntimeConfig$FrameTimeout : com/squar } public final class com/squareup/workflow1/RuntimeConfig$RenderPerAction : com/squareup/workflow1/RuntimeConfig { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/RuntimeConfig$RenderPerAction; } public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public fun ()V protected fun log (Ljava/lang/String;)V protected fun logAfterMethod (Ljava/lang/String;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;[Lkotlin/Pair;)V @@ -53,12 +58,13 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar protected fun logError (Ljava/lang/String;)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; - public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; + public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function5;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; } public final class com/squareup/workflow1/TreeSnapshot { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/TreeSnapshot$Companion; public fun (Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function0;)V public fun equals (Ljava/lang/Object;)Z @@ -79,7 +85,7 @@ public abstract interface annotation class com/squareup/workflow1/WorkflowExperi public abstract interface class com/squareup/workflow1/WorkflowInterceptor { public abstract fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public abstract fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; - public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; + public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function5;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public abstract fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public abstract fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; } @@ -87,20 +93,20 @@ public abstract interface class com/squareup/workflow1/WorkflowInterceptor { public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls { public static fun onInitialState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public static fun onPropsChanged (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; - public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; + public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function5;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public static fun onSessionStarted (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public static fun onSnapshotState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; } public abstract interface class com/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor { public abstract fun onActionSent (Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V - public abstract fun onRenderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; + public abstract fun onRenderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public abstract fun onRunningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V } public final class com/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor$DefaultImpls { public static fun onActionSent (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V - public static fun onRenderChild (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; + public static fun onRenderChild (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public static fun onRunningSideEffect (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V } @@ -115,6 +121,17 @@ public final class com/squareup/workflow1/WorkflowInterceptorKt { public static final fun intercept (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/StatefulWorkflow;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/StatefulWorkflow; } +public final class com/squareup/workflow1/WorkflowRuntimeClock : androidx/compose/runtime/MonotonicFrameClock { + public static final field $stable I + public fun (Lkotlinx/coroutines/flow/Flow;)V + public fun fold (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; + public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; + public fun getKey ()Lkotlin/coroutines/CoroutineContext$Key; + public fun minusKey (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; + public fun plus (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; + public fun withFrameNanos (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/squareup/workflow1/internal/ActiveStagingList { public fun ()V public final fun commitStaging (Lkotlin/jvm/functions/Function1;)V @@ -127,7 +144,7 @@ public final class com/squareup/workflow1/internal/ChainedWorkflowInterceptor : public fun (Ljava/util/List;)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; - public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; + public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function5;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; } @@ -177,14 +194,14 @@ public final class com/squareup/workflow1/internal/RealRenderContext : com/squar public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; public final fun freeze ()V public fun getActionSink ()Lcom/squareup/workflow1/Sink; - public fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Ljava/lang/Object; public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V public fun send (Lcom/squareup/workflow1/WorkflowAction;)V public synthetic fun send (Ljava/lang/Object;)V } public abstract interface class com/squareup/workflow1/internal/RealRenderContext$Renderer { - public abstract fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public abstract fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; } public abstract interface class com/squareup/workflow1/internal/RealRenderContext$SideEffectRunner { @@ -205,13 +222,14 @@ public final class com/squareup/workflow1/internal/SubtreeManager : com/squareup public fun (Ljava/util/Map;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;)V public synthetic fun (Ljava/util/Map;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun commitRenderedChildren ()V - public final fun createChildSnapshots ()Ljava/util/Map; - public fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public final fun createChildSnapshots (Landroidx/compose/runtime/Composer;I)Ljava/util/Map; + public fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public final fun tickChildren (Lkotlinx/coroutines/selects/SelectBuilder;)V } public final class com/squareup/workflow1/internal/SystemUtilsKt { public static final fun currentTimeMillis ()J + public static final fun nanoTime ()J } public final class com/squareup/workflow1/internal/ThrowablesKt { @@ -227,7 +245,7 @@ public final class com/squareup/workflow1/internal/WorkflowChildNode : com/squar public final fun getWorkflow ()Lcom/squareup/workflow1/Workflow; public final fun getWorkflowNode ()Lcom/squareup/workflow1/internal/WorkflowNode; public final fun matches (Lcom/squareup/workflow1/Workflow;Ljava/lang/String;)Z - public final fun render (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;)Ljava/lang/Object; + public final fun render (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public final fun setHandler (Lkotlin/jvm/functions/Function1;)V public synthetic fun setNextListNode (Lcom/squareup/workflow1/internal/InlineLinkedList$InlineListNode;)V public fun setNextListNode (Lcom/squareup/workflow1/internal/WorkflowChildNode;)V @@ -244,9 +262,9 @@ public final class com/squareup/workflow1/internal/WorkflowNode : com/squareup/w public fun getParent ()Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession; public fun getRenderKey ()Ljava/lang/String; public fun getSessionId ()J - public final fun render (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;)Ljava/lang/Object; + public final fun render (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object; public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V - public final fun snapshot (Lcom/squareup/workflow1/StatefulWorkflow;)Lcom/squareup/workflow1/TreeSnapshot; + public final fun snapshot (Lcom/squareup/workflow1/StatefulWorkflow;Landroidx/compose/runtime/Composer;I)Lcom/squareup/workflow1/TreeSnapshot; public final fun tick (Lkotlinx/coroutines/selects/SelectBuilder;)V public fun toString ()Ljava/lang/String; } @@ -283,7 +301,7 @@ public final class com/squareup/workflow1/internal/WorkflowRunner { public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/RuntimeConfig;)V public final fun cancelRuntime (Ljava/util/concurrent/CancellationException;)V public static synthetic fun cancelRuntime$default (Lcom/squareup/workflow1/internal/WorkflowRunner;Ljava/util/concurrent/CancellationException;ILjava/lang/Object;)V - public final fun nextRendering ()Lcom/squareup/workflow1/RenderingAndSnapshot; + public final fun nextRendering (Landroidx/compose/runtime/Composer;I)Lcom/squareup/workflow1/RenderingAndSnapshot; public final fun processActions (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index a4ffd5cae..a16cfc5de 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -5,6 +5,7 @@ plugins { `kotlin-multiplatform` id("org.jetbrains.dokka") id("org.jetbrains.kotlinx.benchmark") + id("app.cash.molecule") } apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) @@ -27,7 +28,9 @@ kotlin { } } } - ios() + // Can't have native targets for molecule yet, because can't have them for jetbrains + // compose runtime 1.1.0, yet. + // ios() sourceSets { all { @@ -39,12 +42,15 @@ kotlin { dependencies { api(project(":workflow-core")) api(libs.kotlinx.coroutines.core) + api(libs.compose.runtime) + implementation(libs.molecule.runtime) } } val commonTest by getting { dependencies { implementation(libs.kotlinx.coroutines.test.common) implementation(libs.kotlin.test.jdk) + implementation(libs.molecule.testing) } } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 476faefd3..ba846e796 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -1,16 +1,18 @@ package com.squareup.workflow1 +import app.cash.molecule.launchMolecule import com.squareup.workflow1.internal.WorkflowRunner import com.squareup.workflow1.internal.chained -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.plus /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -116,22 +118,16 @@ public fun renderWorkflowIn( val runner = WorkflowRunner(scope, workflow, props, initialSnapshot, chainedInterceptor, runtimeConfig) - // Rendering is synchronous, so we can run the first render pass before launching the runtime - // coroutine to calculate the initial rendering. - val renderingsAndSnapshots = MutableStateFlow( - try { + val renderTrigger = MutableSharedFlow() + val workflowRuntimeClock = WorkflowRuntimeClock(renderTrigger) + val clockedScope = scope + workflowRuntimeClock + + // Rendering is synchronous, so we run the first render() before launching the coroutine + // to start the runtime. + val composedRenderingAndSnapshots: StateFlow> = + clockedScope.launchMolecule> { runner.nextRendering() - } catch (e: Throwable) { - // If any part of the workflow runtime fails, the scope should be cancelled. We're not in a - // coroutine yet however, so if the first render pass fails it won't cancel the runtime, - // but this is an implementation detail so we must cancel the scope manually to keep the - // contract. - val cancellation = - (e as? CancellationException) ?: CancellationException("Workflow runtime failed", e) - runner.cancelRuntime(cancellation) - throw e } - ) scope.launch { while (isActive) { @@ -140,16 +136,18 @@ public fun renderWorkflowIn( // launched. val output: WorkflowOutput? = runner.processActions() - // After resuming from runner.nextOutput() our coroutine could now be cancelled, check so we - // don't surprise anyone with an unexpected rendering pass. Show's over, go home. + // After resuming from runner.processActions() our coroutine could now be cancelled, check so + // we don't surprise anyone with an unexpected rendering pass. Show's over, go home. if (!isActive) return@launch - // After receiving an output, the next render pass must be done before emitting that output, - // so that the workflow states appear consistent to observers of the outputs and renderings. - renderingsAndSnapshots.value = runner.nextRendering() + // After processing actions, the next render pass must be done before emitting any output + // that the action produced, so that the workflow states appear consistent to observers of + // the outputs and renderings. + renderTrigger.emit(Unit) + output?.let { onOutput(it.value) } } } - return renderingsAndSnapshots + return composedRenderingAndSnapshots } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 2b6e62433..14fd82caa 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -38,11 +39,12 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { proceed(old, new, state) } + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R = logMethod("onRender", session) { proceed(renderProps, renderState, SimpleLoggingContextInterceptor(session)) diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 6cc3c808e..e304760ab 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -90,11 +91,12 @@ public interface WorkflowInterceptor { /** * Intercepts calls to [StatefulWorkflow.render]. */ + @Composable public fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R = proceed(renderProps, renderState, null) @@ -214,12 +216,13 @@ public interface WorkflowInterceptor { * interceptor to wrap or replace the [child] Workflow, its [childProps], * [key], and the [handler] function to be applied to the child's output. */ + @Composable public fun onRenderChild( child: Workflow, childProps: CP, key: String, handler: (CO) -> WorkflowAction, - proceed: ( + proceed: @Composable ( child: Workflow, childProps: CP, key: String, @@ -254,6 +257,7 @@ internal fun WorkflowInterceptor.intercept( state: S ): S = onPropsChanged(old, new, state, workflow::onPropsChanged, workflowSession) + @Composable override fun render( renderProps: P, renderState: S, @@ -289,6 +293,7 @@ private class InterceptedRenderContext( } } + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt new file mode 100644 index 000000000..d8e54350c --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt @@ -0,0 +1,15 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.MonotonicFrameClock +import com.squareup.workflow1.internal.nanoTime +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.single + +public class WorkflowRuntimeClock( + private val workflowFrames: Flow +) : MonotonicFrameClock { + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + workflowFrames.single() + return onFrame(nanoTime()) + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt index 82493b0c8..504df3c97 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.Snapshot @@ -57,11 +58,12 @@ internal class ChainedWorkflowInterceptor( return chainedProceed(old, new, state) } + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> @@ -114,12 +116,13 @@ internal class ChainedWorkflowInterceptor( } } + @Composable override fun onRenderChild( child: Workflow, childProps: CP, key: String, handler: (CO) -> WorkflowAction, - proceed: ( + proceed: @Composable ( child: Workflow, props: CP, key: String, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt index fdc42ecc7..aa569cfa1 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Sink import com.squareup.workflow1.Workflow @@ -16,6 +17,7 @@ internal class RealRenderContext( ) : BaseRenderContext, Sink> { interface Renderer { + @Composable fun render( child: Workflow, props: ChildPropsT, @@ -51,6 +53,7 @@ internal class RealRenderContext( eventActionsChannel.trySend(value) } + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 434b16c19..b25ea20ab 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.TreeSnapshot @@ -108,6 +109,7 @@ internal class SubtreeManager( snapshotCache = null } + @Composable override fun render( child: Workflow, props: ChildPropsT, @@ -140,6 +142,7 @@ internal class SubtreeManager( } } + @Composable fun createChildSnapshots(): Map { val snapshots = mutableMapOf() children.forEachActive { child -> diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt index 6249da899..39d51f73e 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt @@ -5,3 +5,5 @@ package com.squareup.workflow1.internal * to use after we have processed some actions. Use this milliseconds since epoch for that. */ internal expect fun currentTimeMillis(): Long + +internal expect fun nanoTime(): Long diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt index b6a833a95..0fa3710fe 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction @@ -47,6 +48,7 @@ internal class WorkflowChildNode< /** * Wrapper around [WorkflowNode.render] that allows calling it with erased types. */ + @Composable fun render( workflow: StatefulWorkflow<*, *, *, *>, props: Any? diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index db4921d8c..c45541002 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -1,5 +1,9 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RenderContext @@ -72,13 +76,15 @@ internal class WorkflowNode( private var lastProps: PropsT = initialProps private val eventActionsChannel = Channel>(capacity = UNLIMITED) - private var state: StateT + private var state: MutableState init { interceptor.onSessionStarted(this, this) - state = interceptor.intercept(workflow, this) - .initialState(initialProps, snapshot?.workflowSnapshot) + state = mutableStateOf( + interceptor.intercept(workflow, this) + .initialState(initialProps, snapshot?.workflowSnapshot) + ) } override fun toString(): String { @@ -97,6 +103,7 @@ internal class WorkflowNode( * render themselves and aggregate those child renderings. */ @Suppress("UNCHECKED_CAST") + @Composable fun render( workflow: StatefulWorkflow, input: PropsT @@ -107,12 +114,15 @@ internal class WorkflowNode( * Walk the tree of state machines again, this time gathering snapshots and aggregating them * automatically. */ + @Composable fun snapshot(workflow: StatefulWorkflow<*, *, *, *>): TreeSnapshot { + // TODO: figure out how to use rememberSaveable here, instead of later parcelizing our snapshots? @Suppress("UNCHECKED_CAST") val typedWorkflow = workflow as StatefulWorkflow + // Don't worry the children will remember their own values key'ed on their own state. val childSnapshots = subtreeManager.createChildSnapshots() val rootSnapshot = interceptor.intercept(typedWorkflow, this) - .snapshotState(state) + .snapshotState(state.value) return TreeSnapshot( workflowSnapshot = rootSnapshot, // Create the snapshots eagerly since subtreeManager is mutable. @@ -174,19 +184,21 @@ internal class WorkflowNode( * Contains the actual logic for [render], after we've casted the passed-in [Workflow]'s * state type to our `StateT`. */ + @Composable private fun renderWithStateType( workflow: StatefulWorkflow, props: PropsT ): RenderingT { - updatePropsAndState(workflow, props) val context = RealRenderContext( renderer = subtreeManager, sideEffectRunner = this, eventActionsChannel = eventActionsChannel ) - val rendering = interceptor.intercept(workflow, this) - .render(props, state, RenderContext(context, workflow)) + updatePropsAndState(workflow, props) + val rendering = + interceptor.intercept(workflow, this) + .render(props, state.value, RenderContext(context, workflow)) context.freeze() // Tear down workflows and workers that are obsolete. @@ -195,7 +207,6 @@ internal class WorkflowNode( // be started after context is frozen. sideEffects.forEachStaging { it.job.start() } sideEffects.commitStaging { it.job.cancel() } - return rendering } @@ -205,8 +216,8 @@ internal class WorkflowNode( ) { if (newProps != lastProps) { val newState = interceptor.intercept(workflow, this) - .onPropsChanged(lastProps, newProps, state) - state = newState + .onPropsChanged(lastProps, newProps, state.value) + state.value = newState } lastProps = newProps } @@ -216,8 +227,8 @@ internal class WorkflowNode( * [emits an output to its parent][emitOutputToParent] if necessary. */ private fun applyAction(action: WorkflowAction): T? { - val (newState, tickResult) = action.applyTo(lastProps, state) - state = newState + val (newState, tickResult) = action.applyTo(lastProps, state.value) + state.value = newState @Suppress("UNCHECKED_CAST") return tickResult?.let { emitOutputToParent(it.value) } as T? } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 9a7e70fec..5d1757ae7 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.PropsUpdated import com.squareup.workflow1.RenderingAndSnapshot @@ -65,6 +66,7 @@ internal class WorkflowRunner( * This method must be called before the first call to [processActions], and must be called again * between every subsequent call to [processActions]. */ + @Composable fun nextRendering(): RenderingAndSnapshot { val rendering = rootNode.render(workflow, currentProps) val snapshot = rootNode.snapshot(workflow) diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt index e9363a51c..d19314300 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt @@ -1,893 +1,901 @@ package com.squareup.workflow1 - -import com.squareup.workflow1.RuntimeConfig.FrameTimeout -import com.squareup.workflow1.RuntimeConfig.RenderPerAction -import com.squareup.workflow1.internal.ParameterizedTestRunner -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers.Unconfined -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.produceIn -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import okio.ByteString -import kotlin.test.Test - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, WorkflowExperimentalRuntime::class) -class RenderWorkflowInTest { - - /** - * A [TestScope] that will not run until explicitly told to. - */ - private lateinit var pausedTestScope: TestScope - - /** - * A [TestScope] that will automatically dispatch enqueued routines. - */ - private lateinit var testScope: TestScope - - private val runtimeOptions = arrayOf( - RenderPerAction, - FrameTimeout() - ).asSequence() - - private val runtimeTestRunner = ParameterizedTestRunner() - - private fun setup() { - pausedTestScope = TestScope() - testScope = TestScope(UnconfinedTestDispatcher()) - } - - @Test fun `initial rendering is calculated synchronously`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - // Don't allow the workflow runtime to actually start. - - val renderings = renderWorkflowIn( - workflow = workflow, - scope = pausedTestScope, - props = props, - runtimeConfig = runtimeConfig - ) {} - assertEquals("props: foo", renderings.value.rendering) - } - } - - @Test fun `initial rendering is calculated when scope cancelled before start`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - - pausedTestScope.cancel() - val renderings = renderWorkflowIn( - workflow = workflow, - scope = pausedTestScope, - props = props, - runtimeConfig = runtimeConfig - ) {} - assertEquals("props: foo", renderings.value.rendering) - } - } - - @Test - fun `side effects from initial rendering in root workflow are never started when scope cancelled before start`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var sideEffectWasRan = false - val workflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } - } - - testScope.cancel() - renderWorkflowIn( - workflow, - testScope, - MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - testScope.advanceUntilIdle() - - assertFalse(sideEffectWasRan) - } - } - - @Test - fun `side effects from initial rendering in non-root workflow are never started when scope cancelled before start`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var sideEffectWasRan = false - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } - } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - } - - testScope.cancel() - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - testScope.advanceUntilIdle() - - assertFalse(sideEffectWasRan) - } - } - - @Test fun `new renderings are emitted on update`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = props, - runtimeConfig = runtimeConfig - ) {} - - assertEquals("props: foo", renderings.value.rendering) - - props.value = "bar" - - assertEquals("props: bar", renderings.value.rendering) - } - } - - private val runtimeMatrix = arrayOf( - Pair(RenderPerAction, RenderPerAction), - Pair(RenderPerAction, FrameTimeout()), - Pair(FrameTimeout(), RenderPerAction), - Pair(FrameTimeout(), FrameTimeout()) - ).asSequence() - private val runtimeMatrixTestRunner = - ParameterizedTestRunner>() - - @Test fun `saves to and restores from snapshot`() { - runtimeMatrixTestRunner.runParametrizedTest( - paramSource = runtimeMatrix, - before = ::setup, - ) { (runtimeConfig1, runtimeConfig2) -> - val workflow = Workflow.stateful Unit>>( - initialState = { _, snapshot -> - snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" - }, - snapshot = { state -> - Snapshot.write { it.writeUtf8WithLength(state) } - }, - render = { _, renderState -> - Pair( - renderState, - { newState -> actionSink.send(action { state = newState }) } - ) - } - ) - val props = MutableStateFlow(Unit) - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = props, - runtimeConfig = runtimeConfig1 - ) {} - - // Interact with the workflow to change the state. - renderings.value.rendering.let { (state, updateState) -> - runtimeMatrixTestRunner.assertEquals("initial state", state) - updateState("updated state") - } - - if (runtimeConfig1 is FrameTimeout) { - // Get past frame timeout to ensure snapshot saved. - testScope.advanceTimeBy(runtimeConfig1.frameTimeoutMs + 1) - } - val snapshot = renderings.value.let { (rendering, snapshot) -> - val (state, updateState) = rendering - runtimeMatrixTestRunner.assertEquals("updated state", state) - updateState("ignored rendering") - return@let snapshot - } - - // Create a new scope to launch a second runtime to restore. - val restoreScope = TestScope() - val restoredRenderings = - renderWorkflowIn( - workflow = workflow, - scope = restoreScope, - props = props, - initialSnapshot = snapshot, - runtimeConfig = runtimeConfig2 - ) {} - runtimeMatrixTestRunner.assertEquals( - "updated state", - restoredRenderings.value.rendering.first - ) - } - } - - // https://github.com/square/workflow-kotlin/issues/223 - @Test fun `snapshots are lazy`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - lateinit var sink: Sink - var snapped = false - - val workflow = Workflow.stateful( - initialState = { _, _ -> "unchanging state" }, - snapshot = { - Snapshot.of { - snapped = true - ByteString.of(1) - } - }, - render = { _, renderState -> - sink = actionSink.contraMap { action { state = it } } - renderState - } - ) - val props = MutableStateFlow(Unit) - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = props, - runtimeConfig = runtimeConfig - ) {} - - val emitted = mutableListOf>() - val scope = CoroutineScope(Unconfined) - scope.launch { - renderings.collect { emitted += it } - } - sink.send("unchanging state") - - if (runtimeConfig is FrameTimeout) { - // Get past frame timeout to ensure snapshot saved. - testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) - } - - sink.send("unchanging state") - - if (runtimeConfig is FrameTimeout) { - // Get past frame timeout to ensure snapshot saved. - testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) - } - - scope.cancel() - - assertFalse(snapped) - assertNotSame( - emitted[0].snapshot.workflowSnapshot, - emitted[1].snapshot.workflowSnapshot - ) - assertNotSame( - emitted[1].snapshot.workflowSnapshot, - emitted[2].snapshot.workflowSnapshot - ) - } - } - - @Test fun `onOutput called when output emitted`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val trigger = Channel() - val workflow = Workflow.stateless { - runningWorker( - trigger.consumeAsFlow() - .asWorker() - ) { action { setOutput(it) } } - } - val receivedOutputs = mutableListOf() - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) { - receivedOutputs += it - } - assertTrue(receivedOutputs.isEmpty()) - - trigger.trySend("foo").isSuccess - assertEquals(listOf("foo"), receivedOutputs) - - trigger.trySend("bar").isSuccess - assertEquals(listOf("foo", "bar"), receivedOutputs) - } - } - - @Test fun `onOutput is not called when no output emitted`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val workflow = Workflow.stateless { props -> props } - var onOutputCalls = 0 - val props = MutableStateFlow(0) - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = props, - runtimeConfig = runtimeConfig - ) { onOutputCalls++ } - assertEquals(0, renderings.value.rendering) - assertEquals(0, onOutputCalls) - - props.value = 1 - assertEquals(1, renderings.value.rendering) - assertEquals(0, onOutputCalls) - - props.value = 2 - assertEquals(2, renderings.value.rendering) - assertEquals(0, onOutputCalls) - } - } - - /** - * Since the initial render occurs before launching the coroutine, an exception thrown from it - * doesn't implicitly cancel the scope. If it did, the reception would be reported twice: once to - * the caller, and once to the scope. - */ - @Test fun `exception from initial render doesn't fail parent scope`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val workflow = Workflow.stateless { - throw ExpectedException() - } - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - } - assertTrue(testScope.isActive) - } - } - - @Test - fun `side effects from initial rendering in root workflow are never started when initial render of root workflow fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var sideEffectWasRan = false - val workflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } - throw ExpectedException() - } - - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - } - assertFalse(sideEffectWasRan) - } - } - - @Test - fun `side effects from initial rendering in non-root workflow are cancelled when initial render of root workflow fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var sideEffectWasRan = false - var cancellationException: Throwable? = null - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - throw ExpectedException() - } - - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - } - assertTrue(sideEffectWasRan) - assertNotNull(cancellationException) - val realCause = generateSequence(cancellationException) { it.cause } - .firstOrNull { it !is CancellationException } - assertTrue(realCause is ExpectedException) - } - } - - @Test - fun `side effects from initial rendering in non-root workflow are never started when initial render of non-root workflow fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var sideEffectWasRan = false - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } - throw ExpectedException() - } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - } - - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - } - assertFalse(sideEffectWasRan) - } - } - - @Test fun `exception from non-initial render fails parent scope`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val trigger = CompletableDeferred() - // Throws an exception when trigger is completed. - val workflow = Workflow.stateful( - initialState = { false }, - render = { _, throwNow -> - runningWorker(Worker.from { trigger.await() }) { action { state = true } } - if (throwNow) { - throw ExpectedException() - } - } - ) - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - - assertTrue(testScope.isActive) - - trigger.complete(Unit) - if (runtimeConfig is FrameTimeout) { - testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) - } - assertFalse(testScope.isActive) - } - } - - @Test fun `exception from action fails parent scope`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val trigger = CompletableDeferred() - // Throws an exception when trigger is completed. - val workflow = Workflow.stateless { - runningWorker(Worker.from { trigger.await() }) { - action { - throw ExpectedException() - } - } - } - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - - assertTrue(testScope.isActive) - - trigger.complete(Unit) - assertFalse(testScope.isActive) - } - } - - @Test fun `cancelling scope cancels runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "test1") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - assertNull(cancellationException) - assertTrue(testScope.isActive) - - testScope.cancel() - assertTrue(cancellationException is CancellationException) - assertNull(cancellationException!!.cause) - } - } - - @Test fun `cancelling scope in action cancels runtime and does not render again`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val trigger = CompletableDeferred() - var renderCount = 0 - val workflow = Workflow.stateless { - renderCount++ - runningWorker(Worker.from { trigger.await() }) { - action { - testScope.cancel() - } - } - } - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - assertTrue(testScope.isActive) - assertTrue(renderCount == 1) - - trigger.complete(Unit) - testScope.advanceUntilIdle() - assertFalse(testScope.isActive) - assertEquals( - 1, - renderCount, - "Should not render after CoroutineScope is canceled." - ) - } - } - - @Test fun `failing scope cancels runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "failing") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - assertNull(cancellationException) - assertTrue(testScope.isActive) - - testScope.cancel(CancellationException("fail!", ExpectedException())) - assertTrue(cancellationException is CancellationException) - assertTrue(cancellationException!!.cause is ExpectedException) - } - } - - @Test fun `error from renderings collector doesn't fail parent scope`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val workflow = Workflow.stateless {} - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - - // Collect in separate scope so we actually test that the parent scope is failed when it's - // different from the collecting scope. - val collectScope = TestScope(UnconfinedTestDispatcher()) - collectScope.launch { - renderings.collect { throw ExpectedException() } - } - assertTrue(testScope.isActive) - assertFalse(collectScope.isActive) - } - } - - @Test fun `error from renderings collector cancels runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "test") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> - cancellationException = cause - } - } - } - } - val renderings = renderWorkflowIn( - workflow = workflow, - scope = pausedTestScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) {} - - pausedTestScope.launch { - renderings.collect { throw ExpectedException() } - } - assertNull(cancellationException) - - pausedTestScope.advanceUntilIdle() - assertTrue(cancellationException is CancellationException) - assertTrue(cancellationException!!.cause is ExpectedException) - } - } - - @Test fun `exception from onOutput fails parent scope`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val trigger = CompletableDeferred() - // Emits a Unit when trigger is completed. - val workflow = Workflow.stateless { - runningWorker(Worker.from { trigger.await() }) { action { setOutput(Unit) } } - } - renderWorkflowIn( - workflow = workflow, - scope = pausedTestScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig - ) { - throw ExpectedException() - } - assertTrue(pausedTestScope.isActive) - - trigger.complete(Unit) - assertTrue(pausedTestScope.isActive) - - pausedTestScope.advanceUntilIdle() - assertFalse(pausedTestScope.isActive) - } - } - - @Test fun `output is emitted before next render pass`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val outputTrigger = CompletableDeferred() - // A workflow whose state and rendering is the last output that it emitted. - val workflow = Workflow.stateful( - initialState = { "{no output}" }, - render = { _, renderState -> - runningWorker(Worker.from { outputTrigger.await() }) { output -> - action { - setOutput(output) - state = output - } - } - return@stateful renderState - } - ) - val events = mutableListOf() - - renderWorkflowIn( - workflow = workflow, - scope = pausedTestScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - onOutput = { events += "output($it)" } - ) - .onEach { events += "rendering(${it.rendering})" } - .launchIn(pausedTestScope) - pausedTestScope.runCurrent() - assertEquals(listOf("rendering({no output})"), events) - - outputTrigger.complete("output") - pausedTestScope.runCurrent() - assertEquals( - listOf( - "rendering({no output})", - "output(output)", - "rendering(output)", - ), - events - ) - } - } - - // https://github.com/square/workflow-kotlin/issues/224 - @Test fun `exceptions from Snapshots don't fail runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - val workflow = Workflow.stateful( - snapshot = { - Snapshot.of { - throw ExpectedException() - } - }, - initialState = { _, _ -> }, - render = { _, _ -> } - ) - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val snapshot = renderWorkflowIn( - workflow = workflow, - scope = testScope + exceptionHandler, - props = props, - runtimeConfig = runtimeConfig - ) {} - .value - .snapshot - - assertFailsWith { snapshot.toByteString() } - assertTrue(uncaughtExceptions.isEmpty()) - - props.value += 1 - assertFailsWith { snapshot.toByteString() } - } - } - - // https://github.com/square/workflow-kotlin/issues/224 - @Test fun `exceptions from renderings' equals methods don't fail runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - @Suppress("EqualsOrHashCode", "unused") - class FailRendering(val value: Int) { - override fun equals(other: Any?): Boolean { - throw ExpectedException() - } - } - - val workflow = Workflow.stateless { props -> - FailRendering(props) - } - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val ras = renderWorkflowIn( - workflow = workflow, - scope = testScope + exceptionHandler, - props = props, - runtimeConfig = runtimeConfig - ) {} - val renderings = ras.map { it.rendering } - .produceIn(testScope) - - @Suppress("UnusedEquals") - assertFailsWith { - renderings.tryReceive() - .getOrNull()!! - .equals(Unit) - } - assertTrue(uncaughtExceptions.isEmpty()) - - // Trigger another render pass. - props.value += 1 - } - } - - // https://github.com/square/workflow-kotlin/issues/224 - @Test fun `exceptions from renderings' hashCode methods don't fail runtime`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { runtimeConfig: RuntimeConfig -> - @Suppress("EqualsOrHashCode") - data class FailRendering(val value: Int) { - override fun hashCode(): Int { - throw ExpectedException() - } - } - - val workflow = Workflow.stateless { props -> - FailRendering(props) - } - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val ras = renderWorkflowIn( - workflow = workflow, - scope = testScope + exceptionHandler, - props = props, - runtimeConfig = runtimeConfig - ) {} - val renderings = ras.map { it.rendering } - .produceIn(testScope) - - @Suppress("UnusedEquals") - assertFailsWith { - renderings.tryReceive() - .getOrNull() - .hashCode() - } - assertTrue(uncaughtExceptions.isEmpty()) - - props.value += 1 - @Suppress("UnusedEquals") - assertFailsWith { - renderings.tryReceive() - .getOrNull() - .hashCode() - } - } - } - - private class ExpectedException : RuntimeException() -} +// +// import androidx.compose.runtime.Composable +// import com.squareup.workflow1.RuntimeConfig.FrameTimeout +// import com.squareup.workflow1.RuntimeConfig.RenderPerAction +// import com.squareup.workflow1.internal.ParameterizedTestRunner +// import kotlinx.coroutines.CancellationException +// import kotlinx.coroutines.CompletableDeferred +// import kotlinx.coroutines.CoroutineExceptionHandler +// import kotlinx.coroutines.CoroutineScope +// import kotlinx.coroutines.Dispatchers.Unconfined +// import kotlinx.coroutines.ExperimentalCoroutinesApi +// import kotlinx.coroutines.FlowPreview +// import kotlinx.coroutines.cancel +// import kotlinx.coroutines.channels.Channel +// import kotlinx.coroutines.flow.MutableStateFlow +// import kotlinx.coroutines.flow.StateFlow +// import kotlinx.coroutines.flow.consumeAsFlow +// import kotlinx.coroutines.flow.launchIn +// import kotlinx.coroutines.flow.map +// import kotlinx.coroutines.flow.onEach +// import kotlinx.coroutines.flow.produceIn +// import kotlinx.coroutines.isActive +// import kotlinx.coroutines.launch +// import kotlinx.coroutines.plus +// import kotlinx.coroutines.suspendCancellableCoroutine +// import kotlinx.coroutines.test.TestScope +// import kotlinx.coroutines.test.UnconfinedTestDispatcher +// import kotlinx.coroutines.test.advanceTimeBy +// import kotlinx.coroutines.test.advanceUntilIdle +// import kotlinx.coroutines.test.runCurrent +// import okio.ByteString +// import kotlin.test.Test +// +// @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, WorkflowExperimentalRuntime::class) +// class RenderWorkflowInTest { +// +// /** +// * A [TestScope] that will not run until explicitly told to. +// */ +// private lateinit var pausedTestScope: TestScope +// +// /** +// * A [TestScope] that will automatically dispatch enqueued routines. +// */ +// private lateinit var testScope: TestScope +// +// private val runtimeOptions = arrayOf( +// RenderPerAction, +// FrameTimeout() +// ).asSequence() +// +// private val runtimeTestRunner = ParameterizedTestRunner() +// +// private fun setup() { +// pausedTestScope = TestScope() +// testScope = TestScope(UnconfinedTestDispatcher()) +// } +// +// @Test fun `initial rendering is calculated synchronously`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val props = MutableStateFlow("foo") +// val render = @Composable fun BaseRenderContext.(props: String): String { +// return "props: $props" +// } +// val workflow = Workflow.stateless( +// render = render +// ) +// +// // Don't allow the workflow runtime to actually start. +// +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = pausedTestScope, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// assertEquals("props: foo", renderings.value.rendering) +// } +// } +// +// @Test fun `initial rendering is calculated when scope cancelled before start`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val props = MutableStateFlow("foo") +// val workflow = Workflow.stateless { "props: $it" } +// +// pausedTestScope.cancel() +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = pausedTestScope, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// assertEquals("props: foo", renderings.value.rendering) +// } +// } +// +// @Test +// fun `side effects from initial rendering in root workflow are never started when scope cancelled before start`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var sideEffectWasRan = false +// val workflow = Workflow.stateless { +// runningSideEffect("test") { +// sideEffectWasRan = true +// } +// } +// +// testScope.cancel() +// renderWorkflowIn( +// workflow, +// testScope, +// MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// testScope.advanceUntilIdle() +// +// assertFalse(sideEffectWasRan) +// } +// } +// +// @Test +// fun `side effects from initial rendering in non-root workflow are never started when scope cancelled before start`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var sideEffectWasRan = false +// val childWorkflow = Workflow.stateless { +// runningSideEffect("test") { +// sideEffectWasRan = true +// } +// } +// val workflow = Workflow.stateless { +// renderChild(childWorkflow) +// } +// +// testScope.cancel() +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// testScope.advanceUntilIdle() +// +// assertFalse(sideEffectWasRan) +// } +// } +// +// @Test fun `new renderings are emitted on update`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val props = MutableStateFlow("foo") +// val workflow = Workflow.stateless { "props: $it" } +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// +// assertEquals("props: foo", renderings.value.rendering) +// +// props.value = "bar" +// +// assertEquals("props: bar", renderings.value.rendering) +// } +// } +// +// private val runtimeMatrix = arrayOf( +// Pair(RenderPerAction, RenderPerAction), +// Pair(RenderPerAction, FrameTimeout()), +// Pair(FrameTimeout(), RenderPerAction), +// Pair(FrameTimeout(), FrameTimeout()) +// ).asSequence() +// private val runtimeMatrixTestRunner = +// ParameterizedTestRunner>() +// +// @Test fun `saves to and restores from snapshot`() { +// runtimeMatrixTestRunner.runParametrizedTest( +// paramSource = runtimeMatrix, +// before = ::setup, +// ) { (runtimeConfig1, runtimeConfig2) -> +// val workflow = Workflow.stateful Unit>>( +// initialState = { _, snapshot -> +// snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" +// }, +// snapshot = { state -> +// Snapshot.write { it.writeUtf8WithLength(state) } +// }, +// render = { _, renderState -> +// Pair( +// renderState, +// { newState -> actionSink.send(action { state = newState }) } +// ) +// } +// ) +// val props = MutableStateFlow(Unit) +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = props, +// runtimeConfig = runtimeConfig1 +// ) {} +// +// // Interact with the workflow to change the state. +// renderings.value.rendering.let { (state, updateState) -> +// runtimeMatrixTestRunner.assertEquals("initial state", state) +// updateState("updated state") +// } +// +// if (runtimeConfig1 is FrameTimeout) { +// // Get past frame timeout to ensure snapshot saved. +// testScope.advanceTimeBy(runtimeConfig1.frameTimeoutMs + 1) +// } +// val snapshot = renderings.value.let { (rendering, snapshot) -> +// val (state, updateState) = rendering +// runtimeMatrixTestRunner.assertEquals("updated state", state) +// updateState("ignored rendering") +// return@let snapshot +// } +// +// // Create a new scope to launch a second runtime to restore. +// val restoreScope = TestScope() +// val restoredRenderings = +// renderWorkflowIn( +// workflow = workflow, +// scope = restoreScope, +// props = props, +// initialSnapshot = snapshot, +// runtimeConfig = runtimeConfig2 +// ) {} +// runtimeMatrixTestRunner.assertEquals( +// "updated state", +// restoredRenderings.value.rendering.first +// ) +// } +// } +// +// // https://github.com/square/workflow-kotlin/issues/223 +// @Test fun `snapshots are lazy`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// lateinit var sink: Sink +// var snapped = false +// +// val workflow = Workflow.stateful( +// initialState = { _, _ -> "unchanging state" }, +// snapshot = { +// Snapshot.of { +// snapped = true +// ByteString.of(1) +// } +// }, +// render = { _, renderState -> +// sink = actionSink.contraMap { action { state = it } } +// renderState +// } +// ) +// val props = MutableStateFlow(Unit) +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// +// val emitted = mutableListOf>() +// val scope = CoroutineScope(Unconfined) +// scope.launch { +// renderings.collect { emitted += it } +// } +// sink.send("unchanging state") +// +// if (runtimeConfig is FrameTimeout) { +// // Get past frame timeout to ensure snapshot saved. +// testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) +// } +// +// sink.send("unchanging state") +// +// if (runtimeConfig is FrameTimeout) { +// // Get past frame timeout to ensure snapshot saved. +// testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) +// } +// +// scope.cancel() +// +// assertFalse(snapped) +// assertNotSame( +// emitted[0].snapshot.workflowSnapshot, +// emitted[1].snapshot.workflowSnapshot +// ) +// assertNotSame( +// emitted[1].snapshot.workflowSnapshot, +// emitted[2].snapshot.workflowSnapshot +// ) +// } +// } +// +// @Test fun `onOutput called when output emitted`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val trigger = Channel() +// val workflow = Workflow.stateless { +// runningWorker( +// trigger.consumeAsFlow() +// .asWorker() +// ) { action { setOutput(it) } } +// } +// val receivedOutputs = mutableListOf() +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) { +// receivedOutputs += it +// } +// assertTrue(receivedOutputs.isEmpty()) +// +// trigger.trySend("foo").isSuccess +// assertEquals(listOf("foo"), receivedOutputs) +// +// trigger.trySend("bar").isSuccess +// assertEquals(listOf("foo", "bar"), receivedOutputs) +// } +// } +// +// @Test fun `onOutput is not called when no output emitted`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val workflow = Workflow.stateless { props -> props } +// var onOutputCalls = 0 +// val props = MutableStateFlow(0) +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = props, +// runtimeConfig = runtimeConfig +// ) { onOutputCalls++ } +// assertEquals(0, renderings.value.rendering) +// assertEquals(0, onOutputCalls) +// +// props.value = 1 +// assertEquals(1, renderings.value.rendering) +// assertEquals(0, onOutputCalls) +// +// props.value = 2 +// assertEquals(2, renderings.value.rendering) +// assertEquals(0, onOutputCalls) +// } +// } +// +// /** +// * Since the initial render occurs before launching the coroutine, an exception thrown from it +// * doesn't implicitly cancel the scope. If it did, the reception would be reported twice: once to +// * the caller, and once to the scope. +// */ +// @Test fun `exception from initial render doesn't fail parent scope`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val workflow = Workflow.stateless { +// throw ExpectedException() +// } +// assertFailsWith { +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// } +// assertTrue(testScope.isActive) +// } +// } +// +// @Test +// fun `side effects from initial rendering in root workflow are never started when initial render of root workflow fails`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var sideEffectWasRan = false +// val workflow = Workflow.stateless { +// runningSideEffect("test") { +// sideEffectWasRan = true +// } +// throw ExpectedException() +// } +// +// assertFailsWith { +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// } +// assertFalse(sideEffectWasRan) +// } +// } +// +// @Test +// fun `side effects from initial rendering in non-root workflow are cancelled when initial render of root workflow fails`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var sideEffectWasRan = false +// var cancellationException: Throwable? = null +// val childWorkflow = Workflow.stateless { +// runningSideEffect("test") { +// sideEffectWasRan = true +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> cancellationException = cause } +// } +// } +// } +// val workflow = Workflow.stateless { +// renderChild(childWorkflow) +// throw ExpectedException() +// } +// +// assertFailsWith { +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// } +// assertTrue(sideEffectWasRan) +// assertNotNull(cancellationException) +// val realCause = generateSequence(cancellationException) { it.cause } +// .firstOrNull { it !is CancellationException } +// assertTrue(realCause is ExpectedException) +// } +// } +// +// @Test +// fun `side effects from initial rendering in non-root workflow are never started when initial render of non-root workflow fails`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var sideEffectWasRan = false +// val childWorkflow = Workflow.stateless { +// runningSideEffect("test") { +// sideEffectWasRan = true +// } +// throw ExpectedException() +// } +// val workflow = Workflow.stateless { +// renderChild(childWorkflow) +// } +// +// assertFailsWith { +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// } +// assertFalse(sideEffectWasRan) +// } +// } +// +// @Test fun `exception from non-initial render fails parent scope`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val trigger = CompletableDeferred() +// // Throws an exception when trigger is completed. +// val workflow = Workflow.stateful( +// initialState = { false }, +// render = { _, throwNow -> +// runningWorker(Worker.from { trigger.await() }) { action { state = true } } +// if (throwNow) { +// throw ExpectedException() +// } +// } +// ) +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// +// assertTrue(testScope.isActive) +// +// trigger.complete(Unit) +// if (runtimeConfig is FrameTimeout) { +// testScope.advanceTimeBy(runtimeConfig.frameTimeoutMs + 1) +// } +// assertFalse(testScope.isActive) +// } +// } +// +// @Test fun `exception from action fails parent scope`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val trigger = CompletableDeferred() +// // Throws an exception when trigger is completed. +// val workflow = Workflow.stateless { +// runningWorker(Worker.from { trigger.await() }) { +// action { +// throw ExpectedException() +// } +// } +// } +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// +// assertTrue(testScope.isActive) +// +// trigger.complete(Unit) +// assertFalse(testScope.isActive) +// } +// } +// +// @Test fun `cancelling scope cancels runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var cancellationException: Throwable? = null +// val workflow = Workflow.stateless { +// runningSideEffect(key = "test1") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> cancellationException = cause } +// } +// } +// } +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// assertNull(cancellationException) +// assertTrue(testScope.isActive) +// +// testScope.cancel() +// assertTrue(cancellationException is CancellationException) +// assertNull(cancellationException!!.cause) +// } +// } +// +// @Test fun `cancelling scope in action cancels runtime and does not render again`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val trigger = CompletableDeferred() +// var renderCount = 0 +// val workflow = Workflow.stateless { +// renderCount++ +// runningWorker(Worker.from { trigger.await() }) { +// action { +// testScope.cancel() +// } +// } +// } +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// assertTrue(testScope.isActive) +// assertTrue(renderCount == 1) +// +// trigger.complete(Unit) +// testScope.advanceUntilIdle() +// assertFalse(testScope.isActive) +// assertEquals( +// 1, +// renderCount, +// "Should not render after CoroutineScope is canceled." +// ) +// } +// } +// +// @Test fun `failing scope cancels runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var cancellationException: Throwable? = null +// val workflow = Workflow.stateless { +// runningSideEffect(key = "failing") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> cancellationException = cause } +// } +// } +// } +// renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// assertNull(cancellationException) +// assertTrue(testScope.isActive) +// +// testScope.cancel(CancellationException("fail!", ExpectedException())) +// assertTrue(cancellationException is CancellationException) +// assertTrue(cancellationException!!.cause is ExpectedException) +// } +// } +// +// @Test fun `error from renderings collector doesn't fail parent scope`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val workflow = Workflow.stateless {} +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = testScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// +// // Collect in separate scope so we actually test that the parent scope is failed when it's +// // different from the collecting scope. +// val collectScope = TestScope(UnconfinedTestDispatcher()) +// collectScope.launch { +// renderings.collect { throw ExpectedException() } +// } +// assertTrue(testScope.isActive) +// assertFalse(collectScope.isActive) +// } +// } +// +// @Test fun `error from renderings collector cancels runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// var cancellationException: Throwable? = null +// val workflow = Workflow.stateless { +// runningSideEffect(key = "test") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> +// cancellationException = cause +// } +// } +// } +// } +// val renderings = renderWorkflowIn( +// workflow = workflow, +// scope = pausedTestScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) {} +// +// pausedTestScope.launch { +// renderings.collect { throw ExpectedException() } +// } +// assertNull(cancellationException) +// +// pausedTestScope.advanceUntilIdle() +// assertTrue(cancellationException is CancellationException) +// assertTrue(cancellationException!!.cause is ExpectedException) +// } +// } +// +// @Test fun `exception from onOutput fails parent scope`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val trigger = CompletableDeferred() +// // Emits a Unit when trigger is completed. +// val workflow = Workflow.stateless { +// runningWorker(Worker.from { trigger.await() }) { action { setOutput(Unit) } } +// } +// renderWorkflowIn( +// workflow = workflow, +// scope = pausedTestScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig +// ) { +// throw ExpectedException() +// } +// assertTrue(pausedTestScope.isActive) +// +// trigger.complete(Unit) +// assertTrue(pausedTestScope.isActive) +// +// pausedTestScope.advanceUntilIdle() +// assertFalse(pausedTestScope.isActive) +// } +// } +// +// @Test fun `output is emitted before next render pass`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val outputTrigger = CompletableDeferred() +// // A workflow whose state and rendering is the last output that it emitted. +// val workflow = Workflow.stateful( +// initialState = { "{no output}" }, +// render = { _, renderState -> +// runningWorker(Worker.from { outputTrigger.await() }) { output -> +// action { +// setOutput(output) +// state = output +// } +// } +// return@stateful renderState +// } +// ) +// val events = mutableListOf() +// +// renderWorkflowIn( +// workflow = workflow, +// scope = pausedTestScope, +// props = MutableStateFlow(Unit), +// runtimeConfig = runtimeConfig, +// onOutput = { events += "output($it)" } +// ) +// .onEach { events += "rendering(${it.rendering})" } +// .launchIn(pausedTestScope) +// pausedTestScope.runCurrent() +// assertEquals(listOf("rendering({no output})"), events) +// +// outputTrigger.complete("output") +// pausedTestScope.runCurrent() +// assertEquals( +// listOf( +// "rendering({no output})", +// "output(output)", +// "rendering(output)", +// ), +// events +// ) +// } +// } +// +// // https://github.com/square/workflow-kotlin/issues/224 +// @Test fun `exceptions from Snapshots don't fail runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// val workflow = Workflow.stateful( +// snapshot = { +// Snapshot.of { +// throw ExpectedException() +// } +// }, +// initialState = { _, _ -> }, +// render = { _, _ -> } +// ) +// val props = MutableStateFlow(0) +// val uncaughtExceptions = mutableListOf() +// val exceptionHandler = CoroutineExceptionHandler { _, throwable -> +// uncaughtExceptions += throwable +// } +// val snapshot = renderWorkflowIn( +// workflow = workflow, +// scope = testScope + exceptionHandler, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// .value +// .snapshot +// +// assertFailsWith { snapshot.toByteString() } +// assertTrue(uncaughtExceptions.isEmpty()) +// +// props.value += 1 +// assertFailsWith { snapshot.toByteString() } +// } +// } +// +// // https://github.com/square/workflow-kotlin/issues/224 +// @Test fun `exceptions from renderings' equals methods don't fail runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// @Suppress("EqualsOrHashCode", "unused") +// class FailRendering(val value: Int) { +// override fun equals(other: Any?): Boolean { +// throw ExpectedException() +// } +// } +// +// val workflow = Workflow.stateless { props -> +// FailRendering(props) +// } +// val props = MutableStateFlow(0) +// val uncaughtExceptions = mutableListOf() +// val exceptionHandler = CoroutineExceptionHandler { _, throwable -> +// uncaughtExceptions += throwable +// } +// val ras = renderWorkflowIn( +// workflow = workflow, +// scope = testScope + exceptionHandler, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// val renderings = ras.map { it.rendering } +// .produceIn(testScope) +// +// @Suppress("UnusedEquals") +// assertFailsWith { +// renderings.tryReceive() +// .getOrNull()!! +// .equals(Unit) +// } +// assertTrue(uncaughtExceptions.isEmpty()) +// +// // Trigger another render pass. +// props.value += 1 +// } +// } +// +// // https://github.com/square/workflow-kotlin/issues/224 +// @Test fun `exceptions from renderings' hashCode methods don't fail runtime`() { +// runtimeTestRunner.runParametrizedTest( +// paramSource = runtimeOptions, +// before = ::setup, +// ) { runtimeConfig: RuntimeConfig -> +// @Suppress("EqualsOrHashCode") +// data class FailRendering(val value: Int) { +// override fun hashCode(): Int { +// throw ExpectedException() +// } +// } +// +// val workflow = Workflow.stateless { props -> +// FailRendering(props) +// } +// val props = MutableStateFlow(0) +// val uncaughtExceptions = mutableListOf() +// val exceptionHandler = CoroutineExceptionHandler { _, throwable -> +// uncaughtExceptions += throwable +// } +// val ras = renderWorkflowIn( +// workflow = workflow, +// scope = testScope + exceptionHandler, +// props = props, +// runtimeConfig = runtimeConfig +// ) {} +// val renderings = ras.map { it.rendering } +// .produceIn(testScope) +// +// @Suppress("UnusedEquals") +// assertFailsWith { +// renderings.tryReceive() +// .getOrNull() +// .hashCode() +// } +// assertTrue(uncaughtExceptions.isEmpty()) +// +// props.value += 1 +// @Suppress("UnusedEquals") +// assertFailsWith { +// renderings.tryReceive() +// .getOrNull() +// .hashCode() +// } +// } +// } +// +// private class ExpectedException : RuntimeException() +// } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt index eafd9cb17..abd8ccc40 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt @@ -1,14 +1,21 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable +import app.cash.molecule.launchMolecule import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.coroutines.EmptyCoroutineContext import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.fail +@OptIn(ExperimentalCoroutinesApi::class) internal class SimpleLoggingWorkflowInterceptorTest { @Test fun `onSessionStarted handles logging exceptions`() { @@ -37,13 +44,17 @@ internal class SimpleLoggingWorkflowInterceptorTest { @Test fun `onRender handles logging exceptions`() { val interceptor = ErrorLoggingInterceptor() - interceptor.onRender( - renderProps = Unit, - renderState = Unit, - context = FakeRenderContext, - { _, _, _ -> }, - TestWorkflowSession, - ) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) + + scope.launchMolecule { + interceptor.onRender( + renderProps = Unit, + renderState = Unit, + context = FakeRenderContext, + { _, _, _ -> }, + TestWorkflowSession, + ) + } assertEquals(ErrorLoggingInterceptor.EXPECTED_ERRORS, interceptor.errors) } @@ -88,6 +99,7 @@ internal class SimpleLoggingWorkflowInterceptorTest { override val actionSink: Sink> get() = fail() + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt index f53335429..998069647 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt @@ -2,13 +2,18 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable +import app.cash.molecule.launchMolecule import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext.Key import kotlin.test.Test @@ -55,6 +60,7 @@ internal class WorkflowInterceptorTest { val fakeContext = object : BaseRenderContext { override val actionSink: Sink> get() = fail() + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, @@ -68,10 +74,14 @@ internal class WorkflowInterceptorTest { ) = fail() } - val rendering = intercepted.render("props", "state", RenderContext(fakeContext, TestWorkflow)) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) - assertEquals("props|state", rendering) - assertEquals(listOf("BEGIN|onRender", "END|onRender"), recorder.consumeEventNames()) + scope.launchMolecule { + val rendering = intercepted.render("props", "state", RenderContext(fakeContext, TestWorkflow)) + + assertEquals("props|state", rendering) + assertEquals(listOf("BEGIN|onRender", "END|onRender"), recorder.consumeEventNames()) + } } @Test fun `intercept intercepts calls to snapshotState`() { @@ -95,6 +105,7 @@ internal class WorkflowInterceptorTest { override val actionSink: Sink> = Sink { value -> actions += value } + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, @@ -108,17 +119,21 @@ internal class WorkflowInterceptorTest { ) = fail() } - val rendering = - intercepted.render("props", "string", RenderContext(fakeContext, TestActionWorkflow)) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) - assertTrue(actions.isEmpty()) - rendering.onEvent() - assertTrue(actions.size == 1) + scope.launchMolecule { + val rendering = + intercepted.render("props", "string", RenderContext(fakeContext, TestActionWorkflow)) - assertEquals( - listOf("BEGIN|onRender", "END|onRender", "BEGIN|onActionSent", "END|onActionSent"), - recorder.consumeEventNames() - ) + assertTrue(actions.isEmpty()) + rendering.onEvent() + assertTrue(actions.size == 1) + + assertEquals( + listOf("BEGIN|onRender", "END|onRender", "BEGIN|onActionSent", "END|onActionSent"), + recorder.consumeEventNames() + ) + } } @Test fun `intercept intercepts side effects`() { @@ -128,6 +143,7 @@ internal class WorkflowInterceptorTest { val fakeContext = object : BaseRenderContext { override val actionSink: Sink> get() = fail() + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, @@ -143,26 +159,31 @@ internal class WorkflowInterceptorTest { } } - intercepted.render("props", "string", RenderContext(fakeContext, workflow)) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) - assertEquals( - listOf( - "BEGIN|onRender", - "BEGIN|onSideEffectRunning", - "END|onSideEffectRunning", - "END|onRender" - ), - recorder.consumeEventNames() - ) + scope.launchMolecule { + intercepted.render("props", "string", RenderContext(fakeContext, workflow)) + + assertEquals( + listOf( + "BEGIN|onRender", + "BEGIN|onSideEffectRunning", + "END|onSideEffectRunning", + "END|onRender" + ), + recorder.consumeEventNames() + ) + } } @Test fun `intercept uses interceptor's context for side effect`() { val recorder = object : RecordingWorkflowInterceptor() { + @Composable override fun onRender( renderProps: P, renderState: S, context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, + proceed: @Composable (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { return proceed( @@ -187,6 +208,7 @@ internal class WorkflowInterceptorTest { val fakeContext = object : BaseRenderContext { override val actionSink: Sink> get() = fail() + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, @@ -202,7 +224,11 @@ internal class WorkflowInterceptorTest { } } - intercepted.render("props", "string", RenderContext(fakeContext, workflow)) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) + + scope.launchMolecule { + intercepted.render("props", "string", RenderContext(fakeContext, workflow)) + } } private val Workflow<*, *, *>.session: WorkflowSession @@ -225,6 +251,7 @@ internal class WorkflowInterceptorTest { state: String ): String = "$old|$new|$state" + @Composable override fun render( renderProps: String, renderState: String, @@ -241,6 +268,7 @@ internal class WorkflowInterceptorTest { snapshot: Snapshot? ) = "" + @Composable override fun render( renderProps: String, renderState: String, @@ -264,6 +292,7 @@ internal class WorkflowInterceptorTest { snapshot: Snapshot? ) = "" + @Composable override fun render( renderProps: String, renderState: String, diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt index 62a395e01..7b5604342 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowOperatorsTest.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -22,6 +23,7 @@ class WorkflowOperatorsTest { @Test fun `mapRendering toString`() { val workflow = object : StatelessWorkflow() { override fun toString(): String = "ChildWorkflow" + @Composable override fun render( renderProps: Unit, context: RenderContext @@ -216,6 +218,7 @@ class WorkflowOperatorsTest { override fun run(): Flow = flow.onStart { starts++ } } + @Composable override fun render( renderProps: Unit, context: RenderContext diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt index 896f77570..d2beadabc 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.Sink @@ -10,7 +11,6 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowInterceptor -import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.identifier import com.squareup.workflow1.parse @@ -174,123 +174,129 @@ internal class ChainedWorkflowInterceptorTest { } @Test fun `chains calls to onRender() in left-to-right order`() { - val interceptor1 = object : WorkflowInterceptor { - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ) = "r1: ${proceed("props1: $renderProps" as P, "state1: $renderState" as S, null)}" as R - } - val interceptor2 = object : WorkflowInterceptor { - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ) = "r2: ${proceed("props2: $renderProps" as P, "state2: $renderState" as S, null)}" as R - } - val chained = listOf(interceptor1, interceptor2).chained() - - val finalRendering = - chained.onRender( - "props", "state", FakeRenderContext, { p, s, _ -> "($p|$s)" }, TestSession, - ) - - assertEquals( - "r1: r2: (props2: props1: props|state2: state1: state)", - finalRendering - ) + // val interceptor1 = object : WorkflowInterceptor { + // @Composable + // override fun onRender( + // renderProps: P, + // renderState: S, + // context: BaseRenderContext, + // proceed: (P, S, RenderContextInterceptor?) -> R, + // session: WorkflowSession + // ) = "r1: ${proceed("props1: $renderProps" as P, "state1: $renderState" as S, null)}" as R + // } + // val interceptor2 = object : WorkflowInterceptor { + // @Composable + // override fun onRender( + // renderProps: P, + // renderState: S, + // context: BaseRenderContext, + // proceed: @Composable (P, S, RenderContextInterceptor?) -> R, + // session: WorkflowSession + // ) = "r2: ${proceed("props2: $renderProps" as P, "state2: $renderState" as S, null)}" as R + // } + // val chained = listOf(interceptor1, interceptor2).chained() + // + // val finalRendering = + // chained.onRender( + // "props", "state", FakeRenderContext, { p, s, _ -> "($p|$s)" }, TestSession, + // ) + // + // assertEquals( + // "r1: r2: (props2: props1: props|state2: state1: state)", + // finalRendering + // ) } @Test fun `chains calls with RenderContextInterceptor in left-to-right order`() { - val transcript = mutableListOf() - - class Labeler(val label: String) : RenderContextInterceptor { - override fun onRenderChild( - child: Workflow, - childProps: CP, - key: String, - handler: (CO) -> WorkflowAction, - proceed: ( - child: Workflow, - props: CP, - key: String, - handler: (CO) -> WorkflowAction - ) -> CR - ): CR { - transcript += "START $label" - val r = proceed(child, childProps, key, handler) - transcript += "END $label" - return r - } - } - - val interceptor1 = object : WorkflowInterceptor { - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ) = proceed(renderProps, renderState, Labeler("uno")) - } - - val interceptor2 = object : WorkflowInterceptor { - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ) = proceed(renderProps, renderState, Labeler("dos")) - } - - val chained = listOf(interceptor1, interceptor2).chained() - - chained.onRender( - renderProps = "props", - renderState = "state", - context = FakeRenderContext, - proceed = { _, _, interceptor -> - interceptor?.onRenderChild( - child = TestSession.workflow, - childProps = Unit, - key = TestSession.renderKey, - handler = { error("How did you emit Nothing? Good trick!") }, - proceed = { _, _, _, _ -> } - ) as Any - }, - session = TestSession, - ) - - assertEquals("START uno, START dos, END dos, END uno", transcript.joinToString(", ")) + // val transcript = mutableListOf() + // + // class Labeler(val label: String) : RenderContextInterceptor { + // override fun onRenderChild( + // child: Workflow, + // childProps: CP, + // key: String, + // handler: (CO) -> WorkflowAction, + // proceed: ( + // child: Workflow, + // props: CP, + // key: String, + // handler: (CO) -> WorkflowAction + // ) -> CR + // ): CR { + // transcript += "START $label" + // val r = proceed(child, childProps, key, handler) + // transcript += "END $label" + // return r + // } + // } + // + // val interceptor1 = object : WorkflowInterceptor { + // @Composable + // override fun onRender( + // renderProps: P, + // renderState: S, + // context: BaseRenderContext, + // proceed: @Composable (P, S, RenderContextInterceptor?) -> R, + // session: WorkflowSession + // ) = proceed(renderProps, renderState, Labeler("uno")) + // } + // + // val interceptor2 = object : WorkflowInterceptor { + // @Composable + // override fun onRender( + // renderProps: P, + // renderState: S, + // context: BaseRenderContext, + // proceed: @Composable (P, S, RenderContextInterceptor?) -> R, + // session: WorkflowSession + // ) = proceed(renderProps, renderState, Labeler("dos")) + // } + // + // val chained = listOf(interceptor1, interceptor2).chained() + // + // chained.onRender( + // renderProps = "props", + // renderState = "state", + // context = FakeRenderContext, + // proceed = { _, _, interceptor -> + // interceptor?.onRenderChild( + // child = TestSession.workflow, + // childProps = Unit, + // key = TestSession.renderKey, + // handler = { error("How did you emit Nothing? Good trick!") }, + // proceed = { _, _, _, _ -> } + // ) as Any + // }, + // session = TestSession, + // ) + // + // assertEquals("START uno, START dos, END dos, END uno", transcript.joinToString(", ")) } @Test fun `chains calls to onSnapshotState() in left-to-right order`() { - val interceptor1 = object : WorkflowInterceptor { - override fun onSnapshotState( - state: S, - proceed: (S) -> Snapshot?, - session: WorkflowSession - ): Snapshot = Snapshot.of("r1: " + proceed("state1: $state" as S).readUtf8()) - } - val interceptor2 = object : WorkflowInterceptor { - override fun onSnapshotState( - state: S, - proceed: (S) -> Snapshot?, - session: WorkflowSession - ): Snapshot = Snapshot.of("r2: " + proceed("state2: $state" as S).readUtf8()) - } - val chained = listOf(interceptor1, interceptor2).chained() - fun snapshotState(state: String): Snapshot = Snapshot.of("($state)") - - val finalSnapshot = chained.onSnapshotState("state", ::snapshotState, TestSession) - .readUtf8() - - assertEquals("r1: r2: (state2: state1: state)", finalSnapshot) + // val interceptor1 = object : WorkflowInterceptor { + // @Composable + // override fun onSnapshotState( + // state: S, + // proceed: @Composable (S) -> Snapshot?, + // session: WorkflowSession + // ): Snapshot = Snapshot.of("r1: " + proceed("state1: $state" as S).readUtf8()) + // } + // val interceptor2 = object : WorkflowInterceptor { + // @Composable + // override fun onSnapshotState( + // state: S, + // proceed: @Composable (S) -> Snapshot?, + // session: WorkflowSession + // ): Snapshot = Snapshot.of("r2: " + proceed("state2: $state" as S).readUtf8()) + // } + // val chained = listOf(interceptor1, interceptor2).chained() + // fun snapshotState(state: String): Snapshot = Snapshot.of("($state)") + // + // val finalSnapshot = chained.onSnapshotState("state", ::snapshotState, TestSession) + // .readUtf8() + // + // assertEquals("r1: r2: (state2: state1: state)", finalSnapshot) } private fun Snapshot?.readUtf8() = this?.bytes?.parse { it.readUtf8() } @@ -299,6 +305,7 @@ internal class ChainedWorkflowInterceptorTest { override val actionSink: Sink> get() = fail() + @Composable override fun renderChild( child: Workflow, props: ChildPropsT, diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt index 0162798b5..4d93feb3f 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt @@ -2,10 +2,13 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import app.cash.molecule.launchMolecule import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowRuntimeClock import com.squareup.workflow1.action import com.squareup.workflow1.applyTo import com.squareup.workflow1.internal.RealRenderContext.Renderer @@ -16,6 +19,9 @@ import com.squareup.workflow1.stateless import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -36,6 +42,7 @@ internal class RealRenderContextTest { ) @Suppress("UNCHECKED_CAST") + @Composable override fun render( child: Workflow, props: ChildPropsT, @@ -64,6 +71,7 @@ internal class RealRenderContextTest { snapshot: Snapshot? ): String = fail() + @Composable override fun render( renderProps: String, renderState: String, @@ -76,6 +84,7 @@ internal class RealRenderContextTest { } private class PoisonRenderer : Renderer { + @Composable override fun render( child: Workflow, props: ChildPropsT, @@ -330,18 +339,22 @@ internal class RealRenderContextTest { val context = createTestContext() val workflow = TestWorkflow() - val (child, props, key, handler) = context.renderChild(workflow, "props", "key") { output -> - action { setOutput("output:$output") } - } + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) + + scope.launchMolecule { + val (child, props, key, handler) = context.renderChild(workflow, "props", "key") { output -> + action { setOutput("output:$output") } + } - assertSame(workflow, child) - assertEquals("props", props) - assertEquals("key", key) + assertSame(workflow, child) + assertEquals("props", props) + assertEquals("key", key) - val (state, output) = handler.invoke("output") - .applyTo("props", "state") - assertEquals("state", state) - assertEquals("output:output", output?.value) + val (state, output) = handler.invoke("output") + .applyTo("props", "state") + assertEquals("state", state) + assertEquals("output:output", output?.value) + } } @Test fun `all methods throw after freeze`() { @@ -349,7 +362,13 @@ internal class RealRenderContextTest { context.freeze() val child = Workflow.stateless { fail() } - assertFailsWith { context.renderChild(child) } + assertFailsWith { + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) + + scope.launchMolecule { + context.renderChild(child) + } + } assertFailsWith { context.freeze() } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt index 8bf5d26ef..c32e06003 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt @@ -2,20 +2,27 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import app.cash.molecule.launchMolecule import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.WorkflowRuntimeClock import com.squareup.workflow1.action import com.squareup.workflow1.applyTo import com.squareup.workflow1.identifier import com.squareup.workflow1.internal.SubtreeManagerTest.TestWorkflow.Rendering +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Unconfined import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import kotlinx.coroutines.selects.select +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -44,6 +51,7 @@ internal class SubtreeManagerTest { return "initialState:$props" } + @Composable override fun render( renderProps: String, renderState: String, @@ -72,6 +80,7 @@ internal class SubtreeManagerTest { if (snapshot != null) restores++ } + @Composable override fun render( renderProps: Unit, renderState: Unit, @@ -89,66 +98,79 @@ internal class SubtreeManagerTest { } private val context = Unconfined + val scope = CoroutineScope(UnconfinedTestDispatcher()) + WorkflowRuntimeClock(flowOf(Unit)) @Test fun `render starts new child`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - manager.render(workflow, "props", key = "", handler = { fail() }) - assertEquals(1, workflow.started) + scope.launchMolecule { + manager.render(workflow, "props", key = "", handler = { fail() }) + assertEquals(1, workflow.started) + } } @Test fun `render doesn't start existing child`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - fun render() = manager.render(workflow, "props", key = "", handler = { fail() }) + @Composable fun render() = manager.render(workflow, "props", key = "", handler = { fail() }) .also { manager.commitRenderedChildren() } - render() - render() + scope.launchMolecule { + render() + render() - assertEquals(1, workflow.started) + assertEquals(1, workflow.started) + } } @Test fun `render restarts child after tearing down`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - fun render() = manager.render(workflow, "props", key = "", handler = { fail() }) - .also { manager.commitRenderedChildren() } - render() - assertEquals(1, workflow.started) + @Composable fun render() { + manager.render(workflow, "props", key = "", handler = { fail() }) + manager.commitRenderedChildren() + } + scope.launchMolecule { + render() + assertEquals(1, workflow.started) - // Render without rendering child. - manager.commitRenderedChildren() - assertEquals(1, workflow.started) + // Render without rendering child. + manager.commitRenderedChildren() + assertEquals(1, workflow.started) - render() - assertEquals(2, workflow.started) + render() + assertEquals(2, workflow.started) + } } @Test fun `render throws on duplicate key`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - manager.render(workflow, "props", "foo", handler = { fail() }) - - val error = assertFailsWith { + scope.launchMolecule { manager.render(workflow, "props", "foo", handler = { fail() }) + + val error = assertFailsWith { + manager.render(workflow, "props", "foo", handler = { fail() }) + } + assertEquals( + "Expected keys to be unique for ${workflow.identifier}: key=\"foo\"", + error.message + ) } - assertEquals( - "Expected keys to be unique for ${workflow.identifier}: key=\"foo\"", - error.message - ) } @Test fun `render returns child rendering`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - val (composeProps, composeState) = manager.render( - workflow, "props", key = "", handler = { fail() } - ) - assertEquals("props", composeProps) - assertEquals("initialState:props", composeState) + scope.launchMolecule { + val (composeProps, composeState) = manager.render( + workflow, "props", key = "", handler = { fail() } + ) + assertEquals("props", composeProps) + assertEquals("initialState:props", composeState) + } } @Test fun `tick children handles child output`() { @@ -158,47 +180,53 @@ internal class SubtreeManagerTest { action { setOutput("case output:$output") } } - // Initialize the child so tickChildren has something to work with, and so that we can send - // an event to trigger an output. - val (_, _, eventHandler) = manager.render(workflow, "props", key = "", handler = handler) - manager.commitRenderedChildren() + scope.launchMolecule { + // Initialize the child so tickChildren has something to work with, and so that we can send + // an event to trigger an output. + val (_, _, eventHandler) = manager.render(workflow, "props", key = "", handler = handler) + manager.commitRenderedChildren() - runBlocking { - val tickOutput = async { manager.tickAction() } - assertFalse(tickOutput.isCompleted) + runBlocking { + val tickOutput = async { manager.tickAction() } + assertFalse(tickOutput.isCompleted) - eventHandler("event!") - val update = tickOutput.await().value!! + eventHandler("event!") + val update = tickOutput.await().value!! - val (_, output) = update.applyTo("props", "state") - assertEquals("case output:workflow output:event!", output?.value) + val (_, output) = update.applyTo("props", "state") + assertEquals("case output:workflow output:event!", output?.value) + } } } @Test fun `render updates child's output handler`() { val manager = subtreeManagerForTest() val workflow = TestWorkflow() - fun render(handler: StringHandler) = + @Composable fun render(handler: StringHandler) = manager.render(workflow, "props", key = "", handler = handler) .also { manager.commitRenderedChildren() } - runBlocking { + scope.launchMolecule { // First render + tick pass – uninteresting. render { action { setOutput("initial handler: $it") } } .let { rendering -> - rendering.eventHandler("initial output") - val initialAction = manager.tickAction().value - val (_, initialOutput) = initialAction!!.applyTo("", "") - assertEquals("initial handler: workflow output:initial output", initialOutput?.value) + runBlocking { + rendering.eventHandler("initial output") + val initialAction = manager.tickAction().value + val (_, initialOutput) = initialAction!!.applyTo("", "") + assertEquals("initial handler: workflow output:initial output", initialOutput?.value) + } } // Do a second render + tick, but with a different handler function. render { action { setOutput("second handler: $it") } } .let { rendering -> - rendering.eventHandler("second output") - val secondAction = manager.tickAction().value - val (_, secondOutput) = secondAction!!.applyTo("", "") - assertEquals("second handler: workflow output:second output", secondOutput?.value) + runBlocking { + rendering.eventHandler("second output") + val secondAction = manager.tickAction().value + val (_, secondOutput) = secondAction!!.applyTo("", "") + assertEquals("second handler: workflow output:second output", secondOutput?.value) + } } } } @@ -209,11 +237,13 @@ internal class SubtreeManagerTest { val workflow = SnapshotTestWorkflow() assertEquals(0, workflow.snapshots) - manager.render(workflow, props = Unit, key = "1", handler = { fail() }) - manager.commitRenderedChildren() - manager.createChildSnapshots() + scope.launchMolecule { + manager.render(workflow, props = Unit, key = "1", handler = { fail() }) + manager.commitRenderedChildren() + manager.createChildSnapshots() - assertEquals(1, workflow.snapshots) + assertEquals(1, workflow.snapshots) + } } // See https://github.com/square/workflow/issues/404 @@ -222,15 +252,17 @@ internal class SubtreeManagerTest { val workflow = SnapshotTestWorkflow() assertEquals(0, workflow.serializes) - manager.render(workflow, props = Unit, key = "1", handler = { fail() }) - manager.commitRenderedChildren() - val snapshots = manager.createChildSnapshots() + scope.launchMolecule { + manager.render(workflow, props = Unit, key = "1", handler = { fail() }) + manager.commitRenderedChildren() + val snapshots = manager.createChildSnapshots() - assertEquals(0, workflow.serializes) + assertEquals(0, workflow.serializes) - // Force the snapshots to serialize. - snapshots.forEach { (_, snapshot) -> snapshot.workflowSnapshot } - assertEquals(1, workflow.serializes) + // Force the snapshots to serialize. + snapshots.forEach { (_, snapshot) -> snapshot.workflowSnapshot } + assertEquals(1, workflow.serializes) + } } @Test fun `snapshots applied on first render only`() { @@ -238,24 +270,26 @@ internal class SubtreeManagerTest { val workflowAble = SnapshotTestWorkflow() val workflowBaker = SnapshotTestWorkflow() - manager1.render(workflowAble, props = Unit, key = "able", handler = { fail() }) - manager1.render(workflowBaker, props = Unit, key = "baker", handler = { fail() }) - manager1.commitRenderedChildren() - val snapshots = manager1.createChildSnapshots() - - val manager2 = subtreeManagerForTest(snapshots) - assertEquals(0, workflowAble.restores) - assertEquals(0, workflowBaker.restores) - - manager2.render(workflowAble, props = Unit, key = "able", handler = { fail() }) - manager2.commitRenderedChildren() - assertEquals(1, workflowAble.restores) - - manager2.render(workflowAble, props = Unit, key = "able", handler = { fail() }) - manager2.render(workflowBaker, props = Unit, key = "baker", handler = { fail() }) - manager2.commitRenderedChildren() - assertEquals(1, workflowAble.restores) - assertEquals(0, workflowBaker.restores) + scope.launchMolecule { + manager1.render(workflowAble, props = Unit, key = "able", handler = { fail() }) + manager1.render(workflowBaker, props = Unit, key = "baker", handler = { fail() }) + manager1.commitRenderedChildren() + val snapshots = manager1.createChildSnapshots() + + val manager2 = subtreeManagerForTest(snapshots) + assertEquals(0, workflowAble.restores) + assertEquals(0, workflowBaker.restores) + + manager2.render(workflowAble, props = Unit, key = "able", handler = { fail() }) + manager2.commitRenderedChildren() + assertEquals(1, workflowAble.restores) + + manager2.render(workflowAble, props = Unit, key = "able", handler = { fail() }) + manager2.render(workflowBaker, props = Unit, key = "baker", handler = { fail() }) + manager2.commitRenderedChildren() + assertEquals(1, workflowAble.restores) + assertEquals(0, workflowBaker.restores) + } } @Suppress("UNCHECKED_CAST") diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt index c526c9035..eb101075b 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt @@ -1,1246 +1,1251 @@ -@file:Suppress("EXPERIMENTAL_API_USAGE", "DEPRECATION") - -package com.squareup.workflow1.internal - -import com.squareup.workflow1.ActionProcessingResult -import com.squareup.workflow1.BaseRenderContext -import com.squareup.workflow1.Sink -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.TreeSnapshot -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction -import com.squareup.workflow1.WorkflowIdentifier -import com.squareup.workflow1.WorkflowInterceptor -import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor -import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession -import com.squareup.workflow1.WorkflowOutput -import com.squareup.workflow1.action -import com.squareup.workflow1.contraMap -import com.squareup.workflow1.identifier -import com.squareup.workflow1.parse -import com.squareup.workflow1.readUtf8WithLength -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.rendering -import com.squareup.workflow1.stateful -import com.squareup.workflow1.stateless -import com.squareup.workflow1.writeUtf8WithLength -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers.Unconfined -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.selects.select -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import kotlin.coroutines.CoroutineContext -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import kotlin.test.fail - -@Suppress("UNCHECKED_CAST") -internal class WorkflowNodeTest { - - private abstract class StringWorkflow : StatefulWorkflow() { - override fun snapshotState(state: String): Snapshot = fail("not expected") - } - - private abstract class StringEventWorkflow : - StatefulWorkflow Unit>() { - override fun snapshotState(state: String): Snapshot = fail("not expected") - } - - private class PropsRenderingWorkflow( - private val onPropsChanged: (String, String, String) -> String - ) : StringWorkflow() { - - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return "starting:$props" - } - - override fun onPropsChanged( - old: String, - new: String, - state: String - ): String = onPropsChanged.invoke(old, new, state) - - override fun render( - renderProps: String, - renderState: String, - context: RenderContext - ): String { - return """ - props:$renderProps - state:$renderState - """.trimIndent() - } - } - - private val context: CoroutineContext = Unconfined + Job() - - @AfterTest fun tearDown() { - context.cancel() - } - - @Test fun `onPropsChanged is called when props change`() { - val oldAndNewProps = mutableListOf>() - val workflow = PropsRenderingWorkflow { old, new, state -> - oldAndNewProps += old to new - return@PropsRenderingWorkflow state - } - val node = WorkflowNode(workflow.id(), workflow, "old", null, context) - - node.render(workflow, "new") - - assertEquals(listOf("old" to "new"), oldAndNewProps) - } - - @Test fun `onPropsChanged is not called when props are equal`() { - val oldAndNewProps = mutableListOf>() - val workflow = PropsRenderingWorkflow { old, new, state -> - oldAndNewProps += old to new - return@PropsRenderingWorkflow state - } - val node = WorkflowNode(workflow.id(), workflow, "old", null, context) - - node.render(workflow, "old") - - assertTrue(oldAndNewProps.isEmpty()) - } - - @Test fun `props are rendered`() { - val workflow = PropsRenderingWorkflow { old, new, _ -> - "$old->$new" - } - val node = WorkflowNode(workflow.id(), workflow, "foo", null, context) - - val rendering = node.render(workflow, "foo2") - - assertEquals( - """ - props:foo2 - state:foo->foo2 - """.trimIndent(), - rendering - ) - - val rendering2 = node.render(workflow, "foo3") - - assertEquals( - """ - props:foo3 - state:foo2->foo3 - """.trimIndent(), - rendering2 - ) - } - - @Test fun `accepts event`() { - val workflow = object : StringEventWorkflow() { - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return props - } - - override fun render( - renderProps: String, - renderState: String, - context: RenderContext - ): (String) -> Unit { - return context.eventHandler { event -> setOutput(event) } - } - } - val node = WorkflowNode( - workflow.id(), workflow, "", null, context, - emitOutputToParent = { WorkflowOutput("tick:$it") } - ) - node.render(workflow, "")("event") - - val result = runBlocking { - withTimeout(10) { - select { - node.tick(this) - } as WorkflowOutput? - } - } - assertEquals("tick:event", result?.value) - } - - @Test fun `accepts events sent to stale renderings`() { - val workflow = object : StringEventWorkflow() { - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return props - } - - override fun render( - renderProps: String, - renderState: String, - context: RenderContext - ): (String) -> Unit { - return context.eventHandler { event -> setOutput(event) } - } - } - val node = WorkflowNode( - workflow.id(), workflow, "", null, context, - emitOutputToParent = { WorkflowOutput("tick:$it") } - ) - val sink = node.render(workflow, "") - - sink("event") - sink("event2") - - val result = runBlocking { - withTimeout(10) { - List(2) { - select { - node.tick(this) - } as WorkflowOutput? - } - } - } - assertEquals(listOf("tick:event", "tick:event2"), result.map { it?.value }) - } - - @Test fun `send allows subsequent events on same rendering`() { - lateinit var sink: Sink> - val workflow = object : StringWorkflow() { - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return props - } - - override fun render( - renderProps: String, - renderState: String, - context: RenderContext - ): String { - sink = context.actionSink - return "" - } - } - val node = WorkflowNode(workflow.id(), workflow, "", null, context) - - node.render(workflow, "") - sink.send(action { setOutput("event") }) - - // Should not throw. - sink.send(action { setOutput("event2") }) - } - - @Test fun `sideEffect is not started until after render completes`() { - var started = false - val workflow = Workflow.stateless { - runningSideEffect("key") { - started = true - } - assertFalse(started) - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - - runBlocking { - node.render(workflow.asStatefulWorkflow(), Unit) - assertTrue(started) - } - } - - @Test fun `sideEffect coroutine is named`() { - var contextFromWorker: CoroutineContext? = null - val workflow = Workflow.stateless { - runningSideEffect("the key") { - contextFromWorker = coroutineContext - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - - node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals(WorkflowNodeId(workflow).toString(), node.coroutineContext[CoroutineName]!!.name) - assertEquals( - "sideEffect[the key] for ${WorkflowNodeId(workflow)}", - contextFromWorker!![CoroutineName]!!.name - ) - } - - @Test fun `sideEffect can send to actionSink`() { - val workflow = Workflow.stateless { - runningSideEffect("key") { - actionSink.send(action { setOutput("result") }) - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - val result = runBlocking { - // Result should be available instantly, any delay at all indicates something is broken. - withTimeout(1) { - select { - node.tick(this) - } as WorkflowOutput? - } - } - - assertEquals("result", result?.value) - } - - @Test fun `sideEffect is cancelled when stops being ran`() { - val isRunning = MutableStateFlow(true) - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { props -> - if (props) { - runningSideEffect("key") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = true, - snapshot = null, baseContext = context - ) - - runBlocking { - node.render(workflow.asStatefulWorkflow(), true) - assertNull(cancellationException) - - // Stop running the side effect. - isRunning.value = false - node.render(workflow.asStatefulWorkflow(), false) - - assertTrue(cancellationException is CancellationException) - } - } - - @Test fun `sideEffect is cancelled when workflow is torn down`() { - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect("key") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - - runBlocking { - node.render(workflow.asStatefulWorkflow(), Unit) - assertNull(cancellationException) - - node.cancel() - - assertTrue(cancellationException is CancellationException) - } - } - - @Test fun `sideEffect with matching key lives across render passes`() { - var renderPasses = 0 - var cancelled = false - val workflow = Workflow.stateless { - renderPasses++ - runningSideEffect("") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cancelled = true } - } - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, - snapshot = null, baseContext = context - ) - - runBlocking { - node.render(workflow.asStatefulWorkflow(), 0) - assertFalse(cancelled) - assertEquals(1, renderPasses) - - node.render(workflow.asStatefulWorkflow(), 1) - assertFalse(cancelled) - assertEquals(2, renderPasses) - } - } - - @Test fun `sideEffect isn't restarted on next render pass after finishing`() { - val seenProps = mutableListOf() - var renderPasses = 0 - val workflow = Workflow.stateless { props -> - renderPasses++ - runningSideEffect("") { - seenProps += props - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, - snapshot = null, baseContext = context - ) - - runBlocking { - node.render(workflow.asStatefulWorkflow(), 0) - assertEquals(listOf(0), seenProps) - assertEquals(1, renderPasses) - - node.render(workflow.asStatefulWorkflow(), 1) - assertEquals(listOf(0), seenProps) - assertEquals(2, renderPasses) - } - } - - @Test fun `multiple sideEffects with same key throws`() { - val workflow = Workflow.stateless { - runningSideEffect("same") { fail() } - runningSideEffect("same") { fail() } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - - val error = assertFailsWith { - node.render(workflow.asStatefulWorkflow(), Unit) - } - assertEquals("Expected side effect keys to be unique: \"same\"", error.message) - } - - @Test fun `staggered sideEffects`() { - val events1 = mutableListOf() - val events2 = mutableListOf() - val events3 = mutableListOf() - fun recordingSideEffect(events: MutableList): suspend CoroutineScope.() -> Unit = { - events += "started" - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { events += "cancelled" } - } - } - - val workflow = Workflow.stateless { props -> - if (props in 0..2) runningSideEffect("one", recordingSideEffect(events1)) - if (props == 1) runningSideEffect("two", recordingSideEffect(events2)) - if (props == 2) runningSideEffect("three", recordingSideEffect(events3)) - } - .asStatefulWorkflow() - val node = WorkflowNode( - workflow.id(), workflow, initialProps = 0, snapshot = null, - baseContext = context - ) - - node.render(workflow, 0) - assertEquals(listOf("started"), events1) - assertEquals(emptyList(), events2) - assertEquals(emptyList(), events3) - - node.render(workflow, 1) - assertEquals(listOf("started"), events1) - assertEquals(listOf("started"), events2) - assertEquals(emptyList(), events3) - - node.render(workflow, 2) - assertEquals(listOf("started"), events1) - assertEquals(listOf("started", "cancelled"), events2) - assertEquals(listOf("started"), events3) - - node.render(workflow, 3) - assertEquals(listOf("started", "cancelled"), events1) - assertEquals(listOf("started", "cancelled"), events2) - assertEquals(listOf("started", "cancelled"), events3) - } - - @Test fun `multiple sideEffects started in same pass are both launched`() { - var started1 = false - var started2 = false - val workflow = Workflow.stateless { - runningSideEffect("one") { started1 = true } - runningSideEffect("two") { started2 = true } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = null, baseContext = context - ) - - assertFalse(started1) - assertFalse(started2) - node.render(workflow.asStatefulWorkflow(), Unit) - assertTrue(started1) - assertTrue(started2) - } - - @Test fun `snapshots non-empty without children`() { - val workflow = Workflow.stateful( - initialState = { props, snapshot -> - snapshot?.bytes?.parse { - it.readUtf8WithLength() - .removePrefix("state:") - } ?: props - }, - render = { _, state -> state }, - snapshot = { state -> - Snapshot.write { - it.writeUtf8WithLength("state:$state") - } - } - ) - val originalNode = WorkflowNode( - workflow.id(), - workflow, - initialProps = "initial props", - snapshot = null, - baseContext = Unconfined - ) - - assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) - assertNotEquals(0, snapshot.toByteString().size) - - val restoredNode = WorkflowNode( - workflow.id(), - workflow, - // These props should be ignored, since snapshot is non-null. - initialProps = "new props", - snapshot = snapshot, - baseContext = Unconfined - ) - assertEquals("initial props", restoredNode.render(workflow, "foo")) - } - - @Test fun `snapshots empty without children`() { - val workflow = Workflow.stateful( - initialState = { props, snapshot -> snapshot?.bytes?.utf8() ?: props }, - render = { _, state -> state }, - snapshot = { Snapshot.of("restored") } - ) - val originalNode = WorkflowNode( - workflow.id(), - workflow, - initialProps = "initial props", - snapshot = null, - baseContext = Unconfined - ) - - assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) - assertNotEquals(0, snapshot.toByteString().size) - - val restoredNode = WorkflowNode( - workflow.id(), - workflow, - // These props should be ignored, since snapshot is non-null. - initialProps = "new props", - snapshot = snapshot, - baseContext = Unconfined - ) - assertEquals("restored", restoredNode.render(workflow, "foo")) - } - - @Test fun `snapshots non-empty with children`() { - var restoredChildState: String? = null - var restoredParentState: String? = null - val childWorkflow = Workflow.stateful( - initialState = { props, snapshot -> - snapshot?.bytes?.parse { - it.readUtf8WithLength() - .removePrefix("child state:") - .also { state -> restoredChildState = state } - } ?: props - }, - render = { _, state -> state }, - snapshot = { state -> - Snapshot.write { - it.writeUtf8WithLength("child state:$state") - } - } - ) - val parentWorkflow = Workflow.stateful( - initialState = { props, snapshot -> - snapshot?.bytes?.parse { - it.readUtf8WithLength() - .removePrefix("parent state:") - .also { state -> restoredParentState = state } - } ?: props - }, - render = { _, state -> "$state|" + renderChild(childWorkflow, "child props") }, - snapshot = { state -> - Snapshot.write { - it.writeUtf8WithLength("parent state:$state") - } - } - ) - - val originalNode = WorkflowNode( - parentWorkflow.id(), - parentWorkflow, - initialProps = "initial props", - snapshot = null, - baseContext = Unconfined - ) - - assertEquals("initial props|child props", originalNode.render(parentWorkflow, "foo")) - val snapshot = originalNode.snapshot(parentWorkflow) - assertNotEquals(0, snapshot.toByteString().size) - - val restoredNode = WorkflowNode( - parentWorkflow.id(), - parentWorkflow, - // These props should be ignored, since snapshot is non-null. - initialProps = "new props", - snapshot = snapshot, - baseContext = Unconfined - ) - assertEquals("initial props|child props", restoredNode.render(parentWorkflow, "foo")) - assertEquals("child props", restoredChildState) - assertEquals("initial props", restoredParentState) - } - - @Test fun `snapshot counts`() { - var snapshotCalls = 0 - var restoreCalls = 0 - // Track the number of times the snapshot is actually serialized, not snapshotState is called. - var snapshotWrites = 0 - val workflow = Workflow.stateful( - initialState = { snapshot -> if (snapshot != null) restoreCalls++ }, - render = { }, - snapshot = { - snapshotCalls++ - Snapshot.write { - snapshotWrites++ - // Snapshot will be discarded on restoration if it's empty, so we need to write - // something here so we actually get a non-null snapshot in restore. - it.writeUtf8("dummy value") - } - } - ) - val node = WorkflowNode(workflow.id(), workflow, Unit, null, Unconfined) - - assertEquals(0, snapshotCalls) - assertEquals(0, snapshotWrites) - assertEquals(0, restoreCalls) - - val snapshot = node.snapshot(workflow) - - assertEquals(1, snapshotCalls) - assertEquals(0, snapshotWrites) - assertEquals(0, restoreCalls) - - snapshot.toByteString() - - assertEquals(1, snapshotCalls) - assertEquals(1, snapshotWrites) - assertEquals(0, restoreCalls) - - WorkflowNode(workflow.id(), workflow, Unit, snapshot, Unconfined) - - assertEquals(1, snapshotCalls) - assertEquals(1, snapshotWrites) - assertEquals(1, restoreCalls) - } - - @Test fun `restore gets props`() { - val workflow = Workflow.stateful( - initialState = { props, snapshot -> - snapshot?.bytes?.parse { - // Tags the restored state with the props so we can check it. - val deserialized = it.readUtf8WithLength() - return@parse "props:$props|state:$deserialized" - } ?: props - }, - render = { _, state -> state }, - snapshot = { state -> Snapshot.write { it.writeUtf8WithLength(state) } } - ) - val originalNode = WorkflowNode( - workflow.id(), - workflow, - initialProps = "initial props", - snapshot = null, - baseContext = Unconfined - ) - - assertEquals("initial props", originalNode.render(workflow, "foo")) - val snapshot = originalNode.snapshot(workflow) - assertNotEquals(0, snapshot.toByteString().size) - - val restoredNode = WorkflowNode( - workflow.id(), - workflow, - initialProps = "new props", - snapshot = snapshot, - baseContext = Unconfined - ) - assertEquals("props:new props|state:initial props", restoredNode.render(workflow, "foo")) - } - - @Test fun `toString() formats as WorkflowInstance without parent`() { - val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - parent = null - ) - - assertEquals( - "WorkflowInstance(identifier=${workflow.identifier}, renderKey=foo, " + - "instanceId=0, parent=null)", - node.toString() - ) - } - - @Test fun `toString() formats as WorkflowInstance with parent`() { - val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - parent = TestSession(42) - ) - - assertEquals( - "WorkflowInstance(identifier=${workflow.identifier}, renderKey=foo, " + - "instanceId=0, parent=WorkflowInstance(…))", - node.toString() - ) - } - - @Test fun `interceptor handles scope start and cancellation`() { - lateinit var interceptedScope: CoroutineScope - lateinit var interceptedSession: WorkflowSession - lateinit var cancellationException: Throwable - val interceptor = object : WorkflowInterceptor { - override fun onSessionStarted( - workflowScope: CoroutineScope, - session: WorkflowSession - ) { - interceptedScope = workflowScope - interceptedSession = session - workflowScope.coroutineContext[Job]!!.invokeOnCompletion { - cancellationException = it!! - } - } - } - val workflow = Workflow.rendering(Unit) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - - assertSame(node.coroutineContext, interceptedScope.coroutineContext) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - - val cause = CancellationException("stop") - node.cancel(cause) - assertSame(cause, cancellationException) - } - - @Test fun `interceptor handles initialState()`() { - lateinit var interceptedProps: String - lateinit var interceptedSnapshot: Snapshot - lateinit var interceptedState: String - lateinit var interceptedSession: WorkflowSession - val interceptor = object : WorkflowInterceptor { - override fun onInitialState( - props: P, - snapshot: Snapshot?, - proceed: (P, Snapshot?) -> S, - session: WorkflowSession - ): S { - interceptedProps = props as String - interceptedSnapshot = snapshot!! - interceptedSession = session - return proceed(props, snapshot) - .also { interceptedState = it as String } - } - } - val workflow = Workflow.stateful( - initialState = { props -> "state($props)" }, - render = { _, _ -> fail() } - ) - WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = "props", - snapshot = TreeSnapshot.forRootOnly(Snapshot.of("snapshot")), - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - - assertEquals("props", interceptedProps) - assertEquals(Snapshot.of("snapshot"), interceptedSnapshot) - assertEquals("state(props)", interceptedState) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - } - - @Test fun `interceptor handles onPropsChanged()`() { - lateinit var interceptedOld: String - lateinit var interceptedNew: String - lateinit var interceptedState: String - lateinit var interceptedReturnState: String - lateinit var interceptedSession: WorkflowSession - val interceptor = object : WorkflowInterceptor { - override fun onPropsChanged( - old: P, - new: P, - state: S, - proceed: (P, P, S) -> S, - session: WorkflowSession - ): S { - interceptedOld = old as String - interceptedNew = new as String - interceptedState = state as String - interceptedSession = session - return proceed(old, new, state) - .also { interceptedReturnState = it as String } - } - } - val workflow = Workflow.stateful( - initialState = { "initialState" }, - onPropsChanged = { old, new, state -> "onPropsChanged($old, $new, $state)" }, - render = { _, state -> state } - ) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = "old", - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - val rendering = node.render(workflow, "new") - - assertEquals("old", interceptedOld) - assertEquals("new", interceptedNew) - assertEquals("initialState", interceptedState) - assertEquals("onPropsChanged(old, new, initialState)", interceptedReturnState) - assertEquals("onPropsChanged(old, new, initialState)", rendering) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - } - - @Test fun `interceptor handles render()`() { - lateinit var interceptedProps: String - lateinit var interceptedState: String - lateinit var interceptedRendering: String - lateinit var interceptedSession: WorkflowSession - val interceptor = object : WorkflowInterceptor { - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ): R { - interceptedProps = renderProps as String - interceptedState = renderState as String - interceptedSession = session - return proceed(renderProps, renderState, null) - .also { interceptedRendering = it as String } - } - } - val workflow = Workflow.stateful( - initialState = { "state" }, - render = { props, state -> "render($props, $state)" } - ) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = "props", - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - val rendering = node.render(workflow, "props") - - assertEquals("props", interceptedProps) - assertEquals("state", interceptedState) - assertEquals("render(props, state)", interceptedRendering) - assertEquals("render(props, state)", rendering) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - } - - @Test fun `interceptor handles snapshotState()`() { - lateinit var interceptedState: String - var interceptedSnapshot: Snapshot? = null - lateinit var interceptedSession: WorkflowSession - val interceptor = object : WorkflowInterceptor { - override fun onSnapshotState( - state: S, - proceed: (S) -> Snapshot?, - session: WorkflowSession - ): Snapshot? { - interceptedState = state as String - interceptedSession = session - return proceed(state) - .also { interceptedSnapshot = it } - } - } - val workflow = Workflow.stateful( - initialState = { _, _ -> "state" }, - render = { _, state -> state }, - snapshot = { state -> Snapshot.of("snapshot($state)") } - ) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = "old", - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - val snapshot = node.snapshot(workflow) - - assertEquals("state", interceptedState) - assertEquals(Snapshot.of("snapshot(state)"), interceptedSnapshot) - assertEquals(Snapshot.of("snapshot(state)"), snapshot.workflowSnapshot) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - } - - @Test fun `interceptor handles snapshotState() returning null`() { - lateinit var interceptedState: String - var interceptedSnapshot: Snapshot? = null - lateinit var interceptedSession: WorkflowSession - val interceptor = object : WorkflowInterceptor { - override fun onSnapshotState( - state: S, - proceed: (S) -> Snapshot?, - session: WorkflowSession - ): Snapshot? { - interceptedState = state as String - interceptedSession = session - return proceed(state) - .also { interceptedSnapshot = it } - } - } - val workflow = Workflow.stateful( - initialState = { _, _ -> "state" }, - render = { _, state -> state }, - snapshot = { null } - ) - val node = WorkflowNode( - id = workflow.id(key = "foo"), - workflow = workflow.asStatefulWorkflow(), - initialProps = "old", - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42) - ) - val snapshot = node.snapshot(workflow) - - assertEquals("state", interceptedState) - assertNull(interceptedSnapshot) - assertNull(snapshot.workflowSnapshot) - assertEquals(workflow.identifier, interceptedSession.identifier) - assertEquals(0, interceptedSession.sessionId) - assertEquals("foo", interceptedSession.renderKey) - assertEquals(42, interceptedSession.parent!!.sessionId) - } - - @Test fun `interceptor is propagated to children`() { - val interceptor = object : WorkflowInterceptor { - @Suppress("UNCHECKED_CAST") - override fun onRender( - renderProps: P, - renderState: S, - context: BaseRenderContext, - proceed: (P, S, RenderContextInterceptor?) -> R, - session: WorkflowSession - ) = "[${proceed("[$renderProps]" as P, "[$renderState]" as S, null)}]" as R - } - val leafWorkflow = Workflow.stateful( - initialState = { props -> props }, - render = { props, state -> "leaf($props, $state)" } - ) - val rootWorkflow = Workflow.stateful( - initialState = { props -> props }, - render = { props, _ -> - "root(${renderChild(leafWorkflow, props)})" - } - ) - val node = WorkflowNode( - id = rootWorkflow.id(key = "foo"), - workflow = rootWorkflow.asStatefulWorkflow(), - initialProps = "props", - snapshot = null, - interceptor = interceptor, - baseContext = Unconfined, - parent = TestSession(42), - idCounter = IdCounter() - ) - val rendering = node.render(rootWorkflow.asStatefulWorkflow(), "props") - - assertEquals("[root([leaf([[props]], [[props]])])]", rendering) - } - - @Test fun `eventSink send fails before render pass completed`() { - val workflow = Workflow.stateless { - val sink = eventHandler { _: String -> fail("Expected handler to fail.") } - sink("Foo") - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined - ) - - val error = assertFailsWith { - node.render(workflow.asStatefulWorkflow(), Unit) - } - assertTrue( - error.message!!.startsWith( - "Expected sink to not be sent to until after the render pass. " + - "Received action: WorkflowAction(eventHandler)@" - ) - ) - } - - @Test fun `send fails before render pass completed`() { - class TestAction : WorkflowAction() { - override fun Updater.apply() = fail("Expected sink send to fail.") - override fun toString(): String = "TestAction()" - } - - val workflow = Workflow.stateless { - actionSink.send(TestAction()) - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined - ) - - val error = assertFailsWith { - node.render(workflow.asStatefulWorkflow(), Unit) - } - assertEquals( - "Expected sink to not be sent to until after the render pass. " + - "Received action: TestAction()", - error.message - ) - } - - @Test fun `actionSink action changes state`() { - val workflow = Workflow.stateful>>( - initialState = { "initial" }, - render = { _, renderState -> - renderState to actionSink.contraMap { - action { state = "$state->$it" } - } - } - ) - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined - ) - val (_, sink) = node.render(workflow.asStatefulWorkflow(), Unit) - - sink.send("hello") - - runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - val (state, _) = node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals("initial->hello", state) - } - - @Test fun `actionSink action emits output`() { - val workflow = Workflow.stateless> { - actionSink.contraMap { action { setOutput(it) } } - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput("output:$it") } - ) - val rendering = node.render(workflow.asStatefulWorkflow(), Unit) - - rendering.send("hello") - - val output = runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - assertEquals("output:hello", output?.value) - } - - @Test fun `actionSink action allows null output`() { - val workflow = Workflow.stateless> { - actionSink.contraMap { action { setOutput(null) } } - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput(it) } - ) - val rendering = node.render(workflow.asStatefulWorkflow(), Unit) - - rendering.send("hello") - - val output = runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - assertNull(output?.value) - } - - @Test fun `child action changes state`() { - val workflow = Workflow.stateful( - initialState = { "initial" }, - render = { _, renderState -> - runningSideEffect("test") { - actionSink.send(action { state = "$state->hello" }) - } - return@stateful renderState - } - ) - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - val state = node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals("initial->hello", state) - } - - @Test fun `child action emits output`() { - val workflow = Workflow.stateless { - runningSideEffect("test") { - actionSink.send(action { setOutput("child:hello") }) - } - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput("output:$it") } - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - val output = runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - assertEquals("output:child:hello", output?.value) - } - - @Test fun `child action allows null output`() { - val workflow = Workflow.stateless { - runningSideEffect("test") { - actionSink.send(action { setOutput(null) }) - } - } - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = null, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput(it) } - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - val output = runBlocking { - select { - node.tick(this) - } as WorkflowOutput? - } - - assertNull(output?.value) - } - - private class TestSession(override val sessionId: Long = 0) : WorkflowSession { - override val identifier: WorkflowIdentifier = Workflow.rendering(Unit).identifier - override val renderKey: String = "" - override val parent: WorkflowSession? = null - } -} +// @file:Suppress("EXPERIMENTAL_API_USAGE", "DEPRECATION") +// +// package com.squareup.workflow1.internal +// +// import androidx.compose.runtime.Composable +// import com.squareup.workflow1.ActionProcessingResult +// import com.squareup.workflow1.BaseRenderContext +// import com.squareup.workflow1.Sink +// import com.squareup.workflow1.Snapshot +// import com.squareup.workflow1.StatefulWorkflow +// import com.squareup.workflow1.TreeSnapshot +// import com.squareup.workflow1.Workflow +// import com.squareup.workflow1.WorkflowAction +// import com.squareup.workflow1.WorkflowIdentifier +// import com.squareup.workflow1.WorkflowInterceptor +// import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor +// import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +// import com.squareup.workflow1.WorkflowOutput +// import com.squareup.workflow1.action +// import com.squareup.workflow1.contraMap +// import com.squareup.workflow1.identifier +// import com.squareup.workflow1.parse +// import com.squareup.workflow1.readUtf8WithLength +// import com.squareup.workflow1.renderChild +// import com.squareup.workflow1.rendering +// import com.squareup.workflow1.stateful +// import com.squareup.workflow1.stateless +// import com.squareup.workflow1.writeUtf8WithLength +// import kotlinx.coroutines.CancellationException +// import kotlinx.coroutines.CoroutineName +// import kotlinx.coroutines.CoroutineScope +// import kotlinx.coroutines.Dispatchers.Unconfined +// import kotlinx.coroutines.Job +// import kotlinx.coroutines.cancel +// import kotlinx.coroutines.flow.MutableStateFlow +// import kotlinx.coroutines.runBlocking +// import kotlinx.coroutines.selects.select +// import kotlinx.coroutines.suspendCancellableCoroutine +// import kotlinx.coroutines.withTimeout +// import kotlin.coroutines.CoroutineContext +// import kotlin.test.AfterTest +// import kotlin.test.Test +// import kotlin.test.assertEquals +// import kotlin.test.assertFailsWith +// import kotlin.test.assertFalse +// import kotlin.test.assertNotEquals +// import kotlin.test.assertNull +// import kotlin.test.assertSame +// import kotlin.test.assertTrue +// import kotlin.test.fail +// +// @Suppress("UNCHECKED_CAST") +// internal class WorkflowNodeTest { +// +// private abstract class StringWorkflow : StatefulWorkflow() { +// override fun snapshotState(state: String): Snapshot = fail("not expected") +// } +// +// private abstract class StringEventWorkflow : +// StatefulWorkflow Unit>() { +// override fun snapshotState(state: String): Snapshot = fail("not expected") +// } +// +// private class PropsRenderingWorkflow( +// private val onPropsChanged: (String, String, String) -> String +// ) : StringWorkflow() { +// +// override fun initialState( +// props: String, +// snapshot: Snapshot? +// ): String { +// assertNull(snapshot) +// return "starting:$props" +// } +// +// override fun onPropsChanged( +// old: String, +// new: String, +// state: String +// ): String = onPropsChanged.invoke(old, new, state) +// +// @Composable +// override fun render( +// renderProps: String, +// renderState: String, +// context: RenderContext +// ): String { +// return """ +// props:$renderProps +// state:$renderState +// """.trimIndent() +// } +// } +// +// private val context: CoroutineContext = Unconfined + Job() +// +// @AfterTest fun tearDown() { +// context.cancel() +// } +// +// @Test fun `onPropsChanged is called when props change`() { +// val oldAndNewProps = mutableListOf>() +// val workflow = PropsRenderingWorkflow { old, new, state -> +// oldAndNewProps += old to new +// return@PropsRenderingWorkflow state +// } +// val node = WorkflowNode(workflow.id(), workflow, "old", null, context) +// +// node.render(workflow, "new") +// +// assertEquals(listOf("old" to "new"), oldAndNewProps) +// } +// +// @Test fun `onPropsChanged is not called when props are equal`() { +// val oldAndNewProps = mutableListOf>() +// val workflow = PropsRenderingWorkflow { old, new, state -> +// oldAndNewProps += old to new +// return@PropsRenderingWorkflow state +// } +// val node = WorkflowNode(workflow.id(), workflow, "old", null, context) +// +// node.render(workflow, "old") +// +// assertTrue(oldAndNewProps.isEmpty()) +// } +// +// @Test fun `props are rendered`() { +// val workflow = PropsRenderingWorkflow { old, new, _ -> +// "$old->$new" +// } +// val node = WorkflowNode(workflow.id(), workflow, "foo", null, context) +// +// val rendering = node.render(workflow, "foo2") +// +// assertEquals( +// """ +// props:foo2 +// state:foo->foo2 +// """.trimIndent(), +// rendering +// ) +// +// val rendering2 = node.render(workflow, "foo3") +// +// assertEquals( +// """ +// props:foo3 +// state:foo2->foo3 +// """.trimIndent(), +// rendering2 +// ) +// } +// +// @Test fun `accepts event`() { +// val workflow = object : StringEventWorkflow() { +// override fun initialState( +// props: String, +// snapshot: Snapshot? +// ): String { +// assertNull(snapshot) +// return props +// } +// +// @Composable +// override fun render( +// renderProps: String, +// renderState: String, +// context: RenderContext +// ): (String) -> Unit { +// return context.eventHandler { event -> setOutput(event) } +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow, "", null, context, +// emitOutputToParent = { WorkflowOutput("tick:$it") } +// ) +// node.render(workflow, "")("event") +// +// val result = runBlocking { +// withTimeout(10) { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// } +// assertEquals("tick:event", result?.value) +// } +// +// @Test fun `accepts events sent to stale renderings`() { +// val workflow = object : StringEventWorkflow() { +// override fun initialState( +// props: String, +// snapshot: Snapshot? +// ): String { +// assertNull(snapshot) +// return props +// } +// +// @Composable +// override fun render( +// renderProps: String, +// renderState: String, +// context: RenderContext +// ): (String) -> Unit { +// return context.eventHandler { event -> setOutput(event) } +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow, "", null, context, +// emitOutputToParent = { WorkflowOutput("tick:$it") } +// ) +// val sink = node.render(workflow, "") +// +// sink("event") +// sink("event2") +// +// val result = runBlocking { +// withTimeout(10) { +// List(2) { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// } +// } +// assertEquals(listOf("tick:event", "tick:event2"), result.map { it?.value }) +// } +// +// @Test fun `send allows subsequent events on same rendering`() { +// lateinit var sink: Sink> +// val workflow = object : StringWorkflow() { +// override fun initialState( +// props: String, +// snapshot: Snapshot? +// ): String { +// assertNull(snapshot) +// return props +// } +// +// @Composable +// override fun render( +// renderProps: String, +// renderState: String, +// context: RenderContext +// ): String { +// sink = context.actionSink +// return "" +// } +// } +// val node = WorkflowNode(workflow.id(), workflow, "", null, context) +// +// node.render(workflow, "") +// sink.send(action { setOutput("event") }) +// +// // Should not throw. +// sink.send(action { setOutput("event2") }) +// } +// +// @Test fun `sideEffect is not started until after render completes`() { +// var started = false +// val workflow = Workflow.stateless { +// runningSideEffect("key") { +// started = true +// } +// assertFalse(started) +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// +// runBlocking { +// node.render(workflow.asStatefulWorkflow(), Unit) +// assertTrue(started) +// } +// } +// +// @Test fun `sideEffect coroutine is named`() { +// var contextFromWorker: CoroutineContext? = null +// val workflow = Workflow.stateless { +// runningSideEffect("the key") { +// contextFromWorker = coroutineContext +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// +// node.render(workflow.asStatefulWorkflow(), Unit) +// assertEquals(WorkflowNodeId(workflow).toString(), node.coroutineContext[CoroutineName]!!.name) +// assertEquals( +// "sideEffect[the key] for ${WorkflowNodeId(workflow)}", +// contextFromWorker!![CoroutineName]!!.name +// ) +// } +// +// @Test fun `sideEffect can send to actionSink`() { +// val workflow = Workflow.stateless { +// runningSideEffect("key") { +// actionSink.send(action { setOutput("result") }) +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// node.render(workflow.asStatefulWorkflow(), Unit) +// +// val result = runBlocking { +// // Result should be available instantly, any delay at all indicates something is broken. +// withTimeout(1) { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// } +// +// assertEquals("result", result?.value) +// } +// +// @Test fun `sideEffect is cancelled when stops being ran`() { +// val isRunning = MutableStateFlow(true) +// var cancellationException: Throwable? = null +// val workflow = Workflow.stateless { props -> +// if (props) { +// runningSideEffect("key") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> cancellationException = cause } +// } +// } +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = true, +// snapshot = null, baseContext = context +// ) +// +// runBlocking { +// node.render(workflow.asStatefulWorkflow(), true) +// assertNull(cancellationException) +// +// // Stop running the side effect. +// isRunning.value = false +// node.render(workflow.asStatefulWorkflow(), false) +// +// assertTrue(cancellationException is CancellationException) +// } +// } +// +// @Test fun `sideEffect is cancelled when workflow is torn down`() { +// var cancellationException: Throwable? = null +// val workflow = Workflow.stateless { +// runningSideEffect("key") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cause -> cancellationException = cause } +// } +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// +// runBlocking { +// node.render(workflow.asStatefulWorkflow(), Unit) +// assertNull(cancellationException) +// +// node.cancel() +// +// assertTrue(cancellationException is CancellationException) +// } +// } +// +// @Test fun `sideEffect with matching key lives across render passes`() { +// var renderPasses = 0 +// var cancelled = false +// val workflow = Workflow.stateless { +// renderPasses++ +// runningSideEffect("") { +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { cancelled = true } +// } +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, +// snapshot = null, baseContext = context +// ) +// +// runBlocking { +// node.render(workflow.asStatefulWorkflow(), 0) +// assertFalse(cancelled) +// assertEquals(1, renderPasses) +// +// node.render(workflow.asStatefulWorkflow(), 1) +// assertFalse(cancelled) +// assertEquals(2, renderPasses) +// } +// } +// +// @Test fun `sideEffect isn't restarted on next render pass after finishing`() { +// val seenProps = mutableListOf() +// var renderPasses = 0 +// val workflow = Workflow.stateless { props -> +// renderPasses++ +// runningSideEffect("") { +// seenProps += props +// } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = 0, +// snapshot = null, baseContext = context +// ) +// +// runBlocking { +// node.render(workflow.asStatefulWorkflow(), 0) +// assertEquals(listOf(0), seenProps) +// assertEquals(1, renderPasses) +// +// node.render(workflow.asStatefulWorkflow(), 1) +// assertEquals(listOf(0), seenProps) +// assertEquals(2, renderPasses) +// } +// } +// +// @Test fun `multiple sideEffects with same key throws`() { +// val workflow = Workflow.stateless { +// runningSideEffect("same") { fail() } +// runningSideEffect("same") { fail() } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// +// val error = assertFailsWith { +// node.render(workflow.asStatefulWorkflow(), Unit) +// } +// assertEquals("Expected side effect keys to be unique: \"same\"", error.message) +// } +// +// @Test fun `staggered sideEffects`() { +// val events1 = mutableListOf() +// val events2 = mutableListOf() +// val events3 = mutableListOf() +// fun recordingSideEffect(events: MutableList): suspend CoroutineScope.() -> Unit = { +// events += "started" +// suspendCancellableCoroutine { continuation -> +// continuation.invokeOnCancellation { events += "cancelled" } +// } +// } +// +// val workflow = Workflow.stateless { props -> +// if (props in 0..2) runningSideEffect("one", recordingSideEffect(events1)) +// if (props == 1) runningSideEffect("two", recordingSideEffect(events2)) +// if (props == 2) runningSideEffect("three", recordingSideEffect(events3)) +// } +// .asStatefulWorkflow() +// val node = WorkflowNode( +// workflow.id(), workflow, initialProps = 0, snapshot = null, +// baseContext = context +// ) +// +// node.render(workflow, 0) +// assertEquals(listOf("started"), events1) +// assertEquals(emptyList(), events2) +// assertEquals(emptyList(), events3) +// +// node.render(workflow, 1) +// assertEquals(listOf("started"), events1) +// assertEquals(listOf("started"), events2) +// assertEquals(emptyList(), events3) +// +// node.render(workflow, 2) +// assertEquals(listOf("started"), events1) +// assertEquals(listOf("started", "cancelled"), events2) +// assertEquals(listOf("started"), events3) +// +// node.render(workflow, 3) +// assertEquals(listOf("started", "cancelled"), events1) +// assertEquals(listOf("started", "cancelled"), events2) +// assertEquals(listOf("started", "cancelled"), events3) +// } +// +// @Test fun `multiple sideEffects started in same pass are both launched`() { +// var started1 = false +// var started2 = false +// val workflow = Workflow.stateless { +// runningSideEffect("one") { started1 = true } +// runningSideEffect("two") { started2 = true } +// } +// val node = WorkflowNode( +// workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, +// snapshot = null, baseContext = context +// ) +// +// assertFalse(started1) +// assertFalse(started2) +// node.render(workflow.asStatefulWorkflow(), Unit) +// assertTrue(started1) +// assertTrue(started2) +// } +// +// @Test fun `snapshots non-empty without children`() { +// val workflow = Workflow.stateful( +// initialState = { props, snapshot -> +// snapshot?.bytes?.parse { +// it.readUtf8WithLength() +// .removePrefix("state:") +// } ?: props +// }, +// render = { _, state -> state }, +// snapshot = { state -> +// Snapshot.write { +// it.writeUtf8WithLength("state:$state") +// } +// } +// ) +// val originalNode = WorkflowNode( +// workflow.id(), +// workflow, +// initialProps = "initial props", +// snapshot = null, +// baseContext = Unconfined +// ) +// +// assertEquals("initial props", originalNode.render(workflow, "foo")) +// val snapshot = originalNode.snapshot(workflow) +// assertNotEquals(0, snapshot.toByteString().size) +// +// val restoredNode = WorkflowNode( +// workflow.id(), +// workflow, +// // These props should be ignored, since snapshot is non-null. +// initialProps = "new props", +// snapshot = snapshot, +// baseContext = Unconfined +// ) +// assertEquals("initial props", restoredNode.render(workflow, "foo")) +// } +// +// @Test fun `snapshots empty without children`() { +// val workflow = Workflow.stateful( +// initialState = { props, snapshot -> snapshot?.bytes?.utf8() ?: props }, +// render = { _, state -> state }, +// snapshot = { Snapshot.of("restored") } +// ) +// val originalNode = WorkflowNode( +// workflow.id(), +// workflow, +// initialProps = "initial props", +// snapshot = null, +// baseContext = Unconfined +// ) +// +// assertEquals("initial props", originalNode.render(workflow, "foo")) +// val snapshot = originalNode.snapshot(workflow) +// assertNotEquals(0, snapshot.toByteString().size) +// +// val restoredNode = WorkflowNode( +// workflow.id(), +// workflow, +// // These props should be ignored, since snapshot is non-null. +// initialProps = "new props", +// snapshot = snapshot, +// baseContext = Unconfined +// ) +// assertEquals("restored", restoredNode.render(workflow, "foo")) +// } +// +// @Test fun `snapshots non-empty with children`() { +// var restoredChildState: String? = null +// var restoredParentState: String? = null +// val childWorkflow = Workflow.stateful( +// initialState = { props, snapshot -> +// snapshot?.bytes?.parse { +// it.readUtf8WithLength() +// .removePrefix("child state:") +// .also { state -> restoredChildState = state } +// } ?: props +// }, +// render = { _, state -> state }, +// snapshot = { state -> +// Snapshot.write { +// it.writeUtf8WithLength("child state:$state") +// } +// } +// ) +// val parentWorkflow = Workflow.stateful( +// initialState = { props, snapshot -> +// snapshot?.bytes?.parse { +// it.readUtf8WithLength() +// .removePrefix("parent state:") +// .also { state -> restoredParentState = state } +// } ?: props +// }, +// render = { _, state -> "$state|" + renderChild(childWorkflow, "child props") }, +// snapshot = { state -> +// Snapshot.write { +// it.writeUtf8WithLength("parent state:$state") +// } +// } +// ) +// +// val originalNode = WorkflowNode( +// parentWorkflow.id(), +// parentWorkflow, +// initialProps = "initial props", +// snapshot = null, +// baseContext = Unconfined +// ) +// +// assertEquals("initial props|child props", originalNode.render(parentWorkflow, "foo")) +// val snapshot = originalNode.snapshot(parentWorkflow) +// assertNotEquals(0, snapshot.toByteString().size) +// +// val restoredNode = WorkflowNode( +// parentWorkflow.id(), +// parentWorkflow, +// // These props should be ignored, since snapshot is non-null. +// initialProps = "new props", +// snapshot = snapshot, +// baseContext = Unconfined +// ) +// assertEquals("initial props|child props", restoredNode.render(parentWorkflow, "foo")) +// assertEquals("child props", restoredChildState) +// assertEquals("initial props", restoredParentState) +// } +// +// @Test fun `snapshot counts`() { +// var snapshotCalls = 0 +// var restoreCalls = 0 +// // Track the number of times the snapshot is actually serialized, not snapshotState is called. +// var snapshotWrites = 0 +// val workflow = Workflow.stateful( +// initialState = { snapshot -> if (snapshot != null) restoreCalls++ }, +// render = { }, +// snapshot = { +// snapshotCalls++ +// Snapshot.write { +// snapshotWrites++ +// // Snapshot will be discarded on restoration if it's empty, so we need to write +// // something here so we actually get a non-null snapshot in restore. +// it.writeUtf8("dummy value") +// } +// } +// ) +// val node = WorkflowNode(workflow.id(), workflow, Unit, null, Unconfined) +// +// assertEquals(0, snapshotCalls) +// assertEquals(0, snapshotWrites) +// assertEquals(0, restoreCalls) +// +// val snapshot = node.snapshot(workflow) +// +// assertEquals(1, snapshotCalls) +// assertEquals(0, snapshotWrites) +// assertEquals(0, restoreCalls) +// +// snapshot.toByteString() +// +// assertEquals(1, snapshotCalls) +// assertEquals(1, snapshotWrites) +// assertEquals(0, restoreCalls) +// +// WorkflowNode(workflow.id(), workflow, Unit, snapshot, Unconfined) +// +// assertEquals(1, snapshotCalls) +// assertEquals(1, snapshotWrites) +// assertEquals(1, restoreCalls) +// } +// +// @Test fun `restore gets props`() { +// val workflow = Workflow.stateful( +// initialState = { props, snapshot -> +// snapshot?.bytes?.parse { +// // Tags the restored state with the props so we can check it. +// val deserialized = it.readUtf8WithLength() +// return@parse "props:$props|state:$deserialized" +// } ?: props +// }, +// render = { _, state -> state }, +// snapshot = { state -> Snapshot.write { it.writeUtf8WithLength(state) } } +// ) +// val originalNode = WorkflowNode( +// workflow.id(), +// workflow, +// initialProps = "initial props", +// snapshot = null, +// baseContext = Unconfined +// ) +// +// assertEquals("initial props", originalNode.render(workflow, "foo")) +// val snapshot = originalNode.snapshot(workflow) +// assertNotEquals(0, snapshot.toByteString().size) +// +// val restoredNode = WorkflowNode( +// workflow.id(), +// workflow, +// initialProps = "new props", +// snapshot = snapshot, +// baseContext = Unconfined +// ) +// assertEquals("props:new props|state:initial props", restoredNode.render(workflow, "foo")) +// } +// +// @Test fun `toString() formats as WorkflowInstance without parent`() { +// val workflow = Workflow.rendering(Unit) +// val node = WorkflowNode( +// id = workflow.id(key = "foo"), +// workflow = workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// parent = null +// ) +// +// assertEquals( +// "WorkflowInstance(identifier=${workflow.identifier}, renderKey=foo, " + +// "instanceId=0, parent=null)", +// node.toString() +// ) +// } +// +// @Test fun `toString() formats as WorkflowInstance with parent`() { +// val workflow = Workflow.rendering(Unit) +// val node = WorkflowNode( +// id = workflow.id(key = "foo"), +// workflow = workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// parent = TestSession(42) +// ) +// +// assertEquals( +// "WorkflowInstance(identifier=${workflow.identifier}, renderKey=foo, " + +// "instanceId=0, parent=WorkflowInstance(…))", +// node.toString() +// ) +// } +// +// @Test fun `interceptor handles scope start and cancellation`() { +// lateinit var interceptedScope: CoroutineScope +// lateinit var interceptedSession: WorkflowSession +// lateinit var cancellationException: Throwable +// val interceptor = object : WorkflowInterceptor { +// override fun onSessionStarted( +// workflowScope: CoroutineScope, +// session: WorkflowSession +// ) { +// interceptedScope = workflowScope +// interceptedSession = session +// workflowScope.coroutineContext[Job]!!.invokeOnCompletion { +// cancellationException = it!! +// } +// } +// } +// val workflow = Workflow.rendering(Unit) +// val node = WorkflowNode( +// id = workflow.id(key = "foo"), +// workflow = workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// interceptor = interceptor, +// baseContext = Unconfined, +// parent = TestSession(42) +// ) +// +// assertSame(node.coroutineContext, interceptedScope.coroutineContext) +// assertEquals(workflow.identifier, interceptedSession.identifier) +// assertEquals(0, interceptedSession.sessionId) +// assertEquals("foo", interceptedSession.renderKey) +// assertEquals(42, interceptedSession.parent!!.sessionId) +// +// val cause = CancellationException("stop") +// node.cancel(cause) +// assertSame(cause, cancellationException) +// } +// +// @Test fun `interceptor handles initialState()`() { +// lateinit var interceptedProps: String +// lateinit var interceptedSnapshot: Snapshot +// lateinit var interceptedState: String +// lateinit var interceptedSession: WorkflowSession +// val interceptor = object : WorkflowInterceptor { +// override fun onInitialState( +// props: P, +// snapshot: Snapshot?, +// proceed: (P, Snapshot?) -> S, +// session: WorkflowSession +// ): S { +// interceptedProps = props as String +// interceptedSnapshot = snapshot!! +// interceptedSession = session +// return proceed(props, snapshot) +// .also { interceptedState = it as String } +// } +// } +// val workflow = Workflow.stateful( +// initialState = { props -> "state($props)" }, +// render = { _, _ -> fail() } +// ) +// WorkflowNode( +// id = workflow.id(key = "foo"), +// workflow = workflow.asStatefulWorkflow(), +// initialProps = "props", +// snapshot = TreeSnapshot.forRootOnly(Snapshot.of("snapshot")), +// interceptor = interceptor, +// baseContext = Unconfined, +// parent = TestSession(42) +// ) +// +// assertEquals("props", interceptedProps) +// assertEquals(Snapshot.of("snapshot"), interceptedSnapshot) +// assertEquals("state(props)", interceptedState) +// assertEquals(workflow.identifier, interceptedSession.identifier) +// assertEquals(0, interceptedSession.sessionId) +// assertEquals("foo", interceptedSession.renderKey) +// assertEquals(42, interceptedSession.parent!!.sessionId) +// } +// +// @Test fun `interceptor handles onPropsChanged()`() { +// lateinit var interceptedOld: String +// lateinit var interceptedNew: String +// lateinit var interceptedState: String +// lateinit var interceptedReturnState: String +// lateinit var interceptedSession: WorkflowSession +// val interceptor = object : WorkflowInterceptor { +// override fun onPropsChanged( +// old: P, +// new: P, +// state: S, +// proceed: (P, P, S) -> S, +// session: WorkflowSession +// ): S { +// interceptedOld = old as String +// interceptedNew = new as String +// interceptedState = state as String +// interceptedSession = session +// return proceed(old, new, state) +// .also { interceptedReturnState = it as String } +// } +// } +// val workflow = Workflow.stateful( +// initialState = { "initialState" }, +// onPropsChanged = { old, new, state -> "onPropsChanged($old, $new, $state)" }, +// render = { _, state -> state } +// ) +// val node = WorkflowNode( +// id = workflow.id(key = "foo"), +// workflow = workflow.asStatefulWorkflow(), +// initialProps = "old", +// snapshot = null, +// interceptor = interceptor, +// baseContext = Unconfined, +// parent = TestSession(42) +// ) +// val rendering = node.render(workflow, "new") +// +// assertEquals("old", interceptedOld) +// assertEquals("new", interceptedNew) +// assertEquals("initialState", interceptedState) +// assertEquals("onPropsChanged(old, new, initialState)", interceptedReturnState) +// assertEquals("onPropsChanged(old, new, initialState)", rendering) +// assertEquals(workflow.identifier, interceptedSession.identifier) +// assertEquals(0, interceptedSession.sessionId) +// assertEquals("foo", interceptedSession.renderKey) +// assertEquals(42, interceptedSession.parent!!.sessionId) +// } +// +// @Test fun `interceptor handles render()`() { +// // lateinit var interceptedProps: String +// // lateinit var interceptedState: String +// // lateinit var interceptedRendering: String +// // lateinit var interceptedSession: WorkflowSession +// // val interceptor = object : WorkflowInterceptor { +// // override fun onRender( +// // renderProps: P, +// // renderState: S, +// // context: BaseRenderContext, +// // proceed: (P, S, RenderContextInterceptor?) -> R, +// // session: WorkflowSession +// // ): R { +// // interceptedProps = renderProps as String +// // interceptedState = renderState as String +// // interceptedSession = session +// // return proceed(renderProps, renderState, null) +// // .also { interceptedRendering = it as String } +// // } +// // } +// // val workflow = Workflow.stateful( +// // initialState = { "state" }, +// // render = { props, state -> "render($props, $state)" } +// // ) +// // val node = WorkflowNode( +// // id = workflow.id(key = "foo"), +// // workflow = workflow.asStatefulWorkflow(), +// // initialProps = "props", +// // snapshot = null, +// // interceptor = interceptor, +// // baseContext = Unconfined, +// // parent = TestSession(42) +// // ) +// // val rendering = node.render(workflow, "props") +// // +// // assertEquals("props", interceptedProps) +// // assertEquals("state", interceptedState) +// // assertEquals("render(props, state)", interceptedRendering) +// // assertEquals("render(props, state)", rendering) +// // assertEquals(workflow.identifier, interceptedSession.identifier) +// // assertEquals(0, interceptedSession.sessionId) +// // assertEquals("foo", interceptedSession.renderKey) +// // assertEquals(42, interceptedSession.parent!!.sessionId) +// } +// +// @Test fun `interceptor handles snapshotState()`() { +// // lateinit var interceptedState: String +// // var interceptedSnapshot: Snapshot? = null +// // lateinit var interceptedSession: WorkflowSession +// // val interceptor = object : WorkflowInterceptor { +// // override fun onSnapshotState( +// // state: S, +// // proceed: (S) -> Snapshot?, +// // session: WorkflowSession +// // ): Snapshot? { +// // interceptedState = state as String +// // interceptedSession = session +// // return proceed(state) +// // .also { interceptedSnapshot = it } +// // } +// // } +// // val workflow = Workflow.stateful( +// // initialState = { _, _ -> "state" }, +// // render = { _, state -> state }, +// // snapshot = { state -> Snapshot.of("snapshot($state)") } +// // ) +// // val node = WorkflowNode( +// // id = workflow.id(key = "foo"), +// // workflow = workflow.asStatefulWorkflow(), +// // initialProps = "old", +// // snapshot = null, +// // interceptor = interceptor, +// // baseContext = Unconfined, +// // parent = TestSession(42) +// // ) +// // val snapshot = node.snapshot(workflow) +// // +// // assertEquals("state", interceptedState) +// // assertEquals(Snapshot.of("snapshot(state)"), interceptedSnapshot) +// // assertEquals(Snapshot.of("snapshot(state)"), snapshot.workflowSnapshot) +// // assertEquals(workflow.identifier, interceptedSession.identifier) +// // assertEquals(0, interceptedSession.sessionId) +// // assertEquals("foo", interceptedSession.renderKey) +// // assertEquals(42, interceptedSession.parent!!.sessionId) +// } +// +// @Test fun `interceptor handles snapshotState() returning null`() { +// // lateinit var interceptedState: String +// // var interceptedSnapshot: Snapshot? = null +// // lateinit var interceptedSession: WorkflowSession +// // val interceptor = object : WorkflowInterceptor { +// // override fun onSnapshotState( +// // state: S, +// // proceed: (S) -> Snapshot?, +// // session: WorkflowSession +// // ): Snapshot? { +// // interceptedState = state as String +// // interceptedSession = session +// // return proceed(state) +// // .also { interceptedSnapshot = it } +// // } +// // } +// // val workflow = Workflow.stateful( +// // initialState = { _, _ -> "state" }, +// // render = { _, state -> state }, +// // snapshot = { null } +// // ) +// // val node = WorkflowNode( +// // id = workflow.id(key = "foo"), +// // workflow = workflow.asStatefulWorkflow(), +// // initialProps = "old", +// // snapshot = null, +// // interceptor = interceptor, +// // baseContext = Unconfined, +// // parent = TestSession(42) +// // ) +// // val snapshot = node.snapshot(workflow) +// // +// // assertEquals("state", interceptedState) +// // assertNull(interceptedSnapshot) +// // assertNull(snapshot.workflowSnapshot) +// // assertEquals(workflow.identifier, interceptedSession.identifier) +// // assertEquals(0, interceptedSession.sessionId) +// // assertEquals("foo", interceptedSession.renderKey) +// // assertEquals(42, interceptedSession.parent!!.sessionId) +// } +// +// @Test fun `interceptor is propagated to children`() { +// // val interceptor = object : WorkflowInterceptor { +// // @Suppress("UNCHECKED_CAST") +// // override fun onRender( +// // renderProps: P, +// // renderState: S, +// // context: BaseRenderContext, +// // proceed: (P, S, RenderContextInterceptor?) -> R, +// // session: WorkflowSession +// // ) = "[${proceed("[$renderProps]" as P, "[$renderState]" as S, null)}]" as R +// // } +// // val leafWorkflow = Workflow.stateful( +// // initialState = { props -> props }, +// // render = { props, state -> "leaf($props, $state)" } +// // ) +// // val rootWorkflow = Workflow.stateful( +// // initialState = { props -> props }, +// // render = { props, _ -> +// // "root(${renderChild(leafWorkflow, props)})" +// // } +// // ) +// // val node = WorkflowNode( +// // id = rootWorkflow.id(key = "foo"), +// // workflow = rootWorkflow.asStatefulWorkflow(), +// // initialProps = "props", +// // snapshot = null, +// // interceptor = interceptor, +// // baseContext = Unconfined, +// // parent = TestSession(42), +// // idCounter = IdCounter() +// // ) +// // val rendering = node.render(rootWorkflow.asStatefulWorkflow(), "props") +// // +// // assertEquals("[root([leaf([[props]], [[props]])])]", rendering) +// } +// +// @Test fun `eventSink send fails before render pass completed`() { +// // val workflow = Workflow.stateless { +// // val sink = eventHandler { _: String -> fail("Expected handler to fail.") } +// // sink("Foo") +// // } +// // val node = WorkflowNode( +// // workflow.id(), +// // workflow.asStatefulWorkflow(), +// // initialProps = Unit, +// // snapshot = null, +// // baseContext = Unconfined +// // ) +// // +// // val error = assertFailsWith { +// // node.render(workflow.asStatefulWorkflow(), Unit) +// // } +// // assertTrue( +// // error.message!!.startsWith( +// // "Expected sink to not be sent to until after the render pass. " + +// // "Received action: WorkflowAction(eventHandler)@" +// // ) +// // ) +// } +// +// @Test fun `send fails before render pass completed`() { +// // class TestAction : WorkflowAction() { +// // override fun Updater.apply() = fail("Expected sink send to fail.") +// // override fun toString(): String = "TestAction()" +// // } +// // +// // val workflow = Workflow.stateless { +// // actionSink.send(TestAction()) +// // } +// // val node = WorkflowNode( +// // workflow.id(), +// // workflow.asStatefulWorkflow(), +// // initialProps = Unit, +// // snapshot = null, +// // baseContext = Unconfined +// // ) +// // +// // val error = assertFailsWith { +// // node.render(workflow.asStatefulWorkflow(), Unit) +// // } +// // assertEquals( +// // "Expected sink to not be sent to until after the render pass. " + +// // "Received action: TestAction()", +// // error.message +// // ) +// } +// +// @Test fun `actionSink action changes state`() { +// val workflow = Workflow.stateful>>( +// initialState = { "initial" }, +// render = { _, renderState -> +// renderState to actionSink.contraMap { +// action { state = "$state->$it" } +// } +// } +// ) +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined +// ) +// val (_, sink) = node.render(workflow.asStatefulWorkflow(), Unit) +// +// sink.send("hello") +// +// runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// // val (state, _) = node.render(workflow.asStatefulWorkflow(), Unit) +// // assertEquals("initial->hello", state) +// } +// +// @Test fun `actionSink action emits output`() { +// val workflow = Workflow.stateless> { +// actionSink.contraMap { action { setOutput(it) } } +// } +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// emitOutputToParent = { WorkflowOutput("output:$it") } +// ) +// // val rendering = node.render(workflow.asStatefulWorkflow(), Unit) +// // +// // rendering.send("hello") +// +// val output = runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// assertEquals("output:hello", output?.value) +// } +// +// @Test fun `actionSink action allows null output`() { +// val workflow = Workflow.stateless> { +// actionSink.contraMap { action { setOutput(null) } } +// } +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// emitOutputToParent = { WorkflowOutput(it) } +// ) +// val rendering = node.render(workflow.asStatefulWorkflow(), Unit) +// +// rendering.send("hello") +// +// val output = runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// assertNull(output?.value) +// } +// +// @Test fun `child action changes state`() { +// val workflow = Workflow.stateful( +// initialState = { "initial" }, +// render = { _, renderState -> +// runningSideEffect("test") { +// actionSink.send(action { state = "$state->hello" }) +// } +// return@stateful renderState +// } +// ) +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined +// ) +// node.render(workflow.asStatefulWorkflow(), Unit) +// +// runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// val state = node.render(workflow.asStatefulWorkflow(), Unit) +// assertEquals("initial->hello", state) +// } +// +// @Test fun `child action emits output`() { +// val workflow = Workflow.stateless { +// runningSideEffect("test") { +// actionSink.send(action { setOutput("child:hello") }) +// } +// } +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// emitOutputToParent = { WorkflowOutput("output:$it") } +// ) +// node.render(workflow.asStatefulWorkflow(), Unit) +// +// val output = runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// assertEquals("output:child:hello", output?.value) +// } +// +// @Test fun `child action allows null output`() { +// val workflow = Workflow.stateless { +// runningSideEffect("test") { +// actionSink.send(action { setOutput(null) }) +// } +// } +// val node = WorkflowNode( +// workflow.id(), +// workflow.asStatefulWorkflow(), +// initialProps = Unit, +// snapshot = null, +// baseContext = Unconfined, +// emitOutputToParent = { WorkflowOutput(it) } +// ) +// node.render(workflow.asStatefulWorkflow(), Unit) +// +// val output = runBlocking { +// select { +// node.tick(this) +// } as WorkflowOutput? +// } +// +// assertNull(output?.value) +// } +// +// private class TestSession(override val sessionId: Long = 0) : WorkflowSession { +// override val identifier: WorkflowIdentifier = Workflow.rendering(Unit).identifier +// override val renderKey: String = "" +// override val parent: WorkflowSession? = null +// } +// } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt index 21c2d3a0b..819cfa986 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import app.cash.molecule.launchMolecule import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfig.Companion @@ -9,6 +10,7 @@ import com.squareup.workflow1.Worker import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.WorkflowRuntimeClock import com.squareup.workflow1.action import com.squareup.workflow1.runningWorker import com.squareup.workflow1.stateful @@ -19,7 +21,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -36,7 +40,7 @@ internal class WorkflowRunnerTest { ).asSequence() private fun setup() { - scope = TestScope() + scope = TestScope(StandardTestDispatcher() + WorkflowRuntimeClock(flowOf(Unit))) } private fun tearDown() { @@ -58,8 +62,11 @@ internal class WorkflowRunnerTest { MutableStateFlow(Unit), runtimeConfig ) - val rendering = runner.nextRendering().rendering - assertEquals("foo", rendering) + + scope.launchMolecule { + val rendering = runner.nextRendering().rendering + assertEquals("foo", rendering) + } } } @@ -76,8 +83,10 @@ internal class WorkflowRunnerTest { MutableStateFlow("foo"), runtimeConfig ) - val rendering = runner.nextRendering().rendering - assertEquals("foo", rendering) + scope.launchMolecule { + val rendering = runner.nextRendering().rendering + assertEquals("foo", rendering) + } } } @@ -95,7 +104,9 @@ internal class WorkflowRunnerTest { props, runtimeConfig ) - runner.nextRendering() + scope.launchMolecule { + runner.nextRendering() + } val outputDeferred = scope.async { runner.processActions() } @@ -124,18 +135,20 @@ internal class WorkflowRunnerTest { props.value = "changed" // Get the runner into the state where it's waiting for a props update. - val initialRendering = runner.nextRendering().rendering - assertEquals("initial", initialRendering) - val output = scope.async { runner.processActions() } - assertTrue(output.isActive) - - // Resume the dispatcher to start the coroutines and process the new props value. - scope.runCurrent() - - assertTrue(output.isCompleted) - assertNull(output.getCompleted()) - val rendering = runner.nextRendering().rendering - assertEquals("changed", rendering) + scope.launchMolecule { + val initialRendering = runner.nextRendering().rendering + assertEquals("initial", initialRendering) + val output = scope.async { runner.processActions() } + assertTrue(output.isActive) + + // Resume the dispatcher to start the coroutines and process the new props value. + scope.runCurrent() + + assertTrue(output.isCompleted) + assertNull(output.getCompleted()) + val rendering = runner.nextRendering().rendering + assertEquals("changed", rendering) + } } } @@ -161,14 +174,16 @@ internal class WorkflowRunnerTest { val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - val initialRendering = runner.nextRendering().rendering - assertEquals("initial", initialRendering) + scope.launchMolecule { + val initialRendering = runner.nextRendering().rendering + assertEquals("initial", initialRendering) - val output = runner.runTillNextOutput() - assertEquals("output: work", output?.value) + val output = runner.runTillNextOutput() + assertEquals("output: work", output?.value) - val updatedRendering = runner.nextRendering().rendering - assertEquals("state: work", updatedRendering) + val updatedRendering = runner.nextRendering().rendering + assertEquals("state: work", updatedRendering) + } } } @@ -194,21 +209,23 @@ internal class WorkflowRunnerTest { val props = MutableStateFlow("initial props") val runner = WorkflowRunner(workflow, props, runtimeConfig) props.value = "changed props" - val initialRendering = runner.nextRendering().rendering - assertEquals("initial props|initial state(initial props)", initialRendering) - - // The order in which props update and workflow update are processed is deterministic, based - // on the order they appear in the select block in processActions. - val firstOutput = runner.runTillNextOutput() - // First update will be props, so no output value. - assertNull(firstOutput) - val secondRendering = runner.nextRendering().rendering - assertEquals("changed props|initial state(initial props)", secondRendering) - - val secondOutput = runner.runTillNextOutput() - assertEquals("output: work", secondOutput?.value) - val thirdRendering = runner.nextRendering().rendering - assertEquals("changed props|state: work", thirdRendering) + scope.launchMolecule { + val initialRendering = runner.nextRendering().rendering + assertEquals("initial props|initial state(initial props)", initialRendering) + + // The order in which props update and workflow update are processed is deterministic, based + // on the order they appear in the select block in processActions. + val firstOutput = runner.runTillNextOutput() + // First update will be props, so no output value. + assertNull(firstOutput) + val secondRendering = runner.nextRendering().rendering + assertEquals("changed props|initial state(initial props)", secondRendering) + + val secondOutput = runner.runTillNextOutput() + assertEquals("output: work", secondOutput?.value) + val thirdRendering = runner.nextRendering().rendering + assertEquals("changed props|state: work", thirdRendering) + } } } @@ -221,7 +238,9 @@ internal class WorkflowRunnerTest { val workflow = Workflow.stateless {} val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() + scope.launchMolecule { + runner.nextRendering() + } val output = scope.async { runner.processActions() } scope.runCurrent() assertTrue(output.isActive) @@ -252,7 +271,9 @@ internal class WorkflowRunnerTest { } val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() + scope.launchMolecule { + runner.nextRendering() + } scope.runCurrent() assertNull(cancellationException) @@ -275,7 +296,9 @@ internal class WorkflowRunnerTest { val workflow = Workflow.stateless {} val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() + scope.launchMolecule { + runner.nextRendering() + } val output = scope.async { runner.processActions() } scope.runCurrent() assertTrue(output.isActive) @@ -306,7 +329,9 @@ internal class WorkflowRunnerTest { } val runner = WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() + scope.launchMolecule { + runner.nextRendering() + } val output = scope.async { runner.processActions() } scope.runCurrent() assertTrue(output.isActive) diff --git a/workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt b/workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt index 262a3f3a6..23a9b06b3 100644 --- a/workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt +++ b/workflow-runtime/src/iosMain/kotlin/com.squareup.workflow1.internal/SystemUtils.kt @@ -10,3 +10,5 @@ import platform.Foundation.timeIntervalSince1970 * within a few ms. */ actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() + +actual fun nanoTime(): Long = 0L //TODO diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt index 354e7ef9c..b41392a09 100644 --- a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt @@ -1,3 +1,5 @@ package com.squareup.workflow1.internal internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +internal actual fun nanoTime(): Long = System.nanoTime() diff --git a/workflow-ui/compose-tooling/build.gradle.kts b/workflow-ui/compose-tooling/build.gradle.kts index b79804999..3703c5cea 100644 --- a/workflow-ui/compose-tooling/build.gradle.kts +++ b/workflow-ui/compose-tooling/build.gradle.kts @@ -13,7 +13,7 @@ apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) android { buildFeatures.compose = true composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index 76b27c43e..cdcbe4e8b 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -13,7 +13,7 @@ apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) android { buildFeatures.compose = true composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt index cdff73fd7..846f3988f 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.ui.compose +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -379,6 +380,7 @@ internal class RenderAsStateTest { snapshot: Snapshot? ): String = snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "" + @Composable override fun render( renderProps: Unit, renderState: String,