diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index 196af3579ad..3f0272d5204 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- [changed] Internal refactor for reporting "paths" in response data. + [#7613](https://github.com/firebase/firebase-android-sdk/pull/7613)) + # 17.1.2 - [changed] Internal refactor for managing Auth and App Check tokens diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt index 3bae99ef78f..fc32189b91c 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt @@ -54,3 +54,75 @@ public sealed interface DataConnectPathSegment { override fun toString(): String = index.toString() } } + +internal typealias DataConnectPath = List + +internal fun List.toPathString(): String = buildString { + appendPathStringTo(this) +} + +internal fun List.appendPathStringTo(sb: StringBuilder) { + forEachIndexed { segmentIndex, segment -> + when (segment) { + is DataConnectPathSegment.Field -> { + if (segmentIndex != 0) { + sb.append('.') + } + sb.append(segment.field) + } + is DataConnectPathSegment.ListIndex -> { + sb.append('[') + sb.append(segment.index) + sb.append(']') + } + } + } +} + +internal fun MutableList.addField( + field: String +): DataConnectPathSegment.Field = DataConnectPathSegment.Field(field).also { add(it) } + +internal fun MutableList.addListIndex( + index: Int +): DataConnectPathSegment.ListIndex = DataConnectPathSegment.ListIndex(index).also { add(it) } + +internal inline fun MutableList.withAddedField( + field: String, + block: () -> T +): T = withAddedPathSegment(DataConnectPathSegment.Field(field), block) + +internal inline fun MutableList.withAddedListIndex( + index: Int, + block: () -> T +): T = withAddedPathSegment(DataConnectPathSegment.ListIndex(index), block) + +internal inline fun MutableList.withAddedPathSegment( + pathSegment: S, + block: () -> T +): T { + add(pathSegment) + try { + return block() + } finally { + val removedSegment = removeLastOrNull() + check(removedSegment === pathSegment) { + "internal error x6tzdsszmc: removed $removedSegment, but expected $pathSegment" + } + } +} + +internal fun List.withAddedField( + field: String +): List = withAddedPathSegment(DataConnectPathSegment.Field(field)) + +internal fun List.withAddedListIndex( + index: Int +): List = withAddedPathSegment(DataConnectPathSegment.ListIndex(index)) + +internal fun List.withAddedPathSegment( + pathSegment: DataConnectPathSegment +): List = buildList { + addAll(this@withAddedPathSegment) + add(pathSegment) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt index 85434a64b47..e2912ca02b8 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt @@ -19,6 +19,7 @@ package com.google.firebase.dataconnect.core import com.google.firebase.dataconnect.DataConnectOperationFailureResponse import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo import com.google.firebase.dataconnect.DataConnectPathSegment +import com.google.firebase.dataconnect.appendPathStringTo import java.util.Objects internal class DataConnectOperationFailureResponseImpl( @@ -41,24 +42,10 @@ internal class DataConnectOperationFailureResponseImpl( override fun hashCode(): Int = Objects.hash("ErrorInfoImpl", message, path) override fun toString(): String = buildString { - path.forEachIndexed { segmentIndex, segment -> - when (segment) { - is DataConnectPathSegment.Field -> { - if (segmentIndex != 0) { - append('.') - } - append(segment.field) - } - is DataConnectPathSegment.ListIndex -> { - append('[').append(segment.index).append(']') - } - } - } - - if (path.isNotEmpty()) { + path.appendPathStringTo(this) + if (isNotEmpty()) { append(": ") } - append(message) } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt index a90ce4c3847..41216b54572 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt @@ -19,7 +19,9 @@ package com.google.firebase.dataconnect.util import com.google.firebase.dataconnect.AnyValue +import com.google.firebase.dataconnect.DataConnectPath import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.toPathString import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeBoolean import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeByte import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeChar @@ -34,6 +36,8 @@ import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeShort import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeString import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeStruct import com.google.firebase.dataconnect.util.ProtoUtil.toAny +import com.google.firebase.dataconnect.withAddedField +import com.google.firebase.dataconnect.withAddedListIndex import com.google.protobuf.ListValue import com.google.protobuf.NullValue import com.google.protobuf.Struct @@ -58,59 +62,64 @@ import kotlinx.serialization.modules.SerializersModule * avoids this public API pollution. */ private object ProtoDecoderUtil { - fun decode(value: Value, path: String?, expectedKindCase: KindCase, block: (Value) -> T): T = + fun decode( + value: Value, + path: DataConnectPath, + expectedKindCase: KindCase, + block: (Value) -> T + ): T = if (value.kindCase != expectedKindCase) { throw SerializationException( - (if (path === null) "" else "decoding \"$path\" failed: ") + + (if (path.isEmpty()) "" else "decoding \"${path.toPathString()}\" failed: ") + "expected $expectedKindCase, but got ${value.kindCase} (${value.toAny()})" ) } else { block(value) } - fun decodeBoolean(value: Value, path: String?): Boolean = + fun decodeBoolean(value: Value, path: DataConnectPath): Boolean = decode(value, path, KindCase.BOOL_VALUE) { it.boolValue } - fun decodeByte(value: Value, path: String?): Byte = + fun decodeByte(value: Value, path: DataConnectPath): Byte = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toByte() } - fun decodeChar(value: Value, path: String?): Char = + fun decodeChar(value: Value, path: DataConnectPath): Char = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toChar() } - fun decodeDouble(value: Value, path: String?): Double = + fun decodeDouble(value: Value, path: DataConnectPath): Double = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue } - fun decodeEnum(value: Value, path: String?): String = + fun decodeEnum(value: Value, path: DataConnectPath): String = decode(value, path, KindCase.STRING_VALUE) { it.stringValue } - fun decodeFloat(value: Value, path: String?): Float = + fun decodeFloat(value: Value, path: DataConnectPath): Float = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toFloat() } - fun decodeString(value: Value, path: String?): String = + fun decodeString(value: Value, path: DataConnectPath): String = decode(value, path, KindCase.STRING_VALUE) { it.stringValue } - fun decodeStruct(value: Value, path: String?): Struct = + fun decodeStruct(value: Value, path: DataConnectPath): Struct = decode(value, path, KindCase.STRUCT_VALUE) { it.structValue } - fun decodeList(value: Value, path: String?): ListValue = + fun decodeList(value: Value, path: DataConnectPath): ListValue = decode(value, path, KindCase.LIST_VALUE) { it.listValue } - fun decodeNull(value: Value, path: String?): NullValue = + fun decodeNull(value: Value, path: DataConnectPath): NullValue = decode(value, path, KindCase.NULL_VALUE) { it.nullValue } - fun decodeInt(value: Value, path: String?): Int = + fun decodeInt(value: Value, path: DataConnectPath): Int = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt() } - fun decodeLong(value: Value, path: String?): Long = + fun decodeLong(value: Value, path: DataConnectPath): Long = decode(value, path, KindCase.STRING_VALUE) { it.stringValue.toLong() } - fun decodeShort(value: Value, path: String?): Short = + fun decodeShort(value: Value, path: DataConnectPath): Short = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toShort() } } internal class ProtoValueDecoder( internal val valueProto: Value, - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule ) : Decoder { @@ -162,7 +171,7 @@ internal class ProtoValueDecoder( private class ProtoStructValueDecoder( private val struct: Struct, - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule ) : CompositeDecoder { @@ -228,7 +237,7 @@ private class ProtoStructValueDecoder( private fun decodeValueElement( descriptor: SerialDescriptor, index: Int, - block: (Value, String?) -> T + block: (Value, DataConnectPath) -> T ): T { val elementName = descriptor.getElementName(index) val elementPath = elementPathForName(elementName) @@ -290,13 +299,13 @@ private class ProtoStructValueDecoder( } } - private fun elementPathForName(elementName: String) = - if (path === null) elementName else "${path}.${elementName}" + private fun elementPathForName(elementName: String): DataConnectPath = + path.withAddedField(elementName) } private class ProtoListValueDecoder( private val list: ListValue, - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule ) : CompositeDecoder { @@ -339,7 +348,7 @@ private class ProtoListValueDecoder( override fun decodeStringElement(descriptor: SerialDescriptor, index: Int) = decodeValueElement(index, ProtoDecoderUtil::decodeString) - private inline fun decodeValueElement(index: Int, block: (Value, String?) -> T): T = + private inline fun decodeValueElement(index: Int, block: (Value, DataConnectPath) -> T): T = block(list.valuesList[index], elementPathForIndex(index)) override fun decodeSerializableElement( @@ -373,14 +382,15 @@ private class ProtoListValueDecoder( decodeSerializableElement(descriptor, index, deserializer, previousValue = null) } - private fun elementPathForIndex(index: Int) = if (path === null) "[$index]" else "${path}[$index]" + private fun elementPathForIndex(index: Int): DataConnectPath = path.withAddedListIndex(index) - override fun toString() = "ProtoListValueDecoder{path=$path, size=${list.valuesList.size}" + override fun toString() = + "ProtoListValueDecoder{path=${path.toPathString()}, size=${list.valuesList.size}}" } private class ProtoMapValueDecoder( private val struct: Struct, - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule ) : CompositeDecoder { @@ -435,7 +445,7 @@ private class ProtoMapValueDecoder( decodeValueElement(index, ProtoDecoderUtil::decodeString) } - private inline fun decodeValueElement(index: Int, block: (Value, String?) -> T): T { + private inline fun decodeValueElement(index: Int, block: (Value, DataConnectPath) -> T): T { require(index % 2 != 0) { "invalid value index: $index" } val value = structEntryByElementIndex(index).value val elementPath = elementPathForIndex(index) @@ -491,21 +501,22 @@ private class ProtoMapValueDecoder( return deserializer.deserialize(elementDecoder) } - private fun elementPathForIndex(index: Int): String { + private fun elementPathForIndex(index: Int): DataConnectPath { val structEntry = structEntryByElementIndex(index) val key = structEntry.key return if (index % 2 == 0) { - if (path === null) "[$key]" else "${path}[$key]" + path.withAddedField(key) } else { - if (path === null) "[$key].value" else "${path}[$key].value" + path.withAddedField(key).withAddedField("value") } } - override fun toString() = "ProtoMapValueDecoder{path=$path, size=${struct.fieldsCount}" + override fun toString() = + "ProtoMapValueDecoder{path=${path.toPathString()}, size=${struct.fieldsCount}" } private class ProtoObjectValueDecoder( - val path: String?, + val path: DataConnectPath, override val serializersModule: SerializersModule ) : CompositeDecoder { @@ -553,12 +564,12 @@ private class ProtoObjectValueDecoder( override fun endStructure(descriptor: SerialDescriptor) {} - override fun toString() = "ProtoObjectValueDecoder{path=$path}" + override fun toString() = "ProtoObjectValueDecoder{path=${path.toPathString()}}" } private class MapKeyDecoder( val key: String, - val path: String, + val path: DataConnectPath, override val serializersModule: SerializersModule ) : Decoder { @@ -595,5 +606,5 @@ private class MapKeyDecoder( "The only valid method call on MapKeyDecoder is decodeString()" ) - override fun toString() = "MapKeyDecoder{path=$path}" + override fun toString() = "MapKeyDecoder{path=${path.toPathString()}}" } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt index 5c442c95b9a..8d0b6cd3701 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt @@ -19,9 +19,13 @@ package com.google.firebase.dataconnect.util import com.google.firebase.dataconnect.AnyValue +import com.google.firebase.dataconnect.DataConnectPath import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.toPathString import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import com.google.firebase.dataconnect.withAddedField +import com.google.firebase.dataconnect.withAddedListIndex import com.google.protobuf.ListValue import com.google.protobuf.Struct import com.google.protobuf.Value @@ -37,7 +41,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.serializer internal class ProtoValueEncoder( - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule, val onValue: (Value) -> Unit ) : Encoder { @@ -115,7 +119,7 @@ internal class ProtoValueEncoder( } private abstract class ProtoCompositeValueEncoder( - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule, private val onValue: (Value) -> Unit ) : CompositeEncoder { @@ -127,7 +131,7 @@ private abstract class ProtoCompositeValueEncoder( } protected abstract fun keyOf(descriptor: SerialDescriptor, index: Int): K - protected abstract fun formattedKeyForElementPath(key: K): String + protected abstract fun elementPathForKey(key: K): DataConnectPath override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { putValue(descriptor, index, value.toValueProto()) @@ -198,9 +202,6 @@ private abstract class ProtoCompositeValueEncoder( onValue(Value.newBuilder().also { populate(descriptor, it, valueByKey) }.build()) } - private fun elementPathForKey(key: K): String = - formattedKeyForElementPath(key).let { if (path === null) it else "$path$it" } - protected abstract fun populate( descriptor: SerialDescriptor, valueBuilder: Value.Builder, @@ -209,14 +210,14 @@ private abstract class ProtoCompositeValueEncoder( } private class ProtoListValueEncoder( - private val path: String?, + private val path: DataConnectPath, serializersModule: SerializersModule, onValue: (Value) -> Unit ) : ProtoCompositeValueEncoder(path, serializersModule, onValue) { override fun keyOf(descriptor: SerialDescriptor, index: Int) = index - override fun formattedKeyForElementPath(key: Int) = "[$key]" + override fun elementPathForKey(key: Int): DataConnectPath = path.withAddedListIndex(key) override fun populate( descriptor: SerialDescriptor, @@ -229,7 +230,7 @@ private class ProtoListValueEncoder( listValueBuilder.addValues( valueByKey[i] ?: throw SerializationException( - "$path: list value missing at index $i" + + "${path.toPathString()}: list value missing at index $i" + " (have ${valueByKey.size} indexes:" + " ${valueByKey.keys.sorted().joinToString()})" ) @@ -241,14 +242,14 @@ private class ProtoListValueEncoder( } private class ProtoStructValueEncoder( - path: String?, + private val path: DataConnectPath, serializersModule: SerializersModule, onValue: (Value) -> Unit ) : ProtoCompositeValueEncoder(path, serializersModule, onValue) { override fun keyOf(descriptor: SerialDescriptor, index: Int) = descriptor.getElementName(index) - override fun formattedKeyForElementPath(key: String) = ".$key" + override fun elementPathForKey(key: String): DataConnectPath = path.withAddedField(key) override fun populate( descriptor: SerialDescriptor, @@ -270,7 +271,7 @@ private class ProtoStructValueEncoder( } private class ProtoMapValueEncoder( - private val path: String?, + private val path: DataConnectPath, override val serializersModule: SerializersModule, private val onValue: (Value) -> Unit ) : CompositeEncoder { @@ -336,9 +337,10 @@ private class ProtoMapValueEncoder( if (value === null) { null } else { - val subPath = keyByIndex[index - 1] ?: "$index" + val key = keyByIndex[index - 1] ?: "$index" + val valuePath = path.withAddedField(key) var encodedValue: Value? = null - val encoder = ProtoValueEncoder(subPath, serializersModule) { encodedValue = it } + val encoder = ProtoValueEncoder(valuePath, serializersModule) { encodedValue = it } encoder.encodeNullableSerializableValue(serializer, value) requireNotNull(encodedValue) { "ProtoValueEncoder should have produced a value" } encodedValue @@ -358,8 +360,9 @@ private class ProtoMapValueEncoder( } keyByIndex[index] = value } else { - val subPath = keyByIndex[index - 1] ?: "$index" - val encoder = ProtoValueEncoder(subPath, serializersModule) { valueByIndex[index] = it } + val key = keyByIndex[index - 1] ?: "$index" + val valuePath = path.withAddedField(key) + val encoder = ProtoValueEncoder(valuePath, serializersModule) { valueByIndex[index] = it } encoder.encodeSerializableValue(serializer, value) } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt index 94a3a63a68d..4f44a25f895 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt @@ -379,7 +379,7 @@ internal object ProtoUtil { serializersModule: SerializersModule? ): Value { val values = mutableListOf() - ProtoValueEncoder(null, serializersModule ?: EmptySerializersModule(), values::add) + ProtoValueEncoder(emptyList(), serializersModule ?: EmptySerializersModule(), values::add) .encodeSerializableValue(serializer, value) if (values.isEmpty()) { return Value.getDefaultInstance() @@ -411,7 +411,7 @@ internal object ProtoUtil { serializersModule: SerializersModule? ): T { val decoder = - ProtoValueDecoder(value, path = null, serializersModule ?: EmptySerializersModule()) + ProtoValueDecoder(value, path = emptyList(), serializersModule ?: EmptySerializersModule()) return decoder.decodeSerializableValue(deserializer) } } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt index d4029102a80..3902c7a34c1 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt @@ -18,10 +18,13 @@ package com.google.firebase.dataconnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.dataConnectPath as dataConnectPathArb import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.pathSegment as dataConnectPathSegmentArb import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeSameInstanceAs @@ -30,6 +33,9 @@ import io.kotest.property.EdgeConfig import io.kotest.property.PropTestConfig import io.kotest.property.arbitrary.choice import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.pair import io.kotest.property.arbitrary.string import io.kotest.property.assume import io.kotest.property.checkAll @@ -224,3 +230,332 @@ class DataConnectPathSegmentListIndexUnitTest { } } } + +/** Unit tests for extension functions of [DataConnectPathSegment] */ +class DataConnectPathSegmentExtensionFunctionsUnitTest { + + @Test + fun `toPathString on empty path`() { + val emptyPath: DataConnectPath = emptyList() + emptyPath.toPathString() shouldBe "" + } + + @Test + fun `toPathString on single field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { fieldName -> + val path = listOf(DataConnectPathSegment.Field(fieldName)) + path.toPathString() shouldBe fieldName + } + } + + @Test + fun `toPathString on single list index`() = runTest { + checkAll(propTestConfig, Arb.int()) { listIndex -> + val path = listOf(DataConnectPathSegment.ListIndex(listIndex)) + path.toPathString() shouldBe "[$listIndex]" + } + } + + @Test + fun `toPathString on path of all fields`() = runTest { + checkAll(propTestConfig, Arb.list(fieldPathSegmentArb(), 2..5)) { path -> + path.toPathString() shouldBe path.joinToString(".") { it.field } + } + } + + @Test + fun `toPathString on path of all list indexes`() = runTest { + checkAll(propTestConfig, Arb.list(listIndexPathSegmentArb(), 2..5)) { path -> + path.toPathString() shouldBe path.joinToString("") { "[${it.index}]" } + } + } + + @Test + fun `toPathString on path of alternating fields and list indexes`() = runTest { + val arb = Arb.list(Arb.pair(fieldPathSegmentArb(), listIndexPathSegmentArb()), 1..5) + checkAll(propTestConfig, arb) { pairs -> + val path = pairs.flatMap { it.toList() } + + val pathString = path.toPathString() + + val expectedPathString = buildString { + pairs.forEachIndexed { index, (fieldSegment, indexSegment) -> + if (index > 0) { + append('.') + } + append(fieldSegment.field) + append('[') + append(indexSegment.index) + append(']') + } + } + pathString shouldBe expectedPathString + } + } + + @Test + fun `toPathString on path of alternating list indexes and fields`() = runTest { + val arb = Arb.list(Arb.pair(listIndexPathSegmentArb(), fieldPathSegmentArb()), 1..5) + checkAll(propTestConfig, arb) { pairs -> + val path = pairs.flatMap { it.toList() } + + val pathString = path.toPathString() + + val expectedPathString = buildString { + pairs.forEach { (indexSegment, fieldSegment) -> + append('[') + append(indexSegment.index) + append(']') + append('.') + append(fieldSegment.field) + } + } + pathString shouldBe expectedPathString + } + } + + @Test + fun `appendPathStringTo on empty StringBuilder`() = runTest { + checkAll(propTestConfig, dataConnectPathArb()) { path -> + val sb = StringBuilder() + + path.appendPathStringTo(sb) + + sb.toString() shouldBe path.toPathString() + } + } + + @Test + fun `appendPathStringTo on non-empty StringBuilder`() = runTest { + checkAll(propTestConfig, Arb.string(), dataConnectPathArb()) { prefix, path -> + val sb = StringBuilder(prefix) + + path.appendPathStringTo(sb) + + sb.toString() shouldBe prefix + path.toPathString() + } + } + + @Test + fun `MutableList addField should add a field path segment`() = runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.dataConnect.string()) { path, fieldName -> + val mutablePath = path.toMutableList() + + mutablePath.addField(fieldName) + + val expected = buildList { + addAll(path) + add(DataConnectPathSegment.Field(fieldName)) + } + mutablePath shouldContainExactly expected + } + } + + @Test + fun `MutableList addField should return the added path segment`() = runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.dataConnect.string()) { path, fieldName -> + val mutablePath = path.toMutableList() + + val returnValue = mutablePath.addField(fieldName) + + returnValue shouldBe DataConnectPathSegment.Field(fieldName) + } + } + + @Test + fun `MutableList addListIndex should add a list index path segment`() = runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.int()) { path, listIndex -> + val mutablePath = path.toMutableList() + + mutablePath.addListIndex(listIndex) + + val expected = buildList { + addAll(path) + add(DataConnectPathSegment.ListIndex(listIndex)) + } + mutablePath shouldContainExactly expected + } + } + + @Test + fun `MutableList addListIndex should return the added path segment`() = runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.int()) { path, listIndex -> + val mutablePath = path.toMutableList() + + val returnValue = mutablePath.addListIndex(listIndex) + + returnValue shouldBe DataConnectPathSegment.ListIndex(listIndex) + } + } + + @Test + fun `MutableList withAddedPathSegment should run the given block with the path segment added`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), dataConnectPathSegmentArb()) { + path, + pathSegment -> + val mutablePath = path.toMutableList() + val expectedPathInBlock = buildList { + addAll(path) + add(pathSegment) + } + mutablePath.withAddedPathSegment(pathSegment) { + mutablePath shouldContainExactly expectedPathInBlock + } + } + } + + @Test + fun `MutableList withAddedField should run the given block with the field added`() = runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.dataConnect.string()) { path, fieldName -> + val mutablePath = path.toMutableList() + + mutablePath.withAddedField(fieldName) { + mutablePath shouldContainExactly path.toMutableList().also { it.addField(fieldName) } + } + } + } + + @Test + fun `MutableList withAddedListIndex should run the given block with the list index added`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.int()) { path, listIndex -> + val mutablePath = path.toMutableList() + + mutablePath.withAddedListIndex(listIndex) { + mutablePath shouldContainExactly path.toMutableList().also { it.addListIndex(listIndex) } + } + } + } + + @Test + fun `MutableList withAddedPathSegment should remove the added path segment before returning`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), dataConnectPathSegmentArb()) { + path, + pathSegment -> + val mutablePath = path.toMutableList() + + mutablePath.withAddedPathSegment(pathSegment) {} + + mutablePath shouldContainExactly path + } + } + + @Test + fun `MutableList withAddedField should remove the added path segment before returning`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.dataConnect.string()) { path, fieldName -> + val mutablePath = path.toMutableList() + + mutablePath.withAddedField(fieldName) {} + + mutablePath shouldContainExactly path + } + } + + @Test + fun `MutableList withAddedListIndex should remove the added path segment before returning`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.int()) { path, listIndex -> + val mutablePath = path.toMutableList() + + mutablePath.withAddedListIndex(listIndex) {} + + mutablePath shouldContainExactly path + } + } + + @Test + fun `MutableList withAddedPathSegment should return whatever the block returns`() = runTest { + data class ReturnValue(val value: Int) + checkAll( + propTestConfig, + dataConnectPathArb(), + dataConnectPathSegmentArb(), + Arb.int().map(::ReturnValue) + ) { path, pathSegment, returnValue -> + val mutablePath = path.toMutableList() + + val actualReturnValue = mutablePath.withAddedPathSegment(pathSegment) { returnValue } + + actualReturnValue shouldBeSameInstanceAs returnValue + } + } + + @Test + fun `MutableList withAddedField should return whatever the block returns`() = runTest { + data class ReturnValue(val value: Int) + checkAll( + propTestConfig, + dataConnectPathArb(), + Arb.dataConnect.string(), + Arb.int().map(::ReturnValue) + ) { path, fieldName, returnValue -> + val mutablePath = path.toMutableList() + + val actualReturnValue = mutablePath.withAddedField(fieldName) { returnValue } + + actualReturnValue shouldBeSameInstanceAs returnValue + } + } + + @Test + fun `MutableList withAddedListIndex should return whatever the block returns`() = runTest { + data class ReturnValue(val value: Int) + checkAll(propTestConfig, dataConnectPathArb(), Arb.int(), Arb.int().map(::ReturnValue)) { + path, + listIndex, + returnValue -> + val mutablePath = path.toMutableList() + + val actualReturnValue = mutablePath.withAddedListIndex(listIndex) { returnValue } + + actualReturnValue shouldBeSameInstanceAs returnValue + } + } + + @Test + fun `List withAddedPathSegment should return the receiving path with the given segment added`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), dataConnectPathSegmentArb()) { + path, + pathSegment -> + val result = path.withAddedPathSegment(pathSegment) + + val expected = buildList { + addAll(path) + add(pathSegment) + } + result shouldContainExactly expected + } + } + + @Test + fun `List withAddedField should return the receiving path with a field segment added`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.dataConnect.string()) { path, fieldName -> + val result = path.withAddedField(fieldName) + + val expected = buildList { + addAll(path) + add(DataConnectPathSegment.Field(fieldName)) + } + result shouldContainExactly expected + } + } + + @Test + fun `List withAddedListIndex should return the receiving path with a list index segment added`() = + runTest { + checkAll(propTestConfig, dataConnectPathArb(), Arb.int()) { path, listIndex -> + val result = path.withAddedListIndex(listIndex) + + val expected = buildList { + addAll(path) + add(DataConnectPathSegment.ListIndex(listIndex)) + } + result shouldContainExactly expected + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt index 48c5a12f878..dd8941e414e 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt @@ -98,7 +98,7 @@ class DataConnectSettingsUnitTest { @Test fun `equals() should return false for a different type`() = runTest { - val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.errorPath()) + val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.dataConnectPath()) checkAll(propTestConfig, Arb.dataConnect.dataConnectSettings(), otherTypes) { settings, other -> settings.equals(other) shouldBe false } 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 ee9fac64057..ca6ad432d59 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 @@ -509,7 +509,7 @@ class DataConnectGrpcClientUnitTest { val errorInfo: ErrorInfoImpl, ) { companion object { - private val randomPathComponents = + private val randomPathSegments = Arb.string( minSize = 1, maxSize = 8, @@ -528,13 +528,13 @@ class DataConnectGrpcClientUnitTest { val graphqlErrorPath = ListValue.newBuilder() repeat(6) { if (rs.random.nextFloat() < 0.33f) { - val pathComponent = randomInts.next(rs) - dataConnectErrorPath.add(DataConnectPathSegment.ListIndex(pathComponent)) - graphqlErrorPath.addValues(Value.newBuilder().setNumberValue(pathComponent.toDouble())) + val pathSegment = randomInts.next(rs) + dataConnectErrorPath.add(DataConnectPathSegment.ListIndex(pathSegment)) + graphqlErrorPath.addValues(Value.newBuilder().setNumberValue(pathSegment.toDouble())) } else { - val pathComponent = randomPathComponents.next(rs) - dataConnectErrorPath.add(DataConnectPathSegment.Field(pathComponent)) - graphqlErrorPath.addValues(Value.newBuilder().setStringValue(pathComponent)) + val pathSegment = randomPathSegments.next(rs) + dataConnectErrorPath.add(DataConnectPathSegment.Field(pathSegment)) + graphqlErrorPath.addValues(Value.newBuilder().setStringValue(pathSegment)) } } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt index 994d1b405fa..f1e1df02eb1 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt @@ -21,8 +21,7 @@ package com.google.firebase.dataconnect.core import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl -import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.errorPath as errorPathArb -import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.dataConnectPath as dataConnectPathArb import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect import com.google.firebase.dataconnect.testutil.property.arbitrary.operationData @@ -31,6 +30,7 @@ import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErro import com.google.firebase.dataconnect.testutil.property.arbitrary.operationFailureResponseImpl import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRawData import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText +import com.google.firebase.dataconnect.toPathString import io.kotest.assertions.assertSoftly import io.kotest.assertions.withClue import io.kotest.common.ExperimentalKotest @@ -41,10 +41,9 @@ import io.kotest.matchers.types.shouldBeSameInstanceAs import io.kotest.property.Arb import io.kotest.property.EdgeConfig import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.bind import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.list import io.kotest.property.arbitrary.string import io.kotest.property.assume import io.kotest.property.checkAll @@ -95,7 +94,7 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { @Test fun `constructor should set properties to the given values`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), errorPathArb()) { message, path -> + checkAll(propTestConfig, Arb.dataConnect.string(), dataConnectPathArb()) { message, path -> val errorInfo = ErrorInfoImpl(message, path) errorInfo.message shouldBeSameInstanceAs message errorInfo.path shouldBeSameInstanceAs path @@ -109,7 +108,7 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { } @Test - fun `toString() should return the message if message is non-empty and path is empty`() = runTest { + fun `toString() should return just the message if the path is empty`() = runTest { checkAll(propTestConfig, Arb.dataConnect.string()) { message -> val errorInfo = ErrorInfoImpl(message, emptyList()) errorInfo.toString() shouldBe message @@ -117,121 +116,14 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { } @Test - fun `toString() should not do anything different with an empty message`() = runTest { - checkAll(propTestConfig, errorPathArb()) { path -> - assume(path.isNotEmpty()) - val errorInfo = ErrorInfoImpl("", path) - val errorInfoToStringResult = errorInfo.toString() - errorInfoToStringResult shouldEndWith ": " - path.forEachIndexed { index, pathSegment -> - withClue("path[$index]") { - errorInfoToStringResult shouldContainWithNonAbuttingText pathSegment.toString() - } - } - } - } - - @Test - fun `toString() should print field path segments separated by dots`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), Arb.list(fieldPathSegmentArb(), 1..10)) { - message, - path -> - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe path.joinToString(".") + ": $message" - } - } - - @Test - fun `toString() should print list index path segments separated by dots`() = runTest { + fun `toString() should return the expected string when path is non-empty`() = runTest { checkAll( propTestConfig, Arb.dataConnect.string(), - Arb.list(listIndexPathSegmentArb(), 1..10) + dataConnectPathArb().filterNot { it.toPathString().isEmpty() } ) { message, path -> val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe path.joinToString("") { "[${it.index}]" } + ": $message" - } - } - - @Test - fun `toString() for path is field, listIndex`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.listIndex1) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe "${segments.field1.field}[${segments.listIndex1}]: $message" - } - } - - @Test - fun `toString() for path is listIndex, field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.listIndex1, segments.field1) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe "[${segments.listIndex1}].${segments.field1.field}: $message" - } - } - - @Test - fun `toString() for path is field, listIndex, field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.listIndex1, segments.field2) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe - "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}: $message" - } - } - - @Test - fun `toString() for path is field, listIndex, listIndex`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe - "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}]: $message" - } - } - - @Test - fun `toString() for path is field, field, listIndex`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.field2, segments.listIndex1) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe - "${segments.field1.field}.${segments.field2.field}[${segments.listIndex1}]: $message" - } - } - - @Test - fun `toString() for path is field, listIndex, field, listIndex`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.listIndex1, segments.field2, segments.listIndex2) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe - "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}[${segments.listIndex2}]: $message" - } - } - - @Test - fun `toString() for path is field, listIndex, listIndex, field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { - message, - segments -> - val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2, segments.field2) - val errorInfo = ErrorInfoImpl(message, path) - errorInfo.toString() shouldBe - "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}].${segments.field2.field}: $message" + errorInfo.toString() shouldBe path.toPathString() + ": " + message } } @@ -281,7 +173,7 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { @Test fun `equals() should return false when path differs`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), dataConnectPathArb()) { errorInfo1: ErrorInfoImpl, otherPath: List -> assume(errorInfo1.path != otherPath) @@ -321,7 +213,7 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { @Test fun `hashCode() should return a different value if path is different`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), dataConnectPathArb()) { errorInfo1: ErrorInfoImpl, otherPath: List -> assume(errorInfo1.path.hashCode() != otherPath.hashCode()) @@ -330,21 +222,3 @@ class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { } } } - -private object MyArb { - - fun samplePathSegments( - field: Arb = fieldPathSegmentArb(), - listIndex: Arb = listIndexPathSegmentArb(), - ): Arb = - Arb.bind(field, field, listIndex, listIndex) { field1, field2, listIndex1, listIndex2 -> - SamplePathSegments(field1, field2, listIndex1, listIndex2) - } - - data class SamplePathSegments( - val field1: DataConnectPathSegment.Field, - val field2: DataConnectPathSegment.Field, - val listIndex1: DataConnectPathSegment.ListIndex, - val listIndex2: DataConnectPathSegment.ListIndex, - ) -} 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 1c5e33b5e32..d1a3ede5456 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 @@ -85,7 +85,7 @@ internal fun DataConnectArb.dataConnectGrpcMetadata( internal fun DataConnectArb.operationErrorInfo( message: Arb = string(), - path: Arb> = errorPath(), + path: Arb> = dataConnectPath(), ): Arb = Arb.bind(message, path) { message0, path0 -> ErrorInfoImpl(message0, path0) } 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 96c99e36630..af8a79a3f81 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 @@ -36,12 +36,13 @@ import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.hex import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.list import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.string +import io.kotest.property.asSample import io.mockk.mockk +import kotlin.random.nextInt import kotlinx.serialization.modules.SerializersModule @Suppress("MemberVisibilityCanBePrivate") @@ -152,10 +153,79 @@ object DataConnectArb { listIndexWeight: Int = 1, ): Arb = Arb.choose(fieldWeight to field, listIndexWeight to listIndex) - fun errorPath( - pathSegment: Arb = pathSegment(), - range: IntRange = 0..10, - ): Arb> = Arb.list(pathSegment, range) + fun dataConnectPath( + size: IntRange = 0..5, + field: Arb = fieldPathSegment(), + listIndex: Arb = listIndexPathSegment(), + ): Arb> = + DataConnectPathArb( + sizeArb = Arb.int(size), + fieldArb = field, + listIndexArb = listIndex, + ) +} + +private class DataConnectPathArb( + private val sizeArb: Arb, + private val fieldArb: Arb, + private val listIndexArb: Arb, +) : Arb>() { + + private val probabilityArb = ProbabilityArb() + + override fun sample(rs: RandomSource) = + generate( + rs, + fieldProbability = probabilityArb.next(rs, edgeCase = false), + segmentEdgeCaseProbability = probabilityArb.next(rs, edgeCase = false), + sizeEdgeCaseProbability = probabilityArb.next(rs, edgeCase = false), + ) + .asSample() + + override fun edgecase(rs: RandomSource): List { + val edgeCases: Set = run { + val edgeCaseCount = rs.random.nextInt(1..EdgeCase.entries.size) + EdgeCase.entries.shuffled(rs.random).take(edgeCaseCount).toSet() + } + + val fieldProbability = + probabilityArb.next(rs, edgeCase = edgeCases.contains(EdgeCase.FieldProbability)) + val segmentEdgeCaseProbability = + probabilityArb.next(rs, edgeCase = edgeCases.contains(EdgeCase.SegmentEdgeCaseProbability)) + val sizeEdgeCaseProbability = + probabilityArb.next(rs, edgeCase = edgeCases.contains(EdgeCase.SizeEdgeCaseProbability)) + + return generate( + rs, + fieldProbability = fieldProbability, + segmentEdgeCaseProbability = segmentEdgeCaseProbability, + sizeEdgeCaseProbability = sizeEdgeCaseProbability, + ) + } + + fun generate( + rs: RandomSource, + fieldProbability: Float, + segmentEdgeCaseProbability: Float, + sizeEdgeCaseProbability: Float, + ): List { + val size = sizeArb.next(rs, sizeEdgeCaseProbability) + check(size >= 0) { + "invalid size generated by $sizeArb: $size " + "(must be greater than or equal to zero)" + } + + return List(size) { + val isField = rs.random.nextFloat() < fieldProbability + val segmentArb = if (isField) fieldArb else listIndexArb + segmentArb.next(rs, segmentEdgeCaseProbability) + } + } + + private enum class EdgeCase { + FieldProbability, + SegmentEdgeCaseProbability, + SizeEdgeCaseProbability, + } } val Arb.Companion.dataConnect: DataConnectArb @@ -167,9 +237,21 @@ 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 - } + + val isEdgeCase = + when (edgeCaseProbability) { + 0.0f -> false + 1.0f -> true + else -> rs.random.nextFloat() < edgeCaseProbability + } + + return next(rs, edgeCase = isEdgeCase) +} + +fun Arb.next(rs: RandomSource, edgeCase: Boolean): T = + if (edgeCase) edgecase(rs)!! else sample(rs).value + +class ProbabilityArb : Arb() { + override fun edgecase(rs: RandomSource) = if (rs.random.nextBoolean()) 1.0f else 0.0f + override fun sample(rs: RandomSource) = rs.random.nextFloat().asSample() }