Skip to content

Commit 6472706

Browse files
authored
dataconnect: testing: shouldBe for Struct and Value improved efficiency and difference reporting (#7601)
1 parent 0d508f8 commit 6472706

File tree

11 files changed

+1548
-11
lines changed

11 files changed

+1548
-11
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.dataconnect.testutil
18+
19+
import com.google.protobuf.ListValue
20+
import com.google.protobuf.NullValue
21+
import com.google.protobuf.Struct
22+
import com.google.protobuf.Value
23+
24+
fun Struct.deepCopy(): Struct =
25+
Struct.newBuilder()
26+
.also { builder ->
27+
fieldsMap.entries.forEach { (key, value) -> builder.putFields(key, value.deepCopy()) }
28+
}
29+
.build()
30+
31+
fun ListValue.deepCopy(): ListValue =
32+
ListValue.newBuilder()
33+
.also { builder -> valuesList.forEach { builder.addValues(it.deepCopy()) } }
34+
.build()
35+
36+
fun Value.deepCopy(): Value =
37+
Value.newBuilder().let { builder ->
38+
when (kindCase) {
39+
Value.KindCase.KIND_NOT_SET -> {}
40+
Value.KindCase.NULL_VALUE -> builder.setNullValue(NullValue.NULL_VALUE)
41+
Value.KindCase.NUMBER_VALUE -> builder.setNumberValue(numberValue)
42+
Value.KindCase.STRING_VALUE -> builder.setStringValue(stringValue)
43+
Value.KindCase.BOOL_VALUE -> builder.setBoolValue(boolValue)
44+
Value.KindCase.STRUCT_VALUE -> builder.setStructValue(structValue.deepCopy())
45+
Value.KindCase.LIST_VALUE -> builder.setListValue(listValue.deepCopy())
46+
}
47+
builder.build()
48+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.dataconnect.testutil
18+
19+
import com.google.protobuf.ListValue
20+
import com.google.protobuf.Struct
21+
import com.google.protobuf.Value
22+
23+
fun structFastEqual(struct1: Struct, struct2: Struct): Boolean {
24+
if (struct1 === struct2) {
25+
return true
26+
} else if (struct1.fieldsCount != struct2.fieldsCount) {
27+
return false
28+
}
29+
30+
val struct2FieldsMap = struct2.fieldsMap
31+
struct1.fieldsMap.entries.forEach { (key, value1) ->
32+
val value2 = struct2FieldsMap[key] ?: return false
33+
if (!valueFastEqual(value1, value2)) {
34+
return false
35+
}
36+
}
37+
38+
return true
39+
}
40+
41+
fun listValueFastEqual(listValue1: ListValue, listValue2: ListValue): Boolean {
42+
if (listValue1 === listValue2) {
43+
return true
44+
} else if (listValue1.valuesCount != listValue2.valuesCount) {
45+
return false
46+
}
47+
48+
listValue1.valuesList.zip(listValue2.valuesList).forEach { (value1, value2) ->
49+
if (!valueFastEqual(value1, value2)) {
50+
return false
51+
}
52+
}
53+
54+
return true
55+
}
56+
57+
fun valueFastEqual(value1: Value, value2: Value): Boolean {
58+
if (value1 === value2) {
59+
return true
60+
} else if (value1.kindCase != value2.kindCase) {
61+
return false
62+
}
63+
return when (value1.kindCase) {
64+
Value.KindCase.KIND_NOT_SET -> true
65+
Value.KindCase.NULL_VALUE -> true
66+
Value.KindCase.NUMBER_VALUE -> numberValuesEqual(value1.numberValue, value2.numberValue)
67+
Value.KindCase.STRING_VALUE -> value1.stringValue == value2.stringValue
68+
Value.KindCase.BOOL_VALUE -> value1.boolValue == value2.boolValue
69+
Value.KindCase.STRUCT_VALUE -> structFastEqual(value1.structValue, value2.structValue)
70+
Value.KindCase.LIST_VALUE -> listValueFastEqual(value1.listValue, value2.listValue)
71+
}
72+
}
73+
74+
data class DifferencePathPair<T : Difference>(val path: ProtoValuePath, val difference: T)
75+
76+
sealed interface Difference {
77+
data class KindCase(val value1: Value, val value2: Value) : Difference
78+
data class BoolValue(val value1: Boolean, val value2: Boolean) : Difference
79+
data class NumberValue(val value1: Double, val value2: Double) : Difference
80+
data class StringValue(val value1: String, val value2: String) : Difference
81+
data class StructMissingKey(val key: String, val value: Value) : Difference
82+
data class StructUnexpectedKey(val key: String, val value: Value) : Difference
83+
data class ListMissingElement(val index: Int, val value: Value) : Difference
84+
data class ListUnexpectedElement(val index: Int, val value: Value) : Difference
85+
}
86+
87+
fun structDiff(
88+
struct1: Struct,
89+
struct2: Struct,
90+
path: MutableProtoValuePath = mutableListOf(),
91+
differences: MutableList<DifferencePathPair<*>> = mutableListOf(),
92+
): MutableList<DifferencePathPair<*>> {
93+
val map1 = struct1.fieldsMap
94+
val map2 = struct2.fieldsMap
95+
96+
map1.entries.forEach { (key, value) ->
97+
if (key !in map2) {
98+
differences.add(path, Difference.StructMissingKey(key, value))
99+
} else {
100+
path.withAppendedStructKey(key) { valueDiff(value, map2[key]!!, path, differences) }
101+
}
102+
}
103+
104+
map2.entries.forEach { (key, value) ->
105+
if (key !in map1) {
106+
differences.add(path, Difference.StructUnexpectedKey(key, value))
107+
}
108+
}
109+
110+
return differences
111+
}
112+
113+
fun listValueDiff(
114+
listValue1: ListValue,
115+
listValue2: ListValue,
116+
path: MutableProtoValuePath = mutableListOf(),
117+
differences: MutableList<DifferencePathPair<*>> = mutableListOf(),
118+
): MutableList<DifferencePathPair<*>> {
119+
repeat(listValue1.valuesCount.coerceAtMost(listValue2.valuesCount)) {
120+
val value1 = listValue1.getValues(it)
121+
val value2 = listValue2.getValues(it)
122+
path.withAppendedListIndex(it) { valueDiff(value1, value2, path, differences) }
123+
}
124+
125+
if (listValue1.valuesCount > listValue2.valuesCount) {
126+
(listValue2.valuesCount until listValue1.valuesCount).forEach {
127+
differences.add(path, Difference.ListMissingElement(it, listValue1.getValues(it)))
128+
}
129+
} else if (listValue1.valuesCount < listValue2.valuesCount) {
130+
(listValue1.valuesCount until listValue2.valuesCount).forEach {
131+
differences.add(path, Difference.ListUnexpectedElement(it, listValue2.getValues(it)))
132+
}
133+
}
134+
135+
return differences
136+
}
137+
138+
fun valueDiff(
139+
value1: Value,
140+
value2: Value,
141+
path: MutableProtoValuePath = mutableListOf(),
142+
differences: MutableList<DifferencePathPair<*>> = mutableListOf(),
143+
): MutableList<DifferencePathPair<*>> {
144+
if (value1.kindCase != value2.kindCase) {
145+
differences.add(path, Difference.KindCase(value1, value2))
146+
return differences
147+
}
148+
149+
when (value1.kindCase) {
150+
Value.KindCase.KIND_NOT_SET,
151+
Value.KindCase.NULL_VALUE -> {}
152+
Value.KindCase.STRUCT_VALUE ->
153+
structDiff(value1.structValue, value2.structValue, path, differences)
154+
Value.KindCase.LIST_VALUE ->
155+
listValueDiff(value1.listValue, value2.listValue, path, differences)
156+
Value.KindCase.BOOL_VALUE ->
157+
if (value1.boolValue != value2.boolValue) {
158+
differences.add(path, Difference.BoolValue(value1.boolValue, value2.boolValue))
159+
}
160+
Value.KindCase.NUMBER_VALUE ->
161+
if (!numberValuesEqual(value1.numberValue, value2.numberValue)) {
162+
differences.add(path, Difference.NumberValue(value1.numberValue, value2.numberValue))
163+
}
164+
Value.KindCase.STRING_VALUE ->
165+
if (value1.stringValue != value2.stringValue) {
166+
differences.add(path, Difference.StringValue(value1.stringValue, value2.stringValue))
167+
}
168+
}
169+
170+
return differences
171+
}
172+
173+
private fun MutableCollection<DifferencePathPair<*>>.add(
174+
path: MutableProtoValuePath,
175+
difference: Difference
176+
) {
177+
add(DifferencePathPair(path.toList(), difference))
178+
}
179+
180+
fun Collection<DifferencePathPair<*>>.toSummaryString(): String = buildString {
181+
val differences: Collection<DifferencePathPair<*>> = this@toSummaryString
182+
if (differences.size == 1) {
183+
append("1 difference: ")
184+
append(differences.single().run { "${path.toPathString()}=$difference" })
185+
} else {
186+
append("${differences.size} differences:")
187+
differences.forEachIndexed { index, (path, difference) ->
188+
append('\n')
189+
append(index + 1)
190+
append(": ")
191+
appendPathString(path)
192+
append('=')
193+
append(difference)
194+
}
195+
}
196+
}
197+
198+
fun numberValuesEqual(value1: Double, value2: Double): Boolean =
199+
if (value1.isNaN()) {
200+
value2.isNaN()
201+
} else if (value1 != value2) {
202+
false
203+
} else if (value1 == 0.0) {
204+
// Explicitly consider 0.0 and -0.0 to be "unequal"; the == operator considers them "equal".
205+
value1.toBits() == value2.toBits()
206+
} else {
207+
true
208+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.firebase.dataconnect.testutil
17+
18+
import com.google.protobuf.ListValue
19+
import com.google.protobuf.Struct
20+
import com.google.protobuf.Value
21+
22+
fun Struct.map(callback: (path: ProtoValuePath, value: Value) -> Value?): Struct {
23+
val mappedValue = toValueProto().map(callback)
24+
checkNotNull(mappedValue) {
25+
"callback returned null for root, " +
26+
"but must be a non-null ${Value.KindCase.STRUCT_VALUE} [qhkdn2b8z5]"
27+
}
28+
check(mappedValue.isStructValue) {
29+
"callback returned ${mappedValue.kindCase} for root, " +
30+
"but must be a non-null ${Value.KindCase.STRUCT_VALUE} [tmhxthgwyk]"
31+
}
32+
return mappedValue.structValue
33+
}
34+
35+
fun ListValue.map(callback: (path: ProtoValuePath, value: Value) -> Value?): ListValue {
36+
val mappedValue = toValueProto().map(callback)
37+
checkNotNull(mappedValue) {
38+
"callback returned null for root, " +
39+
"but must be a non-null ${Value.KindCase.LIST_VALUE} [hdm7p67g54]"
40+
}
41+
check(mappedValue.isListValue) {
42+
"callback returned ${mappedValue.kindCase} for root, " +
43+
"but must be a non-null ${Value.KindCase.LIST_VALUE} [nhfe2stftq]"
44+
}
45+
return mappedValue.listValue
46+
}
47+
48+
fun <V : Value?> Value.map(
49+
callback: (path: ProtoValuePath, value: Value) -> V,
50+
): V =
51+
mapRecursive(
52+
value = this,
53+
path = mutableListOf(),
54+
callback = callback,
55+
)
56+
57+
private fun <V : Value?> mapRecursive(
58+
value: Value,
59+
path: MutableProtoValuePath,
60+
callback: (path: ProtoValuePath, value: Value) -> V,
61+
): V {
62+
val processedValue: Value =
63+
if (value.isStructValue) {
64+
Struct.newBuilder().let { structBuilder ->
65+
value.structValue.fieldsMap.entries.forEach { (key, childValue) ->
66+
val mappedChildValue =
67+
path.withAppendedStructKey(key) { mapRecursive(childValue, path, callback) }
68+
if (mappedChildValue !== null) {
69+
structBuilder.putFields(key, mappedChildValue)
70+
}
71+
}
72+
structBuilder.build().toValueProto()
73+
}
74+
} else if (value.isListValue) {
75+
ListValue.newBuilder().let { listValueBuilder ->
76+
value.listValue.valuesList.forEachIndexed { index, childValue ->
77+
val mappedChildValue =
78+
path.withAppendedListIndex(index) { mapRecursive(childValue, path, callback) }
79+
if (mappedChildValue !== null) {
80+
listValueBuilder.addValues(mappedChildValue)
81+
}
82+
}
83+
listValueBuilder.build().toValueProto()
84+
}
85+
} else {
86+
value
87+
}
88+
89+
return callback(path.toList(), processedValue)
90+
}

firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoTestUtils.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ fun beEqualToDefaultInstance(): Matcher<MessageLite?> = neverNullMatcher { value
6565
"the default instance: ${defaultInstance.print().value}"
6666
},
6767
{
68-
"${value::class.qualifiedName} ${value.print().value} should not be equal to : " +
68+
"${value::class.qualifiedName} ${value.print().value} should not be equal to " +
6969
"the default instance: ${defaultInstance.print().value}"
7070
}
7171
)
@@ -114,8 +114,11 @@ fun beEqualTo(other: Struct?): Matcher<Struct?> = neverNullMatcher { value ->
114114
)
115115
} else {
116116
MatcherResult(
117-
value == other,
118-
{ "${value.print().value} should be equal to ${other.print().value}" },
117+
structFastEqual(value, other),
118+
{
119+
"${value.print().value} should be equal to ${other.print().value}, " +
120+
"but found ${structDiff(value, other).toSummaryString()}"
121+
},
119122
{ "${value.print().value} should not be equal to ${other.print().value}" }
120123
)
121124
}
@@ -134,8 +137,11 @@ fun beEqualTo(other: Value?): Matcher<Value?> = neverNullMatcher { value ->
134137
)
135138
} else {
136139
MatcherResult(
137-
value == other,
138-
{ "${value.print().value} should be equal to ${other.print().value}" },
140+
valueFastEqual(value, other),
141+
{
142+
"${value.print().value} should be equal to ${other.print().value}, " +
143+
"but found ${valueDiff(value, other).toSummaryString()}"
144+
},
139145
{ "${value.print().value} should not be equal to ${other.print().value}" }
140146
)
141147
}

0 commit comments

Comments
 (0)