Skip to content

Commit 4d50d3f

Browse files
committed
dataconnect: testing: shouldBe for Struct and Value improved efficiency and difference reporting
1 parent 0d508f8 commit 4d50d3f

File tree

11 files changed

+1555
-11
lines changed

11 files changed

+1555
-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: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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+
differences: DifferenceAccumulator = DifferenceAccumulator(),
91+
): DifferenceAccumulator {
92+
val map1 = struct1.fieldsMap
93+
val map2 = struct2.fieldsMap
94+
95+
map1.entries.forEach { (key, value) ->
96+
if (key !in map2) {
97+
differences.add(Difference.StructMissingKey(key, value))
98+
} else {
99+
differences.withPushedPathComponent({ ProtoValuePathComponent.StructKey(key) }) {
100+
valueDiff(value, map2[key]!!, differences)
101+
}
102+
}
103+
}
104+
105+
map2.entries.forEach { (key, value) ->
106+
if (key !in map1) {
107+
differences.add(Difference.StructUnexpectedKey(key, value))
108+
}
109+
}
110+
111+
return differences
112+
}
113+
114+
fun listValueDiff(
115+
listValue1: ListValue,
116+
listValue2: ListValue,
117+
differences: DifferenceAccumulator = DifferenceAccumulator(),
118+
): DifferenceAccumulator {
119+
repeat(listValue1.valuesCount.coerceAtMost(listValue2.valuesCount)) {
120+
val value1 = listValue1.getValues(it)
121+
val value2 = listValue2.getValues(it)
122+
differences.withPushedPathComponent({ ProtoValuePathComponent.ListIndex(it) }) {
123+
valueDiff(value1, value2, differences)
124+
}
125+
}
126+
127+
if (listValue1.valuesCount > listValue2.valuesCount) {
128+
(listValue2.valuesCount until listValue1.valuesCount).forEach {
129+
differences.add(Difference.ListMissingElement(it, listValue1.getValues(it)))
130+
}
131+
} else if (listValue1.valuesCount < listValue2.valuesCount) {
132+
(listValue1.valuesCount until listValue2.valuesCount).forEach {
133+
differences.add(Difference.ListUnexpectedElement(it, listValue2.getValues(it)))
134+
}
135+
}
136+
137+
return differences
138+
}
139+
140+
fun valueDiff(
141+
value1: Value,
142+
value2: Value,
143+
differences: DifferenceAccumulator = DifferenceAccumulator(),
144+
): DifferenceAccumulator {
145+
if (value1.kindCase != value2.kindCase) {
146+
differences.add(Difference.KindCase(value1, value2))
147+
return differences
148+
}
149+
150+
when (value1.kindCase) {
151+
Value.KindCase.KIND_NOT_SET,
152+
Value.KindCase.NULL_VALUE -> {}
153+
Value.KindCase.STRUCT_VALUE -> structDiff(value1.structValue, value2.structValue, differences)
154+
Value.KindCase.LIST_VALUE -> listValueDiff(value1.listValue, value2.listValue, differences)
155+
Value.KindCase.BOOL_VALUE ->
156+
if (value1.boolValue != value2.boolValue) {
157+
differences.add(Difference.BoolValue(value1.boolValue, value2.boolValue))
158+
}
159+
Value.KindCase.NUMBER_VALUE ->
160+
if (!numberValuesEqual(value1.numberValue, value2.numberValue)) {
161+
differences.add(Difference.NumberValue(value1.numberValue, value2.numberValue))
162+
}
163+
Value.KindCase.STRING_VALUE ->
164+
if (value1.stringValue != value2.stringValue) {
165+
differences.add(Difference.StringValue(value1.stringValue, value2.stringValue))
166+
}
167+
}
168+
169+
return differences
170+
}
171+
172+
class DifferenceAccumulator {
173+
private val differences = mutableListOf<DifferencePathPair<*>>()
174+
private val path: MutableProtoValuePath = mutableListOf()
175+
176+
val size: Int by differences::size
177+
178+
fun toList(): List<DifferencePathPair<*>> = differences.toList()
179+
180+
fun pushPathComponent(pathComponent: ProtoValuePathComponent) {
181+
path.add(pathComponent)
182+
}
183+
184+
fun popPathComponent() {
185+
path.removeAt(path.lastIndex)
186+
}
187+
188+
fun add(difference: Difference) {
189+
differences.add(DifferencePathPair(path.toList(), difference))
190+
}
191+
192+
override fun toString() = buildString {
193+
if (differences.size == 1) {
194+
append("1 difference: ")
195+
append(differences.single().run { "${path.toPathString()}=$difference" })
196+
} else {
197+
append("${differences.size} differences:")
198+
differences.forEachIndexed { index, (path, difference) ->
199+
append('\n')
200+
append(index + 1)
201+
append(": ")
202+
appendPathString(path)
203+
append('=')
204+
append(difference)
205+
}
206+
}
207+
}
208+
}
209+
210+
private inline fun <T> DifferenceAccumulator?.withPushedPathComponent(
211+
pathComponent: () -> ProtoValuePathComponent,
212+
block: () -> T
213+
): T {
214+
this?.pushPathComponent(pathComponent())
215+
return try {
216+
block()
217+
} finally {
218+
this?.popPathComponent()
219+
}
220+
}
221+
222+
fun numberValuesEqual(value1: Double, value2: Double): Boolean =
223+
if (value1.isNaN()) {
224+
value2.isNaN()
225+
} else if (value1 != value2) {
226+
false
227+
} else if (value1 == 0.0) {
228+
// Explicitly consider 0.0 and -0.0 to be "unequal"; the == operator considers them "equal".
229+
value1.toBits() == value2.toBits()
230+
} else {
231+
true
232+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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, value) ->
66+
path.add(ProtoValuePathComponent.StructKey(key))
67+
val mappedValue = mapRecursive(value, path, callback)
68+
path.removeLast()
69+
if (mappedValue !== null) {
70+
structBuilder.putFields(key, mappedValue)
71+
}
72+
}
73+
structBuilder.build().toValueProto()
74+
}
75+
} else if (value.isListValue) {
76+
ListValue.newBuilder().let { listValueBuilder ->
77+
value.listValue.valuesList.forEachIndexed { index, value ->
78+
path.add(ProtoValuePathComponent.ListIndex(index))
79+
val mappedValue = mapRecursive(value, path, callback)
80+
path.removeLast()
81+
if (mappedValue !== null) {
82+
listValueBuilder.addValues(mappedValue)
83+
}
84+
}
85+
listValueBuilder.build().toValueProto()
86+
}
87+
} else {
88+
value
89+
}
90+
91+
return callback(path.toList(), processedValue)
92+
}

0 commit comments

Comments
 (0)