From 4d50d3f5eb56c6b01ede56127d3f65c88f544155 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Thu, 11 Dec 2025 11:46:14 -0500 Subject: [PATCH 1/2] dataconnect: testing: shouldBe for Struct and Value improved efficiency and difference reporting --- .../dataconnect/testutil/ProtoDeepCopy.kt | 48 ++ .../dataconnect/testutil/ProtoDiff.kt | 232 ++++++ .../firebase/dataconnect/testutil/ProtoMap.kt | 92 +++ .../dataconnect/testutil/ProtoTestUtils.kt | 16 +- .../dataconnect/testutil/ProtoValuePath.kt | 37 +- .../dataconnect/testutil/ProtoWalk.kt | 63 ++ .../testutil/property/arbitrary/misc.kt | 10 +- .../testutil/ProtoConvenienceExtsUnitTest.kt | 122 ++++ .../testutil/ProtoDeepCopyUnitTest.kt | 130 ++++ .../dataconnect/testutil/ProtoDiffUnitTest.kt | 673 ++++++++++++++++++ .../dataconnect/testutil/ProtoWalkUnitTest.kt | 143 ++++ 11 files changed, 1555 insertions(+), 11 deletions(-) create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopy.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiff.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoMap.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalk.kt create mode 100644 firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoConvenienceExtsUnitTest.kt create mode 100644 firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDeepCopyUnitTest.kt create mode 100644 firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiffUnitTest.kt create mode 100644 firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoWalkUnitTest.kt 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..4b0d025125a --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiff.kt @@ -0,0 +1,232 @@ +/* + * 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, + differences: DifferenceAccumulator = DifferenceAccumulator(), +): DifferenceAccumulator { + val map1 = struct1.fieldsMap + val map2 = struct2.fieldsMap + + map1.entries.forEach { (key, value) -> + if (key !in map2) { + differences.add(Difference.StructMissingKey(key, value)) + } else { + differences.withPushedPathComponent({ ProtoValuePathComponent.StructKey(key) }) { + valueDiff(value, map2[key]!!, differences) + } + } + } + + map2.entries.forEach { (key, value) -> + if (key !in map1) { + differences.add(Difference.StructUnexpectedKey(key, value)) + } + } + + return differences +} + +fun listValueDiff( + listValue1: ListValue, + listValue2: ListValue, + differences: DifferenceAccumulator = DifferenceAccumulator(), +): DifferenceAccumulator { + repeat(listValue1.valuesCount.coerceAtMost(listValue2.valuesCount)) { + val value1 = listValue1.getValues(it) + val value2 = listValue2.getValues(it) + differences.withPushedPathComponent({ ProtoValuePathComponent.ListIndex(it) }) { + valueDiff(value1, value2, differences) + } + } + + if (listValue1.valuesCount > listValue2.valuesCount) { + (listValue2.valuesCount until listValue1.valuesCount).forEach { + differences.add(Difference.ListMissingElement(it, listValue1.getValues(it))) + } + } else if (listValue1.valuesCount < listValue2.valuesCount) { + (listValue1.valuesCount until listValue2.valuesCount).forEach { + differences.add(Difference.ListUnexpectedElement(it, listValue2.getValues(it))) + } + } + + return differences +} + +fun valueDiff( + value1: Value, + value2: Value, + differences: DifferenceAccumulator = DifferenceAccumulator(), +): DifferenceAccumulator { + if (value1.kindCase != value2.kindCase) { + differences.add(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, differences) + Value.KindCase.LIST_VALUE -> listValueDiff(value1.listValue, value2.listValue, differences) + Value.KindCase.BOOL_VALUE -> + if (value1.boolValue != value2.boolValue) { + differences.add(Difference.BoolValue(value1.boolValue, value2.boolValue)) + } + Value.KindCase.NUMBER_VALUE -> + if (!numberValuesEqual(value1.numberValue, value2.numberValue)) { + differences.add(Difference.NumberValue(value1.numberValue, value2.numberValue)) + } + Value.KindCase.STRING_VALUE -> + if (value1.stringValue != value2.stringValue) { + differences.add(Difference.StringValue(value1.stringValue, value2.stringValue)) + } + } + + return differences +} + +class DifferenceAccumulator { + private val differences = mutableListOf>() + private val path: MutableProtoValuePath = mutableListOf() + + val size: Int by differences::size + + fun toList(): List> = differences.toList() + + fun pushPathComponent(pathComponent: ProtoValuePathComponent) { + path.add(pathComponent) + } + + fun popPathComponent() { + path.removeAt(path.lastIndex) + } + + fun add(difference: Difference) { + differences.add(DifferencePathPair(path.toList(), difference)) + } + + override fun toString() = buildString { + 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) + } + } + } +} + +private inline fun DifferenceAccumulator?.withPushedPathComponent( + pathComponent: () -> ProtoValuePathComponent, + block: () -> T +): T { + this?.pushPathComponent(pathComponent()) + return try { + block() + } finally { + this?.popPathComponent() + } +} + +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..140aa6c000c --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoMap.kt @@ -0,0 +1,92 @@ +/* + * 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, value) -> + path.add(ProtoValuePathComponent.StructKey(key)) + val mappedValue = mapRecursive(value, path, callback) + path.removeLast() + if (mappedValue !== null) { + structBuilder.putFields(key, mappedValue) + } + } + structBuilder.build().toValueProto() + } + } else if (value.isListValue) { + ListValue.newBuilder().let { listValueBuilder -> + value.listValue.valuesList.forEachIndexed { index, value -> + path.add(ProtoValuePathComponent.ListIndex(index)) + val mappedValue = mapRecursive(value, path, callback) + path.removeLast() + if (mappedValue !== null) { + listValueBuilder.addValues(mappedValue) + } + } + 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..54bc300f86d 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)}" + }, { "${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)}" + }, { "${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..c749b2ebf63 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,34 @@ fun ProtoValuePath.withAppendedComponent(component: ProtoValuePathComponent): Pr addAll(this@withAppendedComponent) add(component) } + +@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..837f89d183e --- /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 = mutableListOf() + queue.add(rootProtoValuePathPair) + + while (!queue.isEmpty()) { + 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..8d1b17c5c1b --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoDiffUnitTest.kt @@ -0,0 +1,673 @@ +/* + * 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.toList() + + structFastEqual(struct1, struct2) shouldBe false + structFastEqual(struct2, struct1) shouldBe false + + structDiff(struct1, struct2).asClue { differences -> + differences.toList() shouldContainExactlyInAnyOrder expectedDifferences + } + structDiff(struct2, struct1).asClue { differences -> + differences.toList() 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.toList() + + listValueFastEqual(listValue1, listValue2) shouldBe false + listValueFastEqual(listValue2, listValue1) shouldBe false + + listValueDiff(listValue1, listValue2).asClue { differences -> + differences.toList() shouldContainExactlyInAnyOrder expectedDifferences + } + listValueDiff(listValue2, listValue1).asClue { differences -> + differences.toList() 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, + ) + } +} From 2bd715071bcfad66bbced8f980cee6c93ecd76f1 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Thu, 11 Dec 2025 15:57:55 -0500 Subject: [PATCH 2/2] refactor --- .../dataconnect/testutil/ProtoDiff.kt | 110 +++++++----------- .../firebase/dataconnect/testutil/ProtoMap.kt | 22 ++-- .../dataconnect/testutil/ProtoTestUtils.kt | 4 +- .../dataconnect/testutil/ProtoValuePath.kt | 21 ++++ .../dataconnect/testutil/ProtoWalk.kt | 4 +- .../dataconnect/testutil/ProtoDiffUnitTest.kt | 14 +-- 6 files changed, 84 insertions(+), 91 deletions(-) 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 index 4b0d025125a..25821a4101d 100644 --- 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 @@ -87,24 +87,23 @@ sealed interface Difference { fun structDiff( struct1: Struct, struct2: Struct, - differences: DifferenceAccumulator = DifferenceAccumulator(), -): DifferenceAccumulator { + 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(Difference.StructMissingKey(key, value)) + differences.add(path, Difference.StructMissingKey(key, value)) } else { - differences.withPushedPathComponent({ ProtoValuePathComponent.StructKey(key) }) { - valueDiff(value, map2[key]!!, differences) - } + path.withAppendedStructKey(key) { valueDiff(value, map2[key]!!, path, differences) } } } map2.entries.forEach { (key, value) -> if (key !in map1) { - differences.add(Difference.StructUnexpectedKey(key, value)) + differences.add(path, Difference.StructUnexpectedKey(key, value)) } } @@ -114,23 +113,22 @@ fun structDiff( fun listValueDiff( listValue1: ListValue, listValue2: ListValue, - differences: DifferenceAccumulator = DifferenceAccumulator(), -): DifferenceAccumulator { + path: MutableProtoValuePath = mutableListOf(), + differences: MutableList> = mutableListOf(), +): MutableList> { repeat(listValue1.valuesCount.coerceAtMost(listValue2.valuesCount)) { val value1 = listValue1.getValues(it) val value2 = listValue2.getValues(it) - differences.withPushedPathComponent({ ProtoValuePathComponent.ListIndex(it) }) { - valueDiff(value1, value2, differences) - } + path.withAppendedListIndex(it) { valueDiff(value1, value2, path, differences) } } if (listValue1.valuesCount > listValue2.valuesCount) { (listValue2.valuesCount until listValue1.valuesCount).forEach { - differences.add(Difference.ListMissingElement(it, listValue1.getValues(it))) + differences.add(path, Difference.ListMissingElement(it, listValue1.getValues(it))) } } else if (listValue1.valuesCount < listValue2.valuesCount) { (listValue1.valuesCount until listValue2.valuesCount).forEach { - differences.add(Difference.ListUnexpectedElement(it, listValue2.getValues(it))) + differences.add(path, Difference.ListUnexpectedElement(it, listValue2.getValues(it))) } } @@ -140,82 +138,60 @@ fun listValueDiff( fun valueDiff( value1: Value, value2: Value, - differences: DifferenceAccumulator = DifferenceAccumulator(), -): DifferenceAccumulator { + path: MutableProtoValuePath = mutableListOf(), + differences: MutableList> = mutableListOf(), +): MutableList> { if (value1.kindCase != value2.kindCase) { - differences.add(Difference.KindCase(value1, value2)) + 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, differences) - Value.KindCase.LIST_VALUE -> listValueDiff(value1.listValue, value2.listValue, differences) + 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(Difference.BoolValue(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(Difference.NumberValue(value1.numberValue, value2.numberValue)) + differences.add(path, Difference.NumberValue(value1.numberValue, value2.numberValue)) } Value.KindCase.STRING_VALUE -> if (value1.stringValue != value2.stringValue) { - differences.add(Difference.StringValue(value1.stringValue, value2.stringValue)) + differences.add(path, Difference.StringValue(value1.stringValue, value2.stringValue)) } } return differences } -class DifferenceAccumulator { - private val differences = mutableListOf>() - private val path: MutableProtoValuePath = mutableListOf() - - val size: Int by differences::size - - fun toList(): List> = differences.toList() - - fun pushPathComponent(pathComponent: ProtoValuePathComponent) { - path.add(pathComponent) - } - - fun popPathComponent() { - path.removeAt(path.lastIndex) - } - - fun add(difference: Difference) { - differences.add(DifferencePathPair(path.toList(), difference)) - } - - override fun toString() = buildString { - 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) - } - } - } +private fun MutableCollection>.add( + path: MutableProtoValuePath, + difference: Difference +) { + add(DifferencePathPair(path.toList(), difference)) } -private inline fun DifferenceAccumulator?.withPushedPathComponent( - pathComponent: () -> ProtoValuePathComponent, - block: () -> T -): T { - this?.pushPathComponent(pathComponent()) - return try { - block() - } finally { - this?.popPathComponent() +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) + } } } 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 index 140aa6c000c..35f996cc127 100644 --- 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 @@ -62,24 +62,22 @@ private fun mapRecursive( val processedValue: Value = if (value.isStructValue) { Struct.newBuilder().let { structBuilder -> - value.structValue.fieldsMap.entries.forEach { (key, value) -> - path.add(ProtoValuePathComponent.StructKey(key)) - val mappedValue = mapRecursive(value, path, callback) - path.removeLast() - if (mappedValue !== null) { - structBuilder.putFields(key, mappedValue) + 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, value -> - path.add(ProtoValuePathComponent.ListIndex(index)) - val mappedValue = mapRecursive(value, path, callback) - path.removeLast() - if (mappedValue !== null) { - listValueBuilder.addValues(mappedValue) + value.listValue.valuesList.forEachIndexed { index, childValue -> + val mappedChildValue = + path.withAppendedListIndex(index) { mapRecursive(childValue, path, callback) } + if (mappedChildValue !== null) { + listValueBuilder.addValues(mappedChildValue) } } listValueBuilder.build().toValueProto() 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 54bc300f86d..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 @@ -117,7 +117,7 @@ fun beEqualTo(other: Struct?): Matcher = neverNullMatcher { value -> structFastEqual(value, other), { "${value.print().value} should be equal to ${other.print().value}, " + - "but found ${structDiff(value, other)}" + "but found ${structDiff(value, other).toSummaryString()}" }, { "${value.print().value} should not be equal to ${other.print().value}" } ) @@ -140,7 +140,7 @@ fun beEqualTo(other: Value?): Matcher = neverNullMatcher { value -> valueFastEqual(value, other), { "${value.print().value} should be equal to ${other.print().value}, " + - "but found ${valueDiff(value, other)}" + "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 c749b2ebf63..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 @@ -53,6 +53,27 @@ fun ProtoValuePath.withAppendedComponent(component: ProtoValuePathComponent): Pr 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) } 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 index 837f89d183e..59790149cdd 100644 --- 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 @@ -39,10 +39,10 @@ fun Value.walkValues(includeSelf: Boolean = true): Sequence = private fun valueWalk(value: Value, includeSelf: Boolean) = sequence { val rootProtoValuePathPair = ProtoValuePathPair(emptyList(), value) - val queue = mutableListOf() + val queue = ArrayDeque() queue.add(rootProtoValuePathPair) - while (!queue.isEmpty()) { + while (queue.isNotEmpty()) { val protoValuePathPair = queue.removeFirst() val (path, value) = protoValuePathPair 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 index 8d1b17c5c1b..e9ddfface54 100644 --- 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 @@ -84,17 +84,16 @@ class ProtoDiffUnitTest { checkAll(propTestConfig, structArb, Arb.int(1..5)) { struct1, keyCount -> val mutableDifferences = mutableListOf>() val struct2 = prepare(struct1, keyCount, mutableDifferences) - val expectedDifferences = mutableDifferences.toList() + val expectedDifferences = mutableDifferences structFastEqual(struct1, struct2) shouldBe false structFastEqual(struct2, struct1) shouldBe false structDiff(struct1, struct2).asClue { differences -> - differences.toList() shouldContainExactlyInAnyOrder expectedDifferences + differences shouldContainExactlyInAnyOrder expectedDifferences } structDiff(struct2, struct1).asClue { differences -> - differences.toList() shouldContainExactlyInAnyOrder - expectedDifferences.withInvertedDifferences() + differences shouldContainExactlyInAnyOrder expectedDifferences.withInvertedDifferences() } } } @@ -245,17 +244,16 @@ class ProtoDiffUnitTest { checkAll(propTestConfig, listValueArb, Arb.int(1..5)) { listValue1, itemCount -> val mutableDifferences = mutableListOf>() val listValue2 = prepare(listValue1, itemCount, mutableDifferences) - val expectedDifferences = mutableDifferences.toList() + val expectedDifferences = mutableDifferences listValueFastEqual(listValue1, listValue2) shouldBe false listValueFastEqual(listValue2, listValue1) shouldBe false listValueDiff(listValue1, listValue2).asClue { differences -> - differences.toList() shouldContainExactlyInAnyOrder expectedDifferences + differences shouldContainExactlyInAnyOrder expectedDifferences } listValueDiff(listValue2, listValue1).asClue { differences -> - differences.toList() shouldContainExactlyInAnyOrder - expectedDifferences.withInvertedDifferences() + differences shouldContainExactlyInAnyOrder expectedDifferences.withInvertedDifferences() } } }