Skip to content

Commit 9336a2e

Browse files
WIP Sketching out ComposeWorkflow RenderTester API.
1 parent d151db8 commit 9336a2e

File tree

11 files changed

+564
-14
lines changed

11 files changed

+564
-14
lines changed

samples/hello-compose-workflow/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@ dependencies {
2323

2424
implementation(project(":workflow-ui:core-android"))
2525
implementation(project(":workflow-ui:core-common"))
26+
27+
testImplementation(libs.kotlin.test.jdk)
28+
testImplementation(project(":workflow-testing"))
2629
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.squareup.sample.hellocomposeworkflow
2+
3+
import androidx.compose.runtime.Composable
4+
import com.squareup.workflow1.Workflow
5+
import com.squareup.workflow1.WorkflowExperimentalApi
6+
import com.squareup.workflow1.compose.ComposeWorkflow
7+
import com.squareup.workflow1.compose.renderChild
8+
import com.squareup.workflow1.identifier
9+
import com.squareup.workflow1.testing.expectWorkflow
10+
import com.squareup.workflow1.testing.testRender
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
14+
@OptIn(WorkflowExperimentalApi::class)
15+
class HelloComposeWorkflowTest {
16+
17+
@Test fun foo() {
18+
val child1 = Workflow.compose<Unit, Nothing, String> { _, _ -> "child1" }
19+
val child2 = Workflow.compose<Unit, Nothing, String> { _, _ -> "child2" }
20+
val workflow = Workflow.compose<Unit, Nothing, String> { _, _ ->
21+
renderChild(child1) + renderChild(child2)
22+
}
23+
24+
workflow.testRender(Unit)
25+
.expectWorkflow(child1.identifier, "fakechild1")
26+
.expectWorkflow(child2.identifier, "fakechild2")
27+
.render { rendering ->
28+
assertEquals("fakechild1fakechild2", rendering)
29+
}
30+
}
31+
}
32+
33+
/**
34+
* Returns a stateless [Workflow] via the given [render] function.
35+
*
36+
* Note that while the returned workflow doesn't have any _internal_ state of its own, it may use
37+
* [props][PropsT] received from its parent, and it may render child workflows that do have
38+
* their own internal state.
39+
*/
40+
@WorkflowExperimentalApi
41+
inline fun <PropsT, OutputT, RenderingT> Workflow.Companion.compose(
42+
crossinline produceRendering: @Composable (
43+
props: PropsT,
44+
emitOutput: (OutputT) -> Unit
45+
) -> RenderingT
46+
): Workflow<PropsT, OutputT, RenderingT> =
47+
object : ComposeWorkflow<PropsT, OutputT, RenderingT>() {
48+
@Composable override fun produceRendering(
49+
props: PropsT,
50+
emitOutput: (OutputT) -> Unit
51+
): RenderingT = produceRendering(props, emitOutput)
52+
}

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowNodeAdapter.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import com.squareup.workflow1.compose.ComposeWorkflow
1515
import com.squareup.workflow1.internal.AbstractWorkflowNode
1616
import com.squareup.workflow1.internal.IdCounter
1717
import com.squareup.workflow1.internal.WorkflowNodeId
18-
import com.squareup.workflow1.internal.compose.runtime.SynchronizedMolecule
1918
import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule
2019
import kotlinx.coroutines.channels.Channel
2120
import kotlinx.coroutines.selects.SelectBuilder
@@ -53,7 +52,7 @@ internal class ComposeWorkflowNodeAdapter<PropsT, OutputT, RenderingT>(
5352
) {
5453

5554
private val recomposeChannel = Channel<Unit>(capacity = 1)
56-
private val molecule: SynchronizedMolecule<RenderingT> = launchSynchronizedMolecule(
55+
private val molecule = launchSynchronizedMolecule(
5756
onNeedsRecomposition = { recomposeChannel.trySend(Unit) }
5857
)
5958

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowState.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ import com.squareup.workflow1.compose.ComposeWorkflow
88
*
99
* If we need interceptors to be able to identify compose workflows, we can just make this public.
1010
*/
11-
internal object ComposeWorkflowState
11+
public object ComposeWorkflowState

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import androidx.compose.runtime.currentComposer
1515
*/
1616
@OptIn(InternalComposeApi::class)
1717
@Composable
18-
internal fun <T> withCompositionLocals(
18+
// TODO annotate internal, or pull out
19+
public fun <T> withCompositionLocals(
1920
vararg values: ProvidedValue<*>,
2021
content: @Composable () -> T,
2122
): T {

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import kotlin.concurrent.Volatile
2727
*
2828
* See [SynchronizedMolecule] for more information.
2929
*/
30-
internal fun <R> CoroutineScope.launchSynchronizedMolecule(
30+
// TODO annotate internal, or pull out
31+
public fun CoroutineScope.launchSynchronizedMolecule(
3132
onNeedsRecomposition: () -> Unit
32-
): SynchronizedMolecule<R> = RealSynchronizedMolecule(
33+
): SynchronizedMolecule = RealSynchronizedMolecule(
3334
scope = this,
3435
onNeedsRecomposition = onNeedsRecomposition,
3536
)
@@ -69,7 +70,8 @@ internal fun <R> CoroutineScope.launchSynchronizedMolecule(
6970
* advancing it any time the recomposer reports pending work but hasn't
7071
* requested a frame yet.
7172
*/
72-
internal interface SynchronizedMolecule<R> {
73+
// TODO annotate internal, or pull out
74+
public interface SynchronizedMolecule {
7375

7476
/**
7577
* Returns true if the last composable passed to [recomposeWithContent] needs to be recomposed
@@ -83,7 +85,7 @@ internal interface SynchronizedMolecule<R> {
8385
/**
8486
* Performs a recomposition with the given [content] and returns its result.
8587
*/
86-
fun recomposeWithContent(content: @Composable () -> R): R
88+
fun <R> recomposeWithContent(content: @Composable () -> R): R
8789

8890
/**
8991
* Stop observing composition state (calling `onNeedsRecomposition`). After calling this, it is
@@ -95,10 +97,10 @@ internal interface SynchronizedMolecule<R> {
9597
fun close()
9698
}
9799

98-
private class RealSynchronizedMolecule<R>(
100+
private class RealSynchronizedMolecule(
99101
private val scope: CoroutineScope,
100102
private val onNeedsRecomposition: () -> Unit,
101-
) : SynchronizedMolecule<R>, MonotonicFrameClock {
103+
) : SynchronizedMolecule, MonotonicFrameClock {
102104

103105
init {
104106
GlobalSnapshotManager.ensureStarted()
@@ -114,8 +116,8 @@ private class RealSynchronizedMolecule<R>(
114116
effectCoroutineContext = scope.coroutineContext
115117
)
116118
private val composition: Composition = Composition(UnitApplier, recomposer)
117-
private var content: (@Composable () -> R)? by mutableStateOf(null)
118-
private var lastResult: NullableInitBox<R> = NullableInitBox()
119+
private var content: (@Composable () -> Any?)? by mutableStateOf(null)
120+
private var lastResult: NullableInitBox<Any?> = NullableInitBox()
119121

120122
/** Used to synchronize access to [frameRequest]. */
121123
private val lock = Lock()
@@ -160,7 +162,7 @@ private class RealSynchronizedMolecule<R>(
160162
}
161163
}
162164

163-
override fun recomposeWithContent(content: @Composable () -> R): R {
165+
override fun <R> recomposeWithContent(content: @Composable () -> R): R {
164166
// Update content in a snapshot to ensure it is applied before we ask for a frame.
165167
Snapshot.withMutableSnapshot {
166168
this.content = content
@@ -186,7 +188,9 @@ private class RealSynchronizedMolecule<R>(
186188
// getOrThrow below does so.
187189
dispatcher.advanceUntilIdle()
188190
}
189-
return lastResult.getOrThrow()
191+
192+
@Suppress("UNCHECKED_CAST")
193+
return lastResult.getOrThrow() as R
190194
}
191195

192196
@OptIn(ExperimentalStdlibApi::class)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package com.squareup.workflow1.testing
2+
3+
import androidx.compose.runtime.Composable
4+
import com.squareup.workflow1.NullableInitBox
5+
import com.squareup.workflow1.RuntimeConfig
6+
import com.squareup.workflow1.Workflow
7+
import com.squareup.workflow1.WorkflowAction
8+
import com.squareup.workflow1.WorkflowExperimentalApi
9+
import com.squareup.workflow1.WorkflowIdentifier
10+
import com.squareup.workflow1.WorkflowOutput
11+
import com.squareup.workflow1.compose.ComposeWorkflow
12+
import com.squareup.workflow1.compose.LocalWorkflowComposableRenderer
13+
import com.squareup.workflow1.compose.WorkflowComposableRenderer
14+
import com.squareup.workflow1.identifier
15+
import com.squareup.workflow1.internal.compose.ComposeWorkflowState
16+
import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule
17+
import com.squareup.workflow1.internal.compose.withCompositionLocals
18+
import com.squareup.workflow1.testing.RealRenderTester.Expectation
19+
import com.squareup.workflow1.testing.RealRenderTester.Expectation.ExpectedWorkflow
20+
import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch.Matched
21+
import kotlinx.coroutines.CoroutineScope
22+
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.cancel
24+
25+
// TODO move this to RealComposeRenderTester
26+
@OptIn(WorkflowExperimentalApi::class)
27+
internal class ComposeRenderTester<PropsT, OutputT, RenderingT>(
28+
private val workflow: ComposeWorkflow<PropsT, OutputT, RenderingT>,
29+
private val props: PropsT,
30+
private val runtimeConfig: RuntimeConfig,
31+
) : RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT>(),
32+
WorkflowComposableRenderer,
33+
RenderTestResult<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
34+
35+
private data class OutputWithHandler<ChildOutputT>(
36+
val output: ChildOutputT,
37+
val handler: (ChildOutputT) -> Unit
38+
)
39+
40+
/**
41+
* List of [Expectation]s that are expected when the workflow is rendered. New expectations are
42+
* registered into this list. Once the render pass has started, expectations are moved from this
43+
* list to [consumedExpectations] as soon as they're matched.
44+
*/
45+
private val expectations: MutableList<ExpectedWorkflow> = mutableListOf()
46+
47+
/**
48+
* Empty until the render pass starts, then every time the workflow matches an expectation that
49+
* has `exactMatch` set to true, it is moved from [expectations] to this list.
50+
*/
51+
private val consumedExpectations: MutableList<Expectation<*>> = mutableListOf()
52+
53+
private var processedOutputHandler: OutputWithHandler<*>? = null
54+
55+
/**
56+
* Tracks the identifier/key pairs of all calls to [renderChild], so it can emulate the behavior
57+
* of the real runtime and throw if a workflow is rendered twice in the same pass.
58+
*/
59+
private val renderedChildren: MutableList<WorkflowIdentifier> = mutableListOf()
60+
61+
override fun expectWorkflow(
62+
description: String,
63+
exactMatch: Boolean,
64+
matcher: (RenderChildInvocation) -> ChildWorkflowMatch
65+
): RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> = apply {
66+
expectations += ExpectedWorkflow(matcher, exactMatch, description)
67+
}
68+
69+
override fun expectSideEffect(
70+
description: String,
71+
exactMatch: Boolean,
72+
matcher: (key: String) -> Boolean
73+
): RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
74+
throw AssertionError(
75+
"Expected ComposeWorkflow to have side effect $description, " +
76+
"but ComposeWorkflows use Compose effects."
77+
)
78+
}
79+
80+
override fun expectRemember(
81+
description: String,
82+
exactMatch: Boolean,
83+
matcher: (RememberInvocation) -> Boolean
84+
): RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
85+
throw AssertionError(
86+
"Cannot validate calls to Compose's remember {} function through RenderTester."
87+
)
88+
}
89+
90+
override fun requireExplicitWorkerExpectations():
91+
RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
92+
// Noop
93+
return this
94+
}
95+
96+
override fun requireExplicitSideEffectExpectations():
97+
RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
98+
// Noop
99+
return this
100+
}
101+
102+
override fun requireExplicitRememberExpectations():
103+
RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
104+
// Noop
105+
return this
106+
}
107+
108+
override fun render(
109+
block: (rendering: RenderingT) -> Unit
110+
): RenderTestResult<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
111+
val emitOutput: (OutputT) -> Unit = { output ->
112+
TODO()
113+
}
114+
115+
val scope = CoroutineScope(Dispatchers.Unconfined)
116+
try {
117+
val molecule = scope.launchSynchronizedMolecule(onNeedsRecomposition = {})
118+
val rendering = molecule.recomposeWithContent {
119+
withCompositionLocals(LocalWorkflowComposableRenderer provides this) {
120+
workflow.produceRendering(props, emitOutput)
121+
}
122+
}
123+
block(rendering)
124+
} finally {
125+
scope.cancel()
126+
}
127+
return this
128+
}
129+
130+
@Composable
131+
override fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
132+
childWorkflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
133+
props: ChildPropsT,
134+
onOutput: ((ChildOutputT) -> Unit)?
135+
): ChildRenderingT {
136+
val identifier = childWorkflow.identifier
137+
require(identifier !in renderedChildren) {
138+
"Expected keys to be unique for ${childWorkflow.identifier}"
139+
}
140+
renderedChildren += identifier
141+
142+
val description = buildString {
143+
append("child ")
144+
append(childWorkflow.identifier)
145+
// if (key.isNotEmpty()) {
146+
// append(" with key \"$key\"")
147+
// }
148+
}
149+
val invocation = createRenderChildInvocation(childWorkflow, props, renderKey = "")
150+
val matches = expectations.mapNotNull {
151+
val matchResult = it.matcher(invocation)
152+
if (matchResult is Matched) Pair(it, matchResult) else null
153+
}
154+
if (matches.isEmpty()) {
155+
throw AssertionError("Tried to render unexpected $description")
156+
}
157+
158+
val exactMatches = matches.filter { it.first.exactMatch }
159+
val (_, match) = when {
160+
exactMatches.size == 1 -> {
161+
exactMatches.single()
162+
.also { (expected, _) ->
163+
expectations -= expected
164+
consumedExpectations += expected
165+
}
166+
}
167+
168+
exactMatches.size > 1 -> {
169+
throw AssertionError(
170+
"Multiple expectations matched $description:\n" +
171+
exactMatches.joinToString(separator = "\n") { " ${it.first.describe()}" }
172+
)
173+
}
174+
// Inexact matches are not consumable.
175+
else -> matches.first()
176+
}
177+
178+
if (match.output != null) {
179+
check(processedOutputHandler == null) {
180+
"Expected only one output to be expected: $description expected to emit " +
181+
"${match.output.value} but ${emittedOutput?.debuggingName} was already processed."
182+
}
183+
processedOutputHandler = OutputWithHandler(match.output, onOutput)
184+
@Suppress("UNCHECKED_CAST")
185+
processedAction = handler(match.output.value as ChildOutputT)
186+
}
187+
188+
@Suppress("UNCHECKED_CAST")
189+
return match.childRendering as ChildRenderingT
190+
}
191+
192+
override fun verifyAction(
193+
block: (WorkflowAction<PropsT, ComposeWorkflowState, OutputT>) -> Unit
194+
): RenderTestResult<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
195+
TODO("Not yet implemented")
196+
}
197+
198+
override fun verifyActionResult(
199+
block: (newState: ComposeWorkflowState, appliedResult: WorkflowOutput<OutputT>?) -> Unit
200+
): RenderTestResult<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
201+
TODO("Not yet implemented")
202+
}
203+
204+
override fun testNextRender(): RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> =
205+
testNextRenderWithProps(props)
206+
207+
override fun testNextRenderWithProps(
208+
newProps: PropsT
209+
): RenderTester<PropsT, ComposeWorkflowState, OutputT, RenderingT> {
210+
TODO("Not yet implemented")
211+
}
212+
}

0 commit comments

Comments
 (0)