diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt index 5cce39e1c3c..ee9fac64057 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt @@ -68,6 +68,7 @@ import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.enum import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.next import io.kotest.property.arbitrary.string @@ -104,7 +105,7 @@ class DataConnectGrpcClientUnitTest { private val connectorConfig = Arb.dataConnect.connectorConfig().next(rs) private val requestId = Arb.dataConnect.requestId().next(rs) private val operationName = Arb.dataConnect.operationName().next(rs) - private val variables = Arb.proto.struct().next(rs) + private val variables = Arb.proto.struct().next(rs).struct private val callerSdkType = Arb.enum().next(rs) private val mockDataConnectAuth: DataConnectAuth = @@ -193,7 +194,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeQuery() should return data and errors`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val responseErrors = List(3) { GraphqlErrorInfo.random(RandomSource.default()) } coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } returns ExecuteQueryResponse.newBuilder() @@ -210,7 +211,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeMutation() should return data and errors`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val responseErrors = List(3) { GraphqlErrorInfo.random(RandomSource.default()) } coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } returns ExecuteMutationResponse.newBuilder() @@ -253,7 +254,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeQuery() should retry with a fresh auth token on UNAUTHENTICATED`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val forceRefresh = AtomicBoolean(false) coEvery { mockDataConnectAuth.forceRefresh() } answers { forceRefresh.set(true) } coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } answers @@ -284,7 +285,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeMutation() should retry with a fresh auth token on UNAUTHENTICATED`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val forceRefresh = AtomicBoolean(false) coEvery { mockDataConnectAuth.forceRefresh() } answers { forceRefresh.set(true) } coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } answers @@ -315,7 +316,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeQuery() should retry with a fresh AppCheck token on UNAUTHENTICATED`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val forceRefresh = AtomicBoolean(false) coEvery { mockDataConnectAppCheck.forceRefresh() } answers { forceRefresh.set(true) } coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } answers @@ -346,7 +347,7 @@ class DataConnectGrpcClientUnitTest { @Test fun `executeMutation() should retry with a fresh AppCheck token on UNAUTHENTICATED`() = runTest { - val responseData = Arb.proto.struct().next(rs) + val responseData = Arb.proto.struct().next(rs).struct val forceRefresh = AtomicBoolean(false) coEvery { mockDataConnectAppCheck.forceRefresh() } answers { forceRefresh.set(true) } coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } answers @@ -593,9 +594,9 @@ class DataConnectGrpcClientOperationResultUnitTest { fun `deserialize() with non-null data should treat DataConnectUntypedData specially`() = runTest { checkAll(propTestConfig, Arb.proto.struct(), Arb.dataConnect.operationErrors()) { data, errors -> - val operationResult = OperationResult(data, errors) + val operationResult = OperationResult(data.struct, errors) val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) - result.shouldHaveDataAndErrors(data, errors) + result.shouldHaveDataAndErrors(data.struct, errors) } } @@ -635,7 +636,7 @@ class DataConnectGrpcClientOperationResultUnitTest { runTest { checkAll( propTestConfig, - Arb.proto.struct(), + Arb.proto.struct().map { it.struct }, Arb.dataConnect.operationErrors(range = 1..10) ) { dataStruct, errors -> val operationResult = OperationResult(dataStruct, errors) @@ -697,7 +698,7 @@ class DataConnectGrpcClientOperationResultUnitTest { @Test fun `deserialize() should throw if decoding fails and error list is empty`() = runTest { - checkAll(propTestConfig, Arb.proto.struct()) { dataStruct -> + checkAll(propTestConfig, Arb.proto.struct().map { it.struct }) { dataStruct -> assume(!dataStruct.containsFields("foo")) val operationResult = OperationResult(dataStruct, emptyList()) val exception: DataConnectOperationException = diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index f67df834d81..1c5e33b5e32 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -90,7 +90,7 @@ internal fun DataConnectArb.operationErrorInfo( Arb.bind(message, path) { message0, path0 -> ErrorInfoImpl(message0, path0) } internal fun DataConnectArb.operationRawData(): Arb?> = - Arb.proto.struct().map { it.toMap() }.orNull(nullProbability = 0.33) + Arb.proto.struct().map { it.struct.toMap() }.orNull(nullProbability = 0.33) internal data class SampleOperationData(val value: String) @@ -112,7 +112,7 @@ internal fun DataConnectArb.operationFailureResponseImpl( } internal fun DataConnectArb.operationResult( - data: Arb = Arb.proto.struct().orNull(nullProbability = 0.2), + data: Arb = Arb.proto.struct().map { it.struct }.orNull(nullProbability = 0.2), errors: Arb> = operationErrors(), ) = Arb.bind(data, errors) { data0, errors0 -> DataConnectGrpcClient.OperationResult(data0, errors0) } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExts.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExts.kt new file mode 100644 index 00000000000..e96177bd1cd --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExts.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value + +fun Boolean.toValueProto(): Value = Value.newBuilder().setBoolValue(this).build() + +fun String.toValueProto(): Value = Value.newBuilder().setStringValue(this).build() + +fun Double.toValueProto(): Value = Value.newBuilder().setNumberValue(this).build() + +fun Struct.toValueProto(): Value = Value.newBuilder().setStructValue(this).build() + +fun ListValue.toValueProto(): Value = Value.newBuilder().setListValue(this).build() + +val Value.isStructValue: Boolean + get() = kindCase == Value.KindCase.STRUCT_VALUE + +val Value.isListValue: Boolean + get() = kindCase == Value.KindCase.LIST_VALUE diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValuePath.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValuePath.kt new file mode 100644 index 00000000000..f13b35c2a97 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValuePath.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import com.google.protobuf.Value +import java.util.Objects + +sealed interface ProtoValuePathComponent { + + class StructKey(val key: String) : ProtoValuePathComponent { + override fun equals(other: Any?) = other is StructKey && other.key == key + override fun hashCode() = Objects.hash(StructKey::class.java, key) + override fun toString() = "StructKey(\"$key\")" + } + + class ListIndex(val index: Int) : ProtoValuePathComponent { + override fun equals(other: Any?) = other is ListIndex && other.index == index + override fun hashCode() = Objects.hash(ListIndex::class.java, index) + override fun toString() = "ListIndex($index)" + } +} + +typealias ProtoValuePath = List + +data class ProtoValuePathPair(val path: ProtoValuePath, val value: Value) + +fun ProtoValuePath.withAppendedListIndex(index: Int): ProtoValuePath = + withAppendedComponent(ProtoValuePathComponent.ListIndex(index)) + +fun ProtoValuePath.withAppendedStructKey(key: String): ProtoValuePath = + withAppendedComponent(ProtoValuePathComponent.StructKey(key)) + +fun ProtoValuePath.withAppendedComponent(component: ProtoValuePathComponent): ProtoValuePath = + buildList { + addAll(this@withAppendedComponent) + add(component) + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/PropTestConfigExts.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/PropTestConfigExts.kt new file mode 100644 index 00000000000..544f18c7f4c --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/PropTestConfigExts.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil.property.arbitrary + +import io.kotest.common.ExperimentalKotest +import io.kotest.property.PropTestConfig + +fun PropTestConfig.withIterations(iterations: Int): PropTestConfig { + @OptIn(ExperimentalKotest::class) return copy(iterations = iterations) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index 4a3f89a7ba8..96c99e36630 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -22,6 +22,7 @@ import com.google.firebase.dataconnect.ConnectorConfig import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.DataConnectSettings import io.kotest.property.Arb +import io.kotest.property.RandomSource import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.arabic @@ -161,3 +162,14 @@ val Arb.Companion.dataConnect: DataConnectArb get() = DataConnectArb inline fun Arb.Companion.mock(): Arb = arbitrary { mockk(relaxed = true) } + +fun Arb.next(rs: RandomSource, edgeCaseProbability: Float): T { + require(edgeCaseProbability in 0.0f..1.0f) { + "invalid edgeCaseProbability: $edgeCaseProbability (must be between 0.0 and 1.0, inclusive)" + } + return if (rs.random.nextFloat() < edgeCaseProbability) { + edgecase(rs)!! + } else { + sample(rs).value + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/proto.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/proto.kt index 2b18558c48d..1a2d8e7aced 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/proto.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/proto.kt @@ -14,118 +14,612 @@ * limitations under the License. */ +@file:Suppress("UnusedReceiverParameter") + package com.google.firebase.dataconnect.testutil.property.arbitrary +import com.google.firebase.dataconnect.testutil.ProtoValuePath +import com.google.firebase.dataconnect.testutil.ProtoValuePathPair +import com.google.firebase.dataconnect.testutil.toValueProto +import com.google.firebase.dataconnect.testutil.withAppendedListIndex +import com.google.firebase.dataconnect.testutil.withAppendedStructKey import com.google.protobuf.ListValue import com.google.protobuf.NullValue import com.google.protobuf.Struct import com.google.protobuf.Value import io.kotest.property.Arb +import io.kotest.property.Exhaustive import io.kotest.property.RandomSource import io.kotest.property.Sample import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.boolean -import io.kotest.property.arbitrary.choose +import io.kotest.property.arbitrary.choice import io.kotest.property.arbitrary.double -import io.kotest.property.arbitrary.enum -import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.withEdgecases import io.kotest.property.asSample +import io.kotest.property.exhaustive.constant +import io.kotest.property.exhaustive.of +import kotlin.random.nextInt + +object ProtoArb { + + data class StructInfo( + val struct: Struct, + val depth: Int, + val descendants: List, + ) { + fun toValueProto(): Value = struct.toValueProto() + } + + data class ListValueInfo( + val listValue: ListValue, + val depth: Int, + val descendants: List, + ) { + fun toValueProto(): Value = listValue.toValueProto() + } +} + +val Arb.Companion.proto: ProtoArb + get() = ProtoArb -object Proto +object ProtoExhaustive + +val Exhaustive.Companion.proto: ProtoExhaustive + get() = ProtoExhaustive + +fun ProtoArb.valueOfKind(kindCase: Value.KindCase): Arb = + when (kindCase) { + Value.KindCase.KIND_NOT_SET -> kindNotSetValue() + Value.KindCase.NULL_VALUE -> nullValue() + Value.KindCase.NUMBER_VALUE -> numberValue() + Value.KindCase.STRING_VALUE -> stringValue() + Value.KindCase.BOOL_VALUE -> boolValue() + Value.KindCase.STRUCT_VALUE -> struct().map { it.toValueProto() } + Value.KindCase.LIST_VALUE -> listValue().map { it.toValueProto() } + } + +fun ProtoArb.value(exclude: Value.KindCase? = null): Arb { + val arbs = buildList { + if (exclude != Value.KindCase.KIND_NOT_SET) { + add(kindNotSetValue()) + } + if (exclude != Value.KindCase.NULL_VALUE) { + add(nullValue()) + } + if (exclude != Value.KindCase.NUMBER_VALUE) { + add(numberValue()) + } + if (exclude != Value.KindCase.STRING_VALUE) { + add(stringValue()) + } + if (exclude != Value.KindCase.BOOL_VALUE) { + add(boolValue()) + } + if (exclude != Value.KindCase.STRUCT_VALUE) { + add(struct().map { it.toValueProto() }) + } + if (exclude != Value.KindCase.LIST_VALUE) { + add(listValue().map { it.toValueProto() }) + } + } + return Arb.choice(arbs) +} -val Arb.Companion.proto: Proto - get() = Proto +fun ProtoExhaustive.nullValue(): Exhaustive = + Exhaustive.constant(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) -private enum class ValueType { - Boolean, - String, - Double, - List, - Map, - Null, +fun ProtoArb.nullValue(): Arb = arbitrary { + Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build() } -private class ProtoValueArb( - val valueType: Arb, - val boolean: Arb, - val string: Arb, - val number: Arb, - private val listFactory: ProtoValueArb.() -> Arb, - private val structFactory: ProtoValueArb.() -> Arb, -) : Arb() { - val list: Arb by lazy(LazyThreadSafetyMode.PUBLICATION) { listFactory(this) } - - val struct: Arb by lazy(LazyThreadSafetyMode.PUBLICATION) { structFactory(this) } - - override fun edgecase(rs: RandomSource) = null // no edge cases - - override fun sample(rs: RandomSource): Sample { - val builder = Value.newBuilder() - when (valueType.next(rs)) { - ValueType.Boolean -> builder.setBoolValue(boolean.next(rs)) - ValueType.String -> builder.setStringValue(string.next(rs)) - ValueType.Double -> builder.setNumberValue(number.next(rs)) - ValueType.List -> builder.setListValue(list.next(rs)) - ValueType.Map -> builder.setStructValue(struct.next(rs)) - ValueType.Null -> builder.setNullValue(NullValue.NULL_VALUE) - } - return builder.build().asSample() - } - - companion object { - fun newInstance(): ProtoValueArb { - val string: Arb = Arb.string(0..100, Codepoint.alphanumeric()) - val size: Arb = Arb.choose(1 to 3, 3 to 2, 5 to 1, 6 to 0) - - fun listFactory(value: ProtoValueArb): Arb = - ProtoListValueArb(size = size, value = value) - - fun structFactory(value: ProtoValueArb): Arb = - ProtoStructValueArb(key = string, size = size, value = value) - - return ProtoValueArb( - valueType = Arb.enum(), - boolean = Arb.boolean(), - string = string, - number = Arb.double(), - listFactory = ::listFactory, - structFactory = ::structFactory, +fun ProtoArb.numberValue( + number: Arb = Arb.double(), + filter: ((Double) -> Boolean)? = null, +): Arb = number.filter { filter === null || filter(it) }.map { it.toValueProto() } + +fun ProtoArb.boolValue( + boolean: Arb = Arb.boolean(), +): Arb = boolean.map { it.toValueProto() } + +fun ProtoExhaustive.boolValue(): Exhaustive = + Exhaustive.of(true.toValueProto(), false.toValueProto()) + +fun ProtoArb.stringValue( + string: Arb = Arb.dataConnect.string(), + filter: ((String) -> Boolean)? = null, +): Arb = string.filter { filter === null || filter(it) }.map { it.toValueProto() } + +fun ProtoArb.kindNotSetValue(): Arb = arbitrary { Value.newBuilder().build() } + +fun ProtoExhaustive.kindNotSetValue(): Exhaustive = + Exhaustive.constant(Value.newBuilder().build()) + +fun ProtoArb.scalarValue(exclude: Value.KindCase? = null): Arb { + val arbs = buildList { + if (exclude != Value.KindCase.NULL_VALUE) { + add(nullValue()) + } + if (exclude != Value.KindCase.NUMBER_VALUE) { + add(numberValue()) + } + if (exclude != Value.KindCase.BOOL_VALUE) { + add(boolValue()) + } + if (exclude != Value.KindCase.STRING_VALUE) { + add(stringValue()) + } + if (exclude != Value.KindCase.KIND_NOT_SET) { + add(kindNotSetValue()) + } + } + + return Arb.choice(arbs) +} + +fun ProtoArb.listValue( + size: IntRange = 0..10, + depth: IntRange = 1..3, + scalarValue: Arb = scalarValue(), +): Arb = + ListValueArb( + size = size, + depth = depth, + scalarValueArb = scalarValue, + ) + +fun ProtoArb.structKey(): Arb = Arb.string(1..10, Codepoint.alphanumeric()) + +fun ProtoArb.struct( + size: IntRange = 0..5, + depth: IntRange = 1..3, + key: Arb = structKey(), + scalarValue: Arb = scalarValue(), +): Arb = + StructArb( + size = size, + depth = depth, + keyArb = key, + scalarValueArb = scalarValue, + ) + +fun ProtoArb.struct( + size: Int, + depth: IntRange = 1..3, + key: Arb = structKey(), + scalarValue: Arb = scalarValue(), +): Arb = + StructArb( + size = size..size, + depth = depth, + keyArb = key, + scalarValueArb = scalarValue, + ) + +fun ProtoArb.struct( + size: IntRange = 0..5, + depth: Int, + key: Arb = structKey(), + scalarValue: Arb = scalarValue(), +): Arb = + StructArb( + size = size, + depth = depth..depth, + keyArb = key, + scalarValueArb = scalarValue, + ) + +fun ProtoArb.struct( + size: Int, + depth: Int, + key: Arb = structKey(), + scalarValue: Arb = scalarValue(), +): Arb = + StructArb( + size = size..size, + depth = depth..depth, + keyArb = key, + scalarValueArb = scalarValue, + ) + +////////////////////////////////////////////////////////////////////////////////////////////////// +// StructArb class +////////////////////////////////////////////////////////////////////////////////////////////////// + +private class StructArb( + size: IntRange, + depth: IntRange, + private val keyArb: Arb, + private val scalarValueArb: Arb, + listValueArb: ListValueArb? = null +) : Arb() { + + init { + require(size.first >= 0) { + "size.first must be greater than or equal to zero, but got size=$size" + } + require(!size.isEmpty()) { "size.isEmpty() must be false, but got $size" } + require(depth.first > 0) { "depth.first must be greater than zero, but got depth=$depth" } + require(!depth.isEmpty()) { "depth.isEmpty() must be false, but got $depth" } + require(depth.last == 1 || size.last > 0) { + "depth.last==${depth.last} and size.last=${size.last}, but this is an impossible " + + "combination because the struct size must be at least 1 in order to produce a depth " + + "greater than 1" + } + } + + private val listValueArb = + listValueArb + ?: ListValueArb( + size = size, + depth = depth, + scalarValueArb = scalarValueArb, + structArb = this, + ) + + private val sizeArb: Arb = run { + val edgeCases = listOf(size.first, size.first + 1, size.last, size.last - 1) + Arb.int(size).withEdgecases(edgeCases.distinct().filter { it in size }) + } + + private val nonZeroSizeArb: Arb = run { + val first = size.first.coerceAtLeast(1) + val last = size.last.coerceAtLeast(1) + val edgeCases = listOf(first, first + 1, last, last - 1) + val nonZeroSizeRange = first..last + Arb.int(nonZeroSizeRange).withEdgecases(edgeCases.distinct().filter { it in nonZeroSizeRange }) + } + + private val depthArb: Arb = run { + val edgeCases = listOf(depth.first, depth.last).distinct() + Arb.int(depth).withEdgecases(edgeCases) + } + + override fun sample(rs: RandomSource): Sample { + val sizeEdgeCaseProbability = rs.random.nextFloat() + val keyEdgeCaseProbability = rs.random.nextFloat() + val valueEdgeCaseProbability = rs.random.nextFloat() + val sample = + sample( + rs, + path = emptyList(), + depth = depthArb.next(rs, edgeCaseProbability = rs.random.nextFloat()), + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + keyEdgeCaseProbability = keyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = rs.random.nextFloat(), ) + return sample.asSample() + } + + fun sample( + rs: RandomSource, + path: ProtoValuePath, + depth: Int, + sizeEdgeCaseProbability: Float, + keyEdgeCaseProbability: Float, + valueEdgeCaseProbability: Float, + nestedProbability: Float, + ): ProtoArb.StructInfo { + require(depth > 0) { "invalid depth: $depth (must be greater than zero)" } + + val size = run { + val arb = if (depth > 1) nonZeroSizeArb else sizeArb + arb.next(rs, sizeEdgeCaseProbability) + } + val forcedDepthIndex = if (size == 0 || depth <= 1) -1 else rs.random.nextInt(size) + + fun RandomSource.nextNestedValue(depth: Int, curPath: ProtoValuePath) = + nextNestedValue( + structArb = this@StructArb, + listValueArb = this@StructArb.listValueArb, + path = curPath, + depth = depth, + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + structKeyEdgeCaseProbability = keyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + + val descendants = mutableListOf() + fun NextNestedValueResult.extractValue(): Value { + descendants.addAll(this.descendants) + return value + } + + val structBuilder = Struct.newBuilder() + while (structBuilder.fieldsCount < size) { + val key = keyArb.next(rs, keyEdgeCaseProbability) + if (structBuilder.containsFields(key)) { + continue + } + val curPath = path.withAppendedStructKey(key) + val value = + if (depth > 1 && structBuilder.fieldsCount == forcedDepthIndex) { + rs.nextNestedValue(depth - 1, curPath).extractValue() + } else if (depth > 1 && rs.random.nextFloat() < nestedProbability) { + rs.nextNestedValue(rs.random.nextInt(1 until depth), curPath).extractValue() + } else { + scalarValueArb.next(rs, valueEdgeCaseProbability) + } + + descendants.add(ProtoValuePathPair(curPath, value)) + structBuilder.putFields(key, value) + } + + return ProtoArb.StructInfo(structBuilder.build(), depth, descendants.toList()) + } + + override fun edgecase(rs: RandomSource): ProtoArb.StructInfo { + val edgeCases = rs.nextEdgeCases() + val sizeEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Size)) 1.0f else 0.0f + val depthEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Depth)) 1.0f else 0.0f + val keyEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Keys)) 1.0f else 0.0f + val valueEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Values)) 1.0f else 0.0f + val nestedProbability = if (edgeCases.contains(EdgeCase.OnlyNested)) 1.0f else 0.0f + return sample( + rs, + path = emptyList(), + depth = depthArb.next(rs, depthEdgeCaseProbability), + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + keyEdgeCaseProbability = keyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + } + + private enum class EdgeCase { + Size, + Depth, + Keys, + Values, + OnlyNested, + } + + private companion object { + fun RandomSource.nextEdgeCases(): List { + val edgeCaseCount = random.nextInt(1..EdgeCase.entries.size) + return EdgeCase.entries.shuffled(random).take(edgeCaseCount) } } } -private class ProtoListValueArb( - val size: Arb, - val value: Arb, -) : Arb() { - override fun edgecase(rs: RandomSource) = ListValue.getDefaultInstance()!! +////////////////////////////////////////////////////////////////////////////////////////////////// +// ListValueArb class +////////////////////////////////////////////////////////////////////////////////////////////////// + +private class ListValueArb( + size: IntRange, + depth: IntRange, + private val scalarValueArb: Arb, + structArb: StructArb? = null, +) : Arb() { + + init { + require(size.first >= 0) { + "size.first must be greater than or equal to zero, but got size=$size" + } + require(!size.isEmpty()) { "size.isEmpty() must be false, but got $size" } + require(depth.first > 0) { "depth.first must be greater than zero, but got depth=$depth" } + require(!depth.isEmpty()) { "depth.isEmpty() must be false, but got $depth" } + require(depth.last == 1 || size.last > 0) { + "depth.last==${depth.last} and size.last=${size.last}, but this is an impossible " + + "combination because the list size must be at least 1 in order to produce a depth " + + "greater than 1" + } + } + + private val structArb = + structArb + ?: StructArb( + size = size, + depth = depth, + keyArb = Arb.proto.structKey(), + scalarValueArb = scalarValueArb, + listValueArb = this, + ) + + private val sizeArb: Arb = run { + val edgeCases = listOf(size.first, size.first + 1, size.last, size.last - 1) + Arb.int(size).withEdgecases(edgeCases.distinct().filter { it in size }) + } + + private val nonZeroSizeArb: Arb = run { + val first = size.first.coerceAtLeast(1) + val last = size.last.coerceAtLeast(1) + val edgeCases = listOf(first, first + 1, last, last - 1) + val nonZeroSizeRange = first..last + Arb.int(nonZeroSizeRange).withEdgecases(edgeCases.distinct().filter { it in nonZeroSizeRange }) + } + + private val depthArb: Arb = run { + val edgeCases = listOf(depth.first, depth.last).distinct() + Arb.int(depth).withEdgecases(edgeCases) + } + + override fun sample(rs: RandomSource): Sample { + val sample = + sample( + rs, + path = emptyList(), + depth = depthArb.next(rs, edgeCaseProbability = rs.random.nextFloat()), + sizeEdgeCaseProbability = rs.random.nextFloat(), + structKeyEdgeCaseProbability = rs.random.nextFloat(), + valueEdgeCaseProbability = rs.random.nextFloat(), + nestedProbability = rs.random.nextFloat(), + ) + return sample.asSample() + } + + fun sample( + rs: RandomSource, + path: ProtoValuePath, + depth: Int, + sizeEdgeCaseProbability: Float, + structKeyEdgeCaseProbability: Float, + valueEdgeCaseProbability: Float, + nestedProbability: Float, + ): ProtoArb.ListValueInfo { + require(depth > 0) { "invalid depth: $depth (must be greater than zero)" } + + fun RandomSource.nextNestedValue(depth: Int, curPath: ProtoValuePath) = + nextNestedValue( + structArb = this@ListValueArb.structArb, + listValueArb = this@ListValueArb, + path = curPath, + depth = depth, + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + structKeyEdgeCaseProbability = structKeyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + + val size = run { + val arb = if (depth > 1) nonZeroSizeArb else sizeArb + arb.next(rs, sizeEdgeCaseProbability) + } - override fun sample(rs: RandomSource): Sample { - val builder = ListValue.newBuilder() - repeat(size.next(rs)) { builder.addValues(value.next(rs)) } - return builder.build().asSample() + val forcedDepthIndex = if (size == 0 || depth <= 1) -1 else rs.random.nextInt(size) + val values = mutableListOf() + val descendants = mutableListOf() + fun NextNestedValueResult.extractValue(): Value { + descendants.addAll(this.descendants) + return value + } + + repeat(size) { index -> + val curPath = path.withAppendedListIndex(index) + val value = + if (depth > 1 && index == forcedDepthIndex) { + rs.nextNestedValue(depth - 1, curPath).extractValue() + } else if (depth > 1 && rs.random.nextFloat() < nestedProbability) { + rs.nextNestedValue(rs.random.nextInt(1 until depth), curPath).extractValue() + } else { + scalarValueArb.next(rs, valueEdgeCaseProbability) + } + + descendants.add(ProtoValuePathPair(curPath, value)) + values.add(value) + } + + val listValue = ListValue.newBuilder().addAllValues(values).build() + return ProtoArb.ListValueInfo(listValue, depth, descendants.toList()) + } + + override fun edgecase(rs: RandomSource): ProtoArb.ListValueInfo { + val edgeCases = rs.nextEdgeCases() + val sizeEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Size)) 1.0f else 0.0f + val depthEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Depth)) 1.0f else 0.0f + val structKeyEdgeCaseProbability = if (edgeCases.contains(EdgeCase.StructKey)) 1.0f else 0.0f + val valueEdgeCaseProbability = if (edgeCases.contains(EdgeCase.Values)) 1.0f else 0.0f + val nestedProbability = if (edgeCases.contains(EdgeCase.OnlyNested)) 1.0f else 0.0f + return sample( + rs, + path = emptyList(), + depth = depthArb.next(rs, depthEdgeCaseProbability), + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + structKeyEdgeCaseProbability = structKeyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + } + + private enum class EdgeCase { + Size, + Depth, + StructKey, + Values, + OnlyNested, + } + + private companion object { + fun RandomSource.nextEdgeCases(): List { + val edgeCaseCount = random.nextInt(1..EdgeCase.entries.size) + return EdgeCase.entries.shuffled(random).take(edgeCaseCount) + } } } -private class ProtoStructValueArb( - val key: Arb, - val size: Arb, - val value: Arb, -) : Arb() { - override fun edgecase(rs: RandomSource) = Struct.getDefaultInstance()!! +fun ListValue.maxDepth(): Int { + var maxDepth = 1 + repeat(valuesCount) { + val curMaxDepth = getValues(it).maxDepth() + if (curMaxDepth > maxDepth) { + maxDepth = curMaxDepth + } + } + return maxDepth +} - override fun sample(rs: RandomSource): Sample { - val builder = Struct.newBuilder() - repeat(size.next(rs)) { builder.putFields(key.next(rs), value.next(rs)) } - return builder.build().asSample() +fun Struct.maxDepth(): Int { + var maxDepth = 1 + fieldsMap.values.forEach { value -> + val curMaxDepth = value.maxDepth() + if (curMaxDepth > maxDepth) { + maxDepth = curMaxDepth + } } + return maxDepth } -fun Proto.value(): Arb = ProtoValueArb.newInstance() +fun Value.maxDepth(): Int = + when (kindCase) { + Value.KindCase.STRUCT_VALUE -> 1 + structValue.maxDepth() + Value.KindCase.LIST_VALUE -> 1 + listValue.maxDepth() + else -> 1 + } + +private class NextNestedValueResult( + val value: Value, + val descendants: List, +) -fun Proto.struct(): Arb = ProtoValueArb.newInstance().struct +private enum class NextNestedValueCase { + Struct, + ListValue, +} -fun Proto.list(): Arb = ProtoValueArb.newInstance().list +private fun RandomSource.nextNestedValue( + structArb: StructArb, + listValueArb: ListValueArb, + path: ProtoValuePath, + depth: Int, + sizeEdgeCaseProbability: Float, + structKeyEdgeCaseProbability: Float, + valueEdgeCaseProbability: Float, + nestedProbability: Float, +): NextNestedValueResult = + when (NextNestedValueCase.entries.random(random)) { + NextNestedValueCase.Struct -> { + val sample = + structArb.sample( + this, + path = path, + depth = depth, + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + keyEdgeCaseProbability = structKeyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + NextNestedValueResult(sample.struct.toValueProto(), sample.descendants) + } + NextNestedValueCase.ListValue -> { + val sample = + listValueArb.sample( + this, + path = path, + depth = depth, + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + structKeyEdgeCaseProbability = structKeyEdgeCaseProbability, + valueEdgeCaseProbability = valueEdgeCaseProbability, + nestedProbability = nestedProbability, + ) + NextNestedValueResult(sample.listValue.toValueProto(), sample.descendants) + } + } diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/protoUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/protoUnitTest.kt new file mode 100644 index 00000000000..12ef02eeeaa --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/protoUnitTest.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil.property.arbitrary + +import com.google.firebase.dataconnect.testutil.ProtoValuePathComponent +import com.google.firebase.dataconnect.testutil.ProtoValuePathPair +import com.google.firebase.dataconnect.testutil.RandomSeedTestRule +import com.google.firebase.dataconnect.testutil.isListValue +import com.google.firebase.dataconnect.testutil.isStructValue +import com.google.firebase.dataconnect.testutil.toValueProto +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldBeIn +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.Exhaustive +import io.kotest.property.PropTestConfig +import io.kotest.property.RandomSource +import io.kotest.property.ShrinkingMode +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.map +import io.kotest.property.checkAll +import io.kotest.property.exhaustive.collection +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@Suppress("ClassName") +class protoUnitTest { + + @get:Rule val randomSeedTestRule = RandomSeedTestRule() + + private val rs: RandomSource by randomSeedTestRule.rs + + @Test + fun `nullValue() should produce Values with kindCase NULL_VALUE`() = + verifyArbGeneratesValuesWithKindCase(Arb.proto.nullValue(), Value.KindCase.NULL_VALUE) + + @Test + fun `numberValue() should produce Values with kindCase NUMBER_VALUE`() = + verifyArbGeneratesValuesWithKindCase(Arb.proto.numberValue(), Value.KindCase.NUMBER_VALUE) + + @Test + fun `boolValue() should produce Values with kindCase BOOL_VALUE`() = + verifyArbGeneratesValuesWithKindCase(Arb.proto.boolValue(), Value.KindCase.BOOL_VALUE) + + @Test + fun `stringValue() should produce Values with kindCase STRING_VALUE`() = + verifyArbGeneratesValuesWithKindCase(Arb.proto.stringValue(), Value.KindCase.STRING_VALUE) + + @Test + fun `kindNotSetValue() should produce Values with kindCase KIND_NOT_SET`() = + verifyArbGeneratesValuesWithKindCase(Arb.proto.kindNotSetValue(), Value.KindCase.KIND_NOT_SET) + + @Test + fun `scalarValue() should only produce scalar values`() = runTest { + checkAll(propTestConfig, Arb.proto.scalarValue()) { value -> + value.kindCase shouldBeIn scalarKindCases + } + } + + @Test + fun `scalarValue() should produce all scalar value kind cases`() = runTest { + val encounteredKindCases = mutableSetOf() + checkAll(propTestConfig, Arb.proto.scalarValue()) { value -> + encounteredKindCases.add(value.kindCase) + } + encounteredKindCases shouldContainExactlyInAnyOrder scalarKindCases + } + + @Test + fun `scalarValue() should exclude the given kind case`() = runTest { + checkAll(propTestConfig, Exhaustive.collection(scalarKindCases)) { kindCase -> + val scalarValueArb = Arb.proto.scalarValue(exclude = kindCase) + val encounteredKindCases = + List(propTestConfig.iterations!!) { scalarValueArb.bind().kindCase } + val expectedEncounteredKindCases = scalarKindCases.filterNot { it == kindCase } + encounteredKindCases.distinct() shouldContainExactlyInAnyOrder expectedEncounteredKindCases + } + } + + @Test + fun `listValue() should produce Values with kindCase LIST_VALUE`() = + verifyArbGeneratesValuesWithKindCase( + Arb.proto.listValue().map { it.toValueProto() }, + Value.KindCase.LIST_VALUE + ) + + @Test + fun `listValue() Sample should specify the correct depth`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + sample.depth shouldBe sample.listValue.maxDepth() + } + } + + @Test + fun `listValue() Sample should specify the correct descendants`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + val listValue: ListValue = sample.listValue + val expectedDescendants = listValue.toValueProto().calculateExpectedDescendants() + sample.descendants shouldContainExactlyInAnyOrder expectedDescendants + } + } + + @Test + fun `listValue() should generate depths up to 3 for normal samples`() = runTest { + val arb = Arb.proto.listValue() + val depths = mutableSetOf() + repeat(propTestConfig.iterations!!) { depths.add(arb.sample(rs).value.depth) } + withClue("depths=${depths.sorted()}") { depths.shouldContainExactlyInAnyOrder(1, 2, 3) } + } + + @Test + fun `listValue() should generate depths up to 3 for edge cases`() = runTest { + val arb = Arb.proto.listValue() + val depths = mutableSetOf() + repeat(propTestConfig.iterations!!) { depths.add(arb.edgecase(rs)!!.depth) } + withClue("depths=${depths.sorted()}") { depths.shouldContainExactlyInAnyOrder(1, 2, 3) } + } + + @Test + fun `listValue() should generate nested structs and lists`() = runTest { + var nestedListCount = 0 + var nestedStructCount = 0 + + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + if (sample.listValue.hasNestedKind(Value.KindCase.LIST_VALUE)) { + nestedListCount++ + } + if (sample.listValue.hasNestedKind(Value.KindCase.STRUCT_VALUE)) { + nestedStructCount++ + } + } + + assertSoftly { + withClue("nestedListCount") { nestedListCount shouldBeGreaterThan 0 } + withClue("nestedStructCount") { nestedStructCount shouldBeGreaterThan 0 } + } + } + + @Test + fun `struct() Sample should specify the correct depth`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + sample.depth shouldBe sample.struct.maxDepth() + } + } + + @Test + fun `struct() Sample should specify the correct descendants`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + val struct: Struct = sample.struct + val expectedDescendants = struct.toValueProto().calculateExpectedDescendants() + sample.descendants shouldContainExactlyInAnyOrder expectedDescendants + } + } + + @Test + fun `struct() should generate depths up to 3 for normal samples`() = runTest { + val arb = Arb.proto.struct() + val depths = mutableSetOf() + repeat(propTestConfig.iterations!!) { depths.add(arb.sample(rs).value.depth) } + withClue("depths=${depths.sorted()}") { depths.shouldContainExactlyInAnyOrder(1, 2, 3) } + } + + @Test + fun `struct() should generate depths up to 3 for edge cases`() = runTest { + val arb = Arb.proto.struct() + val depths = mutableSetOf() + repeat(propTestConfig.iterations!!) { depths.add(arb.edgecase(rs)!!.depth) } + withClue("depths=${depths.sorted()}") { depths.shouldContainExactlyInAnyOrder(1, 2, 3) } + } + + @Test + fun `struct() should generate nested structs and lists`() = runTest { + var nestedListCount = 0 + var nestedStructCount = 0 + + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + if (sample.struct.hasNestedKind(Value.KindCase.LIST_VALUE)) { + nestedListCount++ + } + if (sample.struct.hasNestedKind(Value.KindCase.STRUCT_VALUE)) { + nestedStructCount++ + } + } + + assertSoftly { + withClue("nestedListCount") { nestedListCount shouldBeGreaterThan 0 } + withClue("nestedStructCount") { nestedStructCount shouldBeGreaterThan 0 } + } + } + + @Test + fun `valueOfKind() should generate same values when given same random seed`() { + Value.KindCase.entries.forEach { kindCase -> + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.valueOfKind(kindCase)) + } + } + + @Test + fun `value() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.value()) + + @Test + fun `nullValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.nullValue()) + + @Test + fun `numberValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.numberValue()) + + @Test + fun `boolValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.boolValue()) + + @Test + fun `stringValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.stringValue()) + + @Test + fun `kindNotSetValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.kindNotSetValue()) + + @Test + fun `scalarValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.scalarValue()) + + @Test + fun `listValue() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.listValue()) + + @Test + fun `structKey() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.structKey()) + + @Test + fun `struct() should generate same values when given same random seed`() = + verifyArbGeneratesSameValuesWithSameRandomSeed(Arb.proto.struct()) + + private fun verifyArbGeneratesValuesWithKindCase( + arb: Arb, + expectedKindCase: Value.KindCase + ) = runTest { + checkAll(propTestConfig, arb) { value -> value.kindCase shouldBe expectedKindCase } + } + + private fun verifyArbGeneratesSameValuesWithSameRandomSeed(arb: Arb<*>) = runTest { + val edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33) + checkAll(propTestConfig.withIterations(10), Arb.long(), Arb.int(1..20)) { seed, count -> + fun generateSamples() = + arb.generate(RandomSource.seeded(seed), edgeConfig).map { it.value }.take(count).toList() + val samples1 = generateSamples() + val samples2 = generateSamples() + samples1 shouldContainExactly samples2 + } + } + + private companion object { + + @OptIn(ExperimentalKotest::class) + val propTestConfig = + PropTestConfig( + iterations = 1000, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33), + shrinkingMode = ShrinkingMode.Off, + ) + + val scalarKindCases: List = + listOf( + Value.KindCase.NULL_VALUE, + Value.KindCase.BOOL_VALUE, + Value.KindCase.NUMBER_VALUE, + Value.KindCase.STRING_VALUE, + Value.KindCase.KIND_NOT_SET, + ) + + fun ListValue.hasNestedKind(kindCase: Value.KindCase): Boolean { + repeat(valuesCount) { + val value = getValues(it) + if (value.hasNestedKind(kindCase)) { + return true + } + } + return false + } + + fun Struct.hasNestedKind(kindCase: Value.KindCase): Boolean { + fieldsMap.values.forEach { value -> + if (value.hasNestedKind(kindCase)) { + return true + } + } + return false + } + + fun Value.hasNestedKind(kindCase: Value.KindCase): Boolean = + if (this@hasNestedKind.kindCase == kindCase) { + true + } else if (this@hasNestedKind.kindCase == Value.KindCase.LIST_VALUE) { + listValue.hasNestedKind(kindCase) + } else if (this@hasNestedKind.kindCase == Value.KindCase.STRUCT_VALUE) { + structValue.hasNestedKind(kindCase) + } else { + false + } + + fun Value.calculateExpectedDescendants(): List = buildList { + val queue: MutableList = mutableListOf() + queue.add(ProtoValuePathPair(emptyList(), this@calculateExpectedDescendants)) + + while (queue.isNotEmpty()) { + val entry = queue.removeFirst() + val (path, value) = entry + if (path.isNotEmpty()) { + add(entry) + } + + if (value.isStructValue) { + value.structValue.fieldsMap.entries.forEach { (key, childValue) -> + val childPath = buildList { + addAll(path) + add(ProtoValuePathComponent.StructKey(key)) + } + queue.add(ProtoValuePathPair(childPath, childValue)) + } + } else if (value.isListValue) { + value.listValue.valuesList.forEachIndexed { index, childValue -> + val childPath = buildList { + addAll(path) + add(ProtoValuePathComponent.ListIndex(index)) + } + queue.add(ProtoValuePathPair(childPath, childValue)) + } + } + } + } + } +}