Skip to content

Commit f21e91c

Browse files
Merge pull request #1329 from square/sedwards/expect-workflow
Add expectCovariantWorkflow for expecting via workflow classes that have generics
2 parents 6874ccb + 0ea94db commit f21e91c

File tree

5 files changed

+503
-24
lines changed

5 files changed

+503
-24
lines changed

workflow-core/src/jvmMain/kotlin/com/squareup/workflow1/WorkflowIdentifierEx.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import kotlin.reflect.KClass
1010
* This workflow must not be an [ImpostorWorkflow], or this property will throw an
1111
* [IllegalArgumentException].
1212
*/
13-
@OptIn(ExperimentalStdlibApi::class)
1413
@get:TestOnly
1514
public val KClass<out Workflow<*, *, *>>.workflowIdentifier: WorkflowIdentifier
1615
get() {

workflow-testing/api/workflow-testing.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public abstract interface class com/squareup/workflow1/testing/RenderTestResult
2525
}
2626

2727
public abstract class com/squareup/workflow1/testing/RenderTester {
28+
public static final field Companion Lcom/squareup/workflow1/testing/RenderTester$Companion;
29+
public static final field VERIFY_ALL_LEVELS I
2830
public fun <init> ()V
2931
public abstract fun expectRemember (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/testing/RenderTester;
3032
public static synthetic fun expectRemember$default (Lcom/squareup/workflow1/testing/RenderTester;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester;
@@ -51,6 +53,9 @@ public final class com/squareup/workflow1/testing/RenderTester$ChildWorkflowMatc
5153
public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderTester$ChildWorkflowMatch$NotMatched;
5254
}
5355

56+
public final class com/squareup/workflow1/testing/RenderTester$Companion {
57+
}
58+
5459
public final class com/squareup/workflow1/testing/RenderTester$RememberInvocation {
5560
public fun <init> (Ljava/lang/String;Lkotlin/reflect/KType;Ljava/util/List;)V
5661
public final fun getInputs ()Ljava/util/List;
@@ -68,6 +73,8 @@ public final class com/squareup/workflow1/testing/RenderTester$RenderChildInvoca
6873
}
6974

7075
public final class com/squareup/workflow1/testing/RenderTesterKt {
76+
public static final fun expectCovariantWorkflow (Lcom/squareup/workflow1/testing/RenderTester;Lkotlin/reflect/KClass;Lkotlin/reflect/KType;ILkotlin/reflect/KType;ILjava/lang/Object;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/testing/RenderTester;
77+
public static synthetic fun expectCovariantWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lkotlin/reflect/KClass;Lkotlin/reflect/KType;ILkotlin/reflect/KType;ILjava/lang/Object;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester;
7178
public static final fun expectRemember (Lcom/squareup/workflow1/testing/RenderTester;Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/testing/RenderTester;
7279
public static synthetic fun expectRemember$default (Lcom/squareup/workflow1/testing/RenderTester;Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester;
7380
public static final fun expectSideEffect (Lcom/squareup/workflow1/testing/RenderTester;Ljava/lang/String;)Lcom/squareup/workflow1/testing/RenderTester;

workflow-testing/src/main/java/com/squareup/workflow1/testing/RealRenderTester.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,14 +460,25 @@ internal fun createRenderChildInvocation(
460460
* Returns true iff this identifier's [WorkflowIdentifier.getRealIdentifierType] is the same type as
461461
* or a subtype of [expected]'s.
462462
*/
463-
internal fun WorkflowIdentifier.realTypeMatchesExpectation(
463+
internal fun WorkflowIdentifier.realTypeMatchesClassExpectation(
464464
expected: WorkflowIdentifier
465465
): Boolean {
466466
val expectedType = expected.realType
467467
val actualType = realType
468468
return actualType.matchesExpectation(expectedType)
469469
}
470470

471+
/**
472+
* Returns true iff this identifier's [WorkflowIdentifier.getRealIdentifierType] has the same
473+
* class (or is a subtype) of the [expectedKClass].
474+
*/
475+
internal fun WorkflowIdentifier.realTypeMatchesClassExpectation(
476+
expectedKClass: KClass<*>
477+
): Boolean {
478+
val actualType = realType
479+
return actualType.matchesClassExpectation(expectedKClass)
480+
}
481+
471482
internal fun WorkflowIdentifierType.matchesExpectation(expected: WorkflowIdentifierType): Boolean {
472483
return when {
473484
this is Snapshottable && expected is Snapshottable -> matchesSnapshottable(expected)
@@ -476,6 +487,18 @@ internal fun WorkflowIdentifierType.matchesExpectation(expected: WorkflowIdentif
476487
}
477488
}
478489

490+
internal fun WorkflowIdentifierType.matchesClassExpectation(expectedKClass: KClass<*>): Boolean {
491+
return when (this) {
492+
is Snapshottable -> kClass?.let { actualKClass ->
493+
expectedKClass.isSuperclassOf(actualKClass) || actualKClass.isJavaMockOf(expectedKClass)
494+
} == true
495+
is Unsnapshottable -> (kType.classifier as? KClass<*>)?.let { actualKClass ->
496+
expectedKClass.isSuperclassOf(actualKClass) || actualKClass.isJavaMockOf(expectedKClass)
497+
} == true
498+
else -> false
499+
}
500+
}
501+
479502
private fun Snapshottable.matchesSnapshottable(expected: Snapshottable): Boolean =
480503
kClass?.let { actualKClass ->
481504
expected.kClass?.let { expectedKClass ->

workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.squareup.workflow1.WorkflowOutput
1414
import com.squareup.workflow1.config.JvmTestRuntimeConfigTools
1515
import com.squareup.workflow1.identifier
1616
import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch
17+
import com.squareup.workflow1.testing.RenderTester.Companion
1718
import com.squareup.workflow1.workflowIdentifier
1819
import kotlinx.coroutines.CoroutineScope
1920
import kotlin.reflect.KClass
@@ -378,6 +379,10 @@ public abstract class RenderTester<PropsT, StateT, OutputT, RenderingT> {
378379
public val output: WorkflowOutput<*>? = null
379380
) : ChildWorkflowMatch()
380381
}
382+
383+
public companion object {
384+
public const val VERIFY_ALL_LEVELS: Int = -1
385+
}
381386
}
382387

383388
/**
@@ -389,6 +394,13 @@ public abstract class RenderTester<PropsT, StateT, OutputT, RenderingT> {
389394
* concrete class, your render tests can pass the class of the interface to this method instead of
390395
* the actual class that implements it.
391396
*
397+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
398+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
399+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
400+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
401+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
402+
* those types explicitly.
403+
*
392404
* ## Expecting impostor workflows
393405
*
394406
* If the workflow-under-test renders an
@@ -448,6 +460,13 @@ public inline fun <ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
448460
* concrete class, your render tests can pass the class of the interface to this method instead of
449461
* the actual class that implements it.
450462
*
463+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
464+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
465+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
466+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
467+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
468+
* those types explicitly.
469+
*
451470
* ## Expecting impostor workflows
452471
*
453472
* If the workflow-under-test renders an
@@ -509,7 +528,7 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
509528
"output=$output"
510529
}
511530
) { invocation ->
512-
if (invocation.workflow.identifier.realTypeMatchesExpectation(identifier) &&
531+
if (invocation.workflow.identifier.realTypeMatchesClassExpectation(identifier) &&
513532
invocation.renderKey == key
514533
) {
515534
assertProps(invocation.props)
@@ -528,6 +547,13 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
528547
* concrete class, your render tests can pass the class of the interface to this method instead of
529548
* the actual class that implements it.
530549
*
550+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
551+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
552+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
553+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
554+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
555+
* those types explicitly.
556+
*
531557
* ## Expecting impostor workflows
532558
*
533559
* If the workflow-under-test renders an
@@ -549,7 +575,8 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
549575
*
550576
* @param workflowType The [KClass] of the expected workflow. May also be any of the supertypes
551577
* of the expected workflow, e.g. if the workflow type is an interface and the workflow-under-test
552-
* injects a fake.
578+
* injects a fake. See note above about covariance with [PropsT] and [OutputT] and how these cannot
579+
* help with supertypes.
553580
*
554581
* @param rendering The rendering to return from
555582
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild] when this workflow is
@@ -589,6 +616,143 @@ public inline fun <ChildPropsT, ChildOutputT, ChildRenderingT, PropsT, StateT, O
589616
}
590617
)
591618

619+
/**
620+
* @see [expectWorkflow] for more on this expectation.
621+
*
622+
* This is a special use version for when the only reasonable [KClass] you have to verify against
623+
* is the definition of a workflow whose [OutputT] and [RenderingT] are supertypes of the [OutputT]
624+
* and [RenderingT] of the child workflow type you expect to be rendered.
625+
* In other words, the expected workflow is covariant with the class you have to pass to the
626+
* expectation. (There is a slight nuance here, in that if the [OutputT] is a supertype of the
627+
* expected child's [OutputT], then those workflow's are not actually covariant since [OutputT]
628+
* is an invariant generic type. This is not important for the use of this expectation, however.)
629+
*
630+
* The most common need for this is when you are using a generic factory to construct Workflow
631+
* instances that you then wish to expect in your test.
632+
*
633+
* In that case, use this expectation and provide the [KClass] of the Workflow type, along with the
634+
* [KType] of the [OutputT] and [RenderingT]. The [PropsT] can simply be verified for type safety
635+
* inside [assertProps] by casting the [Any?] into the expected [PropsT].
636+
*
637+
* Note that this implementation does not handle [ImpostorWorkflow][com.squareup.workflow1.ImpostorWorkflow]s
638+
* (for proxied identifiers) like the other versions do.
639+
*
640+
* @param childWorkflowClass The [KClass] of the expected workflow or one of its supertypes,
641+
* including covariant supertypes. E.g. if the workflow type is an interface and the
642+
* workflow-under-test injects a fake.
643+
*
644+
* @param childOutputType The [KType] of the [OutputT] of the expected child workflow.
645+
*
646+
* @param outputTypeVerificationLevel The number of 'levels' of generic arguments to verify in
647+
* the [OutputT], e.g., for `Wrapper<*>` and level 1 only `Wrapper` would be checked, whereas for
648+
* level 2, `Wrapper` and `*` (the star projection) would be checked against the
649+
* [RenderChildInvocation].
650+
*
651+
* @param childRenderingType The [KType] of the [RenderingT] of the expected child workflow.
652+
*
653+
* @param renderingTypeVerificationLevel The number of 'levels' of generic arguments to verify in
654+
* the [OutputT], e.g., for `Wrapper<*>` and level 1 only `Wrapper` would be checked, whereas for
655+
* level 2, `Wrapper` and `*` (the star projection) would be checked against the
656+
* [RenderChildInvocation].
657+
*
658+
* @param rendering The rendering to return from
659+
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild] when this workflow is
660+
* rendered.
661+
*
662+
* @param key The key passed to [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild]
663+
* when rendering this workflow.
664+
*
665+
* @param assertProps A function that performs assertions on the props passed to
666+
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild].
667+
*
668+
* @param output If non-null, [WorkflowOutput.value] will be "emitted" when this workflow is
669+
* rendered. The [WorkflowAction] used to handle this output can be verified using methods on
670+
* [RenderTestResult].
671+
*
672+
* @param description Optional string that will be used to describe this expectation in error
673+
* messages.
674+
*/
675+
public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
676+
RenderTester<PropsT, StateT, OutputT, RenderingT>.expectCovariantWorkflow(
677+
childWorkflowClass: KClass<*>,
678+
childOutputType: KType,
679+
outputTypeVerificationLevel: Int = RenderTester.VERIFY_ALL_LEVELS,
680+
childRenderingType: KType,
681+
renderingTypeVerificationLevel: Int = Companion.VERIFY_ALL_LEVELS,
682+
rendering: ChildRenderingT,
683+
output: WorkflowOutput<ChildOutputT>? = null,
684+
key: String = "",
685+
description: String = "",
686+
assertProps: (props: Any?) -> Unit = {}
687+
): RenderTester<PropsT, StateT, OutputT, RenderingT> = expectWorkflow(
688+
exactMatch = true,
689+
description = description.ifBlank {
690+
"workflow " +
691+
"workflowClass=$childWorkflowClass, " +
692+
"childOutputType=$childOutputType, " +
693+
"childRenderingType=$childRenderingType, " +
694+
"key=$key, " +
695+
"rendering=$rendering, " +
696+
"output=$output"
697+
}
698+
) { invocation ->
699+
// Recursive function to verify #n levels of types.
700+
fun verifyTypesToLevel(
701+
levels: Int,
702+
type1: KType,
703+
type2: KType
704+
): Boolean {
705+
if (levels < 1) return true
706+
if (levels == 1) {
707+
// We are at the last level of verification, ignore any further generic type arguments.
708+
return type1.classifier?.equals(type2.classifier) == true
709+
} else {
710+
if (type1.arguments.size != type2.arguments.size) return false
711+
var acc = true
712+
type1.arguments.forEachIndexed { index, kTypeProjection1 ->
713+
val kTypeProjection2 = type2.arguments[index]
714+
if (kTypeProjection1.type == null || kTypeProjection2.type == null) return false
715+
acc =
716+
acc && verifyTypesToLevel(levels - 1, kTypeProjection1.type!!, kTypeProjection2.type!!)
717+
}
718+
return acc
719+
}
720+
}
721+
722+
val childClassTypeMatches =
723+
invocation.workflow.identifier.realTypeMatchesClassExpectation(childWorkflowClass)
724+
val keyMatches = invocation.renderKey == key
725+
val outputTypeMatches = invocation.outputType.type?.equals(childOutputType) == true ||
726+
(
727+
(outputTypeVerificationLevel > 0 && invocation.outputType.type != null) &&
728+
verifyTypesToLevel(
729+
outputTypeVerificationLevel,
730+
invocation.outputType.type!!,
731+
childOutputType
732+
)
733+
)
734+
val renderingTypeMatchers = invocation.renderingType.type?.equals(childRenderingType) == true ||
735+
(
736+
(renderingTypeVerificationLevel > 0 && invocation.renderingType.type != null) &&
737+
verifyTypesToLevel(
738+
renderingTypeVerificationLevel,
739+
invocation.renderingType.type!!,
740+
childRenderingType
741+
)
742+
)
743+
744+
if (childClassTypeMatches &&
745+
keyMatches &&
746+
outputTypeMatches &&
747+
renderingTypeMatchers
748+
) {
749+
assertProps(invocation.props)
750+
ChildWorkflowMatch.Matched(rendering, output)
751+
} else {
752+
ChildWorkflowMatch.NotMatched
753+
}
754+
}
755+
592756
/**
593757
* Specifies that this render pass is expected to run a particular side effect.
594758
*

0 commit comments

Comments
 (0)