Skip to content

Commit d4e710c

Browse files
Add expectCovariantWorkflow for generic type specification
1 parent 6874ccb commit d4e710c

File tree

5 files changed

+470
-24
lines changed

5 files changed

+470
-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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public final class com/squareup/workflow1/testing/RenderTester$RenderChildInvoca
6868
}
6969

7070
public final class com/squareup/workflow1/testing/RenderTesterKt {
71+
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;
72+
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;
7173
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;
7274
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;
7375
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: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,13 @@ public abstract class RenderTester<PropsT, StateT, OutputT, RenderingT> {
389389
* concrete class, your render tests can pass the class of the interface to this method instead of
390390
* the actual class that implements it.
391391
*
392+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
393+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
394+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
395+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
396+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
397+
* those types explicitly.
398+
*
392399
* ## Expecting impostor workflows
393400
*
394401
* If the workflow-under-test renders an
@@ -448,6 +455,13 @@ public inline fun <ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
448455
* concrete class, your render tests can pass the class of the interface to this method instead of
449456
* the actual class that implements it.
450457
*
458+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
459+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
460+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
461+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
462+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
463+
* those types explicitly.
464+
*
451465
* ## Expecting impostor workflows
452466
*
453467
* If the workflow-under-test renders an
@@ -509,7 +523,7 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
509523
"output=$output"
510524
}
511525
) { invocation ->
512-
if (invocation.workflow.identifier.realTypeMatchesExpectation(identifier) &&
526+
if (invocation.workflow.identifier.realTypeMatchesClassExpectation(identifier) &&
513527
invocation.renderKey == key
514528
) {
515529
assertProps(invocation.props)
@@ -528,6 +542,13 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
528542
* concrete class, your render tests can pass the class of the interface to this method instead of
529543
* the actual class that implements it.
530544
*
545+
* Note that Workflow<Int, String, Int> is *not* a sub-type of Workflow<Int, Object, Int> because
546+
* it is not covariant for the [OutputT] generic (the same is true for [PropsT]). This means that
547+
* you cannot use the [WorkflowIdentifier] or [KClass] of a Workflow class whose [OutputT] or
548+
* [PropsT] are supertypes to the one you want to match. If this is the only reasonable class
549+
* definition you have access to, then consider using [expectCovariantWorkflow] and specifying
550+
* those types explicitly.
551+
*
531552
* ## Expecting impostor workflows
532553
*
533554
* If the workflow-under-test renders an
@@ -549,7 +570,8 @@ public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
549570
*
550571
* @param workflowType The [KClass] of the expected workflow. May also be any of the supertypes
551572
* of the expected workflow, e.g. if the workflow type is an interface and the workflow-under-test
552-
* injects a fake.
573+
* injects a fake. See note above about covariance with [PropsT] and [OutputT] and how these cannot
574+
* help with supertypes.
553575
*
554576
* @param rendering The rendering to return from
555577
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild] when this workflow is
@@ -589,6 +611,120 @@ public inline fun <ChildPropsT, ChildOutputT, ChildRenderingT, PropsT, StateT, O
589611
}
590612
)
591613

614+
/**
615+
* @see [expectWorkflow] for the full documentation of [expectWorkflow].
616+
*
617+
* This is a special use version for when the only reasonable [KClass] you have to verify against
618+
* is the definition of a workflow whose [OutputT] and [PropsT] are supertypes of the child you
619+
* expect to be rendered. This can happen in the case that you are using a generic factory to
620+
* construct child instances which may have more specific [PropsT] and [OutputT] types than the
621+
* range of what the factory can produce.
622+
*
623+
* Use this expectation then and provide the [KClass] of the Workflow type, along with the [KType]
624+
* of the [OutputT] and [RenderingT], the [PropsT] can simply be verified for type safety inside
625+
* [assertProps].
626+
*
627+
* Note that this implementation does not handle [ImpostorWorkflow][com.squareup.workflow1.ImpostorWorkflow]s
628+
* (for proxied identifiers) like the other versions do.
629+
*
630+
* @param childWorkflowClass The [KClass] of the expected workflow. May also be any of the supertypes
631+
* of the expected workflow, e.g. if the workflow type is an interface and the workflow-under-test
632+
* injects a fake.
633+
*
634+
* @param childOutputType The [KType] of the [OutputT] of the expected child workflow.
635+
*
636+
* @param outputTypeRecursiveVerification The number of 'levels' of generic arguments to verify in
637+
* the [OutputT], e.g., for Wrapper<*> and level 1 only Wrapper would be checked.
638+
*
639+
* @param childRenderingType The [KType] of the [RenderingT] of the expected child workflow.
640+
*
641+
* @param renderingTypeRecursiveVerification The number of 'levels' of generic arguments to verify
642+
* in the [RenderingT], e.g., for Wrapper<*> and level 1 only Wrapper would be checked.
643+
*
644+
* @param rendering The rendering to return from
645+
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild] when this workflow is
646+
* rendered.
647+
*
648+
* @param key The key passed to [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild]
649+
* when rendering this workflow.
650+
*
651+
* @param assertProps A function that performs assertions on the props passed to
652+
* [renderChild][com.squareup.workflow1.BaseRenderContext.renderChild].
653+
*
654+
* @param output If non-null, [WorkflowOutput.value] will be "emitted" when this workflow is
655+
* rendered. The [WorkflowAction] used to handle this output can be verified using methods on
656+
* [RenderTestResult].
657+
*
658+
* @param description Optional string that will be used to describe this expectation in error
659+
* messages.
660+
*/
661+
public fun <ChildOutputT, ChildRenderingT, PropsT, StateT, OutputT, RenderingT>
662+
RenderTester<PropsT, StateT, OutputT, RenderingT>.expectCovariantWorkflow(
663+
childWorkflowClass: KClass<*>,
664+
childOutputType: KType,
665+
outputTypeRecursiveVerification: Int = -1,
666+
childRenderingType: KType,
667+
renderingTypeRecursiveVerification: Int = -1,
668+
rendering: ChildRenderingT,
669+
output: WorkflowOutput<ChildOutputT>?,
670+
key: String = "",
671+
description: String = "",
672+
assertProps: (props: Any?) -> Unit = {}
673+
): RenderTester<PropsT, StateT, OutputT, RenderingT> = expectWorkflow(
674+
exactMatch = true,
675+
description = description.ifBlank {
676+
"workflow " +
677+
"workflowClass=$childWorkflowClass, " +
678+
"childOutputType=$childOutputType, " +
679+
"childRenderingType=$childRenderingType, " +
680+
"key=$key, " +
681+
"rendering=$rendering, " +
682+
"output=$output"
683+
}
684+
) { invocation ->
685+
fun verifyTypesToLevel(levels: Int, type1: KType, type2: KType): Boolean {
686+
if (levels < 1) return true
687+
if (levels == 1) {
688+
// ignore arguments
689+
return type1.classifier?.equals(type2.classifier) == true
690+
} else {
691+
if (type1.arguments.size != type2.arguments.size) return false
692+
var acc = true
693+
type1.arguments.forEachIndexed { index, kTypeProjection1 ->
694+
val kTypeProjection2 = type2.arguments[index]
695+
if (kTypeProjection1.type == null || kTypeProjection2.type == null) return false
696+
acc = acc && verifyTypesToLevel(levels - 1, kTypeProjection1.type!!, kTypeProjection2.type!!)
697+
}
698+
return acc
699+
}
700+
}
701+
702+
val childClassTypeMatches =
703+
invocation.workflow.identifier.realTypeMatchesClassExpectation(childWorkflowClass)
704+
val keyMatches = invocation.renderKey == key
705+
val outputTypeMatches = invocation.outputType.type?.equals(childOutputType) == true ||
706+
(
707+
(outputTypeRecursiveVerification > 0 && invocation.outputType.type != null) &&
708+
verifyTypesToLevel(outputTypeRecursiveVerification, invocation.outputType.type!!, childOutputType)
709+
)
710+
val renderingTypeMatchers = invocation.renderingType.type?.equals(childRenderingType) == true ||
711+
(
712+
(renderingTypeRecursiveVerification > 0 && invocation.renderingType.type != null) &&
713+
verifyTypesToLevel(renderingTypeRecursiveVerification, invocation.renderingType.type!!, childRenderingType)
714+
)
715+
716+
if (childClassTypeMatches &&
717+
keyMatches &&
718+
outputTypeMatches &&
719+
renderingTypeMatchers
720+
) {
721+
assertProps(invocation.props)
722+
ChildWorkflowMatch.Matched(rendering, output)
723+
} else {
724+
ChildWorkflowMatch.NotMatched
725+
}
726+
}
727+
592728
/**
593729
* Specifies that this render pass is expected to run a particular side effect.
594730
*

0 commit comments

Comments
 (0)