diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopy.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopy.kt new file mode 100644 index 00000000000..601f2758f3e --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopy.kt @@ -0,0 +1,48 @@ +/* + * 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 + +import com.google.protobuf.ListValue +import com.google.protobuf.NullValue +import com.google.protobuf.Struct +import com.google.protobuf.Value + +fun Struct.deepCopy(): Struct = + Struct.newBuilder() + .also { builder -> + fieldsMap.entries.forEach { (key, value) -> builder.putFields(key, value.deepCopy()) } + } + .build() + +fun ListValue.deepCopy(): ListValue = + ListValue.newBuilder() + .also { builder -> valuesList.forEach { builder.addValues(it.deepCopy()) } } + .build() + +fun Value.deepCopy(): Value = + Value.newBuilder().let { builder -> + when (kindCase) { + Value.KindCase.KIND_NOT_SET -> {} + Value.KindCase.NULL_VALUE -> builder.setNullValue(NullValue.NULL_VALUE) + Value.KindCase.NUMBER_VALUE -> builder.setNumberValue(numberValue) + Value.KindCase.STRING_VALUE -> builder.setStringValue(stringValue) + Value.KindCase.BOOL_VALUE -> builder.setBoolValue(boolValue) + Value.KindCase.STRUCT_VALUE -> builder.setStructValue(structValue.deepCopy()) + Value.KindCase.LIST_VALUE -> builder.setListValue(listValue.deepCopy()) + } + builder.build() + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiff.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiff.kt new file mode 100644 index 00000000000..25821a4101d --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiff.kt @@ -0,0 +1,208 @@ +/* + * 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 + +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value + +fun structFastEqual(struct1: Struct, struct2: Struct): Boolean { + if (struct1 === struct2) { + return true + } else if (struct1.fieldsCount != struct2.fieldsCount) { + return false + } + + val struct2FieldsMap = struct2.fieldsMap + struct1.fieldsMap.entries.forEach { (key, value1) -> + val value2 = struct2FieldsMap[key] ?: return false + if (!valueFastEqual(value1, value2)) { + return false + } + } + + return true +} + +fun listValueFastEqual(listValue1: ListValue, listValue2: ListValue): Boolean { + if (listValue1 === listValue2) { + return true + } else if (listValue1.valuesCount != listValue2.valuesCount) { + return false + } + + listValue1.valuesList.zip(listValue2.valuesList).forEach { (value1, value2) -> + if (!valueFastEqual(value1, value2)) { + return false + } + } + + return true +} + +fun valueFastEqual(value1: Value, value2: Value): Boolean { + if (value1 === value2) { + return true + } else if (value1.kindCase != value2.kindCase) { + return false + } + return when (value1.kindCase) { + Value.KindCase.KIND_NOT_SET -> true + Value.KindCase.NULL_VALUE -> true + Value.KindCase.NUMBER_VALUE -> numberValuesEqual(value1.numberValue, value2.numberValue) + Value.KindCase.STRING_VALUE -> value1.stringValue == value2.stringValue + Value.KindCase.BOOL_VALUE -> value1.boolValue == value2.boolValue + Value.KindCase.STRUCT_VALUE -> structFastEqual(value1.structValue, value2.structValue) + Value.KindCase.LIST_VALUE -> listValueFastEqual(value1.listValue, value2.listValue) + } +} + +data class DifferencePathPair(val path: ProtoValuePath, val difference: T) + +sealed interface Difference { + data class KindCase(val value1: Value, val value2: Value) : Difference + data class BoolValue(val value1: Boolean, val value2: Boolean) : Difference + data class NumberValue(val value1: Double, val value2: Double) : Difference + data class StringValue(val value1: String, val value2: String) : Difference + data class StructMissingKey(val key: String, val value: Value) : Difference + data class StructUnexpectedKey(val key: String, val value: Value) : Difference + data class ListMissingElement(val index: Int, val value: Value) : Difference + data class ListUnexpectedElement(val index: Int, val value: Value) : Difference +} + +fun structDiff( + struct1: Struct, + struct2: Struct, + path: MutableProtoValuePath = mutableListOf(), + differences: MutableList> = mutableListOf(), +): MutableList> { + val map1 = struct1.fieldsMap + val map2 = struct2.fieldsMap + + map1.entries.forEach { (key, value) -> + if (key !in map2) { + differences.add(path, Difference.StructMissingKey(key, value)) + } else { + path.withAppendedStructKey(key) { valueDiff(value, map2[key]!!, path, differences) } + } + } + + map2.entries.forEach { (key, value) -> + if (key !in map1) { + differences.add(path, Difference.StructUnexpectedKey(key, value)) + } + } + + return differences +} + +fun listValueDiff( + listValue1: ListValue, + listValue2: ListValue, + path: MutableProtoValuePath = mutableListOf(), + differences: MutableList> = mutableListOf(), +): MutableList> { + repeat(listValue1.valuesCount.coerceAtMost(listValue2.valuesCount)) { + val value1 = listValue1.getValues(it) + val value2 = listValue2.getValues(it) + path.withAppendedListIndex(it) { valueDiff(value1, value2, path, differences) } + } + + if (listValue1.valuesCount > listValue2.valuesCount) { + (listValue2.valuesCount until listValue1.valuesCount).forEach { + differences.add(path, Difference.ListMissingElement(it, listValue1.getValues(it))) + } + } else if (listValue1.valuesCount < listValue2.valuesCount) { + (listValue1.valuesCount until listValue2.valuesCount).forEach { + differences.add(path, Difference.ListUnexpectedElement(it, listValue2.getValues(it))) + } + } + + return differences +} + +fun valueDiff( + value1: Value, + value2: Value, + path: MutableProtoValuePath = mutableListOf(), + differences: MutableList> = mutableListOf(), +): MutableList> { + if (value1.kindCase != value2.kindCase) { + differences.add(path, Difference.KindCase(value1, value2)) + return differences + } + + when (value1.kindCase) { + Value.KindCase.KIND_NOT_SET, + Value.KindCase.NULL_VALUE -> {} + Value.KindCase.STRUCT_VALUE -> + structDiff(value1.structValue, value2.structValue, path, differences) + Value.KindCase.LIST_VALUE -> + listValueDiff(value1.listValue, value2.listValue, path, differences) + Value.KindCase.BOOL_VALUE -> + if (value1.boolValue != value2.boolValue) { + differences.add(path, Difference.BoolValue(value1.boolValue, value2.boolValue)) + } + Value.KindCase.NUMBER_VALUE -> + if (!numberValuesEqual(value1.numberValue, value2.numberValue)) { + differences.add(path, Difference.NumberValue(value1.numberValue, value2.numberValue)) + } + Value.KindCase.STRING_VALUE -> + if (value1.stringValue != value2.stringValue) { + differences.add(path, Difference.StringValue(value1.stringValue, value2.stringValue)) + } + } + + return differences +} + +private fun MutableCollection>.add( + path: MutableProtoValuePath, + difference: Difference +) { + add(DifferencePathPair(path.toList(), difference)) +} + +fun Collection>.toSummaryString(): String = buildString { + val differences: Collection> = this@toSummaryString + if (differences.size == 1) { + append("1 difference: ") + append(differences.single().run { "${path.toPathString()}=$difference" }) + } else { + append("${differences.size} differences:") + differences.forEachIndexed { index, (path, difference) -> + append('\n') + append(index + 1) + append(": ") + appendPathString(path) + append('=') + append(difference) + } + } +} + +fun numberValuesEqual(value1: Double, value2: Double): Boolean = + if (value1.isNaN()) { + value2.isNaN() + } else if (value1 != value2) { + false + } else if (value1 == 0.0) { + // Explicitly consider 0.0 and -0.0 to be "unequal"; the == operator considers them "equal". + value1.toBits() == value2.toBits() + } else { + true + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoMap.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoMap.kt new file mode 100644 index 00000000000..35f996cc127 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoMap.kt @@ -0,0 +1,90 @@ +/* + * 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 + +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value + +fun Struct.map(callback: (path: ProtoValuePath, value: Value) -> Value?): Struct { + val mappedValue = toValueProto().map(callback) + checkNotNull(mappedValue) { + "callback returned null for root, " + + "but must be a non-null ${Value.KindCase.STRUCT_VALUE} [qhkdn2b8z5]" + } + check(mappedValue.isStructValue) { + "callback returned ${mappedValue.kindCase} for root, " + + "but must be a non-null ${Value.KindCase.STRUCT_VALUE} [tmhxthgwyk]" + } + return mappedValue.structValue +} + +fun ListValue.map(callback: (path: ProtoValuePath, value: Value) -> Value?): ListValue { + val mappedValue = toValueProto().map(callback) + checkNotNull(mappedValue) { + "callback returned null for root, " + + "but must be a non-null ${Value.KindCase.LIST_VALUE} [hdm7p67g54]" + } + check(mappedValue.isListValue) { + "callback returned ${mappedValue.kindCase} for root, " + + "but must be a non-null ${Value.KindCase.LIST_VALUE} [nhfe2stftq]" + } + return mappedValue.listValue +} + +fun Value.map( + callback: (path: ProtoValuePath, value: Value) -> V, +): V = + mapRecursive( + value = this, + path = mutableListOf(), + callback = callback, + ) + +private fun mapRecursive( + value: Value, + path: MutableProtoValuePath, + callback: (path: ProtoValuePath, value: Value) -> V, +): V { + val processedValue: Value = + if (value.isStructValue) { + Struct.newBuilder().let { structBuilder -> + value.structValue.fieldsMap.entries.forEach { (key, childValue) -> + val mappedChildValue = + path.withAppendedStructKey(key) { mapRecursive(childValue, path, callback) } + if (mappedChildValue !== null) { + structBuilder.putFields(key, mappedChildValue) + } + } + structBuilder.build().toValueProto() + } + } else if (value.isListValue) { + ListValue.newBuilder().let { listValueBuilder -> + value.listValue.valuesList.forEachIndexed { index, childValue -> + val mappedChildValue = + path.withAppendedListIndex(index) { mapRecursive(childValue, path, callback) } + if (mappedChildValue !== null) { + listValueBuilder.addValues(mappedChildValue) + } + } + listValueBuilder.build().toValueProto() + } + } else { + value + } + + return callback(path.toList(), processedValue) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoTestUtils.kt index fc5ee4ceb39..5a135cdb1fd 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoTestUtils.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoTestUtils.kt @@ -65,7 +65,7 @@ fun beEqualToDefaultInstance(): Matcher = neverNullMatcher { value "the default instance: ${defaultInstance.print().value}" }, { - "${value::class.qualifiedName} ${value.print().value} should not be equal to : " + + "${value::class.qualifiedName} ${value.print().value} should not be equal to " + "the default instance: ${defaultInstance.print().value}" } ) @@ -114,8 +114,11 @@ fun beEqualTo(other: Struct?): Matcher = neverNullMatcher { value -> ) } else { MatcherResult( - value == other, - { "${value.print().value} should be equal to ${other.print().value}" }, + structFastEqual(value, other), + { + "${value.print().value} should be equal to ${other.print().value}, " + + "but found ${structDiff(value, other).toSummaryString()}" + }, { "${value.print().value} should not be equal to ${other.print().value}" } ) } @@ -134,8 +137,11 @@ fun beEqualTo(other: Value?): Matcher = neverNullMatcher { value -> ) } else { MatcherResult( - value == other, - { "${value.print().value} should be equal to ${other.print().value}" }, + valueFastEqual(value, other), + { + "${value.print().value} should be equal to ${other.print().value}, " + + "but found ${valueDiff(value, other).toSummaryString()}" + }, { "${value.print().value} should not be equal to ${other.print().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 index f13b35c2a97..cc5f3a743e2 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * 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. @@ -17,6 +17,8 @@ package com.google.firebase.dataconnect.testutil import com.google.protobuf.Value import java.util.Objects +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract sealed interface ProtoValuePathComponent { @@ -35,6 +37,8 @@ sealed interface ProtoValuePathComponent { typealias ProtoValuePath = List +typealias MutableProtoValuePath = MutableList + data class ProtoValuePathPair(val path: ProtoValuePath, val value: Value) fun ProtoValuePath.withAppendedListIndex(index: Int): ProtoValuePath = @@ -48,3 +52,55 @@ fun ProtoValuePath.withAppendedComponent(component: ProtoValuePathComponent): Pr addAll(this@withAppendedComponent) add(component) } + +fun MutableProtoValuePath.withAppendedListIndex(index: Int, block: () -> T): T = + withAppendedComponent(ProtoValuePathComponent.ListIndex(index), block) + +fun MutableProtoValuePath.withAppendedStructKey(key: String, block: () -> T): T = + withAppendedComponent(ProtoValuePathComponent.StructKey(key), block) + +fun MutableProtoValuePath.withAppendedComponent( + component: ProtoValuePathComponent, + block: () -> T +): T { + val originalSize = size + add(component) + try { + return block() + } finally { + val removedComponent = removeLastOrNull() + check(removedComponent === component) + check(size == originalSize) + } +} + +@OptIn(ExperimentalContracts::class) +fun ProtoValuePathComponent?.isStructKey(): Boolean { + contract { returns(true) implies (this@isStructKey is ProtoValuePathComponent.StructKey) } + return this is ProtoValuePathComponent.StructKey +} + +fun ProtoValuePathComponent?.structKeyOrThrow(): String = + (this as ProtoValuePathComponent.StructKey).key + +fun ProtoValuePath.toPathString(): String = buildString { appendPathString(this@toPathString) } + +fun StringBuilder.appendPathString(path: ProtoValuePath): StringBuilder = apply { + path.forEach { pathComponent -> + when (pathComponent) { + is ProtoValuePathComponent.StructKey -> { + if (isNotEmpty()) { + append('.') + } + append('"') + append(pathComponent.key) + append('"') + } + is ProtoValuePathComponent.ListIndex -> { + append('[') + append(pathComponent.index) + append(']') + } + } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalk.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalk.kt new file mode 100644 index 00000000000..59790149cdd --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalk.kt @@ -0,0 +1,63 @@ +/* + * 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 + +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value + +fun Struct.walk(includeSelf: Boolean = false): Sequence = + toValueProto().walk(includeSelf = includeSelf) + +fun ListValue.walk(includeSelf: Boolean = false): Sequence = + toValueProto().walk(includeSelf = includeSelf) + +fun Value.walk(includeSelf: Boolean = true): Sequence = + valueWalk(this, includeSelf = includeSelf) + +fun Struct.walkValues(includeSelf: Boolean = false): Sequence = + walk(includeSelf = includeSelf).map { it.value } + +fun ListValue.walkValues(includeSelf: Boolean = false): Sequence = + walk(includeSelf = includeSelf).map { it.value } + +fun Value.walkValues(includeSelf: Boolean = true): Sequence = + walk(includeSelf = includeSelf).map { it.value } + +private fun valueWalk(value: Value, includeSelf: Boolean) = sequence { + val rootProtoValuePathPair = ProtoValuePathPair(emptyList(), value) + val queue = ArrayDeque() + queue.add(rootProtoValuePathPair) + + while (queue.isNotEmpty()) { + val protoValuePathPair = queue.removeFirst() + val (path, value) = protoValuePathPair + + if (includeSelf || protoValuePathPair !== rootProtoValuePathPair) { + yield(protoValuePathPair) + } + + if (value.kindCase == Value.KindCase.STRUCT_VALUE) { + value.structValue.fieldsMap.entries.forEach { (key, childValue) -> + queue.add(ProtoValuePathPair(path.withAppendedStructKey(key), childValue)) + } + } else if (value.kindCase == Value.KindCase.LIST_VALUE) { + value.listValue.valuesList.forEachIndexed { index, childValue -> + queue.add(ProtoValuePathPair(path.withAppendedListIndex(index), childValue)) + } + } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/misc.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/misc.kt index a40b78cc9b5..892ad6f8638 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/misc.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/misc.kt @@ -23,8 +23,7 @@ import io.kotest.property.Exhaustive import io.kotest.property.PropertyContext import io.kotest.property.Sample import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.bind -import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.flatMap import io.kotest.property.arbitrary.map import io.kotest.property.exhaustive.enum @@ -32,9 +31,10 @@ import io.kotest.property.exhaustive.exhaustive import kotlin.random.nextInt /** Returns a new [Arb] that produces two _unequal_ values of this [Arb]. */ -fun Arb.distinctPair(): Arb> = flatMap { value1 -> - this@distinctPair.filter { it != value1 }.map { Pair(value1, it) } -} +fun Arb.distinctPair(isEqual: (T, T) -> Boolean = { v1, v2 -> v1 == v2 }): Arb> = + flatMap { value1 -> + this@distinctPair.filterNot { isEqual(value1, it) }.map { Pair(value1, it) } + } fun Arb.withPrefix(prefix: String): Arb = arbitrary { "$prefix${bind()}" } diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExtsUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExtsUnitTest.kt new file mode 100644 index 00000000000..31d99e80a71 --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExtsUnitTest.kt @@ -0,0 +1,122 @@ +/* + * 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 + +import com.google.firebase.dataconnect.testutil.property.arbitrary.listValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import com.google.firebase.dataconnect.testutil.property.arbitrary.value +import com.google.protobuf.Value +import io.kotest.common.ExperimentalKotest +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.ShrinkingMode +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.string +import io.kotest.property.checkAll +import io.kotest.property.exhaustive.boolean +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProtoConvenienceExtsUnitTest { + + @Test + fun `Boolean toValueProto`() = runTest { + checkAll(propTestConfig, Exhaustive.boolean()) { boolean -> + val value = boolean.toValueProto() + value.kindCase shouldBe Value.KindCase.BOOL_VALUE + value.boolValue shouldBe boolean + } + } + + @Test + fun `String toValueProto`() = runTest { + checkAll(propTestConfig, Arb.string()) { string -> + val value = string.toValueProto() + value.kindCase shouldBe Value.KindCase.STRING_VALUE + value.stringValue shouldBe string + } + } + + @Test + fun `Double toValueProto`() = runTest { + checkAll(propTestConfig, Arb.double()) { double -> + val value = double.toValueProto() + value.kindCase shouldBe Value.KindCase.NUMBER_VALUE + value.numberValue shouldBe double + } + } + + @Test + fun `Struct toValueProto`() = runTest { + checkAll(propTestConfig, Arb.proto.struct().map { it.struct }) { struct -> + val value = struct.toValueProto() + value.kindCase shouldBe Value.KindCase.STRUCT_VALUE + value.structValue shouldBe struct + } + } + + @Test + fun `ListValue toValueProto`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue().map { it.listValue }) { listValue -> + val value = listValue.toValueProto() + value.kindCase shouldBe Value.KindCase.LIST_VALUE + value.listValue shouldBe listValue + } + } + + @Test + fun `Value isStructValue returns true when kindCase is STRUCT_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.struct().map { it.struct.toValueProto() }) { value -> + value.isStructValue shouldBe true + } + } + + @Test + fun `Value isStructValue returns false when kindCase is not STRUCT_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.value(exclude = Value.KindCase.STRUCT_VALUE)) { value -> + value.isStructValue shouldBe false + } + } + + @Test + fun `Value isListValue returns true when kindCase is LIST_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue().map { it.listValue.toValueProto() }) { value -> + value.isListValue shouldBe true + } + } + + @Test + fun `Value isListValue returns false when kindCase is not LIST_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.value(exclude = Value.KindCase.LIST_VALUE)) { value -> + value.isListValue shouldBe false + } + } + + private companion object { + @OptIn(ExperimentalKotest::class) + val propTestConfig = + PropTestConfig( + iterations = 200, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33), + shrinkingMode = ShrinkingMode.Off, + ) + } +} diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopyUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopyUnitTest.kt new file mode 100644 index 00000000000..01ef0f1664f --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopyUnitTest.kt @@ -0,0 +1,130 @@ +/* + * 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. + */ + +@file:OptIn(DelicateKotest::class) + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.testutil.property.arbitrary.listValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import com.google.firebase.dataconnect.testutil.property.arbitrary.value +import com.google.protobuf.Value +import io.kotest.common.DelicateKotest +import io.kotest.common.ExperimentalKotest +import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.ShrinkingMode +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProtoDeepCopyUnitTest { + + @Test + fun `deepCopy Struct should produce an equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + sample.struct.deepCopy() shouldBe sample.struct + } + } + + @Test + fun `deepCopy Struct should return a different instance than the receiver`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + sample.struct.deepCopy() shouldNotBeSameInstanceAs sample.struct + } + } + + @Test + fun `deepCopy Struct should deep clone recursively`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + val originalValues = sample.struct.walkValues().toList() + val deepCopyValues = sample.struct.deepCopy().walkValues().toList() + deepCopiedValuesShouldBeDistinctInstances(originalValues, deepCopyValues) + } + } + + @Test + fun `deepCopy ListValue should produce an equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + sample.listValue.deepCopy() shouldBe sample.listValue + } + } + + @Test + fun `deepCopy ListValue should return a different instance than the receiver`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + sample.listValue.deepCopy() shouldNotBeSameInstanceAs sample.listValue + } + } + + @Test + fun `deepCopy ListValue should deep clone recursively`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + val originalValues = sample.listValue.walkValues().toList() + val deepCopyValues = sample.listValue.deepCopy().walkValues().toList() + deepCopiedValuesShouldBeDistinctInstances(originalValues, deepCopyValues) + } + } + + @Test + fun `deepCopy Value should produce an equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> value.deepCopy() shouldBe value } + } + + @Test + fun `deepCopy Value should return a different instance than the receiver`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> + value.deepCopy() shouldNotBeSameInstanceAs value + } + } + + @Test + fun `deepCopy Value should deep clone recursively`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> + val originalValues = value.walkValues().toList() + val deepCopyValues = value.deepCopy().walkValues().toList() + deepCopiedValuesShouldBeDistinctInstances(originalValues, deepCopyValues) + } + } + + private companion object { + @OptIn(ExperimentalKotest::class) + val propTestConfig = + PropTestConfig( + iterations = 200, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33), + shrinkingMode = ShrinkingMode.Off, + ) + } +} + +private fun deepCopiedValuesShouldBeDistinctInstances( + originalValues: List, + deepCopiedValues: List +) { + deepCopiedValues shouldContainExactlyInAnyOrder originalValues + deepCopiedValues.forAll { deepCopiedValue -> + originalValues.forAll { originalValue -> + deepCopiedValue shouldNotBeSameInstanceAs originalValue + } + } +} diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiffUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiffUnitTest.kt new file mode 100644 index 00000000000..e9ddfface54 --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiffUnitTest.kt @@ -0,0 +1,671 @@ +/* + * 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. + */ + +@file:OptIn(DelicateKotest::class) + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.testutil.property.arbitrary.distinctPair +import com.google.firebase.dataconnect.testutil.property.arbitrary.listValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.numberValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.stringValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import com.google.firebase.dataconnect.testutil.property.arbitrary.structKey +import com.google.firebase.dataconnect.testutil.property.arbitrary.value +import com.google.firebase.dataconnect.testutil.property.arbitrary.valueOfKind +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import io.kotest.assertions.asClue +import io.kotest.common.DelicateKotest +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.PropertyContext +import io.kotest.property.ShrinkingMode +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.enum +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.string +import io.kotest.property.assume +import io.kotest.property.checkAll +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProtoDiffUnitTest { + + @Test + fun `structDiff,structFastEqual for same instance`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + val struct = sample.struct + structFastEqual(struct, struct) shouldBe true + structDiff(struct, struct).size shouldBe 0 + } + } + + @Test + fun `structDiff,structFastEqual for distinct, but equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { sample -> + val struct1 = sample.struct + val struct2 = struct1.deepCopy() + structFastEqual(struct1, struct2) shouldBe true + structDiff(struct1, struct2).size shouldBe 0 + } + } + + private suspend fun verifyStructDiffReturnsDifferences( + structArb: Arb, + prepare: + suspend PropertyContext.( + struct1: Struct, keyCount: Int, expectedDifferences: MutableList> + ) -> Struct, + ) { + checkAll(propTestConfig, structArb, Arb.int(1..5)) { struct1, keyCount -> + val mutableDifferences = mutableListOf>() + val struct2 = prepare(struct1, keyCount, mutableDifferences) + val expectedDifferences = mutableDifferences + + structFastEqual(struct1, struct2) shouldBe false + structFastEqual(struct2, struct1) shouldBe false + + structDiff(struct1, struct2).asClue { differences -> + differences shouldContainExactlyInAnyOrder expectedDifferences + } + structDiff(struct2, struct1).asClue { differences -> + differences shouldContainExactlyInAnyOrder expectedDifferences.withInvertedDifferences() + } + } + } + + @Test + fun `structDiff,structFastEqual for keys added to structs`() = runTest { + val valueArb = Arb.proto.value() + val structKeyArb = Arb.proto.structKey() + val structArb = Arb.proto.struct(key = structKeyArb).map { it.struct } + + verifyStructDiffReturnsDifferences(structArb) { struct1, keyCount, expectedDifferences -> + val structPaths: Set = + struct1.walk(includeSelf = true).filter { it.value.isStructValue }.map { it.path }.toSet() + val valuesToAddByPath: Map> = buildMap { + repeat(keyCount) { + val path = structPaths.random(randomSource().random) + getOrPut(path) { mutableListOf() }.add(valueArb.bind()) + } + } + check(valuesToAddByPath.values.flatten().size == keyCount) + struct1.map { path, value -> + val valuesToAdd = valuesToAddByPath[path] + if (valuesToAdd === null) { + value + } else { + value.structValue.toBuilder().let { structBuilder -> + val myStructKeyArb = structKeyArb.filterNot(structBuilder::containsFields) + valuesToAdd.forEach { valueToAdd -> + val keyToAdd = myStructKeyArb.bind() + expectedDifferences.add( + DifferencePathPair( + path, + Difference.StructUnexpectedKey(keyToAdd, valueToAdd), + ) + ) + structBuilder.putFields(keyToAdd, valueToAdd) + } + structBuilder.build().toValueProto() + } + } + } + } + } + + @Test + fun `structDiff,structFastEqual for keys removed`() = runTest { + val structArb = Arb.proto.struct(size = 1..5).map { it.struct } + verifyStructDiffReturnsDifferences(structArb) { struct1, keyCount, expectedDifferences -> + val replaceResult = + replaceRandomValues( + struct1, + keyCount, + filter = { it.path.lastOrNull().isStructKey() }, + replacementValue = { _, _ -> null }, + ) + assume(replaceResult.replacements.isNotEmpty()) + + val differences = + replaceResult.replacements.map { replacement -> + DifferencePathPair( + replacement.path.dropLast(1), + Difference.StructMissingKey( + replacement.path.last().structKeyOrThrow(), + replacement.oldValue + ) + ) + } + + expectedDifferences.addAll(differences) + replaceResult.newItem + } + } + + @Test + fun `structDiff,structFastEqual for KindCase`() = runTest { + val valueArb = Arb.proto.value() + val structKeyArb = Arb.proto.structKey() + val structArb = Arb.proto.struct(size = 1..5, key = structKeyArb).map { it.struct } + + verifyStructDiffReturnsDifferences(structArb) { struct1, keyCount, expectedDifferences -> + val replaceResult = + replaceRandomValues( + struct1, + keyCount, + replacementValue = { _, oldValue -> + valueArb.filterNot { it.kindCase == oldValue.kindCase }.bind() + } + ) + + val differences = + replaceResult.replacements.map { replacement -> + DifferencePathPair( + replacement.path, + Difference.KindCase(replacement.oldValue, replacement.newValue) + ) + } + + expectedDifferences.addAll(differences) + replaceResult.newItem + } + } + + @Test + fun `structDiff,structFastEqual for BoolValue, NumberValue, StringValue`() = runTest { + val structArb = Arb.proto.struct(size = 1..5).map { it.struct } + + verifyStructDiffReturnsDifferences(structArb) { struct1, keyCount, expectedDifferences -> + val replaceResult = + replaceRandomValues( + struct1, + keyCount, + filter = { it.value.isBoolNumberOrString() }, + replacementValue = { _, oldValue -> unequalValueOfSameKind(oldValue) }, + ) + assume(replaceResult.replacements.isNotEmpty()) + expectedDifferences.addAllUnequalValueOfSameKindDifferences(replaceResult.replacements) + replaceResult.newItem + } + } + + @Test + fun `listValueDiff,listValueFastEqual for same instance`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + listValueFastEqual(sample.listValue, sample.listValue) shouldBe true + listValueDiff(sample.listValue, sample.listValue).size shouldBe 0 + } + } + + @Test + fun `listValueDiff,listValueFastEqual for distinct, but equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { sample -> + val listValue1 = sample.listValue + val listValue2 = listValue1.deepCopy() + listValueFastEqual(listValue1, listValue2) shouldBe true + listValueDiff(listValue1, listValue2).size shouldBe 0 + } + } + + private suspend fun verifyListValueDiffReturnsDifferences( + listValueArb: Arb, + prepare: + suspend PropertyContext.( + listValue1: ListValue, + itemCount: Int, + expectedDifferences: MutableList>, + ) -> ListValue, + ) { + checkAll(propTestConfig, listValueArb, Arb.int(1..5)) { listValue1, itemCount -> + val mutableDifferences = mutableListOf>() + val listValue2 = prepare(listValue1, itemCount, mutableDifferences) + val expectedDifferences = mutableDifferences + + listValueFastEqual(listValue1, listValue2) shouldBe false + listValueFastEqual(listValue2, listValue1) shouldBe false + + listValueDiff(listValue1, listValue2).asClue { differences -> + differences shouldContainExactlyInAnyOrder expectedDifferences + } + listValueDiff(listValue2, listValue1).asClue { differences -> + differences shouldContainExactlyInAnyOrder expectedDifferences.withInvertedDifferences() + } + } + } + + @Test + fun `listValueDiff,listValueFastEqual for KindCase`() = runTest { + val valueArb = Arb.proto.value() + val listValueArb = Arb.proto.listValue(size = 1..5).map { it.listValue } + + verifyListValueDiffReturnsDifferences(listValueArb) { listValue1, itemCount, expectedDifferences + -> + val replaceResult = + replaceRandomValues( + listValue1, + itemCount, + replacementValue = { _, oldValue -> + valueArb.filterNot { it.kindCase == oldValue.kindCase }.bind() + } + ) + + val differences = + replaceResult.replacements.map { replacement -> + DifferencePathPair( + replacement.path, + Difference.KindCase(replacement.oldValue, replacement.newValue) + ) + } + + expectedDifferences.addAll(differences) + replaceResult.newItem + } + } + + @Test + fun `listValueDiff,listValueFastEqual for values added to the end of a list`() = runTest { + val valueArb = Arb.proto.value() + val listValueArb = Arb.proto.listValue(size = 1..5).map { it.listValue } + + verifyListValueDiffReturnsDifferences(listValueArb) { listValue1, itemCount, expectedDifferences + -> + val listPaths: Set = + listValue1.walk(includeSelf = true).filter { it.value.isListValue }.map { it.path }.toSet() + val valuesToAddByPath: Map> = buildMap { + repeat(itemCount) { + val path = listPaths.random(randomSource().random) + getOrPut(path) { mutableListOf() }.add(valueArb.bind()) + } + } + check(valuesToAddByPath.values.flatten().size == itemCount) + listValue1.map { path, value -> + val valuesToAdd = valuesToAddByPath[path] + if (valuesToAdd === null) { + value + } else { + value.listValue.toBuilder().let { listValueBuilder -> + valuesToAdd.forEach { valueToAdd -> + expectedDifferences.add( + DifferencePathPair( + path, + Difference.ListUnexpectedElement(listValueBuilder.valuesCount, valueToAdd), + ) + ) + listValueBuilder.addValues(valueToAdd) + } + listValueBuilder.build().toValueProto() + } + } + } + } + } + + @Test + fun `listValueDiff,listValueFastEqual for values removed from the end of the root list`() = + runTest { + val listValueArb = Arb.proto.listValue(size = 1..5).map { it.listValue } + + verifyListValueDiffReturnsDifferences(listValueArb) { + listValue1, + itemCount, + expectedDifferences -> + listValue1.toBuilder().let { listValueBuilder -> + var i = itemCount + while (listValueBuilder.valuesCount > 0 && i > 0) { + i-- + val removeIndex = listValueBuilder.valuesCount - 1 + val oldValue = listValueBuilder.getValues(removeIndex) + listValueBuilder.removeValues(removeIndex) + expectedDifferences.add( + DifferencePathPair( + path = emptyList(), + difference = Difference.ListMissingElement(removeIndex, oldValue), + ) + ) + } + listValueBuilder.build() + } + } + } + + @Test + fun `listValueDiff,listValueFastEqual for BoolValue, NumberValue, StringValue`() = runTest { + val listValueArb = Arb.proto.listValue(size = 1..5).map { it.listValue } + + verifyListValueDiffReturnsDifferences(listValueArb) { listValue1, itemCount, expectedDifferences + -> + val replaceResult = + replaceRandomValues( + listValue1, + itemCount, + filter = { it.value.isBoolNumberOrString() }, + replacementValue = { _, oldValue -> unequalValueOfSameKind(oldValue) }, + ) + assume(replaceResult.replacements.isNotEmpty()) + expectedDifferences.addAllUnequalValueOfSameKindDifferences(replaceResult.replacements) + replaceResult.newItem + } + } + + @Test + fun `valueDiff,valueFastEqual for same instance`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> + valueFastEqual(value, value) shouldBe true + valueDiff(value, value).size shouldBe 0 + } + } + + @Test + fun `valueDiff,valueFastEqual for distinct, but equal instance`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> + val valueCopy = value.deepCopy() + valueFastEqual(value, valueCopy) shouldBe true + valueDiff(value, valueCopy).size shouldBe 0 + } + } + + @Test + fun `valueDiff,valueFastEqual for boolean equal values`() = runTest { + checkAll(propTestConfig, Arb.boolean()) { boolean -> + val value1 = boolean.toValueProto() + val value2 = boolean.toValueProto() + + valueFastEqual(value1, value2) shouldBe true + valueDiff(value1, value2).size shouldBe 0 + } + } + + @Test + fun `valueDiff,valueFastEqual for boolean unequal values`() = runTest { + checkAll(propTestConfig, Arb.boolean()) { boolean -> + val value1 = boolean.toValueProto() + val value2 = (!boolean).toValueProto() + + valueFastEqual(value1, value2) shouldBe false + valueDiff(value1, value2).asClue { differences -> + differences + .toList() + .shouldContainExactlyInAnyOrder( + DifferencePathPair( + path = emptyList(), + difference = Difference.BoolValue(boolean, !boolean) + ) + ) + } + } + } + + @Test + fun `valueDiff,valueFastEqual for string equal values`() = runTest { + checkAll(propTestConfig, Arb.string()) { string -> + val value1 = string.toValueProto() + val value2 = string.toValueProto() + + valueFastEqual(value1, value2) shouldBe true + valueDiff(value1, value2).size shouldBe 0 + } + } + + @Test + fun `valueDiff,valueFastEqual for string unequal values`() = runTest { + checkAll(propTestConfig, Arb.string().distinctPair()) { (string1, string2) -> + val value1 = string1.toValueProto() + val value2 = string2.toValueProto() + + valueFastEqual(value1, value2) shouldBe false + valueDiff(value1, value2).asClue { differences -> + differences + .toList() + .shouldContainExactlyInAnyOrder( + DifferencePathPair( + path = emptyList(), + difference = Difference.StringValue(string1, string2) + ) + ) + } + } + } + + @Test + fun `valueDiff,valueFastEqual for number equal values`() = runTest { + checkAll(propTestConfig, Arb.double()) { double -> + val value1 = double.toValueProto() + val value2 = double.toValueProto() + + valueFastEqual(value1, value2) shouldBe true + valueDiff(value1, value2).size shouldBe 0 + } + } + + @Test + fun `valueDiff,valueFastEqual for number unequal values`() = runTest { + val arb = Arb.double().distinctPair(isEqual = ::numberValuesEqual) + checkAll(propTestConfig, arb) { (double1, double2) -> + val value1 = double1.toValueProto() + val value2 = double2.toValueProto() + + valueFastEqual(value1, value2) shouldBe false + valueDiff(value1, value2).asClue { differences -> + differences + .toList() + .shouldContainExactlyInAnyOrder( + DifferencePathPair( + path = emptyList(), + difference = Difference.NumberValue(double1, double2) + ) + ) + } + } + } + + @Test + fun `valueDiff,valueFastEqual for different kind cases`() = runTest { + checkAll(propTestConfig, Arb.enum().distinctPair()) { (kind1, kind2) -> + val value1 = Arb.proto.valueOfKind(kind1).bind() + val value2 = Arb.proto.valueOfKind(kind2).bind() + + valueFastEqual(value1, value2) shouldBe false + valueDiff(value1, value2).asClue { differences -> + differences + .toList() + .shouldContainExactlyInAnyOrder( + DifferencePathPair(path = emptyList(), difference = Difference.KindCase(value1, value2)) + ) + } + } + } + + private companion object { + @OptIn(ExperimentalKotest::class) + val propTestConfig = + PropTestConfig( + iterations = 200, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33), + shrinkingMode = ShrinkingMode.Off, + ) + + fun PropertyContext.randomPathsToReplace( + value: Value, + maxNumPaths: Int, + filter: (ProtoValuePathPair) -> Boolean = { true }, + ): List = randomSource().random.pathsToReplace(value, maxNumPaths, filter) + + fun Random.pathsToReplace( + value: Value, + maxNumPaths: Int, + filter: (ProtoValuePathPair) -> Boolean = { true }, + ): List { + val candidatePaths = + value.walk(includeSelf = false).filter(filter).map { it.path }.toMutableList() + + return buildList { + while (size < maxNumPaths && candidatePaths.isNotEmpty()) { + val path = candidatePaths.random(this@pathsToReplace) + add(path) + candidatePaths.removeAll { candidatePath -> + candidatePath.isPrefixOf(path) || path.isPrefixOf(candidatePath) + } + } + } + } + + fun List<*>.isPrefixOf(otherList: List<*>): Boolean = + if (size > otherList.size) { + false + } else { + otherList.subList(0, size) == this + } + + data class ReplaceRandomValuesResult( + val newItem: T, + val replacements: List>, + ) { + data class Replacement( + val path: ProtoValuePath, + val oldValue: Value, + val newValue: V, + ) + } + + fun PropertyContext.replaceRandomValues( + struct: Struct, + maxNumPaths: Int, + filter: (ProtoValuePathPair) -> Boolean = { true }, + replacementValue: (path: ProtoValuePath, oldValue: Value) -> V, + ): ReplaceRandomValuesResult { + val pathsToReplace = randomPathsToReplace(struct.toValueProto(), maxNumPaths, filter) + + val replacements = mutableListOf>() + val newStruct = + struct.map { path, value -> + if (pathsToReplace.contains(path)) { + val newValue = replacementValue(path, value) + replacements.add(ReplaceRandomValuesResult.Replacement(path, value, newValue)) + newValue + } else { + value + } + } + + return ReplaceRandomValuesResult(newStruct, replacements.toList()) + } + + fun PropertyContext.replaceRandomValues( + listValue: ListValue, + maxNumPaths: Int, + filter: (ProtoValuePathPair) -> Boolean = { true }, + replacementValue: (path: ProtoValuePath, oldValue: Value) -> V, + ): ReplaceRandomValuesResult { + val pathsToReplace = randomPathsToReplace(listValue.toValueProto(), maxNumPaths, filter) + + val replacements = mutableListOf>() + val newListValue = + listValue.map { path, value -> + if (pathsToReplace.contains(path)) { + val newValue = replacementValue(path, value) + replacements.add(ReplaceRandomValuesResult.Replacement(path, value, newValue)) + newValue + } else { + value + } + } + + return ReplaceRandomValuesResult(newListValue, replacements.toList()) + } + + fun List>.withInvertedDifferences(): List> = map { + it.withInvertedDifference() + } + + fun DifferencePathPair<*>.withInvertedDifference(): DifferencePathPair<*> = + DifferencePathPair(path, difference.inverse()) + + fun Difference.inverse(): Difference = + when (this) { + is Difference.BoolValue -> Difference.BoolValue(value1 = value2, value2 = value1) + is Difference.KindCase -> Difference.KindCase(value1 = value2, value2 = value1) + is Difference.ListMissingElement -> + Difference.ListUnexpectedElement(index = index, value = value) + is Difference.ListUnexpectedElement -> + Difference.ListMissingElement(index = index, value = value) + is Difference.NumberValue -> Difference.NumberValue(value1 = value2, value2 = value1) + is Difference.StringValue -> Difference.StringValue(value1 = value2, value2 = value1) + is Difference.StructMissingKey -> Difference.StructUnexpectedKey(key = key, value = value) + is Difference.StructUnexpectedKey -> Difference.StructMissingKey(key = key, value = value) + } + + fun Value.isBoolNumberOrString(): Boolean = + when (kindCase) { + Value.KindCase.NUMBER_VALUE, + Value.KindCase.STRING_VALUE, + Value.KindCase.BOOL_VALUE -> true + else -> false + } + + fun PropertyContext.unequalValueOfSameKind(value: Value): Value = + when (val kindCase = value.kindCase) { + Value.KindCase.BOOL_VALUE -> value.toBuilder().setBoolValue(!value.boolValue).build() + Value.KindCase.NUMBER_VALUE -> + Arb.proto.numberValue(filter = { !numberValuesEqual(it, value.numberValue) }).bind() + Value.KindCase.STRING_VALUE -> + Arb.proto.stringValue(filter = { it != value.stringValue }).bind() + else -> + throw IllegalStateException( + "should never get here: kindCase=$kindCase value=$value [vqrnqxwcds]" + ) + } + + fun MutableList>.addAllUnequalValueOfSameKindDifferences( + replacements: List> + ) { + replacements.forEach { replacement -> + val difference = + when (val kindCase = replacement.oldValue.kindCase) { + Value.KindCase.NUMBER_VALUE -> + Difference.NumberValue( + replacement.oldValue.numberValue, + replacement.newValue.numberValue + ) + Value.KindCase.STRING_VALUE -> + Difference.StringValue( + replacement.oldValue.stringValue, + replacement.newValue.stringValue + ) + Value.KindCase.BOOL_VALUE -> + Difference.BoolValue(replacement.oldValue.boolValue, replacement.newValue.boolValue) + else -> + throw IllegalStateException( + "should never get here: kindCase=$kindCase replacement=$replacement [xgdwtkgtg2]" + ) + } + + add(DifferencePathPair(replacement.path, difference)) + } + } + } +} diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalkUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalkUnitTest.kt new file mode 100644 index 00000000000..4701782dd17 --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalkUnitTest.kt @@ -0,0 +1,143 @@ +/* + * 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. + */ + +@file:OptIn(DelicateKotest::class) + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.testutil.property.arbitrary.listValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.scalarValue +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import com.google.firebase.dataconnect.testutil.property.arbitrary.value +import com.google.firebase.dataconnect.testutil.property.arbitrary.valueOfKind +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import io.kotest.common.DelicateKotest +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.ShrinkingMode +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProtoWalkUnitTest { + + @Test + fun `walk Value scalars`() = runTest { + checkAll(propTestConfig, Arb.proto.scalarValue()) { value: Value -> + val walkResult = value.walk().toList() + walkResult.shouldContainExactly(ProtoValuePathPair(path = emptyList(), value = value)) + } + } + + @Test + fun `walk Value LIST_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.valueOfKind(Value.KindCase.LIST_VALUE)) { value: Value -> + val walkResult = value.walk().toList() + + val expectedWalkResult = buildList { + add(ProtoValuePathPair(emptyList(), value)) + addAll(value.listValue.walk().toList()) + } + walkResult shouldContainExactlyInAnyOrder expectedWalkResult + } + } + + @Test + fun `walk Value STRUCT_VALUE`() = runTest { + checkAll(propTestConfig, Arb.proto.valueOfKind(Value.KindCase.STRUCT_VALUE)) { value: Value -> + val walkResult = value.walk().toList() + + val expectedWalkResult = buildList { + add(ProtoValuePathPair(emptyList(), value)) + addAll(value.structValue.walk().toList()) + } + walkResult shouldContainExactlyInAnyOrder expectedWalkResult + } + } + + @Test + fun `walk ListValue`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { listValueSample -> + val listValue: ListValue = listValueSample.listValue + + val walkResult = listValue.walk().toList() + + walkResult shouldContainExactlyInAnyOrder listValueSample.descendants + } + } + + @Test + fun `walk Struct`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { structSample -> + val struct: Struct = structSample.struct + + val walkResult = struct.walk().toList() + + walkResult shouldContainExactlyInAnyOrder structSample.descendants + } + } + + @Test + fun `walkValues Value`() = runTest { + checkAll(propTestConfig, Arb.proto.value()) { value -> + val walkValuesResult = value.walkValues().toList() + + val walkResult = value.walk().map { it.value }.toList() + walkValuesResult shouldContainExactlyInAnyOrder walkResult + } + } + + @Test + fun `walkValues ListValue`() = runTest { + checkAll(propTestConfig, Arb.proto.listValue()) { listValueSample -> + val listValue: ListValue = listValueSample.listValue + + val walkValuesResult = listValue.walkValues().toList() + + val walkResult = listValue.walk().map { it.value }.toList() + walkValuesResult shouldContainExactlyInAnyOrder walkResult + } + } + + @Test + fun `walkValues Struct`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { structSample -> + val struct: Struct = structSample.struct + + val walkValuesResult = struct.walkValues().toList() + + val walkResult = struct.walk().map { it.value }.toList() + walkValuesResult shouldContainExactlyInAnyOrder walkResult + } + } + + private companion object { + @OptIn(ExperimentalKotest::class) + val propTestConfig = + PropTestConfig( + iterations = 200, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33), + shrinkingMode = ShrinkingMode.Off, + ) + } +}